Skip to content

Commit e57176e

Browse files
committed
refactor poetry export
1 parent 70e38a1 commit e57176e

4 files changed

Lines changed: 562 additions & 205 deletions

File tree

src/poetry/packages/locker.py

Lines changed: 67 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333

3434
if TYPE_CHECKING:
35+
from poetry.core.version.markers import BaseMarker
3536
from tomlkit.items import InlineTable
3637
from tomlkit.toml_document import TOMLDocument
3738

@@ -203,145 +204,113 @@ def locked_repository(self, with_dev_reqs: bool = False) -> Repository:
203204

204205
@staticmethod
205206
def __get_locked_package(
206-
_dependency: Dependency, packages_by_name: dict[str, list[Package]]
207+
dependency: Dependency,
208+
packages_by_name: dict[str, list[Package]],
209+
decided: dict[Package, Dependency] | None = None,
207210
) -> Package | None:
208211
"""
209212
Internal helper to identify corresponding locked package using dependency
210213
version constraints.
211214
"""
212-
for _package in packages_by_name.get(_dependency.name, []):
213-
if _dependency.constraint.allows(_package.version):
214-
return _package
215-
return None
215+
decided = decided or {}
216+
217+
# Get the packages that are consistent with this dependency.
218+
packages = [
219+
package
220+
for package in packages_by_name.get(dependency.name, [])
221+
if package.python_constraint.allows_all(dependency.python_constraint)
222+
and dependency.constraint.allows(package.version)
223+
]
224+
225+
# If we've previously made a choice that is compatible with the current
226+
# requirement, stick with it.
227+
for package in packages:
228+
old_decision = decided.get(package)
229+
if (
230+
old_decision is not None
231+
and not old_decision.marker.intersect(dependency.marker).is_empty()
232+
):
233+
return package
234+
235+
return next(iter(packages), None)
216236

217237
@classmethod
218-
def __walk_dependency_level(
238+
def __walk_dependencies(
219239
cls,
220240
dependencies: list[Dependency],
221-
level: int,
222-
pinned_versions: bool,
223241
packages_by_name: dict[str, list[Package]],
224-
project_level_dependencies: set[str],
225-
nested_dependencies: dict[tuple[str, str], Dependency],
226-
) -> dict[tuple[str, str], Dependency]:
227-
if not dependencies:
228-
return nested_dependencies
229-
230-
next_level_dependencies = []
242+
) -> dict[Package, Dependency]:
243+
nested_dependencies: dict[Package, Dependency] = {}
231244

232-
for requirement in dependencies:
233-
key = (requirement.name, requirement.pretty_constraint)
234-
locked_package = cls.__get_locked_package(requirement, packages_by_name)
235-
236-
if locked_package:
237-
# create dependency from locked package to retain dependency metadata
238-
# if this is not done, we can end-up with incorrect nested dependencies
239-
constraint = requirement.constraint
240-
pretty_constraint = requirement.pretty_constraint
241-
marker = requirement.marker
242-
requirement = locked_package.to_dependency()
243-
requirement.marker = requirement.marker.intersect(marker)
244-
245-
key = (requirement.name, pretty_constraint)
245+
visited: set[tuple[Dependency, BaseMarker]] = set()
246+
while dependencies:
247+
requirement = dependencies.pop(0)
248+
if (requirement, requirement.marker) in visited:
249+
continue
250+
visited.add((requirement, requirement.marker))
246251

247-
if not pinned_versions:
248-
requirement.set_constraint(constraint)
252+
locked_package = cls.__get_locked_package(
253+
requirement, packages_by_name, nested_dependencies
254+
)
249255

250-
for require in locked_package.requires:
251-
if require.marker.is_empty():
252-
require.marker = requirement.marker
253-
else:
254-
require.marker = require.marker.intersect(requirement.marker)
256+
if not locked_package:
257+
raise RuntimeError(f"Dependency walk failed at {requirement}")
255258

256-
require.marker = require.marker.intersect(locked_package.marker)
259+
# create dependency from locked package to retain dependency metadata
260+
# if this is not done, we can end-up with incorrect nested dependencies
261+
constraint = requirement.constraint
262+
marker = requirement.marker
263+
extras = requirement.extras
264+
requirement = locked_package.to_dependency()
265+
requirement.marker = requirement.marker.intersect(marker)
257266

