Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: abravalheri/validate-pyproject
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.24
Choose a base ref
...
head repository: abravalheri/validate-pyproject
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v0.24.1
Choose a head ref
  • 17 commits
  • 8 files changed
  • 4 contributors

Commits on Mar 13, 2025

  1. fix: multi plugin id was read from the wrong place

    Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
    henryiii committed Mar 13, 2025
    Copy the full SHA
    706e1cc View commit details
  2. fix: multi plugin id was read from the wrong place (#240)

    abravalheri authored Mar 13, 2025
    Copy the full SHA
    60b8ee4 View commit details
  3. Update CHANGELOG

    abravalheri committed Mar 13, 2025
    Copy the full SHA
    9eadb82 View commit details
  4. fix: more readable plugin id for stored plugins

    Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
    henryiii committed Mar 13, 2025
    Copy the full SHA
    05db999 View commit details
  5. fix: id and source seperation

    Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
    henryiii committed Mar 13, 2025
    Copy the full SHA
    7f8a317 View commit details
  6. fix: more readable plugin id for stored plugins (#241)

    abravalheri authored Mar 13, 2025
    Copy the full SHA
    4cb2185 View commit details

Commits on Mar 14, 2025

  1. fix: priority is more important than plugin name

    Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
    henryiii authored and abravalheri committed Mar 14, 2025
    Copy the full SHA
    5266e8f View commit details
  2. Copy the full SHA
    d5ca823 View commit details
  3. Add test helper to fake entrypoints

    abravalheri committed Mar 14, 2025
    Copy the full SHA
    4388d04 View commit details
  4. Remove expression difficult to understand

    abravalheri authored Mar 14, 2025
    Copy the full SHA
    40ebd81 View commit details

Commits on Mar 17, 2025

  1. Change default plugin priority but allow customisation

    abravalheri committed Mar 17, 2025
    Copy the full SHA
    cdb1a0b View commit details
  2. Remove '.priority' from plugin protocol

    abravalheri committed Mar 17, 2025
    Copy the full SHA
    bcc733d View commit details

Commits on Mar 19, 2025

  1. docs: add a few newer peps to docs list

    Signed-off-by: Henry Schreiner <henryschreineriii@gmail.com>
    henryiii committed Mar 19, 2025
    Copy the full SHA
    01a87ae View commit details
  2. [pre-commit.ci] pre-commit autoupdate (#245)

    * [pre-commit.ci] pre-commit autoupdate
    
    updates:
    - [github.com/astral-sh/ruff-pre-commit: v0.9.10 → v0.11.0](astral-sh/ruff-pre-commit@v0.9.10...v0.11.0)
    
    * [pre-commit.ci] auto fixes from pre-commit.com hooks
    
    for more information, see https://pre-commit.ci
    
    ---------
    
    Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
    pre-commit-ci[bot] authored Mar 19, 2025
    Copy the full SHA
    d1b4cab View commit details

Commits on Mar 21, 2025

  1. Implement alternative plugin sort (#243)

    abravalheri authored Mar 21, 2025
    Copy the full SHA
    40ec0e5 View commit details
  2. docs: add a few newer peps to docs list (#246)

    abravalheri authored Mar 21, 2025
    Copy the full SHA
    643d2c6 View commit details
  3. Update CHANGELOG

    abravalheri authored Mar 21, 2025
    Copy the full SHA
    78f5e0f View commit details
Showing with 219 additions and 151 deletions.
  1. +1 −1 .pre-commit-config.yaml
  2. +6 −1 CHANGELOG.rst
  3. +22 −4 docs/dev-guide.rst
  4. +1 −1 docs/index.rst
  5. +5 −7 src/validate_pyproject/api.py
  6. +50 −20 src/validate_pyproject/plugins/__init__.py
  7. +1 −1 src/validate_pyproject/remote.py
  8. +133 −116 tests/test_plugins.py
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -26,7 +26,7 @@ repos:
args: [-w, -L, "THIRDPARTY"]

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.10 # Ruff version
rev: v0.11.0 # Ruff version
hooks:
- id: ruff
args: [--fix, --show-fixes]
7 changes: 6 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -6,6 +6,12 @@ Changelog
Development Version
====================

Version 0.24.1
==============
* Fixed multi plugin id was read from the wrong place by @henryiii, #240.
* Implemented alternative plugin sorting, #243

Version 0.24
============
* Fix integration with ``SchemaStore`` by loading extra/side schemas, #226, #229.
@@ -21,7 +27,6 @@ Version 0.24

Contributions by @henryiii.


Version 0.23
============
* Validate SPDX license expressions by @cdce8p in #217
26 changes: 22 additions & 4 deletions docs/dev-guide.rst
Original file line number Diff line number Diff line change
@@ -118,14 +118,11 @@ When using a :pep:`621`-compliant backend, the following can be add to your
The plugin function will be automatically called with the ``tool_name``
argument as same name as given to the entrypoint (e.g. :samp:`your_plugin({"your-tool"})`).

Also notice plugins are activated in a specific order, using Python's built-in
``sorted`` function.


Providing multiple schemas
--------------------------

A second system is provided for providing multiple schemas in a single plugin.
A second system is defined for providing multiple schemas in a single plugin.
This is useful when a single plugin is responsible for multiple subtables
under the ``tool`` table, or if you need to provide multiple schemas for a
a single subtable.
@@ -158,6 +155,27 @@ An example of the plugin structure needed for this system is shown below:
Fragments for schemas are also supported with this system; use ``#`` to split
the tool name and fragment path in the dictionary key.


.. admonition:: Experimental: Conflict Resolution

Please notice that when two plugins define the same ``tool``
(or auxiliary schemas with the same ``$id``),
an internal conflict resolution heuristic is employed to decide
which schema will take effect.

To influence this heuristic you can:

- Define a numeric ``.priority`` property in the functions
pointed by the ``validate_pyproject.tool_schema`` entry-points.
- Add a ``"priority"`` key with a numeric value into the dictionary
returned by the ``validate_pyproject.multi_schema`` plugins.

Typical values for ``priority`` are ``0`` and ``1``.

The exact order in which the plugins are loaded is considered an
implementation detail.


.. _entry-point: https://setuptools.pypa.io/en/stable/userguide/entry_point.html#entry-points
.. _JSON Schema: https://json-schema.org/
.. _Python package: https://packaging.python.org/
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
@@ -4,7 +4,7 @@ validate-pyproject

**validate-pyproject** is a command line tool and Python library for validating
``pyproject.toml`` files based on JSON Schema, and includes checks for
:pep:`517`, :pep:`518` and :pep:`621`.
:pep:`517`, :pep:`518`, :pep:`621`, :pep:`639`, and :pep:`735`.


Contents
12 changes: 5 additions & 7 deletions src/validate_pyproject/api.py
Original file line number Diff line number Diff line change
@@ -98,7 +98,7 @@ def __init__(self, plugins: Sequence["PluginProtocol"] = ()):
self._schemas: Dict[str, Tuple[str, str, Schema]] = {}
# (which part of the TOML, who defines, schema)

top_level = typing.cast(dict, load(TOP_LEVEL_SCHEMA)) # Make it mutable
top_level = typing.cast("dict", load(TOP_LEVEL_SCHEMA)) # Make it mutable
self._spec_version: str = top_level["$schema"]
top_properties = top_level["properties"]
tool_properties = top_properties["tool"].setdefault("properties", {})
@@ -116,12 +116,10 @@ def __init__(self, plugins: Sequence["PluginProtocol"] = ()):
if plugin.tool:
allow_overwrite: Optional[str] = None
if plugin.tool in tool_properties:
_logger.warning(
f"{plugin.id} overwrites `tool.{plugin.tool}` schema"
)
_logger.warning(f"{plugin} overwrites `tool.{plugin.tool}` schema")
allow_overwrite = plugin.schema.get("$id")
else:
_logger.info(f"{plugin.id} defines `tool.{plugin.tool}` schema")
_logger.info(f"{plugin} defines `tool.{plugin.tool}` schema")
compatible = self._ensure_compatibility(
plugin.tool, plugin.schema, allow_overwrite
)
@@ -130,7 +128,7 @@ def __init__(self, plugins: Sequence["PluginProtocol"] = ()):
tool_properties[plugin.tool] = {"$ref": sref}
self._schemas[sid] = (f"tool.{plugin.tool}", plugin.id, plugin.schema)
else:
_logger.info(f"Extra schema: {plugin.id}")
_logger.info(f"{plugin} defines extra schema {plugin.id}")
self._schemas[plugin.id] = (plugin.id, plugin.id, plugin.schema)

self._main_id: str = top_level["$id"]
@@ -280,7 +278,7 @@ def __call__(self, pyproject: T) -> T:
self.schema, self.handlers, dict(self.formats), use_default=False
)
fn = partial(compiled, custom_formats=self._format_validators)
self._cache = typing.cast(ValidationFn, fn)
self._cache = typing.cast("ValidationFn", fn)

with detailed_errors():
self._cache(pyproject)
70 changes: 50 additions & 20 deletions src/validate_pyproject/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -25,6 +25,9 @@
from .. import __version__
from ..types import Plugin, Schema

_DEFAULT_MULTI_PRIORITY = 0
_DEFAULT_TOOL_PRIORITY = 1


class PluginProtocol(Protocol):
@property
@@ -64,6 +67,10 @@ def schema(self) -> Schema:
def fragment(self) -> str:
return ""

@property
def priority(self) -> float:
return getattr(self._load_fn, "priority", _DEFAULT_TOOL_PRIORITY)

@property
def help_text(self) -> str:
tpl = self._load_fn.__doc__
@@ -74,15 +81,20 @@ def help_text(self) -> str:
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.tool!r}, {self.id})"

def __str__(self) -> str:
return self.id


class StoredPlugin:
def __init__(self, tool: str, schema: Schema):
def __init__(self, tool: str, schema: Schema, source: str, priority: float):
self._tool, _, self._fragment = tool.partition("#")
self._schema = schema
self._source = source
self._priority = priority

@property
def id(self) -> str:
return self.schema.get("id", "MISSING ID")
return self._schema["$id"] # type: ignore[no-any-return]

@property
def tool(self) -> str:
@@ -96,10 +108,17 @@ def schema(self) -> Schema:
def fragment(self) -> str:
return self._fragment

@property
def priority(self) -> float:
return self._priority

@property
def help_text(self) -> str:
return self.schema.get("description", "")

def __str__(self) -> str:
return self._source

def __repr__(self) -> str:
args = [repr(self.tool), self.id]
if self.fragment:
@@ -108,7 +127,7 @@ def __repr__(self) -> str:


if typing.TYPE_CHECKING:
_: PluginProtocol = typing.cast(PluginWrapper, None)
_: PluginProtocol = typing.cast("PluginWrapper", None)


def iterate_entry_points(group: str) -> Iterable[EntryPoint]:
@@ -124,7 +143,7 @@ def iterate_entry_points(group: str) -> Iterable[EntryPoint]:
# The select method was introduced in importlib_metadata 3.9 (and Python 3.10)
# and the previous dict interface was declared deprecated
select = typing.cast(
Callable[..., Iterable[EntryPoint]],
"Callable[..., Iterable[EntryPoint]]",
getattr(entries, "select"), # noqa: B009
) # typecheck gymnastics
return select(group=group)
@@ -150,25 +169,40 @@ def load_from_multi_entry_point(
try:
fn = entry_point.load()
output = fn()
id_ = f"{fn.__module__}.{fn.__name__}"
except Exception as ex:
raise ErrorLoadingPlugin(entry_point=entry_point) from ex

priority = output.get("priority", _DEFAULT_MULTI_PRIORITY)
for tool, schema in output["tools"].items():
yield StoredPlugin(tool, schema)
for schema in output.get("schemas", []):
yield StoredPlugin("", schema)
yield StoredPlugin(tool, schema, f"{id_}:{tool}", priority)
for i, schema in enumerate(output.get("schemas", [])):
yield StoredPlugin("", schema, f"{id_}:{i}", priority)


class _SortablePlugin(NamedTuple):
priority: int
name: str
plugin: Union[PluginWrapper, StoredPlugin]

def key(self) -> str:
return self.plugin.tool or self.plugin.id

def __lt__(self, other: Any) -> bool:
return (self.plugin.tool or self.plugin.id, self.name, self.priority) < (
other.plugin.tool or other.plugin.id,
# **Major concern**:
# Consistency and reproducibility on which entry-points have priority
# for a given environment.
# The plugin with higher priority overwrites the schema definition.
# The exact order that they are listed itself is not important for now.
# **Implementation detail**:
# By default, "single tool plugins" have priority 1 and "multi plugins"
# have priority 0.
# The order that the plugins will be listed is inverse to the priority.
# If 2 plugins have the same numerical priority, the one whose
# entry-point name is "higher alphabetically" wins.
return (self.plugin.priority, self.name, self.key()) < (
other.plugin.priority,
other.name,
other.priority,
other.key(),
)


@@ -184,23 +218,19 @@ def list_from_entry_points(
plugin should be included.
"""
tool_eps = (
_SortablePlugin(0, e.name, load_from_entry_point(e))
_SortablePlugin(e.name, load_from_entry_point(e))
for e in iterate_entry_points("validate_pyproject.tool_schema")
if filtering(e)
)
multi_eps = (
_SortablePlugin(1, e.name, p)
for e in sorted(
iterate_entry_points("validate_pyproject.multi_schema"),
key=lambda e: e.name,
reverse=True,
)
_SortablePlugin(e.name, p)
for e in iterate_entry_points("validate_pyproject.multi_schema")
for p in load_from_multi_entry_point(e)
if filtering(e)
)
eps = chain(tool_eps, multi_eps)
dedup = {e.plugin.tool or e.plugin.id: e.plugin for e in sorted(eps, reverse=True)}
return list(dedup.values())[::-1]
dedup = {e.key(): e.plugin for e in sorted(eps)}
return list(dedup.values())


class ErrorLoadingPlugin(RuntimeError):
2 changes: 1 addition & 1 deletion src/validate_pyproject/remote.py
Original file line number Diff line number Diff line change
@@ -89,4 +89,4 @@ def load_store(pyproject_url: str) -> Generator[RemotePlugin, None, None]:
if typing.TYPE_CHECKING:
from .plugins import PluginProtocol

_: PluginProtocol = typing.cast(RemotePlugin, None)
_: PluginProtocol = typing.cast("RemotePlugin", None)
Loading