Skip to content

Commit d76d1ce

Browse files
committed
refactor poetry export
1 parent 31657e7 commit d76d1ce

File tree

4 files changed

+473
-190
lines changed

4 files changed

+473
-190
lines changed

src/poetry/packages/locker.py

Lines changed: 66 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
if TYPE_CHECKING:
3939
from tomlkit.toml_document import TOMLDocument
4040

41+
from poetry.core.version.markers import BaseMarker
4142
from poetry.repositories import Repository
4243

4344
logger = logging.getLogger(__name__)
@@ -204,102 +205,104 @@ def locked_repository(self, with_dev_reqs: bool = False) -> "Repository":
204205

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

218238
@classmethod
219-
def __walk_dependency_level(
239+
def __walk_dependencies(
220240
cls,
221241
dependencies: List[Dependency],
222-
level: int,
223-
pinned_versions: bool,
224242
packages_by_name: Dict[str, List[Package]],
225-
project_level_dependencies: Set[str],
226-
nested_dependencies: Dict[Tuple[str, str], Dependency],
227-
) -> Dict[Tuple[str, str], Dependency]:
228-
if not dependencies:
229-
return nested_dependencies
230-
231-
next_level_dependencies = []
232-
233-
for requirement in dependencies:
234-
key = (requirement.name, requirement.pretty_constraint)
235-
locked_package = cls.__get_locked_package(requirement, packages_by_name)
236-
237-
if locked_package:
238-
# create dependency from locked package to retain dependency metadata
239-
# if this is not done, we can end-up with incorrect nested dependencies
240-
constraint = requirement.constraint
241-
pretty_constraint = requirement.pretty_constraint
242-
marker = requirement.marker
243-
requirement = locked_package.to_dependency()
244-
requirement.marker = requirement.marker.intersect(marker)
243+
) -> Dict[Package, Dependency]:
244+
nested_dependencies: Dict[Package, Dependency] = {}
245245

246-
key = (requirement.name, pretty_constraint)
247-
248-
if not pinned_versions:
249-
requirement.set_constraint(constraint)
246+
visited: Set[Tuple[Dependency, "BaseMarker"]] = set()
247+
while dependencies:
248+
requirement = dependencies.pop(0)
249+
if (requirement, requirement.marker) in visited:
250+
continue
251+
visited.add((requirement, requirement.marker))
250252

251-
for require in locked_package.requires:
252-
if require.marker.is_empty():
253-
require.marker = requirement.marker
254-
else:
255-
require.marker = require.marker.intersect(requirement.marker)
253+
locked_package = cls.__get_locked_package(
254+
requirement, packages_by_name, nested_dependencies
255+
)
256256

257-
require.marker = require.marker.intersect(locked_package.marker)
257+
if not locked_package:
258+
# Should normally be able to satisfy all requirements, but this case is
259+
# permissible eg if we encounter a dev dependency when walking the
260+
# non-dev dependencies.
261+
continue
258262

259-
if key not in nested_dependencies:
260-
next_level_dependencies.append(require)
263+
# create dependency from locked package to retain dependency metadata
264+
# if this is not done, we can end-up with incorrect nested dependencies
265+
constraint = requirement.constraint
266+
marker = requirement.marker
267+
requirement = locked_package.to_dependency()
268+
requirement.marker = requirement.marker.intersect(marker)
261269

262-
if requirement.name in project_level_dependencies and level == 0:
263-
# project level dependencies take precedence
264-
continue
270+
requirement.set_constraint(constraint)
265271

266-
if not locked_package:
267-
# we make a copy to avoid any side-effects
268-
requirement = deepcopy(requirement)
272+
for require in locked_package.requires:
273+
require = deepcopy(require)
274+
require.marker = require.marker.intersect(requirement.marker)
275+
if not require.marker.is_empty():
276+
dependencies.append(require)
269277

278+
key = locked_package
270279
if key not in nested_dependencies:
271280
nested_dependencies[key] = requirement
272281
else:
273282
nested_dependencies[key].marker = nested_dependencies[key].marker.union(
274283
requirement.marker
275284
)
276285

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

286288
@classmethod
287289
def get_project_dependencies(
288290
cls,
289291
project_requires: List[Dependency],
290292
locked_packages: List[Package],
291-
pinned_versions: bool = False,
292-
with_nested: bool = False,
293-
) -> Iterable[Dependency]:
293+
) -> Iterable[Tuple[Package, Dependency]]:
294294
# group packages entries by name, this is required because requirement might use
295-
# different constraints
295+
# different constraints.
296296
packages_by_name = {}
297297
for pkg in locked_packages:
298298
if pkg.name not in packages_by_name:
299299
packages_by_name[pkg.name] = []
300300
packages_by_name[pkg.name].append(pkg)
301301

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

