-
Notifications
You must be signed in to change notification settings - Fork 53
feat(constraints): add per-specifier provenance tracking #1189
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,15 +27,28 @@ def _is_blocked_specifier(specifier: SpecifierSet) -> bool: | |
| ) | ||
|
|
||
|
|
||
| def _format_provenance(sources: set[str]) -> str: | ||
| """Format provenance sources as a human-readable string. | ||
|
|
||
| Args: | ||
| sources: Set of source file paths or URLs. | ||
|
|
||
| Returns: | ||
| Comma-separated string of sources, e.g. | ||
| ``"/path/to/base.txt, /path/to/override.txt"``. | ||
| """ | ||
| return ", ".join(sorted(sources)) | ||
|
|
||
|
|
||
| class InvalidConstraintError(ValueError): | ||
| pass | ||
|
|
||
|
|
||
| class Constraints: | ||
| def __init__(self) -> None: | ||
| # mapping of canonical names to requirements | ||
| # mapping of canonical names to (requirement, provenance sources) | ||
| # NOTE: Requirement.name is not normalized | ||
| self._data: dict[NormalizedName, Requirement] = {} | ||
| self._data: dict[NormalizedName, tuple[Requirement, set[str]]] = {} | ||
|
|
||
| def __iter__(self) -> Generator[NormalizedName, None, None]: | ||
| yield from self._data | ||
|
|
@@ -46,17 +59,26 @@ def __bool__(self) -> bool: | |
| def __len__(self) -> int: | ||
| return len(self._data) | ||
|
|
||
| def add_constraint(self, unparsed: str) -> None: | ||
| """Add new constraint, must not conflict with any existing constraints | ||
| def add_constraint(self, unparsed: str, *, provenance: str | None = None) -> None: | ||
| """Add new constraint, must not conflict with any existing constraints. | ||
|
|
||
| Args: | ||
| unparsed: Raw constraint string, e.g. ``"foo>=2.0"``. | ||
| provenance: Path or URL of the file that contains this | ||
| constraint. Used for provenance tracking in error messages | ||
| and merged output. | ||
|
|
||
| .. versionchanged: 0.83.0 | ||
| .. versionchanged:: 0.83.0 | ||
| Non-conflicting constraints are now combined. Constraints with | ||
| conflicts now raise :exc:`InvalidConstraintError`. Inputs without a | ||
| version specifier or with extras are also refused. | ||
| version specifier or with extras or url are also refused. | ||
|
|
||
| .. versionchanged:: 0.84.0 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to fix this version |
||
| Added *provenance* parameter for source file tracking. | ||
| """ | ||
| req = Requirement(unparsed) | ||
| canon_name = canonicalize_name(req.name) | ||
| previous = self._data.get(canon_name) | ||
| existing = self._data.get(canon_name) | ||
|
|
||
| # validator properties: must have a specifier, must not have extras or URL | ||
| if req.extras: | ||
|
|
@@ -78,46 +100,106 @@ def add_constraint(self, unparsed: str) -> None: | |
| logger.debug(f"Constraint {req} does not match environment") | ||
| return | ||
|
|
||
| if previous is not None: | ||
| if existing is not None: | ||
| previous, prev_sources = existing | ||
| prev_blocked = _is_blocked_specifier(previous.specifier) | ||
| prev_prov = _format_provenance(prev_sources) | ||
| existing_desc = ( | ||
| f"{previous} from {prev_prov}" if prev_prov else str(previous) | ||
| ) | ||
| new_desc = f"{req} from {provenance}" if provenance else str(req) | ||
| if blocked != prev_blocked: | ||
| raise InvalidConstraintError( | ||
| f"Cannot combine blocked and non-blocked constraints " | ||
| f"(existing: {previous}, new: {req})" | ||
| f"(existing: {existing_desc}, new: {new_desc})" | ||
| ) | ||
| if not blocked: | ||
| logger.debug("combining constraints %s and %s", previous, req) | ||
| new_specifier = req.specifier & previous.specifier | ||
| if new_specifier.is_unsatisfiable(): | ||
| raise InvalidConstraintError( | ||
| f"Combined specifier '{new_specifier}' is not satisfiable " | ||
| f"(existing: {previous}, new: {req})" | ||
| f"(existing: {existing_desc}, new: {new_desc})" | ||
| ) | ||
| req.specifier = new_specifier | ||
| sources = prev_sources | ||
| else: | ||
| logger.debug(f"adding constraint {req}") | ||
| sources = set() | ||
|
|
||
| self._data[canon_name] = req | ||
| if provenance is not None: | ||
| sources.add(provenance) | ||
| self._data[canon_name] = (req, sources) | ||
|
|
||
| def load_constraints_file(self, constraints_file: str | pathlib.Path) -> None: | ||
| """Load constraints from a constraints file or URL""" | ||
| """Load constraints from a constraints file or URL.""" | ||
| logger.info("loading constraints from %s", constraints_file) | ||
| file_provenance = str(constraints_file) | ||
| content = requirements_file.parse_requirements_file(constraints_file) | ||
| for line in content: | ||
| self.add_constraint(line) | ||
| self.add_constraint(line, provenance=file_provenance) | ||
|
|
||
| def dump_constraints(self, output: typing.TextIO) -> None: | ||
| """Dump combined constraints to a text stream""" | ||
| # sort by normalized name | ||
| for _, req in sorted(self._data.items()): | ||
| # write requirement without markers. They have been evaluated | ||
| # in add_constraint() | ||
| """Dump combined constraints to a text stream. | ||
|
|
||
| Source files that contributed each constraint are listed as comment | ||
| lines above the constraint line. | ||
|
|
||
| Args: | ||
| output: Writable text stream. | ||
|
|
||
| .. versionchanged:: 0.84.0 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The version need to be fixed. |
||
| Output now includes provenance comments above each constraint. | ||
| """ | ||
| # sort by normalized name, write requirement without markers. | ||
| # They have been evaluated in add_constraint() | ||
| for _name, (req, sources) in sorted(self._data.items()): | ||
| for source in sorted(sources): | ||
| output.write(f"# {source}\n") | ||
| output.write(f"{req.name}{req.specifier}\n") | ||
|
|
||
| def get_constraint(self, name: str) -> Requirement | None: | ||
| return self._data.get(canonicalize_name(name)) | ||
| """Return the merged constraint for *name*, or ``None``.""" | ||
| constraint_entry = self._data.get(canonicalize_name(name)) | ||
| if constraint_entry is not None: | ||
| return constraint_entry[0] | ||
| return None | ||
|
|
||
| def get_constraint_with_provenance( | ||
| self, name: str | ||
| ) -> tuple[Requirement, set[str]] | tuple[None, None]: | ||
| """Return the constraint and its provenance sources for *name*. | ||
|
|
||
| Returns: | ||
| ``(requirement, source_files)`` if constrained, or | ||
| ``(None, None)`` if the package has no constraints. | ||
| The returned set is a copy. | ||
|
|
||
| .. versionadded:: 0.84.0 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We need to fix this to 0.93.0 |
||
| """ | ||
| constraint_entry = self._data.get(canonicalize_name(name)) | ||
| if constraint_entry is not None: | ||
| req, sources = constraint_entry | ||
| return req, set(sources) | ||
| return None, None | ||
|
|
||
| def format_provenance(self, name: str) -> str: | ||
| """Return a human-readable provenance string for *name*. | ||
|
|
||
| Returns: | ||
| Comma-separated list of source files, e.g. | ||
| ``"/path/to/base.txt, /path/to/override.txt"``, | ||
| or an empty string if the package has no constraints. | ||
|
|
||
| .. versionadded:: 0.84.0 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here. |
||
| """ | ||
| constraint_entry = self._data.get(canonicalize_name(name)) | ||
| if constraint_entry is not None: | ||
| return _format_provenance(constraint_entry[1]) | ||
| return "" | ||
|
|
||
| def allow_prerelease(self, pkg_name: str) -> bool: | ||
| """Return ``True`` if the constraint for *pkg_name* allows prereleases.""" | ||
| constraint = self.get_constraint(pkg_name) | ||
| if constraint: | ||
| return bool(constraint.specifier.prereleases) | ||
|
|
@@ -131,6 +213,7 @@ def is_blocked(self, pkg_name: str) -> bool: | |
| return False | ||
|
|
||
| def is_satisfied_by(self, pkg_name: str, version: Version) -> bool: | ||
| """Return ``True`` if *version* satisfies the constraint for *pkg_name*.""" | ||
| constraint = self.get_constraint(pkg_name) | ||
| if constraint: | ||
| return constraint.specifier.contains(version, prereleases=True) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.