Skip to content

Commit a241cb5

Browse files
committed
Add a solver from Rust dependency specifier to Python packaging lib
Signed-off-by: Jorge J. Perez <jjperez@ekumenlabs.com>
1 parent f232bee commit a241cb5

2 files changed

Lines changed: 266 additions & 0 deletions

File tree

pallet_patcher/solver.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from packaging.version import Version
2+
from packaging.specifiers import SpecifierSet
3+
4+
# I heavily relied on AI to help with this one.
5+
# I added each case by iterating on the ones that throw me errors while debugging
6+
# We can add tests to make sure it handles most common Rust cases
7+
# (or at least the ones used right now
8+
def _parse_rust_specifier(spec_str: str) -> SpecifierSet:
9+
clean_spec = spec_str.strip()
10+
11+
# 1. Handle "Explicit Equals" (=1.2.3 -> ==1.2.3)
12+
if clean_spec.startswith("=") and not clean_spec.startswith("=="):
13+
return SpecifierSet(f"=={clean_spec[1:]}")
14+
15+
# 2. Handle Tilde (~1.2.3) - Minimal update
16+
if clean_spec.startswith("~"):
17+
version_part = clean_spec.lstrip("~")
18+
parts = version_part.split('.')
19+
try:
20+
if len(parts) >= 2:
21+
major, minor = int(parts[0]), int(parts[1])
22+
return SpecifierSet(f">={version_part},<{major}.{minor + 1}.0")
23+
elif len(parts) == 1:
24+
major = int(parts[0])
25+
return SpecifierSet(f">={version_part},<{major + 1}.0.0")
26+
except ValueError:
27+
pass
28+
29+
# 3. Handle Caret (^1.2.3) - Maximal update (Compatible)
30+
# Rust: ^1.2.3 is the same as 1.2.3 (it's the default)
31+
# We strip the caret and let it fall through to the "Bare" logic below.
32+
if clean_spec.startswith("^"):
33+
clean_spec = clean_spec[1:]
34+
35+
# 4. Handle "Bare" / Caret versions
36+
if clean_spec and clean_spec[0].isdigit():
37+
parts = clean_spec.split('.')
38+
try:
39+
major = int(parts[0])
40+
41+
# Case A: Major > 0 (e.g. ^1.2.3) -> Lock Major
42+
if major > 0:
43+
return SpecifierSet(f">={clean_spec},<{major + 1}.0.0")
44+
45+
# Case B: Major is 0
46+
if major == 0:
47+
# Case B.1: Single digit (^0) -> Allow 0.x.x
48+
if len(parts) == 1:
49+
return SpecifierSet(f">={clean_spec},<1.0.0")
50+
51+
minor = int(parts[1])
52+
53+
# Case B.2: Major 0, Minor > 0 (e.g. ^0.2.3) -> Lock Minor
54+
if minor > 0:
55+
return SpecifierSet(f">={clean_spec},<0.{minor + 1}.0")
56+
57+
# Case B.3: Major 0, Minor 0 (e.g. ^0.0.3) -> Lock Patch
58+
# In Rust, 0.0.x changes are always breaking.
59+
elif minor == 0 and len(parts) > 2:
60+
patch = int(parts[2])
61+
return SpecifierSet(f">={clean_spec},<0.0.{patch + 1}")
62+
63+
# Case B.4: ^0.0 (Implies 0.0.x)
64+
elif minor == 0:
65+
return SpecifierSet(f">={clean_spec},<0.1.0")
66+
67+
except ValueError:
68+
pass # Fallback to standard handling if parsing fails
69+
70+
# Fallback for standard Python specifiers (>=1.2, etc.)
71+
return SpecifierSet(clean_spec)
72+
73+
74+
def solve_dependency(version_specifier, available_versions):
75+
"""
76+
Find if one version available in our versions_dict matches the expected
77+
version_specifier provided.
78+
79+
:param version_spec: Specifier for the version we want to match.
80+
:type version_spec: str
81+
82+
:param available_versions: List of versions available.
83+
:type available_versions: str
84+
85+
:returns: matched version string, or None if available versions don't match the spec
86+
:rtype: dict
87+
"""
88+
spec = _parse_rust_specifier(version_specifier)
89+
90+
# We sort them first to prioritize higher versions for the packages
91+
sorted_versions = sorted(
92+
available_versions,
93+
key=lambda x: [int(part) for part in x.split('.')],
94+
reverse=True
95+
)
96+
97+
# Iterate over the sorted list
98+
for version in sorted_versions:
99+
v = Version(version)
100+
# print(v, spec)
101+
if v in spec:
102+
return str(v)
103+
104+
return None

