Skip to content

Commit 071002e

Browse files
smoparthclaude
andcommitted
feat(constraints): add per-specifier provenance tracking
When multiple constraint files are merged, engineers had no way to determine which input file contributed each specifier. This adds provenance tracking so every constraint records its source file. - Add `_provenance` dict to `Constraints` class, grouped by source file - Make `source` a required keyword-only parameter on `add_constraint()` - Add `get_provenance()` public method returning `{source: [lines]}` - Include inline provenance comments in `merged-constraints.txt` output - Enrich `InvalidConstraintError` messages with source file info - Add provenance to resolver rejection logs and exception messages - Add `_format_provenance()` helper for human-readable formatting Co-Authored-By: Claude <claude@anthropic.com> Closes: #1186 Signed-off-by: Shanmukh Pawan <smoparth@redhat.com>
1 parent b6c8fc7 commit 071002e

5 files changed

Lines changed: 247 additions & 62 deletions

File tree

src/fromager/constraints.py

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,23 @@ def _is_blocked_specifier(specifier: SpecifierSet) -> bool:
2727
)
2828

2929

30+
def _format_provenance(provenance: dict[str, list[str]]) -> str:
31+
"""Format a per-package provenance dict as a human-readable string.
32+
33+
Args:
34+
provenance: Mapping of source file to original constraint lines.
35+
36+
Returns:
37+
Formatted string, e.g.
38+
``"/path/to/base.txt (>=2.0), /path/to/override.txt (!=2.1.1)"``.
39+
"""
40+
parts: list[str] = []
41+
for source, lines in provenance.items():
42+
specifiers = ", ".join(str(Requirement(line).specifier) for line in lines)
43+
parts.append(f"{source} ({specifiers})")
44+
return ", ".join(parts)
45+
46+
3047
class InvalidConstraintError(ValueError):
3148
pass
3249

@@ -36,6 +53,8 @@ def __init__(self) -> None:
3653
# mapping of canonical names to requirements
3754
# NOTE: Requirement.name is not normalized
3855
self._data: dict[NormalizedName, Requirement] = {}
56+
# per-package provenance: {canonical_name: {source_file: [original_lines]}}
57+
self._provenance: dict[NormalizedName, dict[str, list[str]]] = {}
3958

4059
def __iter__(self) -> Generator[NormalizedName, None, None]:
4160
yield from self._data
@@ -46,13 +65,21 @@ def __bool__(self) -> bool:
4665
def __len__(self) -> int:
4766
return len(self._data)
4867

