Skip to content

Commit 503010c

Browse files
dimblebyradoering
authored andcommitted
Fix complicated export case
By treating different python version ranges independently, we buy the flexibility needed to make better decisions.
1 parent 2e6f184 commit 503010c

File tree

5 files changed

+208
-38
lines changed

5 files changed

+208
-38
lines changed

poetry.lock

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ include = [
1717
[tool.poetry.dependencies]
1818
python = "^3.7"
1919
poetry = "^1.2.0b3"
20+
poetry-core = "^1.1.0b3"
2021

2122
[tool.poetry.dev-dependencies]
2223
pre-commit = "^2.18"
@@ -43,7 +44,11 @@ use_parentheses = true
4344
[tool.mypy]
4445
namespace_packages = true
4546
show_error_codes = true
46-
enable_error_code = ["ignore-without-code"]
47+
enable_error_code = [
48+
"ignore-without-code",
49+
"redundant-expr",
50+
"truthy-bool",
51+
]
4752
strict = true
4853
files = ["src", "tests"]
4954
exclude = ["^tests/fixtures/"]

src/poetry_plugin_export/walker.py

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

3-
from copy import deepcopy
43
from typing import TYPE_CHECKING
54

5+
from poetry.core.semver.util import constraint_regions
6+
from poetry.core.version.markers import AnyMarker
7+
from poetry.core.version.markers import SingleMarker
68
from poetry.packages import DependencyPackage
79
from poetry.utils.extras import get_extra_package_names
810

@@ -18,6 +20,33 @@
1820
from poetry.packages import Locker
1921

2022

23+
def get_python_version_region_markers(packages: list[Package]) -> list[BaseMarker]:
24+
markers = []
25+
26+
regions = constraint_regions([package.python_constraint for package in packages])
27+
for region in regions:
28+
marker: BaseMarker = AnyMarker()
29+
if region.min is not None:
30+
min_operator = ">=" if region.include_min else ">"
31+
marker_name = (
32+
"python_full_version" if region.min.precision > 2 else "python_version"
33+
)
34+
lo = SingleMarker(marker_name, f"{min_operator} {region.min}")
35+
marker = marker.intersect(lo)
36+
37+
if region.max is not None:
38+
max_operator = "<=" if region.include_max else "<"
39+
marker_name = (
40+
"python_full_version" if region.max.precision > 2 else "python_version"
41+
)
42+
hi = SingleMarker(marker_name, f"{max_operator} {region.max}")
43+
marker = marker.intersect(hi)
44+
45+
markers.append(marker)
46+
47+
return markers
48+
49+
2150
def get_project_dependency_packages(
2251
locker: Locker,
2352
project_requires: list[Dependency],
@@ -28,7 +57,7 @@ def get_project_dependency_packages(
2857
if project_python_marker is not None:
2958
marked_requires: list[Dependency] = []
3059
for require in project_requires:
31-
require = deepcopy(require)
60+
require = require.clone()
3261
require.marker = require.marker.intersect(project_python_marker)
3362
marked_requires.append(require)
3463
project_requires = marked_requires
@@ -136,12 +165,23 @@ def walk_dependencies(
136165
):
137166
continue
138167

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)
168+
base_marker = require.marker.intersect(requirement.marker.without_extras())
169+
170+
if not base_marker.is_empty():
171+
# So as to give ourselves enough flexibility in choosing a solution,
172+
# we need to split the world up into the python version ranges that
173+
# this package might care about.
174+
#
175+
# We create a marker for all of the possible regions, and add a
176+
# requirement for each separately.
177+
candidates = packages_by_name.get(require.name, [])
178+
region_markers = get_python_version_region_markers(candidates)
179+
for region_marker in region_markers:
180+
marker = region_marker.intersect(base_marker)
181+
if not marker.is_empty():
182+
require2 = require.clone()
183+
require2.marker = marker
184+
dependencies.append(require2)
145185

146186
key = locked_package
147187
if key not in nested_dependencies:
@@ -165,22 +205,38 @@ def get_locked_package(
165205
"""
166206
decided = decided or {}
167207

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-
]
208+
candidates = packages_by_name.get(dependency.name, [])
175209

176-
# If we've previously made a choice that is compatible with the current
177-
# requirement, stick with it.
178-
for package in packages:
210+
# If we've previously chosen a version of this package that is compatible with
211+
# the current requirement, we are forced to stick with it. (Else we end up with
212+
# different versions of the same package at the same time.)
213+
overlapping_candidates = set()
214+
for package in candidates:
179215
old_decision = decided.get(package)
180216
if (
181217
old_decision is not None
182218
and not old_decision.marker.intersect(dependency.marker).is_empty()
183219
):
184-
return package
220+
overlapping_candidates.add(package)
221+
222+
# If we have more than one overlapping candidate, we've run into trouble.
223+
if len(overlapping_candidates) > 1:
224+
return None
225+
226+
# Get the packages that are consistent with this dependency.
227+
compatible_candidates = [
228+
package
229+
for package in candidates
230+
if package.python_constraint.allows_all(dependency.python_constraint)
231+
and dependency.constraint.allows(package.version)
232+
]
233+
234+
# If we have an overlapping candidate, we must use it.
235+
if overlapping_candidates:
236+
compatible_candidates = [
237+
package
238+
for package in compatible_candidates
239+
if package in overlapping_candidates
240+
]
185241

186-
return next(iter(packages), None)
242+
return next(iter(compatible_candidates), None)

tests/markers.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,21 @@
88
MARKER_LINUX = parse_marker('sys_platform == "linux"')
99
MARKER_DARWIN = parse_marker('sys_platform == "darwin"')
1010

11+
MARKER_CPYTHON = parse_marker('implementation_name == "cpython"')
12+
1113
MARKER_PY27 = parse_marker('python_version >= "2.7" and python_version < "2.8"')
1214

1315
MARKER_PY36 = parse_marker('python_version >= "3.6" and python_version < "4.0"')
1416
MARKER_PY36_38 = parse_marker('python_version >= "3.6" and python_version < "3.8"')
17+
MARKER_PY36_PY362 = parse_marker(
18+
'python_version >= "3.6" and python_full_version < "3.6.2"'
19+
)
20+
MARKER_PY362_PY40 = parse_marker(
21+
'python_full_version >= "3.6.2" and python_version < "4.0"'
22+
)
1523
MARKER_PY36_ONLY = parse_marker('python_version >= "3.6" and python_version < "3.7"')
1624

1725
MARKER_PY37 = parse_marker('python_version >= "3.7" and python_version < "4.0"')
18-
MARKER_PY37_PY400 = parse_marker(
19-
'python_version >= "3.7" and python_full_version < "4.0.0"'
20-
)
2126

2227
MARKER_PY = MARKER_PY27.union(MARKER_PY36)
2328

tests/test_exporter.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@
1717
from poetry.repositories.legacy_repository import LegacyRepository
1818

1919
from poetry_plugin_export.exporter import Exporter
20+
from tests.markers import MARKER_CPYTHON
2021
from tests.markers import MARKER_PY
2122
from tests.markers import MARKER_PY27
2223
from tests.markers import MARKER_PY36
2324
from tests.markers import MARKER_PY36_38
2425
from tests.markers import MARKER_PY36_ONLY
26+
from tests.markers import MARKER_PY36_PY362
2527
from tests.markers import MARKER_PY37
28+
from tests.markers import MARKER_PY362_PY40
2629
from tests.markers import MARKER_PY_DARWIN
2730
from tests.markers import MARKER_PY_LINUX
2831
from tests.markers import MARKER_PY_WIN32
@@ -2019,10 +2022,10 @@ def test_exporter_doesnt_confuse_repeated_packages(
20192022
expected = f"""\
20202023
celery==5.1.2 ; {MARKER_PY36_ONLY}
20212024
celery==5.2.3 ; {MARKER_PY37}
2022-
click-didyoumean==0.0.3 ; {MARKER_PY36_ONLY}
2023-
click-didyoumean==0.3.0 ; {MARKER_PY37}
2024-
click-plugins==1.1.1 ; {MARKER_PY36_ONLY.union(MARKER_PY37)}
2025-
click==7.1.2 ; {MARKER_PY36_ONLY}
2025+
click-didyoumean==0.0.3 ; {MARKER_PY36_PY362}
2026+
click-didyoumean==0.3.0 ; {MARKER_PY362_PY40}
2027+
click-plugins==1.1.1 ; {MARKER_PY36}
2028+
click==7.1.2 ; python_version < "3.7" and python_version >= "3.6"
20262029
click==8.0.3 ; {MARKER_PY37}
20272030
"""
20282031

@@ -2141,6 +2144,107 @@ def test_exporter_handles_extras_next_to_non_extras(
21412144
assert io.fetch_output() == expected
21422145

21432146

2147+
def test_exporter_handles_overlapping_python_versions(
2148+
tmp_dir: str, poetry: Poetry
2149+
) -> None:
2150+
# Testcase derived from
2151+
# https://github.com/python-poetry/poetry-plugin-export/issues/32.
2152+
poetry.locker.mock_lock_data( # type: ignore[attr-defined]
2153+
{
2154+
"package": [
2155+
{
2156+
"name": "ipython",
2157+
"python-versions": ">=3.6",
2158+
"version": "7.16.3",
2159+
"category": "main",
2160+
"optional": False,
2161+
"dependencies": {},
2162+
},
2163+
{
2164+
"name": "ipython",
2165+
"python-versions": ">=3.7",
2166+
"version": "7.34.0",
2167+
"category": "main",
2168+
"optional": False,
2169+
"dependencies": {},
2170+
},
2171+
{
2172+
"name": "slash",
2173+
"python-versions": ">=3.6.*",
2174+
"version": "1.13.0",
2175+
"category": "main",
2176+
"optional": False,
2177+
"dependencies": {
2178+
"ipython": [
2179+
{
2180+
"version": "*",
2181+
"markers": (
2182+
'python_version >= "3.6" and implementation_name !='
2183+
' "pypy"'
2184+
),
2185+
},
2186+
{
2187+
"version": "<7.17.0",
2188+
"markers": (
2189+
'python_version < "3.6" and implementation_name !='
2190+
' "pypy"'
2191+
),
2192+
},
2193+
],
2194+
},
2195+
},
2196+
],
2197+
"metadata": {
2198+
"lock-version": "1.1",
2199+
"python-versions": "^3.6",
2200+
"content-hash": (
2201+
"832b13a88e5020c27cbcd95faa577bf0dbf054a65c023b45dc9442b640d414e6"
2202+
),
2203+
"hashes": {
2204+
"ipython": [],
2205+
"slash": [],
2206+
},
2207+
},
2208+
}
2209+
)
2210+
root = poetry.package.with_dependency_groups([], only=True)
2211+
root.python_versions = "^3.6"
2212+
root.add_dependency(
2213+
Factory.create_dependency(
2214+
name="ipython",
2215+
constraint={"version": "*", "python": "~3.6"},
2216+
)
2217+
)
2218+
root.add_dependency(
2219+
Factory.create_dependency(
2220+
name="ipython",
2221+
constraint={"version": "^7.17", "python": "^3.7"},
2222+
)
2223+
)
2224+
root.add_dependency(
2225+
Factory.create_dependency(
2226+
name="slash",
2227+
constraint={
2228+
"version": "^1.12",
2229+
"markers": "implementation_name == 'cpython'",
2230+
},
2231+
)
2232+
)
2233+
poetry._package = root
2234+
2235+
exporter = Exporter(poetry)
2236+
io = BufferedIO()
2237+
exporter.export("requirements.txt", Path(tmp_dir), io)
2238+
2239+
expected = f"""\
2240+
ipython==7.16.3 ; {MARKER_PY36_ONLY}
2241+
ipython==7.34.0 ; {MARKER_PY37}
2242+
slash==1.13.0 ; {MARKER_PY36} and {MARKER_CPYTHON}
2243+
"""
2244+
2245+
assert io.fetch_output() == expected
2246+
2247+
21442248
@pytest.mark.parametrize(
21452249
["with_extras", "expected"],
21462250
[

0 commit comments

Comments
 (0)