test/test_solver.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import pytest
2+
from pallet_patcher.solver import _parse_rust_specifier, solve_dependency
3+
4+
# Test code generated with LLM help
5+
@pytest.mark.parametrize("rust_input, expected_matches, expected_non_matches", [
6+
# 1. Explicit Equals (=)
7+
("=1.2.3", ["1.2.3"], ["1.2.4", "1.2.2"]),
8+
9+
# 2. Tilde Requirements (~)
10+
# ~1.2.3 := >=1.2.3, <1.3.0
11+
("~1.2.3", ["1.2.3", "1.2.9"], ["1.3.0", "1.1.0"]),
12+
# ~1.2 := >=1.2, <1.3.0
13+
("~1.2", ["1.2.0", "1.2.5"], ["1.3.0", "1.1.0"]),
14+
# ~1 := >=1, <2.0.0
15+
("~1", ["1.0.0", "1.5.0", "1.9.9"], ["2.0.0", "0.9.0"]),
16+
17+
# 3. Caret Requirements (^) - Major > 0
18+
# ^1.2.3 := >=1.2.3, <2.0.0
19+
("^1.2.3", ["1.2.3", "1.9.9"], ["2.0.0", "1.2.2"]),
20+
# Bare 1.2.3 (same as ^)
21+
("1.2.3", ["1.2.3", "1.5.0", "1.9.9"], ["2.0.0", "1.2.2"]),
22+
23+
# 4. Caret Requirements (^) - Major == 0 (The tricky ones)
24+
25+
# Case B.1: ^0 := >=0.0.0, <1.0.0
26+
("^0", ["0.0.0", "0.1.0", "0.9.9"], ["1.0.0"]),
27+
("0", ["0.1.5"], ["1.0.0"]),
28+
29+
# Case B.2: ^0.2.3 (Minor > 0) := >=0.2.3, <0.3.0
30+
("^0.2.3", ["0.2.3", "0.2.9"], ["0.3.0", "0.2.2"]),
31+
("0.2.3", ["0.2.3", "0.2.9"], ["0.3.0"]),
32+
33+
# Case B.3: ^0.0.3 (Minor == 0, Patch specified) := >=0.0.3, <0.0.4
34+
("^0.0.3", ["0.0.3"], ["0.0.4", "0.0.2", "0.1.0"]),
35+
("0.0.3", ["0.0.3"], ["0.0.4"]),
36+
37+
# Case B.4: ^0.0 (Minor == 0, Patch missing) := >=0.0, <0.1.0
38+
("^0.0", ["0.0.0", "0.0.5"], ["0.1.0"]),
39+
("0.0", ["0.0.1"], ["0.1.0"]),
40+
])
41+
def test_rust_specifier_logic(rust_input, expected_matches, expected_non_matches):
42+
"""
43+
Tests that the converted Python SpecifierSet correctly matches
44+
and excludes the versions dictated by Rust SemVer logic.
45+
"""
46+
spec_set = _parse_rust_specifier(rust_input)
47+
48+
for version in expected_matches:
49+
assert version in spec_set, \
50+
f"Rust spec '{rust_input}' should MATCH {version}, but Python spec {spec_set} did not."
51+
52+
for version in expected_non_matches:
53+
assert version not in spec_set, \
54+
f"Rust spec '{rust_input}' should NOT match {version}, but Python spec {spec_set} did."
55+
56+
57+
@pytest.mark.parametrize("input_str, expected_str_repr", [
58+
(">=1.5", ">=1.5"), # Passthrough standard python
59+
("<2.0", "<2.0"), # Passthrough standard python
60+
("==1.2.3", "==1.2.3"), # Passthrough explicit equality
61+
("", ""), # Empty string handling
62+
])
63+
def test_standard_python_fallback(input_str, expected_str_repr):
64+
"""
65+
Tests that standard Python specifiers or other strings
66+
are passed through to SpecifierSet mostly unchanged.
67+
"""
68+
spec = _parse_rust_specifier(input_str)
69+
# Note: SpecifierSet normalization might change string spacing,
70+
# but the logic checks if it parses without crashing.
71+
assert str(spec) == expected_str_repr or not input_str
72+
73+
def test_invalid_version_strings():
74+
"""
75+
Ensure the function handles malformed strings gracefully
76+
(falling back to SpecifierSet which might raise its own error or handle it).
77+
"""
78+
# This falls through to "Bare" logic, fails try/except, hits fallback
79+
# SpecifierSet("invalid") is technically valid in packaging but matches nothing usually
80+
# or raises InvalidSpecifier depending on version.
81+
# Here we just want to ensure your code doesn't crash internally.
82+
try:
83+
_parse_rust_specifier("invalid_string_with_char")
84+
except Exception as e:
85+
# It is acceptable if SpecifierSet raises, but your parsing logic shouldn't
86+
assert "Invalid specifier" in str(e) or isinstance(e, ValueError)
87+
88+
89+
@pytest.mark.parametrize("spec, available, expected", [
90+
# 1. Basic Caret Priority
91+
# ^1.2.0 allows >=1.2.0, <2.0.0.
92+
# It should pick 1.9.9 (highest), ignore 2.0.0 (too high) and 1.1.0 (too low).
93+
("^1.2.0", ["1.1.0", "1.2.0", "1.2.5", "1.9.9", "2.0.0"], "1.9.9"),
94+
95+
# 2. Basic Tilde Priority
96+
# ~1.2.0 allows >=1.2.0, <1.3.0.
97+
# Should pick 1.2.9, ignore 1.3.0.
98+
("~1.2.0", ["1.2.0", "1.2.5", "1.2.9", "1.3.0", "1.4.0"], "1.2.9"),
99+
100+
# 3. Rust "0.x.y" Semantics (Major 0 breaking changes)
101+
# ^0.2.0 allows >=0.2.0, <0.3.0.
102+
# Should ignore 0.3.0 even though it's higher.
103+
("^0.2.0", ["0.1.9", "0.2.0", "0.2.5", "0.3.0"], "0.2.5"),
104+
105+
# 4. Rust "0.0.x" Semantics (Patch 0 breaking changes)
106+
# ^0.0.3 allows >=0.0.3, <0.0.4.
107+
# Should strictly match 0.0.3.
108+
("^0.0.3", ["0.0.2", "0.0.3", "0.0.4", "0.1.0"], "0.0.3"),
109+
110+
# 5. Exact Match
111+
("=1.5.0", ["1.4.0", "1.5.0", "1.6.0"], "1.5.0"),
112+
113+
# 6. No Match Found
114+
# Range is >=1.0.0, <2.0.0. Only have 0.9 and 2.1.
115+
("^1.0.0", ["0.9.0", "2.1.0", "3.0.0"], None),
116+
117+
# 7. Empty List
118+
("^1.0.0", [], None),
119+
120+
# 8. Handling "Bare" versions (Implies ^)
121+
("1.2.0", ["1.2.0", "1.5.0", "2.0.0"], "1.5.0"),
122+
])
123+
def test_solve_dependency_logic(spec, available, expected):
124+
"""
125+
Verifies that the solver picks the highest version that satisfies
126+
the Rust-style specifier.
127+
"""
128+
result = solve_dependency(spec, available)
129+
assert result == expected, \
130+
f"For spec '{spec}' and versions {available}, expected '{expected}' but got '{result}'"
131+
132+
133+
def test_solve_dependency_sorting_correctness():
134+
"""
135+
Specifically tests that 1.10.0 is considered greater than 1.2.0.
136+
String sorting would say "1.2" > "1.10", but numeric sorting says 10 > 2.
137+
"""
138+
spec = "^1.0.0"
139+
# If sorting was string-based, it might encounter 1.2.0 first (descending).
140+
# Correct numeric sort: 1.10.0 is highest.
141+
available = ["1.1.0", "1.2.0", "1.9.0", "1.10.0"]
142+
143+
result = solve_dependency(spec, available)
144+
assert result == "1.10.0"
145+
146+
147+
def test_solve_dependency_invalid_input_handling():
148+
"""
149+
Test how the function handles non-integer version strings
150+
given the lambda sorting logic provided.
151+
"""
152+
# NOTE: The provided function uses `int(part)` which will crash on "beta".
153+
# This test documents that behavior. If you want to support prereleases,
154+
# the sort key in the function needs to change.
155+
spec = "^1.0.0"
156+
available = ["1.0.0", "1.1.0-beta"]
157+
158+
with pytest.raises(ValueError) as excinfo:
159+
solve_dependency(spec, available)
160+
161+
# Confirm it failed where we expected (in the sort lambda)
162+
assert "invalid literal for int()" in str(excinfo.value)

0 commit comments

Comments
 (0)