49-
def add_constraint(self, unparsed: str) -> None:
50-
"""Add new constraint, must not conflict with any existing constraints
68+
def add_constraint(self, unparsed: str, *, source: str) -> None:
69+
"""Add new constraint, must not conflict with any existing constraints.
70+
71+
Args:
72+
unparsed: Raw constraint string, e.g. ``"foo>=2.0"``.
73+
source: Path or URL of the file that contains this constraint.
74+
Required for provenance tracking.
5175
52-
.. versionchanged: 0.83.0
76+
.. versionchanged:: 0.83.0
5377
Non-conflicting constraints are now combined. Constraints with
5478
conflicts now raise :exc:`InvalidConstraintError`. Inputs without a
5579
version specifier or with extras or url are also refused.
80+
81+
.. versionchanged:: 0.84.0
82+
Added *source* parameter for provenance tracking.
5683
"""
5784
req = Requirement(unparsed)
5885
canon_name = canonicalize_name(req.name)
@@ -81,43 +108,77 @@ def add_constraint(self, unparsed: str) -> None:
81108
if previous is not None:
82109
prev_blocked = _is_blocked_specifier(previous.specifier)
83110
if blocked != prev_blocked:
111+
prev_prov = _format_provenance(self._provenance.get(canon_name, {}))
84112
raise InvalidConstraintError(
85113
f"Cannot combine blocked and non-blocked constraints "
86-
f"(existing: {previous}, new: {req})"
114+
f"(existing: {previous} from {prev_prov}, "
115+
f"new: {req} from {source})"
87116
)
88117
if not blocked:
89118
logger.debug("combining constraints %s and %s", previous, req)
90119
new_specifier = req.specifier & previous.specifier
91120
if new_specifier.is_unsatisfiable():
121+
prev_prov = _format_provenance(self._provenance.get(canon_name, {}))
92122
raise InvalidConstraintError(
93123
f"Combined specifier '{new_specifier}' is not satisfiable "
94-
f"(existing: {previous}, new: {req})"
124+
f"(existing: {previous} from {prev_prov}, "
125+
f"new: {req} from {source})"
95126
)
96127
req.specifier = new_specifier
97128
else:
98129
logger.debug(f"adding constraint {req}")
99130

100131
self._data[canon_name] = req
132+
pkg_prov = self._provenance.setdefault(canon_name, {})
133+
pkg_prov.setdefault(source, []).append(unparsed)
101134

102135
def load_constraints_file(self, constraints_file: str | pathlib.Path) -> None:
103-
"""Load constraints from a constraints file or URL"""
136+
"""Load constraints from a constraints file or URL."""
104137
logger.info("loading constraints from %s", constraints_file)
138+
source = str(constraints_file)
105139
content = requirements_file.parse_requirements_file(constraints_file)
106140
for line in content:
107-
self.add_constraint(line)
141+
self.add_constraint(line, source=source)
108142

109143
def dump_constraints(self, output: typing.TextIO) -> None:
110-
"""Dump combined constraints to a text stream"""
111-
# sort by normalized name
112-
for _, req in sorted(self._data.items()):
113-
# write requirement without markers. They have been evaluated
114-
# in add_constraint()
115-
output.write(f"{req.name}{req.specifier}\n")
144+
"""Dump combined constraints to a text stream.
145+
146+
Each line includes an inline comment showing which source file(s)
147+
contributed each specifier.
148+
149+
Args:
150+
output: Writable text stream.
151+
152+
.. versionchanged:: 0.84.0
153+
Output now includes per-line provenance comments.
154+
"""
155+
# sort by normalized name, write requirement without markers.
156+
# They have been evaluated in add_constraint()
157+
for name, req in sorted(self._data.items()):
158+
line = f"{req.name}{req.specifier}"
159+
prov = self._provenance.get(name, {})
160+
if prov:
161+
line = f"{line} # {_format_provenance(prov)}"
162+
output.write(f"{line}\n")
116163

117164
def get_constraint(self, name: str) -> Requirement | None:
165+
"""Return the merged constraint for *name*, or ``None``."""
118166
return self._data.get(canonicalize_name(name))
119167

168+
def get_provenance(self, name: str) -> dict[str, list[str]]:
169+
"""Return provenance info for *name*.
170+
171+
Returns:
172+
Mapping of ``{source_file: [original_constraint_lines]}``,
173+
or an empty dict if the package has no constraints.
174+
175+
.. versionadded:: 0.84.0
176+
"""
177+
prov = self._provenance.get(canonicalize_name(name), {})
178+
return {source: list(lines) for source, lines in prov.items()}
179+
120180
def allow_prerelease(self, pkg_name: str) -> bool:
181+
"""Return ``True`` if the constraint for *pkg_name* allows prereleases."""
121182
constraint = self.get_constraint(pkg_name)
122183
if constraint:
123184
return bool(constraint.specifier.prereleases)
@@ -131,6 +192,7 @@ def is_blocked(self, pkg_name: str) -> bool:
131192
return False
132193

133194
def is_satisfied_by(self, pkg_name: str, version: Version) -> bool:
195+
"""Return ``True`` if *version* satisfies the constraint for *pkg_name*."""
134196
constraint = self.get_constraint(pkg_name)
135197
if constraint:
136198
return constraint.specifier.contains(version, prereleases=True)

src/fromager/resolver.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
from . import overrides
3434
from .candidate import Candidate, Cooldown
35-
from .constraints import Constraints
35+
from .constraints import Constraints, _format_provenance
3636
from .extras_provider import ExtrasProvider
3737
from .http_retry import RETRYABLE_EXCEPTIONS, retry_on_exception
3838
from .request_session import session
@@ -287,10 +287,15 @@ def find_all_matching_from_provider(
287287
)
288288
except resolvelib.resolvers.ResolverException as err:
289289
constraint = provider.constraints.get_constraint(req.name)
290+
provenance = provider.constraints.get_provenance(req.name)
290291
provider_desc = provider.get_provider_description()
291292
original_msg = str(err)
293+
prov_msg = ""
294+
if provenance:
295+
prov_msg = f" (from {_format_provenance(provenance)})"
292296
raise resolvelib.resolvers.ResolverException(
293-
f"Unable to resolve requirement specifier {req} with constraint {constraint} using {provider_desc}: {original_msg}"
297+
f"Unable to resolve requirement specifier {req} with constraint "
298+
f"{constraint}{prov_msg} using {provider_desc}: {original_msg}"
294299
) from err
295300

296301
# Materialize candidates so we can iterate more than once if filtering
@@ -689,8 +694,13 @@ def is_satisfied_by(self, requirement: Requirement, candidate: Candidate) -> boo
689694
if not self.constraints.is_satisfied_by(requirement.name, candidate.version):
690695
if DEBUG_RESOLVER:
691696
c = self.constraints.get_constraint(requirement.name)
697+
provenance = self.constraints.get_provenance(requirement.name)
698+
prov_msg = ""
699+
if provenance:
700+
prov_msg = f" from {_format_provenance(provenance)}"
692701
logger.debug(
693-
f"{requirement.name}: skipping {candidate.version} due to constraint {c}"
702+
f"{requirement.name}: skipping {candidate.version} "
703+
f"due to constraint {c}{prov_msg}"
694704
)
695705
return False
696706

0 commit comments

Comments
 (0)