305308
for dependency in project_requires:
@@ -311,38 +314,18 @@ def get_project_dependencies(
311314
locked_package.marker
312315
)
313316

314-
if not pinned_versions:
315-
locked_dependency.set_constraint(dependency.constraint)
317+
locked_dependency.set_constraint(dependency.constraint)
316318

317319
dependency = locked_dependency
318320

319-
project_level_dependencies.add(dependency.name)
320321
dependencies.append(dependency)
321322

322-
if not with_nested:
323-
# return only with project level dependencies
324-
return dependencies
325-
326-
nested_dependencies = cls.__walk_dependency_level(
323+
nested_dependencies = cls.__walk_dependencies(
327324
dependencies=dependencies,
328-
level=0,
329-
pinned_versions=pinned_versions,
330325
packages_by_name=packages_by_name,
331-
project_level_dependencies=project_level_dependencies,
332-
nested_dependencies={},
333326
)
334327

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

347330
def get_project_dependency_packages(
348331
self,
@@ -382,16 +365,10 @@ def get_project_dependency_packages(
382365

383366
selected.append(dependency)
384367

385-
for dependency in self.get_project_dependencies(
368+
for package, dependency in self.get_project_dependencies(
386369
project_requires=selected,
387370
locked_packages=repository.packages,
388-
with_nested=True,
389371
):
390-
try:
391-
package = repository.find_packages(dependency=dependency)[0]
392-
except IndexError:
393-
continue
394-
395372
for extra in dependency.extras:
396373
package.requires_extras.append(extra)
397374

src/poetry/utils/exporter.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import itertools
21
import urllib.parse
32

3+
from copy import deepcopy
44
from typing import TYPE_CHECKING
55
from typing import Optional
66
from typing import Sequence
@@ -70,21 +70,25 @@ 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(
80+
root_package.python_marker
81+
)
82+
project_requires.append(require)
83+
84+
for dependency_package in self._poetry.locker.get_project_dependency_packages(
85+
project_requires=project_requires,
86+
dev=dev,
87+
extras=extras,
8088
):
8189
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
90+
dependency = dependency_package.dependency
91+
package = dependency_package.package
8892

8993
if package.develop:
9094
line += "-e "

tests/console/commands/test_export.py

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

8484
expected = """\
85-
foo==1.0.0
85+
foo==1.0.0 ;\
86+
python_version >= "2.7" and python_version < "2.8" or\
87+
python_version >= "3.4" and python_version < "4.0"
8688
"""
8789

8890
assert content == expected
@@ -111,7 +113,9 @@ def test_export_fails_on_invalid_format(tester: "CommandTester", do_lock: None):
111113
def test_export_prints_to_stdout_by_default(tester: "CommandTester", do_lock: None):
112114
tester.execute("--format requirements.txt")
113115
expected = """\
114-
foo==1.0.0
116+
foo==1.0.0 ;\
117+
python_version >= "2.7" and python_version < "2.8" or\
118+
python_version >= "3.4" and python_version < "4.0"
115119
"""
116120
assert tester.io.fetch_output() == expected
117121

@@ -121,16 +125,22 @@ def test_export_uses_requirements_txt_format_by_default(
121125
):
122126
tester.execute()
123127
expected = """\
124-
foo==1.0.0
128+
foo==1.0.0 ;\
129+
python_version >= "2.7" and python_version < "2.8" or\
130+
python_version >= "3.4" and python_version < "4.0"
125131
"""
126132
assert tester.io.fetch_output() == expected
127133

128134

129135
def test_export_includes_extras_by_flag(tester: "CommandTester", do_lock: None):
130136
tester.execute("--format requirements.txt --extras feature_bar")
131137
expected = """\
132-
bar==1.1.0
133-
foo==1.0.0
138+
bar==1.1.0 ;\
139+
python_version >= "2.7" and python_version < "2.8" or\
140+
python_version >= "3.4" and python_version < "4.0"
141+
foo==1.0.0 ;\
142+
python_version >= "2.7" and python_version < "2.8" or\
143+
python_version >= "3.4" and python_version < "4.0"
134144
"""
135145
assert tester.io.fetch_output() == expected
136146

0 commit comments

Comments
 (0)