@@ -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+
3047class 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 )
0 commit comments