Skip to content

Commit 7a848b9

Browse files
committed
feat(commands): add find-updates command for checking constraint updates
Add new `find-updates` command that analyzes constraints files and identifies available package versions that exceed current constraints. Features: - Supports equality (==), less than (<), and less than or equal (<=) constraints - Ignores lower bound constraints (>=) as they don't pin specific versions - Multiple output formats: requirements, JSON, CSV - File output support via --output option - Distribution type filtering (--distribution-type) with sdist/wheel/both options - Reuses existing constraint parsing and version resolution infrastructure For == constraints, finds versions newer than the pinned version. For < and <= constraints, finds versions that would violate the constraint. Example usage: ```bash fromager find-updates constraints.txt fromager find-updates --format json --output updates.json constraints.txt ``` Chat log: https://gist.github.com/dhellmann/168b3f52d5715f0f174c03643dcad566 Co-authored-by: Claude 3.5 Sonnet (Anthropic AI Assistant) Signed-off-by: Doug Hellmann <dhellmann@redhat.com>
1 parent 63b235e commit 7a848b9

2 files changed

Lines changed: 257 additions & 0 deletions

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: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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.0.0" (ignored - not a specific constraint)
75+
76+
Distribution types:
77+
- "default": Use package settings for include_sdists/include_wheels
78+
- "sdist": Only include source distributions
79+
- "wheel": Only include wheels
80+
- "both": Include both source distributions and wheels
81+
82+
Output formats:
83+
- "requirements": List as requirement specifiers (package==version)
84+
- "json": JSON array of objects with "name" and "version" fields
85+
- "csv": CSV format with "name" and "version" columns
86+
87+
Use --output to write results to a file instead of stdout.
88+
"""
89+
# Load the constraints file
90+
constraint_loader = constraints.Constraints()
91+
constraint_loader.load_constraints_file(constraints_file)
92+
93+
updates_found: list[dict[str, str]] = []
94+
95+
# Process each constraint
96+
for package_name in constraint_loader:
97+
constraint = constraint_loader.get_constraint(package_name)
98+
if not constraint:
99+
continue
100+
101+
# Check if this is a constraint we can check for updates (==, <, <=)
102+
constraint_version = _get_constraint_version(constraint)
103+
if not constraint_version:
104+
logger.debug(f"Skipping {package_name}: not a supported constraint type")
105+
continue
106+
107+
logger.info(f"Looking for updates to {package_name} (constraint: {constraint})")
108+
109+
# Find available versions for this package
110+
try:
111+
newer_versions = _find_newer_versions(
112+
wkctx,
113+
constraint,
114+
constraint_version,
115+
distribution_type,
116+
sdist_server_url,
117+
)
118+
119+
if newer_versions:
120+
logger.info(
121+
f"Found {len(newer_versions)} newer version(s) for {package_name}"
122+
)
123+
for version in newer_versions:
124+
updates_found.append(
125+
{"name": package_name, "version": str(version)}
126+
)
127+
else:
128+
logger.debug(f"No newer versions found for {package_name}")
129+
130+
except Exception as e:
131+
logger.warning(f"Could not check versions for {package_name}: {e}")
132+
133+
# Output results in requested format
134+
if not updates_found:
135+
return
136+
137+
# Determine output destination
138+
output_file: TextIO
139+
if output:
140+
output_file = output.open("w")
141+
else:
142+
output_file = sys.stdout
143+
144+
try:
145+
output_fmt = OutputFormat(output_format)
146+
if output_fmt == OutputFormat.REQUIREMENTS:
147+
for update in updates_found:
148+
print(f"{update['name']}=={update['version']}", file=output_file)
149+
elif output_fmt == OutputFormat.JSON:
150+
json.dump(updates_found, output_file, indent=2)
151+
print(file=output_file) # Add newline for better output
152+
elif output_fmt == OutputFormat.CSV:
153+
writer = csv.writer(output_file)
154+
writer.writerow(["name", "version"])
155+
for update in updates_found:
156+
writer.writerow([update["name"], update["version"]])
157+
finally:
158+
if output:
159+
output_file.close()
160+
161+
162+
def _get_constraint_version(constraint: Requirement) -> Version | None:
163+
"""Extract version from supported constraint types (==, <, <=), return None if not supported."""
164+
if not constraint.specifier:
165+
return None
166+
167+
# Look for supported constraint operators
168+
supported_specs = [
169+
spec for spec in constraint.specifier if spec.operator in ("==", "<", "<=")
170+
]
171+
if len(supported_specs) != 1:
172+
return None
173+
174+
# Parse the version from the constraint specifier
175+
try:
176+
return Version(supported_specs[0].version)
177+
except Exception:
178+
return None
179+
180+
181+
def _find_newer_versions(
182+
wkctx: context.WorkContext,
183+
constraint: Requirement,
184+
constraint_version: Version,
185+
distribution_type: str,
186+
sdist_server_url: str,
187+
) -> list[Version]:
188+
"""Find versions that would be updates beyond the constraint for a package."""
189+
# Get package build info to determine distribution preferences
190+
pbi = wkctx.package_build_info(constraint)
191+
override_sdist_server_url = pbi.resolver_sdist_server_url(sdist_server_url)
192+
193+
# Determine include flags based on distribution type
194+
dist_type = DistributionType(distribution_type)
195+
match dist_type:
196+
case DistributionType.SDIST:
197+
include_sdists = True
198+
include_wheels = False
199+
case DistributionType.WHEEL:
200+
include_sdists = False
201+
include_wheels = True
202+
case DistributionType.BOTH:
203+
include_sdists = True
204+
include_wheels = True
205+
case _: # DEFAULT
206+
# Use package settings defaults
207+
package_settings = wkctx.settings.package_setting(constraint.name)
208+
include_sdists = package_settings.resolver_dist.include_sdists
209+
include_wheels = package_settings.resolver_dist.include_wheels
210+
211+
# Get resolver provider
212+
provider = overrides.find_and_invoke(
213+
constraint.name,
214+
"get_resolver_provider",
215+
resolver.default_resolver_provider,
216+
ctx=wkctx,
217+
req=constraint,
218+
include_sdists=include_sdists,
219+
include_wheels=include_wheels,
220+
sdist_server_url=override_sdist_server_url,
221+
)
222+
223+
# Create a requirement without version constraints to get all available versions
224+
unconstrained_req = Requirement(constraint.name)
225+
226+
# Get all available candidates
227+
candidates = list(
228+
provider.find_matches(
229+
identifier=constraint.name,
230+
requirements={constraint.name: [unconstrained_req]},
231+
incompatibilities={constraint.name: []},
232+
)
233+
)
234+
235+
if not candidates:
236+
return []
237+
238+
# Extract versions and filter based on constraint type
239+
all_versions = sorted(set(candidate.version for candidate in candidates))
240+
241+
# Get the constraint operator to determine what "update" means
242+
constraint_spec = next(
243+
spec for spec in constraint.specifier if spec.operator in ("==", "<", "<=")
244+
)
245+
if constraint_spec.operator == "==":
246+
# For equality constraints, find versions newer than the pinned version
247+
newer_versions = [v for v in all_versions if v > constraint_version]
248+
elif constraint_spec.operator in ("<", "<="):
249+
# For upper bound constraints, find versions that exceed the constraint
250+
# (these would be versions that violate the current constraint)
251+
newer_versions = [v for v in all_versions if v >= constraint_version]
252+
else:
253+
newer_versions = []
254+
255+
return newer_versions

0 commit comments

Comments
 (0)