Skip to content

Commit 2e6f184

Browse files
radoeringdimbleby
andcommitted
move export code into export plugin
Co-authored-by: David Hotham <[email protected]>
1 parent 988d740 commit 2e6f184

File tree

2 files changed

+190
-1
lines changed

2 files changed

+190
-1
lines changed

src/poetry_plugin_export/exporter.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from poetry.core.packages.dependency_group import MAIN_GROUP
1010
from poetry.repositories.http import HTTPRepository
1111

12+
from poetry_plugin_export.walker import get_project_dependency_packages
13+
1214

1315
if TYPE_CHECKING:
1416
from pathlib import Path
@@ -80,7 +82,8 @@ def _export_requirements_txt(self, cwd: Path, output: IO | str) -> None:
8082
list(self._groups), only=True
8183
)
8284

83-
for dependency_package in self._poetry.locker.get_project_dependency_packages(
85+
for dependency_package in get_project_dependency_packages(
86+
self._poetry.locker,
8487
project_requires=root.all_requires,
8588
project_python_marker=root.python_marker,
8689
extras=self._extras,

src/poetry_plugin_export/walker.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
from __future__ import annotations
2+
3+
from copy import deepcopy
4+
from typing import TYPE_CHECKING
5+
6+
from poetry.packages import DependencyPackage
7+
from poetry.utils.extras import get_extra_package_names
8+
9+
10+
if TYPE_CHECKING:
11+
from collections.abc import Iterable
12+
from collections.abc import Iterator
13+
from collections.abc import Sequence
14+
15+
from poetry.core.packages.dependency import Dependency
16+
from poetry.core.packages.package import Package
17+
from poetry.core.version.markers import BaseMarker
18+
from poetry.packages import Locker
19+
20+
21+
def get_project_dependency_packages(
22+
locker: Locker,
23+
project_requires: list[Dependency],
24+
project_python_marker: BaseMarker | None = None,
25+
extras: bool | Sequence[str] | None = None,
26+
) -> Iterator[DependencyPackage]:
27+
# Apply the project python marker to all requirements.
28+
if project_python_marker is not None:
29+
marked_requires: list[Dependency] = []
30+
for require in project_requires:
31+
require = deepcopy(require)
32+
require.marker = require.marker.intersect(project_python_marker)
33+
marked_requires.append(require)
34+
project_requires = marked_requires
35+
36+
repository = locker.locked_repository()
37+
38+
# Build a set of all packages required by our selected extras
39+
extra_package_names: set[str] | None = None
40+
41+
if extras is not True:
42+
extra_package_names = set(
43+
get_extra_package_names(
44+
repository.packages,
45+
locker.lock_data.get("extras", {}),
46+
extras or (),
47+
)
48+
)
49+
50+
# If a package is optional and we haven't opted in to it, do not select
51+
selected = []
52+
for dependency in project_requires:
53+
try:
54+
package = repository.find_packages(dependency=dependency)[0]
55+
except IndexError:
56+
continue
57+
58+
if extra_package_names is not None and (
59+
package.optional and package.name not in extra_package_names
60+
):
61+
# a package is locked as optional, but is not activated via extras
62+
continue
63+
64+
selected.append(dependency)
65+
66+
for package, dependency in get_project_dependencies(
67+
project_requires=selected,
68+
locked_packages=repository.packages,
69+
):
70+
yield DependencyPackage(dependency=dependency, package=package)
71+
72+
73+
def get_project_dependencies(
74+
project_requires: list[Dependency],
75+
locked_packages: list[Package],
76+
) -> Iterable[tuple[Package, Dependency]]:
77+
# group packages entries by name, this is required because requirement might use
78+
# different constraints.
79+
packages_by_name: dict[str, list[Package]] = {}
80+
for pkg in locked_packages:
81+
if pkg.name not in packages_by_name:
82+
packages_by_name[pkg.name] = []
83+
packages_by_name[pkg.name].append(pkg)
84+
85+
# Put higher versions first so that we prefer them.
86+
for packages in packages_by_name.values():
87+
packages.sort(
88+
key=lambda package: package.version,
89+
reverse=True,
90+
)
91+
92+
nested_dependencies = walk_dependencies(
93+
dependencies=project_requires,
94+
packages_by_name=packages_by_name,
95+
)
96+
97+
return nested_dependencies.items()
98+
99+
100+
def walk_dependencies(
101+
dependencies: list[Dependency],
102+
packages_by_name: dict[str, list[Package]],
103+
) -> dict[Package, Dependency]:
104+
nested_dependencies: dict[Package, Dependency] = {}
105+
106+
visited: set[tuple[Dependency, BaseMarker]] = set()
107+
while dependencies:
108+
requirement = dependencies.pop(0)
109+
if (requirement, requirement.marker) in visited:
110+
continue
111+
visited.add((requirement, requirement.marker))
112+
113+
locked_package = get_locked_package(
114+
requirement, packages_by_name, nested_dependencies
115+
)
116+
117+
if not locked_package:
118+
raise RuntimeError(f"Dependency walk failed at {requirement}")
119+
120+
if requirement.extras:
121+
locked_package = locked_package.with_features(requirement.extras)
122+
123+
# create dependency from locked package to retain dependency metadata
124+
# if this is not done, we can end-up with incorrect nested dependencies
125+
constraint = requirement.constraint
126+
marker = requirement.marker
127+
requirement = locked_package.to_dependency()
128+
requirement.marker = requirement.marker.intersect(marker)
129+
130+
requirement.constraint = constraint
131+
132+
for require in locked_package.requires:
133+
if require.is_optional() and not any(
134+
require in locked_package.extras[feature]
135+
for feature in locked_package.features
136+
):
137+
continue
138+
139+
require = deepcopy(require)
140+
require.marker = require.marker.intersect(
141+
requirement.marker.without_extras()
142+
)
143+
if not require.marker.is_empty():
144+
dependencies.append(require)
145+
146+
key = locked_package
147+
if key not in nested_dependencies:
148+
nested_dependencies[key] = requirement
149+
else:
150+
nested_dependencies[key].marker = nested_dependencies[key].marker.union(
151+
requirement.marker
152+
)
153+
154+
return nested_dependencies
155+
156+
157+
def get_locked_package(
158+
dependency: Dependency,
159+
packages_by_name: dict[str, list[Package]],
160+
decided: dict[Package, Dependency] | None = None,
161+
) -> Package | None:
162+
"""
163+
Internal helper to identify corresponding locked package using dependency
164+
version constraints.
165+
"""
166+
decided = decided or {}
167+
168+
# Get the packages that are consistent with this dependency.
169+
packages = [
170+
package
171+
for package in packages_by_name.get(dependency.name, [])
172+
if package.python_constraint.allows_all(dependency.python_constraint)
173+
and dependency.constraint.allows(package.version)
174+
]
175+
176+
# If we've previously made a choice that is compatible with the current
177+
# requirement, stick with it.
178+
for package in packages:
179+
old_decision = decided.get(package)
180+
if (
181+
old_decision is not None
182+
and not old_decision.marker.intersect(dependency.marker).is_empty()
183+
):
184+
return package
185+
186+
return next(iter(packages), None)

0 commit comments

Comments
 (0)