258-
if key not in nested_dependencies:
259-
next_level_dependencies.append(require)
267+
requirement.set_constraint(constraint)
260268

261-
if requirement.name in project_level_dependencies and level == 0:
262-
# project level dependencies take precedence
263-
continue
269+
for require in locked_package.requires:
270+
if require.in_extras and extras.isdisjoint(require.in_extras):
271+
continue
264272

265-
if not locked_package:
266-
# we make a copy to avoid any side-effects
267-
requirement = deepcopy(requirement)
273+
require = deepcopy(require)
274+
require.marker = require.marker.intersect(
275+
requirement.marker.without_extras()
276+
)
277+
if not require.marker.is_empty():
278+
dependencies.append(require)
268279

280+
key = locked_package
269281
if key not in nested_dependencies:
270282
nested_dependencies[key] = requirement
271283
else:
272284
nested_dependencies[key].marker = nested_dependencies[key].marker.union(
273285
requirement.marker
274286
)
275287

276-
return cls.__walk_dependency_level(
277-
dependencies=next_level_dependencies,
278-
level=level + 1,
279-
pinned_versions=pinned_versions,
280-
packages_by_name=packages_by_name,
281-
project_level_dependencies=project_level_dependencies,
282-
nested_dependencies=nested_dependencies,
283-
)
288+
return nested_dependencies
284289

285290
@classmethod
286291
def get_project_dependencies(
287292
cls,
288293
project_requires: list[Dependency],
289294
locked_packages: list[Package],
290-
pinned_versions: bool = False,
291-
with_nested: bool = False,
292-
) -> Iterable[Dependency]:
295+
) -> Iterable[tuple[Package, Dependency]]:
293296
# group packages entries by name, this is required because requirement might use
294-
# different constraints
297+
# different constraints.
295298
packages_by_name: dict[str, list[Package]] = {}
296299
for pkg in locked_packages:
297300
if pkg.name not in packages_by_name:
298301
packages_by_name[pkg.name] = []
299302
packages_by_name[pkg.name].append(pkg)
300303

301-
project_level_dependencies = set()
302-
dependencies = []
304+
# Put higher versions first so that we prefer them.
305+
for packages in packages_by_name.values():
306+
packages.sort(key=lambda package: package.version, reverse=True)
303307

