Skip to content

Commit 2a03733

Browse files
authored
Merge pull request #716 from dhellmann/find-updates-command
feat(commands): add find-updates command for checking constraint updates
2 parents f3ab972 + 39a9a45 commit 2a03733

4 files changed

Lines changed: 353 additions & 1 deletion

File tree

src/fromager/commands/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
build_order,
55
canonicalize,
66
download_sequence,
7+
find_updates,
78
graph,
89
lint,
910
lint_requirements,
@@ -23,6 +24,7 @@
2324
build.build_sequence,
2425
build.build_parallel,
2526
build_order.build_order,
27+
find_updates.find_updates,
2628
graph.graph,
2729
lint.lint,
2830
list_overrides.list_overrides,
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import csv
2+
import json
3+
import logging
4+
import pathlib
5+
import sys
6+
from enum import Enum
7+
from typing import TextIO
8+
9+
import click
10+
from packaging.requirements import Requirement
11+
from packaging.version import Version
12+
13+
from fromager import constraints, context, overrides, resolver
14+
from fromager.commands.list_versions import DistributionType
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
class OutputFormat(Enum):
20+
"""Output format for find-updates command"""
21+
22+
REQUIREMENTS = "requirements"
23+
JSON = "json"
24+
CSV = "csv"
25+
26+
27+
@click.command()
28+
@click.option(
29+
"--format",
30+
"output_format",
31+
type=click.Choice(OutputFormat, case_sensitive=False),
32+
default=OutputFormat.REQUIREMENTS.value,
33+
help="Output format (requirements: name==version, json: JSON array, csv: CSV with name,version columns)",
34+
)
35+
@click.option(
36+
"--distribution-type",
37+
type=click.Choice(DistributionType, case_sensitive=False),
38+
default=DistributionType.DEFAULT.value,
39+
help="Distribution type to include in version lookup (default: use package settings, sdist: source only, wheel: wheels only, both: include both sdists and wheels)",
40+
)
41+
@click.option(
42+
"--sdist-server-url",
43+
default=resolver.PYPI_SERVER_URL,
44+
help="URL to the Python package index to use for version lookup",
45+
)
46+
@click.option(
47+
"-o",
48+
"--output",
49+
type=click.Path(path_type=pathlib.Path),
50+
help="Write output to file instead of stdout",
51+
)
52+
@click.argument(
53+
"constraints_file", type=click.Path(exists=True, path_type=pathlib.Path)
54+
)
55+
@click.pass_obj
56+
def find_updates(
57+
wkctx: context.WorkContext,
58+
constraints_file: pathlib.Path,
59+
output_format: str,
60+
distribution_type: str,
61+
sdist_server_url: str,
62+
output: pathlib.Path | None,
63+
) -> None:
64+
"""Find available updates for packages with specific version constraints.
65+
66+
This command reads a constraints file and for each package that has a
67+
supported constraint (==, <, <=, ~=, !=, ===), it lists newer versions available.
68+
Constraints with lower bounds (>=) are ignored as they don't pin to specific versions.
69+
70+
The CONSTRAINTS_FILE should be a pip-style constraints file with entries like:
71+
- "package_name==1.0.0" (will look for versions > 1.0.0)
72+
- "package_name<2.0.0" (will look for versions >= 2.0.0)
73+
- "package_name<=1.5.0" (will look for versions > 1.5.0)
74+
- "package_name~=1.4.0" (will look for versions outside compatible range)
75+
- "package_name!=1.0.0" (will look for versions other than 1.0.0)
76+
- "package_name===1.0.0" (will look for versions other than exactly 1.0.0)
77+
- "package_name>=1.0.0" (ignored - not a specific constraint)
78+
79+
Distribution types:
80+
- "default": Use package settings for include_sdists/include_wheels
81+
- "sdist": Only include source distributions
82+
- "wheel": Only include wheels
83+
- "both": Include both source distributions and wheels
84+
85+
Output formats:
86+
- "requirements": List as requirement specifiers (package==version)
87+
- "json": JSON array of objects with "name" and "version" fields
88+
- "csv": CSV format with "name" and "version" columns
89+
90+
Use --output to write results to a file instead of stdout.
91+
"""
92+
# Load the constraints file
93+
constraint_loader = constraints.Constraints()
94+
constraint_loader.load_constraints_file(constraints_file)
95+
96+
updates_found: list[dict[str, str]] = []
97+
98+
# Process each constraint
99+
for package_name in constraint_loader:
100+
constraint = constraint_loader.get_constraint(package_name)
101+
if not constraint:
102+
continue
103+
104+
# Check if this is a constraint we can check for updates (==, <, <=)
105+
constraint_version = _get_constraint_version(constraint)
106+
if not constraint_version:
107+
logger.debug(f"Skipping {package_name}: not a supported constraint type")
108+
continue
109+
110+
logger.info(f"Looking for updates to {package_name} (constraint: {constraint})")
111+
112+
# Find available versions for this package
113+
try:
114+
newer_versions = _find_newer_versions(
115+
wkctx,
116+
constraint,
117+
constraint_version,
118+
distribution_type,
119+
sdist_server_url,
120+
)
121+
122+
if newer_versions:
123+
logger.info(
124+
f"Found {len(newer_versions)} newer version(s) for {package_name}"
125+
)
126+
for version in newer_versions:
127+
updates_found.append(
128+
{"name": package_name, "version": str(version)}
129+
)
130+
else:
131+
logger.debug(f"No newer versions found for {package_name}")
132+
133+
except Exception as e:
134+
logger.warning(f"Could not check versions for {package_name}: {e}")
135+
136+
# Output results in requested format
137+
if not updates_found:
138+
return
139+
140+
# Determine output destination
141+
output_file: TextIO
142+
if output:
143+
output_file = output.open("w")
144+
else:
145+
output_file = sys.stdout
146+
147+
try:
148+
output_fmt = OutputFormat(output_format)
149+
if output_fmt == OutputFormat.REQUIREMENTS:
150+
for update in updates_found:
151+
print(f"{update['name']}=={update['version']}", file=output_file)
152+
elif output_fmt == OutputFormat.JSON:
153+
json.dump(updates_found, output_file, indent=2)
154+
print(file=output_file) # Add newline for better output
155+
elif output_fmt == OutputFormat.CSV:
156+
writer = csv.writer(output_file)
157+
writer.writerow(["name", "version"])
158+
for update in updates_found:
159+
writer.writerow([update["name"], update["version"]])
160+
finally:
161+
if output:
162+
output_file.close()
163+
164+
165+
CONSTRAINT_OPERATORS = ("==", "<", "<=", "~=", "!=", "===")
166+
167+
168+
def _get_constraint_version(constraint: Requirement) -> Version | None:
169+
"""Extract version from supported constraint types (==, <, <=, ~=, !=, ===), return None if not supported."""
170+
if not constraint.specifier:
171+
return None
172+
173+
# Look for supported constraint operators
174+
supported_specs = [
175+
spec for spec in constraint.specifier if spec.operator in CONSTRAINT_OPERATORS
176+
]
177+
if len(supported_specs) != 1:
178+
return None
179+
180+
# Parse the version from the constraint specifier
181+
try:
182+
return Version(supported_specs[0].version)
183+
except Exception:
184+
return None
185+
186+
187+
def _find_newer_versions(
188+
wkctx: context.WorkContext,
189+
constraint: Requirement,
190+
constraint_version: Version,
191+
distribution_type: str,
192+
sdist_server_url: str,
193+
) -> list[Version]:
194+
"""Find versions that would be updates beyond the constraint for a package."""
195+
# Get package build info to determine distribution preferences
196+
pbi = wkctx.package_build_info(constraint)
197+
override_sdist_server_url = pbi.resolver_sdist_server_url(sdist_server_url)
198+
199+
# Determine include flags based on distribution type
200+
dist_type = DistributionType(distribution_type)
201+
match dist_type:
202+
case DistributionType.SDIST:
203+
include_sdists = True
204+
include_wheels = False
205+
case DistributionType.WHEEL:
206+
include_sdists = False
207+
include_wheels = True
208+
case DistributionType.BOTH:
209+
include_sdists = True
210+
include_wheels = True
211+
case _: # DEFAULT
212+
# Use package settings defaults
213+
package_settings = wkctx.settings.package_setting(constraint.name)
214+
include_sdists = package_settings.resolver_dist.include_sdists
215+
include_wheels = package_settings.resolver_dist.include_wheels
216+
217+
# Get resolver provider
218+
provider = overrides.find_and_invoke(
219+
constraint.name,
220+
"get_resolver_provider",
221+
resolver.default_resolver_provider,
222+
ctx=wkctx,
223+
req=constraint,
224+
include_sdists=include_sdists,
225+
include_wheels=include_wheels,
226+
sdist_server_url=override_sdist_server_url,
227+
)
228+
229+
# Create a requirement without version constraints to get all available versions
230+
unconstrained_req = Requirement(constraint.name)
231+
232+
# Get all available candidates
233+
candidates = list(
234+
provider.find_matches(
235+
identifier=constraint.name,
236+
requirements={constraint.name: [unconstrained_req]},
237+
incompatibilities={constraint.name: []},
238+
)
239+
)
240+
241+
if not candidates:
242+
return []
243+
244+
# Extract versions and filter based on constraint type
245+
all_versions = sorted(set(candidate.version for candidate in candidates))
246+
247+
# Get the constraint operator to determine what "update" means
248+
constraint_spec = next(
249+
spec for spec in constraint.specifier if spec.operator in CONSTRAINT_OPERATORS
250+
)
251+
if constraint_spec.operator in ("==", "==="):
252+
# For equality constraints, find versions newer than the pinned version
253+
newer_versions = [v for v in all_versions if v > constraint_version]
254+
elif constraint_spec.operator in ("<", "<=", "~=", "!="):
255+
# For upper bound constraints, find versions that exceed the constraint
256+
# (these would be versions that violate the current constraint)
257+
newer_versions = [v for v in all_versions if v >= constraint_version]
258+
else:
259+
newer_versions = []
260+
261+
return newer_versions

