Current File : /home/inlingua/miniconda3/lib/python3.12/site-packages/conda/testing/fixtures.py |
# Copyright (C) 2012 Anaconda, Inc
# SPDX-License-Identifier: BSD-3-Clause
"""Collection of pytest fixtures used in conda tests."""
from __future__ import annotations
import json
import os
import uuid
import warnings
from contextlib import contextmanager, nullcontext
from dataclasses import dataclass
from logging import getLogger
from pathlib import Path
from shutil import copyfile
from typing import TYPE_CHECKING, Literal, TypeVar, overload
import py
import pytest
from .. import CONDA_SOURCE_ROOT
from ..auxlib.entity import EntityEncoder
from ..auxlib.ish import dals
from ..base.constants import PACKAGE_CACHE_MAGIC_FILE
from ..base.context import conda_tests_ctxt_mgmt_def_pol, context, reset_context
from ..cli.main import main_subshell
from ..common.configuration import YamlRawParameter
from ..common.io import env_vars
from ..common.serialize import yaml_round_trip_load
from ..common.url import path_to_url
from ..core.package_cache_data import PackageCacheData
from ..core.subdir_data import SubdirData
from ..exceptions import CondaExitZero
from ..gateways.disk.create import TemporaryDirectory
from ..models.records import PackageRecord
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from _pytest.capture import MultiCapture
from pytest import (
CaptureFixture,
ExceptionInfo,
FixtureRequest,
MonkeyPatch,
TempPathFactory,
)
from pytest_mock import MockerFixture
log = getLogger(__name__)
@pytest.fixture(autouse=True)
def suppress_resource_warning():
"""
Suppress `Unclosed Socket Warning`
It seems urllib3 keeps a socket open to avoid costly recreation costs.
xref: https://github.com/kennethreitz/requests/issues/1882
"""
warnings.filterwarnings("ignore", category=ResourceWarning)
@pytest.fixture(scope="function")
def tmpdir(tmpdir, request):
tmpdir = TemporaryDirectory(dir=str(tmpdir))
request.addfinalizer(tmpdir.cleanup)
return py.path.local(tmpdir.name)
@pytest.fixture(autouse=True)
def clear_subdir_cache():
SubdirData.clear_cached_local_channel_data()
@pytest.fixture(scope="function")
def disable_channel_notices():
"""
Fixture that will set "context.number_channel_notices" to 0 and then set
it back to its original value.
This is also a good example of how to override values in the context object.
"""
yaml_str = dals(
"""
number_channel_notices: 0
"""
)
reset_context(())
rd = {
"testdata": YamlRawParameter.make_raw_parameters(
"testdata", yaml_round_trip_load(yaml_str)
)
}
context._set_raw_data(rd)
yield
reset_context(())
@pytest.fixture(scope="function")
def reset_conda_context():
"""Resets the context object after each test function is run."""
yield
reset_context()
@pytest.fixture()
def temp_package_cache(tmp_path_factory):
"""
Used to isolate package or index cache from other tests.
"""
pkgs_dir = tmp_path_factory.mktemp("pkgs")
with env_vars(
{"CONDA_PKGS_DIRS": str(pkgs_dir)}, stack_callback=conda_tests_ctxt_mgmt_def_pol
):
yield pkgs_dir
@pytest.fixture(
# allow CI to set the solver backends via the CONDA_TEST_SOLVERS env var
params=os.environ.get("CONDA_TEST_SOLVERS", "libmamba,classic").split(",")
)
def parametrized_solver_fixture(
request: FixtureRequest,
monkeypatch: MonkeyPatch,
) -> Iterable[Literal["libmamba", "classic"]]:
"""
A parameterized fixture that sets the solver backend to (1) libmamba
and (2) classic for each test. It's using autouse=True, so only import it in
modules that actually need it.
Note that skips and xfails need to be done _inside_ the test body.
Decorators can't be used because they are evaluated before the
fixture has done its work!
So, instead of:
@pytest.mark.skipif(context.solver == "libmamba", reason="...")
def test_foo():
...
Do:
def test_foo():
if context.solver == "libmamba":
pytest.skip("...")
...
"""
yield from _solver_helper(request, monkeypatch, request.param)
@pytest.fixture
def solver_classic(
request: FixtureRequest,
monkeypatch: MonkeyPatch,
) -> Iterable[Literal["classic"]]:
yield from _solver_helper(request, monkeypatch, "classic")
@pytest.fixture
def solver_libmamba(
request: FixtureRequest,
monkeypatch: MonkeyPatch,
) -> Iterable[Literal["libmamba"]]:
yield from _solver_helper(request, monkeypatch, "libmamba")
Solver = TypeVar("Solver", Literal["libmamba"], Literal["classic"])
def _solver_helper(
request: FixtureRequest,
monkeypatch: MonkeyPatch,
solver: Solver,
) -> Iterable[Solver]:
# clear cached solver backends before & after each test
context.plugin_manager.get_cached_solver_backend.cache_clear()
request.addfinalizer(context.plugin_manager.get_cached_solver_backend.cache_clear)
monkeypatch.setenv("CONDA_SOLVER", solver)
reset_context()
assert context.solver == solver
yield solver
@pytest.fixture(scope="session")
def session_capsys(request) -> Iterator[MultiCapture]:
# https://github.com/pytest-dev/pytest/issues/2704#issuecomment-603387680
capmanager = request.config.pluginmanager.getplugin("capturemanager")
with capmanager.global_and_fixture_disabled():
yield capmanager._global_capturing
@dataclass
class CondaCLIFixture:
capsys: CaptureFixture | MultiCapture
@overload
def __call__(
self,
*argv: str | os.PathLike | Path,
raises: type[Exception] | tuple[type[Exception], ...],
) -> tuple[str, str, ExceptionInfo]: ...
@overload
def __call__(self, *argv: str | os.PathLike | Path) -> tuple[str, str, int]: ...
def __call__(
self,
*argv: str | os.PathLike | Path,
raises: type[Exception] | tuple[type[Exception], ...] | None = None,
) -> tuple[str, str, int | ExceptionInfo]:
"""Test conda CLI. Mimic what is done in `conda.cli.main.main`.
`conda ...` == `conda_cli(...)`
:param argv: Arguments to parse.
:param raises: Expected exception to intercept. If provided, the raised exception
will be returned instead of exit code (see pytest.raises and pytest.ExceptionInfo).
:return: Command results (stdout, stderr, exit code or pytest.ExceptionInfo).
"""
# clear output
self.capsys.readouterr()
# ensure arguments are string
argv = tuple(map(str, argv))
# run command
code = None
with pytest.raises(raises) if raises else nullcontext() as exception:
code = main_subshell(*argv)
# capture output
out, err = self.capsys.readouterr()
# restore to prior state
reset_context()
return out, err, exception if raises else code
@pytest.fixture
def conda_cli(capsys: CaptureFixture) -> Iterator[CondaCLIFixture]:
"""A function scoped fixture returning CondaCLIFixture instance.
Use this for any commands that are local to the current test (e.g., creating a
conda environment only used in the test).
"""
yield CondaCLIFixture(capsys)
@pytest.fixture(scope="session")
def session_conda_cli(session_capsys: MultiCapture) -> Iterator[CondaCLIFixture]:
"""A session scoped fixture returning CondaCLIFixture instance.
Use this for any commands that are global to the test session (e.g., creating a
conda environment shared across tests, `conda info`, etc.).
"""
yield CondaCLIFixture(session_capsys)
@dataclass
class PathFactoryFixture:
tmp_path: Path
def __call__(
self,
name: str | None = None,
prefix: str | None = None,
suffix: str | None = None,
) -> Path:
"""Unique, non-existent path factory.
Extends pytest's `tmp_path` fixture with a new unique, non-existent path for usage in cases
where we need a temporary path that doesn't exist yet.
:param name: Path name to append to `tmp_path`
:param prefix: Prefix to prepend to unique name generated
:param suffix: Suffix to append to unique name generated
:return: A new unique path
"""
prefix = prefix or ""
name = name or uuid.uuid4().hex
suffix = suffix or ""
return self.tmp_path / (prefix + name + suffix)
@pytest.fixture
def path_factory(tmp_path: Path) -> Iterator[PathFactoryFixture]:
"""A function scoped fixture returning PathFactoryFixture instance.
Use this to generate any number of temporary paths for the test that are unique and
do not exist yet.
"""
yield PathFactoryFixture(tmp_path)
@dataclass
class TmpEnvFixture:
path_factory: PathFactoryFixture | TempPathFactory
conda_cli: CondaCLIFixture
def get_path(self) -> Path:
if isinstance(self.path_factory, PathFactoryFixture):
# scope=function
return self.path_factory()
else:
# scope=session
return self.path_factory.mktemp("tmp_env-")
@contextmanager
def __call__(
self,
*packages: str,
prefix: str | os.PathLike | None = None,
) -> Iterator[Path]:
"""Generate a conda environment with the provided packages.
:param packages: The packages to install into environment
:param prefix: The prefix at which to install the conda environment
:return: The conda environment's prefix
"""
prefix = Path(prefix or self.get_path())
self.conda_cli("create", "--prefix", prefix, *packages, "--yes", "--quiet")
yield prefix
# no need to remove prefix since it is in a temporary directory
@pytest.fixture
def tmp_env(
path_factory: PathFactoryFixture,
conda_cli: CondaCLIFixture,
) -> Iterator[TmpEnvFixture]:
"""A function scoped fixture returning TmpEnvFixture instance.
Use this when creating a conda environment that is local to the current test.
"""
yield TmpEnvFixture(path_factory, conda_cli)
@pytest.fixture(scope="session")
def session_tmp_env(
tmp_path_factory: TempPathFactory,
session_conda_cli: CondaCLIFixture,
) -> Iterator[TmpEnvFixture]:
"""A session scoped fixture returning TmpEnvFixture instance.
Use this when creating a conda environment that is shared across tests.
"""
yield TmpEnvFixture(tmp_path_factory, session_conda_cli)
@dataclass
class TmpChannelFixture:
path_factory: PathFactoryFixture
conda_cli: CondaCLIFixture
@contextmanager
def __call__(self, *packages: str) -> Iterator[tuple[Path, str]]:
# download packages
self.conda_cli(
"create",
f"--prefix={self.path_factory()}",
*packages,
"--yes",
"--quiet",
"--download-only",
raises=CondaExitZero,
)
pkgs_dir = Path(PackageCacheData.first_writable().pkgs_dir)
pkgs_cache = PackageCacheData(pkgs_dir)
channel = self.path_factory()
subdir = channel / context.subdir
subdir.mkdir(parents=True)
noarch = channel / "noarch"
noarch.mkdir(parents=True)
repodata = {"info": {}, "packages": {}}
for package in packages:
for pkg_data in pkgs_cache.query(package):
fname = pkg_data["fn"]
copyfile(pkgs_dir / fname, subdir / fname)
repodata["packages"][fname] = PackageRecord(
**{
field: value
for field, value in pkg_data.dump().items()
if field not in ("url", "channel", "schannel")
}
)
(subdir / "repodata.json").write_text(json.dumps(repodata, cls=EntityEncoder))
(noarch / "repodata.json").write_text(json.dumps({}, cls=EntityEncoder))
for package in packages:
assert any(PackageCacheData.query_all(package))
yield channel, path_to_url(str(channel))
@pytest.fixture
def tmp_channel(
path_factory: PathFactoryFixture,
conda_cli: CondaCLIFixture,
) -> Iterator[TmpChannelFixture]:
"""A function scoped fixture returning TmpChannelFixture instance."""
yield TmpChannelFixture(path_factory, conda_cli)
@pytest.fixture(name="monkeypatch")
def context_aware_monkeypatch(monkeypatch: MonkeyPatch) -> MonkeyPatch:
"""A monkeypatch fixture that resets context after each test."""
yield monkeypatch
# reset context if any CONDA_ variables were set/unset
if conda_vars := [
name
for obj, name, _ in monkeypatch._setitem
if obj is os.environ and name.startswith("CONDA_")
]:
log.debug(f"monkeypatch cleanup: undo & reset context: {', '.join(conda_vars)}")
monkeypatch.undo()
# reload context without search paths
reset_context([])
@pytest.fixture
def tmp_pkgs_dir(
path_factory: PathFactoryFixture, mocker: MockerFixture
) -> Iterator[Path]:
"""A function scoped fixture returning a temporary package cache directory."""
pkgs_dir = path_factory() / "pkgs"
pkgs_dir.mkdir(parents=True)
(pkgs_dir / PACKAGE_CACHE_MAGIC_FILE).touch()
mocker.patch(
"conda.base.context.Context.pkgs_dirs",
new_callable=mocker.PropertyMock,
return_value=(pkgs_dir_str := str(pkgs_dir),),
)
assert context.pkgs_dirs == (pkgs_dir_str,)
yield pkgs_dir
PackageCacheData._cache_.pop(pkgs_dir_str, None)
@pytest.fixture
def tmp_envs_dir(
path_factory: PathFactoryFixture, mocker: MockerFixture
) -> Iterator[Path]:
"""A function scoped fixture returning a temporary environment directory."""
envs_dir = path_factory() / "envs"
envs_dir.mkdir(parents=True)
mocker.patch(
"conda.base.context.Context.envs_dirs",
new_callable=mocker.PropertyMock,
return_value=(envs_dir_str := str(envs_dir),),
)
assert context.envs_dirs == (envs_dir_str,)
yield envs_dir
@pytest.fixture(scope="session", autouse=True)
def PYTHONPATH():
"""
We need to set this so Python loads the dev version of 'conda', usually taken
from `conda/` in the root of the cloned repo. This root is usually the working
directory when we run `pytest`.
Otherwise, it will import the one installed in the base environment, which might
have not been overwritten with `pip install -e . --no-deps`. This doesn't happen
in other tests because they run with the equivalent of `python -m conda`. However,
some tests directly run `conda (shell function) which calls `conda` (Python entry
point). When a script is called this way, it bypasses the automatic "working directory
is first on sys.path" behavior you find in `python -m` style calls. See
https://docs.python.org/3/library/sys_path_init.html for details.
"""
if "PYTHONPATH" in os.environ:
yield
else:
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setenv("PYTHONPATH", CONDA_SOURCE_ROOT)
yield