diff --git a/README.md b/README.md index a028c8f6..9501f491 100644 --- a/README.md +++ b/README.md @@ -153,15 +153,36 @@ The default category is `main`. `conda-lock` can also lock the `dependencies.pip` section of [environment.yml][envyaml], using a vendored copy of [Poetry's][poetry] dependency solver. + ### private pip repositories -Right now `conda-lock` only supports [legacy](https://warehouse.pypa.io/api-reference/legacy.html) pypi repos with basic auth. Most self-hosted repositories like Nexus, Artifactory etc. use this. To use this feature, add your private repo into Poetry's config _including_ the basic auth in the url: -```bash -poetry config repositories.foo https://username:password@foo.repo/simple/ +Right now `conda-lock` only supports [legacy](https://warehouse.pypa.io/api-reference/legacy.html) pypi repos with basic auth. Most self-hosted repositories like Nexus, Artifactory etc. use this. You can configure private pip repositories in a similar way to channels, for example: + +```yaml +channels: + - conda-forge +pip-repositories: + - http://$PIP_USER:$PIP_PASSWORD@private-pypi.org/api/pypi/simple +dependencies: + - python=3.11 + - requests=2.26 + - pip: + - fake-private-package==1.0.0 ``` -The private repo will be used in addition to `pypi.org`. For projects using `pyproject.toml`, it is possible to [disable `pypi.org` entirely](#disabling-pypiorg). +See [the related docs for private channels](./docs/authenticated_channels.md#what_gets_stored) to understand the rules regarding environment variable substitution. + +Alternatively, you can use the `poetry` configuration file format to configure private PyPi repositories. The configuration file should be named `config.toml` and have the following format: + +```toml +[repositories.example] +url = "https://username:password@example.repo/simple" +``` + +The location of this file can be determined with `python -c 'from conda_lock._vendor.poetry.locations import CONFIG_DIR; print(CONFIG_DIR)'` + +Private repositories will be used in addition to `pypi.org`. For projects using `pyproject.toml`, it is possible to [disable `pypi.org` entirely](#disabling-pypiorg). ### --dev-dependencies/--no-dev-dependencies By default conda-lock will include dev dependencies in the specification of the lock (if the files that the lock diff --git a/conda_lock/conda_lock.py b/conda_lock/conda_lock.py index 239e909c..32231d4a 100644 --- a/conda_lock/conda_lock.py +++ b/conda_lock/conda_lock.py @@ -73,6 +73,7 @@ from conda_lock.lookup import set_lookup_location from conda_lock.models.channel import Channel from conda_lock.models.lock_spec import LockSpecification +from conda_lock.models.pip_repository import PipRepository from conda_lock.pypi_solver import solve_pypi from conda_lock.src_parser import make_lock_spec from conda_lock.virtual_package import ( @@ -715,6 +716,7 @@ def _solve_for_arch( spec: LockSpecification, platform: str, channels: List[Channel], + pip_repositories: List[PipRepository], update_spec: Optional[UpdateSpecification] = None, strip_auth: bool = False, ) -> List[LockedDependency]: @@ -756,6 +758,7 @@ def _solve_for_arch( conda_locked={dep.name: dep for dep in conda_deps.values()}, python_version=conda_deps["python"].version, platform=platform, + pip_repositories=pip_repositories, allow_pypi_requests=spec.allow_pypi_requests, strip_auth=strip_auth, ) @@ -820,6 +823,7 @@ def create_lockfile_from_spec( spec=spec, platform=platform, channels=[*spec.channels, virtual_package_channel], + pip_repositories=spec.pip_repositories, update_spec=update_spec, strip_auth=strip_auth, ) diff --git a/conda_lock/models/lock_spec.py b/conda_lock/models/lock_spec.py index c85c3e53..6448800b 100644 --- a/conda_lock/models/lock_spec.py +++ b/conda_lock/models/lock_spec.py @@ -5,11 +5,12 @@ from typing import Dict, List, Optional, Union -from pydantic import BaseModel, validator +from pydantic import BaseModel, Field, validator from typing_extensions import Literal from conda_lock.models import StrictModel from conda_lock.models.channel import Channel +from conda_lock.models.pip_repository import PipRepository from conda_lock.virtual_package import FakeRepoData @@ -61,6 +62,7 @@ class LockSpecification(BaseModel): # TODO: Should we store the auth info in here? channels: List[Channel] sources: List[pathlib.Path] + pip_repositories: List[PipRepository] = Field(default_factory=list) virtual_package_repo: Optional[FakeRepoData] = None allow_pypi_requests: bool = True @@ -84,6 +86,8 @@ def content_hash_for_platform(self, platform: str) -> str: ) ], } + if self.pip_repositories: + data["pip_repositories"] = [repo.json() for repo in self.pip_repositories] if self.virtual_package_repo is not None: vpr_data = self.virtual_package_repo.all_repodata data["virtual_package_hash"] = { @@ -103,3 +107,12 @@ def validate_channels(cls, v: List[Union[Channel, str]]) -> List[Channel]: if e.url == "nodefaults": raise ValueError("nodefaults channel is not allowed, ref #418") return typing.cast(List[Channel], v) + + @validator("pip_repositories", pre=True) + def validate_pip_repositories( + cls, value: List[Union[PipRepository, str]] + ) -> List[PipRepository]: + for index, repository in enumerate(value): + if isinstance(repository, str): + value[index] = PipRepository.from_string(repository) + return typing.cast(List[PipRepository], value) diff --git a/conda_lock/models/pip_repository.py b/conda_lock/models/pip_repository.py new file mode 100644 index 00000000..2bf498fd --- /dev/null +++ b/conda_lock/models/pip_repository.py @@ -0,0 +1,48 @@ +from hashlib import sha256 +from urllib.parse import urlparse, urlunparse + +from pydantic import BaseModel, ConfigDict + + +class PipRepository(BaseModel): + model_config = ConfigDict(frozen=True) + + url: str + + @classmethod + def from_string(cls, url: str) -> "PipRepository": + return PipRepository(url=url) + + @property + def base_url(self) -> str: + """The base URL of the pip repository, without a URL path.""" + full_url = urlparse(self.url) + return full_url.scheme + "://" + full_url.netloc + + @property + def stripped_base_url(self) -> str: + """The base URL of the pip repository, without any basic auth.""" + base_url = urlparse(self.base_url) + return urlunparse(base_url._replace(netloc=base_url.netloc.split("@", 1)[-1])) + + @property + def name(self) -> str: + """Poetry solver requires a name for each repository. + + We use this to match solver results back to their relevant + repository. + """ + sha = sha256() + sha.update(self.url.encode("utf-8")) + return sha.hexdigest() + + def normalize_solver_url(self, solver_url: str) -> str: + """Normalize the URL returned by Poetry's solver. + + Poetry doesn't return URLs with URL-based basic Auth, because it gets converted to + header-based Basic Auth. Because of this, we have to add the auth back in here. + """ + if not solver_url.startswith(self.stripped_base_url): + # The resolved package URL is at a different host to the repository + return solver_url + return solver_url.replace(self.stripped_base_url, self.base_url, 1) diff --git a/conda_lock/pypi_solver.py b/conda_lock/pypi_solver.py index 56b85d81..9fe7f742 100644 --- a/conda_lock/pypi_solver.py +++ b/conda_lock/pypi_solver.py @@ -2,8 +2,9 @@ import sys from pathlib import Path +from posixpath import expandvars from typing import TYPE_CHECKING, Dict, List, Optional -from urllib.parse import urldefrag, urlsplit, urlunsplit +from urllib.parse import urldefrag, urlparse, urlsplit, urlunsplit from clikit.api.io.flags import VERY_VERBOSE from clikit.io import ConsoleIO, NullIO @@ -32,6 +33,7 @@ ) from conda_lock.lookup import conda_name_to_pypi_name from conda_lock.models import lock_spec +from conda_lock.models.pip_repository import PipRepository if TYPE_CHECKING: @@ -188,6 +190,7 @@ def get_requirements( platform: str, pool: Pool, env: Env, + pip_repositories: Optional[List[PipRepository]] = None, strip_auth: bool = False, ) -> List[LockedDependency]: """Extract distributions from Poetry package plan, ignoring uninstalls @@ -196,10 +199,17 @@ def get_requirements( """ chooser = Chooser(pool, env=env) requirements: List[LockedDependency] = [] + + repositories_by_name = { + repository.name: repository for repository in pip_repositories or [] + } + for op in result: if not isinstance(op, Uninstall) and not op.skipped: # Take direct references verbatim source: Optional[DependencySource] = None + source_repository = repositories_by_name.get(op.package.source_reference) + if op.package.source_type == "url": url, fragment = urldefrag(op.package.source_url) hash_type, hash = fragment.split("=") @@ -221,6 +231,9 @@ def get_requirements( hashes[link.hash_name] = link.hash hash = HashModel.parse_obj(hashes) + if source_repository: + url = source_repository.normalize_solver_url(url) + requirements.append( LockedDependency( name=op.package.name, @@ -245,6 +258,7 @@ def solve_pypi( conda_locked: Dict[str, LockedDependency], python_version: str, platform: str, + pip_repositories: Optional[List[PipRepository]] = None, allow_pypi_requests: bool = True, verbose: bool = False, strip_auth: bool = False, @@ -283,7 +297,9 @@ def solve_pypi( for dep in dependencies: dummy_package.add_dependency(dep) - pool = _prepare_repositories_pool(allow_pypi_requests) + pool = _prepare_repositories_pool( + allow_pypi_requests, pip_repositories=pip_repositories + ) installed = Repository() locked = Repository() @@ -334,7 +350,14 @@ def solve_pypi( with s.use_environment(env): result = s.solve(use_latest=to_update) - requirements = get_requirements(result, platform, pool, env, strip_auth=strip_auth) + requirements = get_requirements( + result, + platform, + pool, + env, + pip_repositories=pip_repositories, + strip_auth=strip_auth, + ) # use PyPI names of conda packages to walking the dependency tree and propagate # categories from explicit to transitive dependencies @@ -361,7 +384,9 @@ def solve_pypi( return {dep.name: dep for dep in requirements} -def _prepare_repositories_pool(allow_pypi_requests: bool) -> Pool: +def _prepare_repositories_pool( + allow_pypi_requests: bool, pip_repositories: Optional[List[PipRepository]] = None +) -> Pool: """ Prepare the pool of repositories to solve pip dependencies @@ -373,6 +398,12 @@ def _prepare_repositories_pool(allow_pypi_requests: bool) -> Pool: factory = Factory() config = factory.create_config() repos = [ + factory.create_legacy_repository( + {"name": pip_repository.name, "url": expandvars(pip_repository.url)}, + config, + ) + for pip_repository in pip_repositories or [] + ] + [ factory.create_legacy_repository( {"name": source[0], "url": source[1]["url"]}, config ) diff --git a/conda_lock/src_parser/__init__.py b/conda_lock/src_parser/__init__.py index 1bb673b0..02889974 100644 --- a/conda_lock/src_parser/__init__.py +++ b/conda_lock/src_parser/__init__.py @@ -6,6 +6,7 @@ from conda_lock.common import ordered_union from conda_lock.models.channel import Channel from conda_lock.models.lock_spec import Dependency, LockSpecification +from conda_lock.models.pip_repository import PipRepository from conda_lock.src_parser.aggregation import aggregate_lock_specs from conda_lock.src_parser.environment_yaml import ( parse_environment_file, @@ -76,6 +77,7 @@ def make_lock_spec( src_files: List[pathlib.Path], virtual_package_repo: FakeRepoData, channel_overrides: Optional[Sequence[str]] = None, + pip_repository_overrides: Optional[Sequence[str]] = None, platform_overrides: Optional[Sequence[str]] = None, required_categories: Optional[AbstractSet[str]] = None, ) -> LockSpecification: @@ -98,6 +100,15 @@ def make_lock_spec( else aggregated_lock_spec.channels ) + pip_repositories = ( + [ + PipRepository.from_string(repo_override) + for repo_override in pip_repository_overrides + ] + if pip_repository_overrides + else aggregated_lock_spec.pip_repositories + ) + if required_categories is None: dependencies = aggregated_lock_spec.dependencies else: @@ -118,6 +129,7 @@ def dep_has_category(d: Dependency, categories: AbstractSet[str]) -> bool: return LockSpecification( dependencies=dependencies, channels=channels, + pip_repositories=pip_repositories, sources=aggregated_lock_spec.sources, virtual_package_repo=virtual_package_repo, allow_pypi_requests=aggregated_lock_spec.allow_pypi_requests, diff --git a/conda_lock/src_parser/aggregation.py b/conda_lock/src_parser/aggregation.py index 3a035ec8..d2b5349b 100644 --- a/conda_lock/src_parser/aggregation.py +++ b/conda_lock/src_parser/aggregation.py @@ -1,12 +1,13 @@ import logging from itertools import chain -from typing import Dict, List, Tuple +from typing import Dict, List, Tuple, TypeVar from conda_lock.common import ordered_union from conda_lock.errors import ChannelAggregationError from conda_lock.models.channel import Channel from conda_lock.models.lock_spec import Dependency, LockSpecification +from conda_lock.models.pip_repository import PipRepository logger = logging.getLogger(__name__) @@ -44,10 +45,20 @@ def aggregate_lock_specs( except ValueError as e: raise ChannelAggregationError(*e.args) + try: + # For discussion see + # + pip_repositories = unify_package_sources( + [lock_spec.pip_repositories for lock_spec in lock_specs] + ) + except ValueError as e: + raise ChannelAggregationError(*e.args) + return LockSpecification( dependencies=dependencies, # Ensure channel are correctly ordered channels=channels, + pip_repositories=pip_repositories, # uniquify metadata, preserving order sources=ordered_union(lock_spec.sources for lock_spec in lock_specs), allow_pypi_requests=all( @@ -56,7 +67,12 @@ def aggregate_lock_specs( ) -def unify_package_sources(collections: List[List[Channel]]) -> List[Channel]: +PackageSource = TypeVar("PackageSource", Channel, PipRepository) + + +def unify_package_sources( + collections: List[List[PackageSource]], +) -> List[PackageSource]: """Unify the package sources from multiple lock specs. To be able to merge the lock specs, the package sources must be compatible between diff --git a/conda_lock/src_parser/environment_yaml.py b/conda_lock/src_parser/environment_yaml.py index 7cbab58f..a331ed98 100644 --- a/conda_lock/src_parser/environment_yaml.py +++ b/conda_lock/src_parser/environment_yaml.py @@ -120,6 +120,8 @@ def parse_environment_file( except ValueError: pass + pip_repositories: List[str] = env_yaml_data.get("pip-repositories", []) + # These extension fields are nonstandard category: str = env_yaml_data.get("category") or "main" @@ -132,5 +134,6 @@ def parse_environment_file( return LockSpecification( dependencies=dep_map, channels=channels, # type: ignore + pip_repositories=pip_repositories, # type: ignore sources=[environment_file], ) diff --git a/conda_lock/src_parser/meta_yaml.py b/conda_lock/src_parser/meta_yaml.py index 5ebd8768..95a4bf74 100644 --- a/conda_lock/src_parser/meta_yaml.py +++ b/conda_lock/src_parser/meta_yaml.py @@ -109,6 +109,8 @@ def parse_meta_yaml_file( except ValueError: pass + pip_repositories = get_in(["extra", "pip-repositories"], meta_yaml_data, []) + # parse with selectors for each target platform dep_map = { platform: _parse_meta_yaml_file_for_platform(meta_yaml_file, platform) @@ -118,6 +120,7 @@ def parse_meta_yaml_file( return LockSpecification( dependencies=dep_map, channels=channels, + pip_repositories=pip_repositories, sources=[meta_yaml_file], ) diff --git a/conda_lock/src_parser/pyproject_toml.py b/conda_lock/src_parser/pyproject_toml.py index e30368c4..fcb23e1f 100644 --- a/conda_lock/src_parser/pyproject_toml.py +++ b/conda_lock/src_parser/pyproject_toml.py @@ -354,9 +354,14 @@ def specification_with_dependencies( except ValueError: pass + pip_repositories = get_in( + ["tool", "conda-lock", "pip-repositories"], toml_contents, [] + ) + return LockSpecification( dependencies={platform: dependencies for platform in platforms}, channels=channels, + pip_repositories=pip_repositories, sources=[path], allow_pypi_requests=get_in( ["tool", "conda-lock", "allow-pypi-requests"], toml_contents, True diff --git a/environments/dev-environment.yaml b/environments/dev-environment.yaml index 4fe18d87..34b7f9d7 100644 --- a/environments/dev-environment.yaml +++ b/environments/dev-environment.yaml @@ -29,3 +29,4 @@ dependencies: - docker-py - gitpython - pip +- requests-mock diff --git a/tests/test-pip-repositories/.gitignore b/tests/test-pip-repositories/.gitignore new file mode 100644 index 00000000..335ec957 --- /dev/null +++ b/tests/test-pip-repositories/.gitignore @@ -0,0 +1 @@ +*.tar.gz diff --git a/tests/test-pip-repositories/environment.yaml b/tests/test-pip-repositories/environment.yaml new file mode 100644 index 00000000..82c43fc0 --- /dev/null +++ b/tests/test-pip-repositories/environment.yaml @@ -0,0 +1,9 @@ +channels: + - conda-forge +pip-repositories: + - http://$PIP_USER:$PIP_PASSWORD@private-pypi.org/api/pypi/simple +dependencies: + - python=3.11 + - requests=2.26 + - pip: + - fake-private-package==1.0.0 diff --git a/tests/test-pip-repositories/fake-private-package-1.0.0/PKG-INFO b/tests/test-pip-repositories/fake-private-package-1.0.0/PKG-INFO new file mode 100644 index 00000000..a1c0e03d --- /dev/null +++ b/tests/test-pip-repositories/fake-private-package-1.0.0/PKG-INFO @@ -0,0 +1,3 @@ +Metadata-Version: 2.1 +Name: fake-private-package +Version: 1.0.0 diff --git a/tests/test-pip-repositories/fake-private-package-1.0.0/setup.cfg b/tests/test-pip-repositories/fake-private-package-1.0.0/setup.cfg new file mode 100644 index 00000000..96fadd5c --- /dev/null +++ b/tests/test-pip-repositories/fake-private-package-1.0.0/setup.cfg @@ -0,0 +1,3 @@ +[egg_info] +tag_build = +tag_date = 0 diff --git a/tests/test-pip-repositories/fake-private-package-1.0.0/setup.py b/tests/test-pip-repositories/fake-private-package-1.0.0/setup.py new file mode 100644 index 00000000..bd40909d --- /dev/null +++ b/tests/test-pip-repositories/fake-private-package-1.0.0/setup.py @@ -0,0 +1,7 @@ +from setuptools import setup + + +setup( + name="fake-private-package", + version="1.0.0", +) diff --git a/tests/test_conda_lock.py b/tests/test_conda_lock.py index afc13172..adab0dad 100644 --- a/tests/test_conda_lock.py +++ b/tests/test_conda_lock.py @@ -59,7 +59,8 @@ MetadataOption, ) from conda_lock.models.channel import Channel -from conda_lock.models.lock_spec import VCSDependency, VersionedDependency +from conda_lock.models.lock_spec import Dependency, VCSDependency, VersionedDependency +from conda_lock.models.pip_repository import PipRepository from conda_lock.pypi_solver import _strip_auth, parse_pip_requirement, solve_pypi from conda_lock.src_parser import ( DEFAULT_PLATFORMS, @@ -734,10 +735,10 @@ def test_parse_poetry_git(poetry_pyproject_toml_git: Path): res = parse_pyproject_toml(poetry_pyproject_toml_git, ["linux-64"]) specs = { - dep.name: typing.cast(VersionedDependency, dep) - for dep in res.dependencies["linux-64"] + dep.name: typing.cast(Dependency, dep) for dep in res.dependencies["linux-64"] } + assert isinstance(specs["pydantic"], VCSDependency) assert specs["pydantic"].vcs == "git" assert specs["pydantic"].rev == "v2.0b2" @@ -1648,6 +1649,33 @@ def test_aggregate_lock_specs_invalid_channels(): ) +def test_aggregate_lock_specs_invalid_pip_repos(): + """Ensure that aggregating specs from mismatched pip repo orderings raises an error.""" + repo_a = PipRepository.from_string("http://private-pypi-a.org/api/pypi/simple") + repo_b = PipRepository.from_string("http://private-pypi-b.org/api/pypi/simple") + base_spec = LockSpecification( + channels=[], + dependencies={}, + pip_repositories=[], + sources=[], + ) + + spec_a_b = base_spec.copy(update={"pip_repositories": [repo_a, repo_b]}) + agg_spec = aggregate_lock_specs([base_spec, spec_a_b, spec_a_b], platforms=[]) + assert agg_spec.pip_repositories == spec_a_b.pip_repositories + + # swap the order of the two repositories, which is an error + spec_b_a = base_spec.copy(update={"pip_repositories": [repo_b, repo_a]}) + with pytest.raises(ChannelAggregationError): + agg_spec = aggregate_lock_specs([base_spec, spec_a_b, spec_b_a], platforms=[]) + + # We can combine ["a"] with ["b", "a"], but not with ["a", "b"]. + spec_a = base_spec.copy(update={"pip_repositories": [repo_a]}) + aggregate_lock_specs([base_spec, spec_a, spec_b_a], platforms=[]) + with pytest.raises(ChannelAggregationError): + aggregate_lock_specs([base_spec, spec_a, spec_a_b], platforms=[]) + + def _check_package_installed(package: str, prefix: str): import glob diff --git a/tests/test_pip_repositories.py b/tests/test_pip_repositories.py new file mode 100644 index 00000000..2ec642cc --- /dev/null +++ b/tests/test_pip_repositories.py @@ -0,0 +1,172 @@ +import base64 +import os +import tarfile + +from io import BytesIO +from pathlib import Path +from typing import Optional, Tuple +from urllib.parse import urlparse + +import pytest +import requests +import requests_mock + +from conda_lock.conda_lock import DEFAULT_LOCKFILE_NAME, run_lock +from conda_lock.lockfile import parse_conda_lock_file +from tests.test_conda_lock import clone_test_dir + + +_PRIVATE_REPO_USERNAME = "secret-user" +_PRIVATE_REPO_PASSWORD = "secret-password" + +_PRIVATE_REPO_ROOT = """ + + + fake-private-package + + +""" + +_PRIVATE_REPO_PACKAGE = """ + + + fake-private-package-1.0.0.tar.gz + + +""" + +_PRIVATE_PACKAGE_SDIST_PATH = ( + Path(__file__).parent / "test-pip-repositories" / "fake-private-package-1.0.0" +) + + +@pytest.fixture(scope="module") +def private_package_tar(): + tar_path = _PRIVATE_PACKAGE_SDIST_PATH.parent / "fake-private-package-1.0.0.tar.gz" + with tarfile.open(tar_path, "w:gz") as tar: + tar.add( + _PRIVATE_PACKAGE_SDIST_PATH, + arcname=os.path.basename(_PRIVATE_PACKAGE_SDIST_PATH), + ) + try: + yield tar_path + finally: + os.remove(tar_path) + + +@pytest.fixture(autouse=True) +def mock_private_pypi(private_package_tar: Path): + with requests_mock.Mocker(real_http=True) as mocker: + + def _make_response( + request: requests.Request, + status: int, + headers: Optional[dict] = None, + text: str = "", + reason: str = "", + file: Optional[str] = None, + ) -> requests.Response: + headers = headers or {} + response = requests.Response() + response.status_code = status + for name, value in headers.items(): + response.headers[name] = value + if not file: + response.encoding = "utf-8" + response._content = text.encode(encoding=response.encoding) + response._content_consumed = True # type: ignore + else: + assert not text + response.headers.setdefault("Content-Type", "application/octet-stream") + response.raw = BytesIO() + with open(file, "rb") as file_handler: + response.raw.write(file_handler.read()) + response.raw.seek(0) + + url = urlparse(request.url) + response.url = request.url.replace(url.netloc, url.hostname) + response.reason = reason + return response + + def _parse_auth(request: requests.Request) -> Tuple[str, str]: + url = urlparse(request.url) + if url.username: + return url.username, url.password + header = request.headers.get("Authorization") + if not header or not header.startswith("Basic"): + return "", "" + username, password = ( + base64.b64decode(header.split()[-1]).decode("utf-8").split(":", 1) + ) + return username, password + + @mocker._adapter.add_matcher + def handle_request(request: requests.Request) -> Optional[requests.Response]: + url = urlparse(request.url) + if url.hostname != "private-pypi.org": + return None + username, password = _parse_auth(request) + if username != _PRIVATE_REPO_USERNAME or password != _PRIVATE_REPO_PASSWORD: + return _make_response(request, status=401, reason="Not authorized") + path = url.path.rstrip("/") + if path == "/api/pypi/simple": + return _make_response(request, status=200, text=_PRIVATE_REPO_ROOT) + if path == "/api/pypi/simple/fake-private-package": + return _make_response(request, status=200, text=_PRIVATE_REPO_PACKAGE) + if path == "/files/fake-private-package-1.0.0.tar.gz": + return _make_response( + request, status=200, file=str(private_package_tar) + ) + return _make_response(request, status=404, reason="Not Found") + + yield + + +@pytest.fixture(autouse=True) +def configure_auth(monkeypatch): + monkeypatch.setenv("PIP_USER", _PRIVATE_REPO_USERNAME) + monkeypatch.setenv("PIP_PASSWORD", _PRIVATE_REPO_PASSWORD) + + +def test_it_uses_pip_repositories_with_env_var_substitution( + monkeypatch: "pytest.MonkeyPatch", + conda_exe: str, + tmp_path: Path, +): + # GIVEN an environment.yaml with custom pip repositories + directory = clone_test_dir("test-pip-repositories", tmp_path) + monkeypatch.chdir(directory) + environment_file = directory / "environment.yaml" + assert environment_file.exists(), list(directory.iterdir()) + + # WHEN I create the lockfile + run_lock([directory / "environment.yaml"], conda_exe=conda_exe) + + # THEN the lockfile is generated correctly + lockfile_path = directory / DEFAULT_LOCKFILE_NAME + assert lockfile_path.exists(), list(directory.iterdir()) + lockfile = parse_conda_lock_file(lockfile_path) + lockfile_content = lockfile_path.read_text(encoding="utf-8") + packages = {package.name: package for package in lockfile.package} + + # AND the private package is in the lockfile + package = packages.get("fake-private-package") + assert package, lockfile_content + + package_url = urlparse(package.url) + + # AND the package was sourced from the private repository + assert package_url.hostname == "private-pypi.org", ( + "Package was fetched from incorrect host. See full lock-file:\n" + + lockfile_content + ) + + # AND environment variables are occluded + assert package_url.username == "$PIP_USER", ( + "User environment variable was not respected, See full lock-file:\n" + + lockfile_content + ) + assert package_url.password == "$PIP_PASSWORD", ( + "Password environment variable was not respected, See full lock-file:\n" + + lockfile_content + )