src/fromager/commands/list_versions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def list_versions(
129129
else:
130130
raise click.ClickException(f"No versions found for {req.name}")
131131

132-
versions: list[Version] = sorted(candidate.version for candidate in candidates)
132+
versions: list[Version] = sorted(set(candidate.version for candidate in candidates))
133133
logger.info(f"Found {len(versions)} version(s)")
134134

135135
for version in versions:

tests/test_find_updates.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from packaging.requirements import Requirement
2+
from packaging.version import Version
3+
4+
from fromager.commands.find_updates import _get_constraint_version
5+
6+
7+
class TestGetConstraintVersion:
8+
"""Test cases for _get_constraint_version function."""
9+
10+
def test_equality_constraint(self):
11+
"""Test that equality constraints return the correct version."""
12+
req = Requirement("package==1.2.3")
13+
result = _get_constraint_version(req)
14+
assert result == Version("1.2.3")
15+
16+
def test_less_than_constraint(self):
17+
"""Test that less than constraints return the correct version."""
18+
req = Requirement("package<2.0.0")
19+
result = _get_constraint_version(req)
20+
assert result == Version("2.0.0")
21+
22+
def test_less_than_or_equal_constraint(self):
23+
"""Test that less than or equal constraints return the correct version."""
24+
req = Requirement("package<=1.5.0")
25+
result = _get_constraint_version(req)
26+
assert result == Version("1.5.0")
27+
28+
def test_greater_than_constraint_unsupported(self):
29+
"""Test that greater than constraints return None (unsupported)."""
30+
req = Requirement("package>1.0.0")
31+
result = _get_constraint_version(req)
32+
assert result is None
33+
34+
def test_greater_than_or_equal_constraint_unsupported(self):
35+
"""Test that greater than or equal constraints return None (unsupported)."""
36+
req = Requirement("package>=1.0.0")
37+
result = _get_constraint_version(req)
38+
assert result is None
39+
40+
def test_no_version_constraint(self):
41+
"""Test that requirements without version constraints return None."""
42+
req = Requirement("package")
43+
result = _get_constraint_version(req)
44+
assert result is None
45+
46+
def test_multiple_constraints_with_one_supported(self):
47+
"""Test that multiple constraints with one supported constraint return the supported one."""
48+
req = Requirement("package>=1.0.0,<2.0.0")
49+
result = _get_constraint_version(req)
50+
assert result == Version("2.0.0")
51+
52+
def test_constraint_with_markers(self):
53+
"""Test that constraints with environment markers still work correctly."""
54+
req = Requirement("package==1.2.3; python_version >= '3.8'")
55+
result = _get_constraint_version(req)
56+
assert result == Version("1.2.3")
57+
58+
def test_tilde_equal_constraint_supported(self):
59+
"""Test that tilde equal constraints (~=) return the correct version."""
60+
req = Requirement("package~=1.4.2")
61+
result = _get_constraint_version(req)
62+
assert result == Version("1.4.2")
63+
64+
def test_not_equal_constraint_supported(self):
65+
"""Test that not equal constraints (!=) return the correct version."""
66+
req = Requirement("package!=1.0.0")
67+
result = _get_constraint_version(req)
68+
assert result == Version("1.0.0")
69+
70+
def test_arbitrary_equality_constraint_supported(self):
71+
"""Test that arbitrary equality constraints (===) return the correct version."""
72+
req = Requirement("package===1.0.0")
73+
result = _get_constraint_version(req)
74+
assert result == Version("1.0.0")
75+
76+
def test_invalid_version_format(self):
77+
"""Test that invalid version formats return None."""
78+
# We need to test the case where Version() construction fails
79+
# Let's create a requirement and then mock the Version parsing
80+
import unittest.mock
81+
82+
req = Requirement("package==1.0.0")
83+
84+
with unittest.mock.patch(
85+
"fromager.commands.find_updates.Version"
86+
) as mock_version:
87+
mock_version.side_effect = ValueError("Invalid version")
88+
result = _get_constraint_version(req)
89+
assert result is None

0 commit comments

Comments
 (0)