diff --git a/news/218-add-logger b/news/218-add-logger new file mode 100644 index 0000000..9db8afc --- /dev/null +++ b/news/218-add-logger @@ -0,0 +1,20 @@ +### Enhancements + +* Add option to log `conda-standalone` and `conda` outputs into a log file + using the `--log-file` CLI option. (#218) + +### Bug fixes + +* + +### Deprecations + +* + +### Docs + +* + +### Other + +* diff --git a/src/conda_constructor/cli.py b/src/conda_constructor/cli.py index 479f4b0..6e59c8d 100644 --- a/src/conda_constructor/cli.py +++ b/src/conda_constructor/cli.py @@ -4,11 +4,7 @@ from pathlib import Path from typing import TYPE_CHECKING -from .extract import DEFAULT_NUM_PROCESSORS, ExtractType, _NumProcessorsAction, extract -from .uninstall import uninstall - -if sys.platform == "win32": - from .windows.path import add_remove_path +from .extract import DEFAULT_NUM_PROCESSORS, ExtractType, _NumProcessorsAction if TYPE_CHECKING: from argparse import ArgumentParser, Namespace @@ -173,6 +169,8 @@ def execute(args: Namespace) -> None | int: action: Callable kwargs = {} if args.cmd == "extract": + from .extract import extract + action = extract kwargs.update( { @@ -181,6 +179,8 @@ def execute(args: Namespace) -> None | int: } ) elif args.cmd == "uninstall": + from .uninstall import uninstall + action = uninstall kwargs.update( { @@ -191,6 +191,8 @@ def execute(args: Namespace) -> None | int: ) elif args.cmd == "windows" and sys.platform == "win32": if args.windows_cmd == "path": + from .windows.path import add_remove_path + action = add_remove_path kwargs.update( { diff --git a/src/conda_constructor/uninstall.py b/src/conda_constructor/uninstall.py index d33f906..3855479 100644 --- a/src/conda_constructor/uninstall.py +++ b/src/conda_constructor/uninstall.py @@ -1,3 +1,4 @@ +import logging import os import re import sys @@ -22,6 +23,13 @@ from menuinst.cli.cli import install as install_shortcut from ruamel.yaml import YAML +logger = logging.getLogger() +# On Windows, these warnings are expected because the uninstaller may still be +# accessing files (like install.log) that conda cannot rename. +if sys.platform == "win32": + conda_logger = logging.getLogger("conda.gateways.disk.delete") + conda_logger.addFilter(lambda record: "Could not remove or rename" not in record.getMessage()) + def _remove_file_directory(file: Path, raise_on_error: bool = False): """ @@ -37,11 +45,14 @@ def _remove_file_directory(file: Path, raise_on_error: bool = False): elif file.is_symlink() or file.is_file(): file.unlink() except PermissionError as e: + message = ( + f"Could not remove {file}. " + "You may need to re-run with elevated privileges or manually remove this file." + ) if raise_on_error: - raise PermissionError( - f"Could not remove {file}. " - "You may need to re-run with elevated privileges or manually remove this file." - ) from e + raise PermissionError(message) from e + else: + logger.warning(message, exc_info=e) def _remove_config_file_and_parents(file: Path, raise_on_error: bool = False): @@ -163,7 +174,14 @@ def _run_conda_init_reverse(for_user: bool, prefix: Path, prefixes: list[Path]): # That function will search for activation scripts in sys.prefix which do no exist # in the extraction directory of conda-standalone. run_plan(plan) - run_plan_elevated(plan) + try: + run_plan_elevated(plan) + except Exception as exc: + logger.error( + "Could not revert some shell profiles because they require elevated privileges. " + "Check the output for lines with `needs sudo` and edit those files manually.", + exc_info=exc, + ) print_plan_results(plan) for initializer in plan: target_path = initializer["kwargs"]["target_path"] @@ -202,7 +220,13 @@ def _remove_environments(prefix: Path, prefixes: list[Path]): # Unprotect frozen environments first frozen_file = env_prefix / PREFIX_FROZEN_FILE if frozen_file.is_file(): - _remove_file_directory(frozen_file, raise_on_error=True) + try: + _remove_file_directory(frozen_file, raise_on_error=True) + except PermissionError as e: + raise PermissionError( + f"Failed to unprotect '{env_prefix}'. Try to re-run the uninstallation with " + f"elevated privileges or remove the file '{frozen_file}' manually.", + ) from e install_shortcut(env_prefix, root_prefix=str(menuinst_base_prefix), remove_shortcuts=[]) # If conda_root_prefix is the same as prefix, conda remove will not be able @@ -214,7 +238,11 @@ def _remove_environments(prefix: Path, prefixes: list[Path]): if default_activation_prefix == env_prefix: os.environ["CONDA_DEFAULT_ACTIVATION_ENV"] = sys.prefix reset_context() - conda_main("remove", "-y", "-p", str(env_prefix), "--all") + + return_code = conda_main("remove", "-y", "-p", str(env_prefix), "--all") + if return_code != 0: + raise RuntimeError(f"Failed to remove environment '{env_prefix}'.") + if conda_root_prefix and conda_root_prefix == env_prefix: os.environ["CONDA_ROOT_PREFIX"] = str(conda_root_prefix) reset_context() @@ -224,7 +252,9 @@ def _remove_environments(prefix: Path, prefixes: list[Path]): def _remove_caches(): - conda_main("clean", "--all", "-y") + return_code = conda_main("clean", "--all", "-y") + if return_code != 0: + logger.warning("Failed to remove all cache files.") # Delete empty package cache directories for directory in context.pkgs_dirs: pkgs_dir = Path(directory) diff --git a/src/entry_point.py b/src/entry_point.py index 95b4dd8..3ac8fc3 100755 --- a/src/entry_point.py +++ b/src/entry_point.py @@ -7,11 +7,28 @@ preliminary work and handling some special cases that arise when PyInstaller is involved. """ +import argparse +import logging import os import sys +from contextlib import contextmanager, nullcontext from multiprocessing import freeze_support from pathlib import Path + +class StreamToLogger: + def __init__(self, logger, log_level=logging.INFO): + self.logger = logger + self.log_level = log_level + + def write(self, buf): + self.logger.log(self.log_level, buf.rstrip()) + + def flush(self): + # Stream handlers need a flush method, but the log command flushes the buffer already + pass + + if os.name == "nt" and "SSLKEYLOGFILE" in os.environ: # This causes a crash with requests 2.32+ on Windows # Root cause is 'urllib3.util.ssl_.create_urllib3_context()' @@ -114,6 +131,39 @@ def _patch_for_conda_run(): os.environ.setdefault("CONDA_EXE", sys.executable) +@contextmanager +def setup_logger(logfile: Path): + """Forward all stdout and stderr output into a file logger. + + This automatically captures all logger output. + """ + plain_formatter = logging.Formatter("%(message)s") + plain_logfile_handler = logging.FileHandler(logfile) + plain_logfile_handler.setFormatter(plain_formatter) + + stdout_logger = logging.getLogger("stdout_logger") + stdout_logger.setLevel(logging.INFO) + stdout_logger.propagate = False + stdout_console_handler = logging.StreamHandler(sys.__stdout__) + stdout_console_handler.setFormatter(plain_formatter) + stdout_logger.addHandler(stdout_console_handler) + stdout_logger.addHandler(plain_logfile_handler) + + stderr_logger = logging.getLogger("stderr_logger") + stderr_logger.setLevel(logging.INFO) + stderr_logger.propagate = False + stderr_console_handler = logging.StreamHandler(sys.__stderr__) + stderr_console_handler.setFormatter(plain_formatter) + stderr_logger.addHandler(stderr_console_handler) + stderr_logger.addHandler(plain_logfile_handler) + + sys.stdout = StreamToLogger(stdout_logger) + sys.stderr = StreamToLogger(stderr_logger) + yield + sys.stdout = sys.__stdout__ + sys.stderr = sys.__stderr__ + + def _conda_main(): from conda.cli import main @@ -132,7 +182,17 @@ def _conda_main(): manager = get_plugin_manager() manager.load_plugins(plugin) - return main() + logger_parser = argparse.ArgumentParser(add_help=False) + logger_parser.add_argument("--log-file", type=Path) + args, remaining = logger_parser.parse_known_args() + if args.log_file: + sys.argv[1:] = remaining + logger_context = setup_logger(args.log_file.resolve()) + else: + logger_context = nullcontext() + + with logger_context: + return main() def _patch_constructor_args(argv: list[str] = sys.argv) -> list[str]: @@ -146,9 +206,7 @@ def _patch_constructor_args(argv: list[str] = sys.argv) -> list[str]: if len(used_legacy_args) == 0: return argv elif len(used_legacy_args) > 1: - from argparse import ArgumentError - - raise ArgumentError( + raise argparse.ArgumentError( None, f"The following arguments are mutually exclusive: {', '.join(used_legacy_args)}." ) legacy_arg = used_legacy_args.pop() diff --git a/tests/test_main.py b/tests/test_main.py index b2eb8c2..34a5b8d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -252,8 +252,10 @@ def test_conda_run(): assert not process.stderr -def test_conda_run_conda_exe(): +@pytest.mark.parametrize("with_log", (True, False), ids=("with log", "no log")) +def test_conda_run_conda_exe(tmp_path: Path, with_log: bool): env = os.environ.copy() + log_file = tmp_path / "conda_run.log" for key in os.environ: if key.startswith(("CONDA", "_CONDA_", "__CONDA", "_CE_")): env.pop(key, None) @@ -265,9 +267,18 @@ def test_conda_run_conda_exe(): "python", "-c", "import sys,os;print(os.environ['CONDA_EXE'])", + *(("--log-file", str(log_file)) if with_log else ()), check=True, text=True, capture_output=True, env=env, ) assert os.path.realpath(process.stdout.strip()) == os.path.realpath(CONDA_EXE) + if with_log: + assert log_file.exists() + log_text = log_file.read_text().strip() + if sys.platform.startswith("win") and os.environ.get("CI"): + # on CI, setup-miniconda registers `test` as auto-activate for every CMD + # which adds some unnecessary stderr output; so, only read the first line + log_text = log_text.split("\n")[0] + assert os.path.realpath(log_text.strip()) == os.path.realpath(CONDA_EXE)