Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions news/218-add-logger
Original file line number Diff line number Diff line change
@@ -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

* <news item>

### Deprecations

* <news item>

### Docs

* <news item>

### Other

* <news item>
12 changes: 7 additions & 5 deletions src/conda_constructor/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
{
Expand All @@ -181,6 +179,8 @@ def execute(args: Namespace) -> None | int:
}
)
elif args.cmd == "uninstall":
from .uninstall import uninstall

action = uninstall
kwargs.update(
{
Expand All @@ -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(
{
Expand Down
46 changes: 38 additions & 8 deletions src/conda_constructor/uninstall.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
import os
import re
import sys
Expand All @@ -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())
Comment on lines +26 to +31
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this as import-time logic, or should it be a separate function we call as part of the CLI initialization? Is that too late in the process?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it for the visibility, but you are right that it isn't strictly needed at import time. So, I went a third way: importing these main execution function isn't needed until we actually use it. So, I just moved the import statements into the execute function.



def _remove_file_directory(file: Path, raise_on_error: bool = False):
"""
Expand All @@ -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):
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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.")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here (if we should add the return_code to the warning). Perhaps even less important since it's just a warning.

# Delete empty package cache directories
for directory in context.pkgs_dirs:
pkgs_dir = Path(directory)
Expand Down
66 changes: 62 additions & 4 deletions src/entry_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()'
Expand Down Expand Up @@ -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

Expand All @@ -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]:
Expand All @@ -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()
Expand Down
13 changes: 12 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to assert whether the process had any output? All of it should have technically gone to the logfile, right?

Suggested change
assert os.path.realpath(log_text.strip()) == os.path.realpath(CONDA_EXE)
assert os.path.realpath(log_text.strip()) == os.path.realpath(CONDA_EXE)
assert not process.stdout + process.stderr

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Output goes to both the screen and the log file, so we should still check the output.

Loading