304-
for dependency in project_requires:
305-
dependency = deepcopy(dependency)
306-
locked_package = cls.__get_locked_package(dependency, packages_by_name)
307-
if locked_package:
308-
locked_dependency = locked_package.to_dependency()
309-
locked_dependency.marker = dependency.marker.intersect(
310-
locked_package.marker
311-
)
312-
313-
if not pinned_versions:
314-
locked_dependency.set_constraint(dependency.constraint)
315-
316-
dependency = locked_dependency
317-
318-
project_level_dependencies.add(dependency.name)
319-
dependencies.append(dependency)
320-
321-
if not with_nested:
322-
# return only with project level dependencies
323-
return dependencies
324-
325-
nested_dependencies = cls.__walk_dependency_level(
326-
dependencies=dependencies,
327-
level=0,
328-
pinned_versions=pinned_versions,
308+
nested_dependencies = cls.__walk_dependencies(
309+
dependencies=project_requires,
329310
packages_by_name=packages_by_name,
330-
project_level_dependencies=project_level_dependencies,
331-
nested_dependencies={},
332311
)
333312

334-
# Merge same dependencies using marker union
335-
for requirement in dependencies:
336-
key = (requirement.name, requirement.pretty_constraint)
337-
if key not in nested_dependencies:
338-
nested_dependencies[key] = requirement
339-
else:
340-
nested_dependencies[key].marker = nested_dependencies[key].marker.union(
341-
requirement.marker
342-
)
343-
344-
return sorted(nested_dependencies.values(), key=lambda x: x.name.lower())
313+
return nested_dependencies.items()
345314

346315
def get_project_dependency_packages(
347316
self,
@@ -379,16 +348,10 @@ def get_project_dependency_packages(
379348

380349
selected.append(dependency)
381350

382-
for dependency in self.get_project_dependencies(
351+
for package, dependency in self.get_project_dependencies(
383352
project_requires=selected,
384353
locked_packages=repository.packages,
385-
with_nested=True,
386354
):
387-
try:
388-
package = repository.find_packages(dependency=dependency)[0]
389-
except IndexError:
390-
continue
391-
392355
for extra in dependency.extras:
393356
package.requires_extras.append(extra)
394357

src/poetry/utils/exporter.py

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from __future__ import annotations
22

3-
import itertools
43
import urllib.parse
54

5+
from copy import deepcopy
66
from typing import TYPE_CHECKING
77
from typing import Sequence
88

@@ -70,21 +70,23 @@ def _export_requirements_txt(
7070
content = ""
7171
dependency_lines = set()
7272

73-
for package, groups in itertools.groupby(
74-
self._poetry.locker.get_project_dependency_packages(
75-
project_requires=self._poetry.package.all_requires,
76-
dev=dev,
77-
extras=extras,
78-
),
79-
lambda dependency_package: dependency_package.package,
73+
# Get project dependencies, and add the project-wide marker to them.
74+
groups = ["dev"] if dev else []
75+
root_package = self._poetry.package.with_dependency_groups(groups)
76+
project_requires = []
77+
for require in root_package.all_requires:
78+
require = deepcopy(require)
79+
require.marker = require.marker.intersect(root_package.python_marker)
80+
project_requires.append(require)
81+
82+
for dependency_package in self._poetry.locker.get_project_dependency_packages(
83+
project_requires=project_requires,
84+
dev=dev,
85+
extras=extras,
8086
):
8187
line = ""
82-
dependency_packages = list(groups)
83-
dependency = dependency_packages[0].dependency
84-
marker = dependency.marker
85-
for dep_package in dependency_packages[1:]:
86-
marker = marker.union(dep_package.dependency.marker)
87-
dependency.marker = marker
88+
dependency = dependency_package.dependency
89+
package = dependency_package.package
8890

8991
if package.develop:
9092
line += "-e "

tests/console/commands/test_export.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,9 @@ def _export_requirements(tester: CommandTester, poetry: Poetry) -> None:
8484
assert poetry.locker.lock.exists()
8585

8686
expected = """\
87-
foo==1.0.0
87+
foo==1.0.0 ;\
88+
python_version >= "2.7" and python_version < "2.8" or\
89+
python_version >= "3.4" and python_version < "4.0"
8890
"""
8991

9092
assert content == expected
@@ -113,7 +115,9 @@ def test_export_fails_on_invalid_format(tester: CommandTester, do_lock: None):
113115
def test_export_prints_to_stdout_by_default(tester: CommandTester, do_lock: None):
114116
tester.execute("--format requirements.txt")
115117
expected = """\
116-
foo==1.0.0
118+
foo==1.0.0 ;\
119+
python_version >= "2.7" and python_version < "2.8" or\
120+
python_version >= "3.4" and python_version < "4.0"
117121
"""
118122
assert tester.io.fetch_output() == expected
119123

@@ -123,16 +127,22 @@ def test_export_uses_requirements_txt_format_by_default(
123127
):
124128
tester.execute()
125129
expected = """\
126-
foo==1.0.0
130+
foo==1.0.0 ;\
131+
python_version >= "2.7" and python_version < "2.8" or\
132+
python_version >= "3.4" and python_version < "4.0"
127133
"""
128134
assert tester.io.fetch_output() == expected
129135

130136

131137
def test_export_includes_extras_by_flag(tester: CommandTester, do_lock: None):
132138
tester.execute("--format requirements.txt --extras feature_bar")
133139
expected = """\
134-
bar==1.1.0
135-
foo==1.0.0
140+
bar==1.1.0 ;\
141+
python_version >= "2.7" and python_version < "2.8" or\
142+
python_version >= "3.4" and python_version < "4.0"
143+
foo==1.0.0 ;\
144+
python_version >= "2.7" and python_version < "2.8" or\
145+
python_version >= "3.4" and python_version < "4.0"
136146
"""
137147
assert tester.io.fetch_output() == expected
138148

0 commit comments

Comments
 (0)