From 10e2800d726f917eea8960d092fe8d5074253247 Mon Sep 17 00:00:00 2001 From: David Hotham Date: Mon, 7 Feb 2022 11:34:33 +0000 Subject: [PATCH] refactor poetry export --- src/poetry/packages/locker.py | 171 +++----- src/poetry/utils/exporter.py | 30 +- tests/console/commands/test_export.py | 20 +- tests/utils/test_exporter.py | 562 ++++++++++++++++++++++---- 4 files changed, 578 insertions(+), 205 deletions(-) diff --git a/src/poetry/packages/locker.py b/src/poetry/packages/locker.py index 775e16c8a3d..69aadd292e2 100644 --- a/src/poetry/packages/locker.py +++ b/src/poetry/packages/locker.py @@ -32,6 +32,7 @@ if TYPE_CHECKING: + from poetry.core.version.markers import BaseMarker from tomlkit.items import InlineTable from tomlkit.toml_document import TOMLDocument @@ -203,69 +204,80 @@ def locked_repository(self, with_dev_reqs: bool = False) -> Repository: @staticmethod def __get_locked_package( - _dependency: Dependency, packages_by_name: dict[str, list[Package]] + dependency: Dependency, + packages_by_name: dict[str, list[Package]], + decided: dict[Package, Dependency] | None = None, ) -> Package | None: """ Internal helper to identify corresponding locked package using dependency version constraints. """ - for _package in packages_by_name.get(_dependency.name, []): - if _dependency.constraint.allows(_package.version): - return _package - return None + decided = decided or {} + + # Get the packages that are consistent with this dependency. + packages = [ + package + for package in packages_by_name.get(dependency.name, []) + if package.python_constraint.allows_all(dependency.python_constraint) + and dependency.constraint.allows(package.version) + ] + + # If we've previously made a choice that is compatible with the current + # requirement, stick with it. + for package in packages: + old_decision = decided.get(package) + if ( + old_decision is not None + and not old_decision.marker.intersect(dependency.marker).is_empty() + ): + return package + + return next(iter(packages), None) @classmethod - def __walk_dependency_level( + def __walk_dependencies( cls, dependencies: list[Dependency], - level: int, - pinned_versions: bool, packages_by_name: dict[str, list[Package]], - project_level_dependencies: set[str], - nested_dependencies: dict[tuple[str, str], Dependency], - ) -> dict[tuple[str, str], Dependency]: - if not dependencies: - return nested_dependencies - - next_level_dependencies = [] + ) -> dict[Package, Dependency]: + nested_dependencies: dict[Package, Dependency] = {} - for requirement in dependencies: - key = (requirement.name, requirement.pretty_constraint) - locked_package = cls.__get_locked_package(requirement, packages_by_name) - - if locked_package: - # create dependency from locked package to retain dependency metadata - # if this is not done, we can end-up with incorrect nested dependencies - constraint = requirement.constraint - pretty_constraint = requirement.pretty_constraint - marker = requirement.marker - requirement = locked_package.to_dependency() - requirement.marker = requirement.marker.intersect(marker) - - key = (requirement.name, pretty_constraint) + visited: set[tuple[Dependency, BaseMarker]] = set() + while dependencies: + requirement = dependencies.pop(0) + if (requirement, requirement.marker) in visited: + continue + visited.add((requirement, requirement.marker)) - if not pinned_versions: - requirement.set_constraint(constraint) + locked_package = cls.__get_locked_package( + requirement, packages_by_name, nested_dependencies + ) - for require in locked_package.requires: - if require.marker.is_empty(): - require.marker = requirement.marker - else: - require.marker = require.marker.intersect(requirement.marker) + if not locked_package: + raise RuntimeError(f"Dependency walk failed at {requirement}") - require.marker = require.marker.intersect(locked_package.marker) + # create dependency from locked package to retain dependency metadata + # if this is not done, we can end-up with incorrect nested dependencies + constraint = requirement.constraint + marker = requirement.marker + extras = requirement.extras + requirement = locked_package.to_dependency() + requirement.marker = requirement.marker.intersect(marker) - if key not in nested_dependencies: - next_level_dependencies.append(require) + requirement.set_constraint(constraint) - if requirement.name in project_level_dependencies and level == 0: - # project level dependencies take precedence - continue + for require in locked_package.requires: + if require.in_extras and extras.isdisjoint(require.in_extras): + continue - if not locked_package: - # we make a copy to avoid any side-effects - requirement = deepcopy(requirement) + require = deepcopy(require) + require.marker = require.marker.intersect( + requirement.marker.without_extras() + ) + if not require.marker.is_empty(): + dependencies.append(require) + key = locked_package if key not in nested_dependencies: nested_dependencies[key] = requirement else: @@ -273,75 +285,32 @@ def __walk_dependency_level( requirement.marker ) - return cls.__walk_dependency_level( - dependencies=next_level_dependencies, - level=level + 1, - pinned_versions=pinned_versions, - packages_by_name=packages_by_name, - project_level_dependencies=project_level_dependencies, - nested_dependencies=nested_dependencies, - ) + return nested_dependencies @classmethod def get_project_dependencies( cls, project_requires: list[Dependency], locked_packages: list[Package], - pinned_versions: bool = False, - with_nested: bool = False, - ) -> Iterable[Dependency]: + ) -> Iterable[tuple[Package, Dependency]]: # group packages entries by name, this is required because requirement might use - # different constraints + # different constraints. packages_by_name: dict[str, list[Package]] = {} for pkg in locked_packages: if pkg.name not in packages_by_name: packages_by_name[pkg.name] = [] packages_by_name[pkg.name].append(pkg) - project_level_dependencies = set() - dependencies = [] + # Put higher versions first so that we prefer them. + for packages in packages_by_name.values(): + packages.sort(key=lambda package: package.version, reverse=True) - for dependency in project_requires: - dependency = deepcopy(dependency) - locked_package = cls.__get_locked_package(dependency, packages_by_name) - if locked_package: - locked_dependency = locked_package.to_dependency() - locked_dependency.marker = dependency.marker.intersect( - locked_package.marker - ) - - if not pinned_versions: - locked_dependency.set_constraint(dependency.constraint) - - dependency = locked_dependency - - project_level_dependencies.add(dependency.name) - dependencies.append(dependency) - - if not with_nested: - # return only with project level dependencies - return dependencies - - nested_dependencies = cls.__walk_dependency_level( - dependencies=dependencies, - level=0, - pinned_versions=pinned_versions, + nested_dependencies = cls.__walk_dependencies( + dependencies=project_requires, packages_by_name=packages_by_name, - project_level_dependencies=project_level_dependencies, - nested_dependencies={}, ) - # Merge same dependencies using marker union - for requirement in dependencies: - key = (requirement.name, requirement.pretty_constraint) - if key not in nested_dependencies: - nested_dependencies[key] = requirement - else: - nested_dependencies[key].marker = nested_dependencies[key].marker.union( - requirement.marker - ) - - return sorted(nested_dependencies.values(), key=lambda x: x.name.lower()) + return nested_dependencies.items() def get_project_dependency_packages( self, @@ -379,16 +348,10 @@ def get_project_dependency_packages( selected.append(dependency) - for dependency in self.get_project_dependencies( + for package, dependency in self.get_project_dependencies( project_requires=selected, locked_packages=repository.packages, - with_nested=True, ): - try: - package = repository.find_packages(dependency=dependency)[0] - except IndexError: - continue - for extra in dependency.extras: package.requires_extras.append(extra) diff --git a/src/poetry/utils/exporter.py b/src/poetry/utils/exporter.py index 147b930fec2..5d33f1a808f 100644 --- a/src/poetry/utils/exporter.py +++ b/src/poetry/utils/exporter.py @@ -1,8 +1,8 @@ from __future__ import annotations -import itertools import urllib.parse +from copy import deepcopy from typing import TYPE_CHECKING from typing import Sequence @@ -70,21 +70,23 @@ def _export_requirements_txt( content = "" dependency_lines = set() - for package, groups in itertools.groupby( - self._poetry.locker.get_project_dependency_packages( - project_requires=self._poetry.package.all_requires, - dev=dev, - extras=extras, - ), - lambda dependency_package: dependency_package.package, + # Get project dependencies, and add the project-wide marker to them. + groups = ["dev"] if dev else [] + root_package = self._poetry.package.with_dependency_groups(groups) + project_requires = [] + for require in root_package.all_requires: + require = deepcopy(require) + require.marker = require.marker.intersect(root_package.python_marker) + project_requires.append(require) + + for dependency_package in self._poetry.locker.get_project_dependency_packages( + project_requires=project_requires, + dev=dev, + extras=extras, ): line = "" - dependency_packages = list(groups) - dependency = dependency_packages[0].dependency - marker = dependency.marker - for dep_package in dependency_packages[1:]: - marker = marker.union(dep_package.dependency.marker) - dependency.marker = marker + dependency = dependency_package.dependency + package = dependency_package.package if package.develop: line += "-e " diff --git a/tests/console/commands/test_export.py b/tests/console/commands/test_export.py index c277ffa6b0c..6e704b5338a 100644 --- a/tests/console/commands/test_export.py +++ b/tests/console/commands/test_export.py @@ -84,7 +84,9 @@ def _export_requirements(tester: CommandTester, poetry: Poetry) -> None: assert poetry.locker.lock.exists() expected = """\ -foo==1.0.0 +foo==1.0.0 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.4" and python_version < "4.0" """ assert content == expected @@ -113,7 +115,9 @@ def test_export_fails_on_invalid_format(tester: CommandTester, do_lock: None): def test_export_prints_to_stdout_by_default(tester: CommandTester, do_lock: None): tester.execute("--format requirements.txt") expected = """\ -foo==1.0.0 +foo==1.0.0 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.4" and python_version < "4.0" """ assert tester.io.fetch_output() == expected @@ -123,7 +127,9 @@ def test_export_uses_requirements_txt_format_by_default( ): tester.execute() expected = """\ -foo==1.0.0 +foo==1.0.0 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.4" and python_version < "4.0" """ assert tester.io.fetch_output() == expected @@ -131,8 +137,12 @@ def test_export_uses_requirements_txt_format_by_default( def test_export_includes_extras_by_flag(tester: CommandTester, do_lock: None): tester.execute("--format requirements.txt --extras feature_bar") expected = """\ -bar==1.1.0 -foo==1.0.0 +bar==1.1.0 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.4" and python_version < "4.0" +foo==1.0.0 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.4" and python_version < "4.0" """ assert tester.io.fetch_output() == expected diff --git a/tests/utils/test_exporter.py b/tests/utils/test_exporter.py index 4bee492ebe4..1c1cd3f97d2 100644 --- a/tests/utils/test_exporter.py +++ b/tests/utils/test_exporter.py @@ -1,7 +1,6 @@ from __future__ import annotations import sys -import textwrap from pathlib import Path from typing import TYPE_CHECKING @@ -126,8 +125,12 @@ def test_exporter_can_export_requirements_txt_with_standard_packages( content = f.read() expected = """\ -bar==4.5.6 -foo==1.2.3 +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" """ assert content == expected @@ -181,9 +184,15 @@ def test_exporter_can_export_requirements_txt_with_standard_packages_and_markers content = f.read() expected = """\ -bar==4.5.6 -baz==7.8.9 ; sys_platform == "win32" -foo==1.2.3 ; python_version < "3.7" +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +baz==7.8.9 ;\ + python_version >= "2.7" and python_version < "2.8" and sys_platform == "win32" or\ + python_version >= "3.6" and python_version < "4.0" and sys_platform == "win32" +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "3.7" """ assert content == expected @@ -286,17 +295,29 @@ def test_exporter_can_export_requirements_txt_poetry(tmp_dir: str, poetry: Poetr # │ ├── cryptography >=2.0 # │ │ └── six >=1.4.1 # │ └── jeepney >=0.6 (circular dependency aborted here) + python27 = 'python_version >= "2.7" and python_version < "2.8"' + python36 = 'python_version >= "3.6" and python_version < "4.0"' + linux = 'sys_platform=="linux"' expected = { - "poetry": Dependency.create_from_pep_508("poetry==1.1.4"), - "junit-xml": Dependency.create_from_pep_508("junit-xml==1.9"), - "keyring": Dependency.create_from_pep_508("keyring==21.8.0"), + "poetry": Dependency.create_from_pep_508( + f"poetry==1.1.4; {python27} or {python36}" + ), + "junit-xml": Dependency.create_from_pep_508( + f"junit-xml==1.9 ; {python27} or {python36}" + ), + "keyring": Dependency.create_from_pep_508( + f"keyring==21.8.0 ; {python27} or {python36}" + ), "secretstorage": Dependency.create_from_pep_508( - "secretstorage==3.3.0 ; sys_platform=='linux'" + f"secretstorage==3.3.0 ; {python27} and {linux} or {python36} and {linux}" ), "cryptography": Dependency.create_from_pep_508( - "cryptography==3.2 ; sys_platform=='linux'" + f"cryptography==3.2 ; {python27} and {linux} or {python36} and {linux}" + ), + "six": Dependency.create_from_pep_508( + f"six==1.15.0 ; {python27} or {python36} or {python27} and {linux} or" + f" {python36} and {linux}" ), - "six": Dependency.create_from_pep_508("six==1.15.0"), } for line in content.strip().split("\n"): @@ -368,11 +389,19 @@ def test_exporter_can_export_requirements_txt_pyinstaller(tmp_dir: str, poetry: # ├── altgraph * dependencies into a single package. # ├── macholib >=1.8 -- only on Darwin # │ └── altgraph >=0.15 + python27 = 'python_version >= "2.7" and python_version < "2.8"' + python36 = 'python_version >= "3.6" and python_version < "4.0"' + darwin = 'sys_platform=="darwin"' expected = { - "pyinstaller": Dependency.create_from_pep_508("pyinstaller==4.0"), - "altgraph": Dependency.create_from_pep_508("altgraph==0.17"), + "pyinstaller": Dependency.create_from_pep_508( + f"pyinstaller==4.0 ; {python27} or {python36}" + ), + "altgraph": Dependency.create_from_pep_508( + f"altgraph==0.17 ; {python27} or {python36} or {python27} and {darwin} or" + f" {python36} and {darwin}" + ), "macholib": Dependency.create_from_pep_508( - "macholib==1.8 ; sys_platform == 'darwin'" + f"macholib==1.8 ; {python27} and {darwin} or {python36} and {darwin}" ), } @@ -441,17 +470,21 @@ def test_exporter_can_export_requirements_txt_with_nested_packages_and_markers( with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f: content = f.read() + python27 = 'python_version >= "2.7" and python_version < "2.8"' + python36 = 'python_version >= "3.6" and python_version < "3.7"' + windows = 'platform_system == "Windows"' + win32 = 'sys_platform == "win32"' expected = { - "a": Dependency.create_from_pep_508("a==1.2.3 ; python_version < '3.7'"), + "a": Dependency.create_from_pep_508(f"a==1.2.3 ; {python27} or {python36}"), "b": Dependency.create_from_pep_508( - "b==4.5.6 ; platform_system == 'Windows' and python_version < '3.7'" + f"b==4.5.6 ; {python27} and {windows} or {python36} and {windows}" ), "c": Dependency.create_from_pep_508( - "c==7.8.9 ; sys_platform == 'win32' and python_version < '3.7'" + f"c==7.8.9 ; {python27} and {win32} or {python36} and {win32}" ), "d": Dependency.create_from_pep_508( - "d==0.0.1 ; platform_system == 'Windows' and python_version < '3.7' or" - " sys_platform == 'win32' and python_version < '3.7'" + f"d==0.0.1 ; {python27} and {windows} or {python36} and {windows} or" + f" {python27} and {win32} or {python36} and {win32}" ), } @@ -467,7 +500,25 @@ def test_exporter_can_export_requirements_txt_with_nested_packages_and_markers( @pytest.mark.parametrize( ["dev", "lines"], - [(False, ['a==1.2.3 ; python_version < "3.8"']), (True, ["a==1.2.3", "b==4.5.6"])], + [ + ( + False, + [ + 'a==1.2.3 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "3.8"' + ], + ), + ( + True, + [ + 'a==1.2.3 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "3.8" or python_version' + ' >= "3.6" and python_version < "4.0"', + 'b==4.5.6 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + ], + ), + ], ) def test_exporter_can_export_requirements_txt_with_nested_packages_and_markers_any( tmp_dir: str, poetry: Poetry, dev: bool, lines: list[str] @@ -560,9 +611,13 @@ def test_exporter_can_export_requirements_txt_with_standard_packages_and_hashes( content = f.read() expected = """\ -bar==4.5.6 \\ +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:67890 -foo==1.2.3 \\ +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:12345 """ @@ -609,8 +664,12 @@ def test_exporter_can_export_requirements_txt_with_standard_packages_and_hashes_ content = f.read() expected = """\ -bar==4.5.6 -foo==1.2.3 +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" """ assert content == expected @@ -654,7 +713,9 @@ def test_exporter_exports_requirements_txt_without_dev_packages_by_default( content = f.read() expected = """\ -foo==1.2.3 \\ +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:12345 """ @@ -699,9 +760,13 @@ def test_exporter_exports_requirements_txt_with_dev_packages_if_opted_in( content = f.read() expected = """\ -bar==4.5.6 \\ +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:67890 -foo==1.2.3 \\ +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:12345 """ @@ -746,7 +811,9 @@ def test_exporter_exports_requirements_txt_without_optional_packages( content = f.read() expected = """\ -foo==1.2.3 \\ +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:12345 """ @@ -756,10 +823,42 @@ def test_exporter_exports_requirements_txt_without_optional_packages( @pytest.mark.parametrize( ["extras", "lines"], [ - (None, ["foo==1.2.3"]), - (False, ["foo==1.2.3"]), - (True, ["bar==4.5.6", "foo==1.2.3", "spam==0.1.0"]), - (["feature_bar"], ["bar==4.5.6", "foo==1.2.3", "spam==0.1.0"]), + ( + None, + [ + 'foo==1.2.3 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"' + ], + ), + ( + False, + [ + 'foo==1.2.3 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"' + ], + ), + ( + True, + [ + 'bar==4.5.6 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + 'foo==1.2.3 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + 'spam==0.1.0 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + ], + ), + ( + ["feature_bar"], + [ + 'bar==4.5.6 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + 'foo==1.2.3 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + 'spam==0.1.0 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + ], + ), ], ) def test_exporter_exports_requirements_txt_with_optional_packages( @@ -859,7 +958,9 @@ def test_exporter_can_export_requirements_txt_with_git_packages( content = f.read() expected = """\ -foo @ git+https://github.com/foo/foo.git@123456 +foo @ git+https://github.com/foo/foo.git@123456 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" """ assert content == expected @@ -909,8 +1010,12 @@ def test_exporter_can_export_requirements_txt_with_nested_packages( content = f.read() expected = """\ -bar==4.5.6 -foo @ git+https://github.com/foo/foo.git@123456 +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +foo @ git+https://github.com/foo/foo.git@123456 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" """ assert content == expected @@ -964,9 +1069,15 @@ def test_exporter_can_export_requirements_txt_with_nested_packages_cyclic( content = f.read() expected = """\ -bar==4.5.6 -baz==7.8.9 -foo==1.2.3 +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +baz==7.8.9 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" """ assert content == expected @@ -1036,26 +1147,21 @@ def test_exporter_can_export_requirements_txt_with_nested_packages_and_multiple_ with (Path(tmp_dir) / "requirements.txt").open(encoding="utf-8") as f: content = f.read() - expected = ( - # expectation for poetry-core <= 1.1.0a6 - textwrap.dedent( - """\ - bar==7.8.9 ; platform_system != "Windows" or platform_system == "Windows" - baz==10.11.13 ; platform_system == "Windows" - foo==1.2.3 - """ - ), - # expectation for poetry-core > 1.1.0a6 - textwrap.dedent( - """\ - bar==7.8.9 - baz==10.11.13 ; platform_system == "Windows" - foo==1.2.3 - """ - ), - ) + expected = """\ +bar==7.8.9 ;\ + python_version >= "2.7" and python_version < "2.8" and platform_system != "Windows" or\ + python_version >= "3.6" and python_version < "4.0" and platform_system != "Windows" or\ + python_version >= "2.7" and python_version < "2.8" and platform_system == "Windows" or\ + python_version >= "3.6" and python_version < "4.0" and platform_system == "Windows" +baz==10.11.13 ;\ + python_version >= "2.7" and python_version < "2.8" and platform_system == "Windows" or\ + python_version >= "3.6" and python_version < "4.0" and platform_system == "Windows" +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +""" - assert content in expected + assert content == expected def test_exporter_can_export_requirements_txt_with_git_packages_and_markers( @@ -1095,7 +1201,9 @@ def test_exporter_can_export_requirements_txt_with_git_packages_and_markers( content = f.read() expected = """\ -foo @ git+https://github.com/foo/foo.git@123456 ; python_version < "3.7" +foo @ git+https://github.com/foo/foo.git@123456 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "3.7" """ assert content == expected @@ -1137,7 +1245,9 @@ def test_exporter_can_export_requirements_txt_with_directory_packages( content = f.read() expected = f"""\ -foo @ {working_directory.as_uri()}/tests/fixtures/sample_project +foo @ {working_directory.as_uri()}/tests/fixtures/sample_project ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" """ assert content == expected @@ -1203,9 +1313,15 @@ def test_exporter_can_export_requirements_txt_with_nested_directory_packages( content = f.read() expected = f"""\ -bar @ {working_directory.as_uri()}/tests/fixtures/project_with_nested_local/bar -baz @ {working_directory.as_uri()}/tests/fixtures/project_with_nested_local -foo @ {working_directory.as_uri()}/tests/fixtures/sample_project +bar @ {working_directory.as_uri()}/tests/fixtures/project_with_nested_local/bar ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +baz @ {working_directory.as_uri()}/tests/fixtures/project_with_nested_local ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +foo @ {working_directory.as_uri()}/tests/fixtures/sample_project ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" """ assert content == expected @@ -1248,8 +1364,9 @@ def test_exporter_can_export_requirements_txt_with_directory_packages_and_marker content = f.read() expected = f"""\ -foo @ {working_directory.as_uri()}/tests/fixtures/sample_project\ - ; python_version < "3.7" +foo @ {working_directory.as_uri()}/tests/fixtures/sample_project ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "3.7" """ assert content == expected @@ -1291,7 +1408,9 @@ def test_exporter_can_export_requirements_txt_with_file_packages( content = f.read() expected = f"""\ -foo @ {working_directory.as_uri()}/tests/fixtures/distributions/demo-0.1.0.tar.gz +foo @ {working_directory.as_uri()}/tests/fixtures/distributions/demo-0.1.0.tar.gz ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" """ assert content == expected @@ -1334,8 +1453,9 @@ def test_exporter_can_export_requirements_txt_with_file_packages_and_markers( content = f.read() expected = f"""\ -foo @ {working_directory.as_uri()}/tests/fixtures/distributions/demo-0.1.0.tar.gz\ - ; python_version < "3.7" +foo @ {working_directory.as_uri()}/tests/fixtures/distributions/demo-0.1.0.tar.gz ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "3.7" """ assert content == expected @@ -1392,9 +1512,13 @@ def test_exporter_exports_requirements_txt_with_legacy_packages( expected = """\ --extra-index-url https://example.com/simple -bar==4.5.6 \\ +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:67890 -foo==1.2.3 \\ +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:12345 """ @@ -1450,9 +1574,13 @@ def test_exporter_exports_requirements_txt_with_url_false(tmp_dir: str, poetry: content = f.read() expected = """\ -bar==4.5.6 \\ +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:67890 -foo==1.2.3 \\ +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:12345 """ @@ -1503,7 +1631,9 @@ def test_exporter_exports_requirements_txt_with_legacy_packages_trusted_host( --trusted-host example.com --extra-index-url http://example.com/simple -bar==4.5.6 \\ +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:67890 """ @@ -1513,8 +1643,26 @@ def test_exporter_exports_requirements_txt_with_legacy_packages_trusted_host( @pytest.mark.parametrize( ["dev", "expected"], [ - (True, ["bar==1.2.2", "baz==1.2.3", "foo==1.2.1"]), - (False, ["bar==1.2.2", "foo==1.2.1"]), + ( + True, + [ + 'bar==1.2.2 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + 'baz==1.2.3 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + 'foo==1.2.1 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + ], + ), + ( + False, + [ + 'bar==1.2.2 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + 'foo==1.2.1 ; python_version >= "2.7" and python_version < "2.8" or' + ' python_version >= "3.6" and python_version < "4.0"', + ], + ), ], ) def test_exporter_exports_requirements_txt_with_dev_extras( @@ -1647,11 +1795,17 @@ def test_exporter_exports_requirements_txt_with_legacy_packages_and_duplicate_so --extra-index-url https://example.com/simple --extra-index-url https://foobaz.com/simple -bar==4.5.6 \\ +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:67890 -baz==7.8.9 \\ +baz==7.8.9 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:24680 -foo==1.2.3 \\ +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:12345 """ @@ -1718,9 +1872,13 @@ def test_exporter_exports_requirements_txt_with_legacy_packages_and_credentials( expected = """\ --extra-index-url https://foo:bar@example.com/simple -bar==4.5.6 \\ +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:67890 -foo==1.2.3 \\ +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" \\ --hash=sha256:12345 """ @@ -1763,8 +1921,248 @@ def test_exporter_exports_requirements_txt_to_standard_output( out, err = capsys.readouterr() expected = """\ -bar==4.5.6 -foo==1.2.3 +bar==4.5.6 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +foo==1.2.3 ;\ + python_version >= "2.7" and python_version < "2.8" or\ + python_version >= "3.6" and python_version < "4.0" +""" + + assert out == expected + + +def test_exporter_doesnt_confuse_repeated_packages( + tmp_dir: str, poetry: Poetry, capsys: CaptureFixture +): + # Testcase derived from . + poetry.locker.mock_lock_data( + { + "package": [ + { + "name": "celery", + "version": "5.1.2", + "category": "main", + "optional": False, + "python-versions": "<3.7", + "dependencies": { + "click": ">=7.0,<8.0", + "click-didyoumean": ">=0.0.3", + "click-plugins": ">=1.1.1", + }, + }, + { + "name": "celery", + "version": "5.2.3", + "category": "main", + "optional": False, + "python-versions": ">=3.7", + "dependencies": { + "click": ">=8.0.3,<9.0", + "click-didyoumean": ">=0.0.3", + "click-plugins": ">=1.1.1", + }, + }, + { + "name": "click", + "version": "7.1.2", + "category": "main", + "optional": False, + "python-versions": ( + ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + ), + }, + { + "name": "click", + "version": "8.0.3", + "category": "main", + "optional": False, + "python-versions": ">=3.6", + "dependencies": {}, + }, + { + "name": "click-didyoumean", + "version": "0.0.3", + "category": "main", + "optional": False, + "python-versions": "*", + "dependencies": {"click": "*"}, + }, + { + "name": "click-didyoumean", + "version": "0.3.0", + "category": "main", + "optional": False, + "python-versions": ">=3.6.2,<4.0.0", + "dependencies": {"click": ">=7"}, + }, + { + "name": "click-plugins", + "version": "1.1.1", + "category": "main", + "optional": False, + "python-versions": "*", + "dependencies": {"click": ">=4.0"}, + }, + ], + "metadata": { + "lock-version": "1.1", + "python-versions": "^3.6", + "content-hash": ( + "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" + ), + "hashes": { + "celery": [], + "click-didyoumean": [], + "click-plugins": [], + "click": [], + }, + }, + } + ) + root = poetry.package.with_dependency_groups([], only=True) + root.python_versions = "^3.6" + root.add_dependency( + Factory.create_dependency( + name="celery", constraint={"version": "5.1.2", "python": "<3.7"} + ) + ) + root.add_dependency( + Factory.create_dependency( + name="celery", constraint={"version": "5.2.3", "python": ">=3.7"} + ) + ) + poetry._package = root + + exporter = Exporter(poetry) + + exporter.export("requirements.txt", Path(tmp_dir), sys.stdout) + + out, err = capsys.readouterr() + expected = """\ +celery==5.1.2 ; python_version >= "3.6" and python_version < "3.7" +celery==5.2.3 ; python_version >= "3.7" and python_version < "4.0" +click-didyoumean==0.0.3 ; python_version >= "3.6" and python_version < "3.7" +click-didyoumean==0.3.0 ; python_version >= "3.7" and python_full_version < "4.0.0" +click-plugins==1.1.1 ;\ + python_version >= "3.6" and python_version < "3.7" or\ + python_version >= "3.7" and python_version < "4.0" +click==7.1.2 ; python_version >= "3.6" and python_version < "3.7" +click==8.0.3 ;\ + python_version >= "3.7" and python_version < "4.0" or\ + python_version >= "3.7" and python_full_version < "4.0.0" +""" + + assert out == expected + + +def test_exporter_handles_extras_next_to_non_extras( + tmp_dir: str, poetry: Poetry, capsys: CaptureFixture +): + # Testcase similar to the solver testcase added at #5305. + poetry.locker.mock_lock_data( + { + "package": [ + { + "name": "localstack", + "python-versions": "*", + "version": "1.0.0", + "category": "main", + "optional": False, + "dependencies": { + "localstack-ext": [ + {"version": ">=1.0.0"}, + { + "version": ">=1.0.0", + "extras": ["bar"], + "markers": 'extra == "foo"', + }, + ] + }, + "extras": {"foo": ["localstack-ext (>=1.0.0)"]}, + }, + { + "name": "localstack-ext", + "python-versions": "*", + "version": "1.0.0", + "category": "main", + "optional": False, + "dependencies": { + "something": "*", + "something-else": { + "version": ">=1.0.0", + "markers": 'extra == "bar"', + }, + "another-thing": { + "version": ">=1.0.0", + "markers": 'extra == "baz"', + }, + }, + "extras": { + "bar": ["something-else (>=1.0.0)"], + "baz": ["another-thing (>=1.0.0)"], + }, + }, + { + "name": "something", + "python-versions": "*", + "version": "1.0.0", + "category": "main", + "optional": False, + "dependencies": {}, + }, + { + "name": "something-else", + "python-versions": "*", + "version": "1.0.0", + "category": "main", + "optional": False, + "dependencies": {}, + }, + { + "name": "another-thing", + "python-versions": "*", + "version": "1.0.0", + "category": "main", + "optional": False, + "dependencies": {}, + }, + ], + "metadata": { + "lock-version": "1.1", + "python-versions": "^3.6", + "content-hash": ( + "832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6" + ), + "hashes": { + "localstack": [], + "localstack-ext": [], + "something": [], + "something-else": [], + "another-thing": [], + }, + }, + } + ) + root = poetry.package.with_dependency_groups([], only=True) + root.python_versions = "^3.6" + root.add_dependency( + Factory.create_dependency( + name="localstack", constraint={"version": "^1.0.0", "extras": ["foo"]} + ) + ) + poetry._package = root + + exporter = Exporter(poetry) + + exporter.export("requirements.txt", Path(tmp_dir), sys.stdout) + + out, err = capsys.readouterr() + expected = """\ +localstack-ext==1.0.0 ; python_version >= "3.6" and python_version < "4.0" +localstack==1.0.0 ; python_version >= "3.6" and python_version < "4.0" +something-else==1.0.0 ; python_version >= "3.6" and python_version < "4.0" +something==1.0.0 ; python_version >= "3.6" and python_version < "4.0" """ assert out == expected