• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

abravalheri / validate-pyproject / 6178271622070272

01 May 2026 06:37PM UTC coverage: 97.628% (-0.07%) from 97.694%
6178271622070272

push

cirrus-ci

web-flow
fix: support relative urls in SchemaStore (#306)

**Root cause**: SchemaStore's `pyproject.json` recently switched from absolute URLs to **relative `$ref` URLs** (e.g. `"$ref": "partial-black.json"`). The code was passing these directly to `load_from_uri`, which tried to open them as local files, causing `FileNotFoundError`.

1. **`src/validate_pyproject/remote.py`** — Resolve relative `$ref` URLs using `urllib.parse.urljoin`:
   - When loading tool schemas from the store, resolve `$ref` against the store URL.
   - When loading nested `$ref`s inside a schema's `properties`, resolve against the schema's `$id`.
   - Also fixed a typo in the protocol check (`("https://", "https://")` → `("http://", "https://")`).

2. **`tools/cache_urls_for_tests.py`** — Updated the cache script to:
   - Resolve relative `$ref`s against `SCHEMA_STORE`.
   - Recursively discover and download nested relative refs (so offline tests work too).

3. **Populated `tests/.cache/`** with all required schemas so the tests run offline.


Assisted-by: OpenCode:Kimi-K2.6

Signed-off-by: Henry Schreiner <henryfs@princeton.edu>

418 of 434 branches covered (96.31%)

Branch coverage included in aggregate %.

5 of 5 new or added lines in 1 file covered. (100.0%)

8 existing lines in 2 files now uncovered.

1064 of 1084 relevant lines covered (98.15%)

0.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.67
/src/validate_pyproject/plugins/__init__.py
1
# The code in this module is mostly borrowed/adapted from PyScaffold and was originally
2
# published under the MIT license
3
# The original PyScaffold license can be found in 'NOTICE.txt'
4
"""
1✔
5
.. _entry point: https://setuptools.readthedocs.io/en/latest/userguide/entry_point.html
6
"""
7

8
from __future__ import annotations
1✔
9

10
import typing
1✔
11
from importlib.metadata import EntryPoint, entry_points
1✔
12
from itertools import chain
1✔
13
from string import Template
1✔
14
from textwrap import dedent
1✔
15
from typing import (
1✔
16
    Any,
17
    Callable,
18
    NamedTuple,
19
    Protocol,
20
)
21

22
from .. import __version__
1✔
23

24
if typing.TYPE_CHECKING:
25
    from collections.abc import Generator, Iterable
26

27
    from ..types import Plugin, Schema
28

29
_DEFAULT_MULTI_PRIORITY = 0
1✔
30
_DEFAULT_TOOL_PRIORITY = 1
1✔
31

32

33
class PluginProtocol(Protocol):
1✔
34
    @property
1✔
35
    def id(self) -> str: ...
1!
36

37
    @property
1✔
38
    def tool(self) -> str: ...
1!
39

40
    @property
1✔
41
    def schema(self) -> Schema: ...
1!
42

43
    @property
1✔
44
    def help_text(self) -> str: ...
1!
45

46
    @property
1✔
47
    def fragment(self) -> str: ...
1!
48

49

50
class PluginWrapper:
1✔
51
    def __init__(self, tool: str, load_fn: Plugin):
1✔
52
        self._tool = tool
1✔
53
        self._load_fn = load_fn
1✔
54

55
    @property
1✔
56
    def id(self) -> str:
1✔
57
        return f"{self._load_fn.__module__}.{self._load_fn.__name__}"
1✔
58

59
    @property
1✔
60
    def tool(self) -> str:
1✔
61
        return self._tool
1✔
62

63
    @property
1✔
64
    def schema(self) -> Schema:
1✔
65
        return self._load_fn(self.tool)
1✔
66

67
    @property
1✔
68
    def fragment(self) -> str:
1✔
69
        return ""
1✔
70

71
    @property
1✔
72
    def priority(self) -> float:
1✔
73
        return getattr(self._load_fn, "priority", _DEFAULT_TOOL_PRIORITY)
1✔
74

75
    @property
1✔
76
    def help_text(self) -> str:
1✔
77
        tpl = self._load_fn.__doc__
1✔
78
        if not tpl:
1✔
79
            return ""
1✔
80
        return Template(tpl).safe_substitute(tool=self.tool, id=self.id)
1✔
81

82
    def __repr__(self) -> str:
83
        return f"{self.__class__.__name__}({self.tool!r}, {self.id})"
84

85
    def __str__(self) -> str:
1✔
86
        return self.id
1✔
87

88

89
class StoredPlugin:
1✔
90
    def __init__(self, tool: str, schema: Schema, source: str, priority: float):
1✔
91
        self._tool, _, self._fragment = tool.partition("#")
1✔
92
        self._schema = schema
1✔
93
        self._source = source
1✔
94
        self._priority = priority
1✔
95

96
    @property
1✔
97
    def id(self) -> str:
1✔
98
        return self._schema["$id"]  # type: ignore[no-any-return]
1✔
99

100
    @property
1✔
101
    def tool(self) -> str:
1✔
102
        return self._tool
1✔
103

104
    @property
1✔
105
    def schema(self) -> Schema:
1✔
106
        return self._schema
1✔
107

108
    @property
1✔
109
    def fragment(self) -> str:
1✔
110
        return self._fragment
1✔
111

112
    @property
1✔
113
    def priority(self) -> float:
1✔
114
        return self._priority
1✔
115

116
    @property
1✔
117
    def help_text(self) -> str:
1✔
118
        return self.schema.get("description") or ""
1✔
119

120
    def __str__(self) -> str:
1✔
121
        return self._source
1✔
122

123
    def __repr__(self) -> str:
124
        args = [repr(self.tool), self.id]
125
        if self.fragment:
126
            args.append(f"fragment={self.fragment!r}")
127
        return f"{self.__class__.__name__}({', '.join(args)}, <schema: {self.id}>)"
128

129

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

133

134
def iterate_entry_points(group: str) -> Iterable[EntryPoint]:
1✔
135
    """Produces an iterable yielding an EntryPoint object for each plugin registered
136
    via ``setuptools`` `entry point`_ mechanism.
137

138
    This method can be used in conjunction with :obj:`load_from_entry_point` to filter
139
    the plugins before actually loading them. The entry points are not
140
    deduplicated.
141
    """
142
    entries = entry_points()
1✔
143
    if hasattr(entries, "select"):  # pragma: no cover
144
        # The select method was introduced in importlib_metadata 3.9 (and Python 3.10)
145
        # and the previous dict interface was declared deprecated
146
        select = typing.cast(
147
            "Callable[..., Iterable[EntryPoint]]",
148
            getattr(entries, "select"),  # noqa: B009
149
        )  # typecheck gymnastics
150
        return select(group=group)
151
    # pragma: no cover
152
    # TODO: Once Python 3.10 becomes the oldest version supported, this fallback and
153
    #       conditional statement can be removed.
UNCOV
154
    return (plugin for plugin in entries.get(group, []))
×
155

156

157
def load_from_entry_point(entry_point: EntryPoint) -> PluginWrapper:
1✔
158
    """Carefully load the plugin, raising a meaningful message in case of errors"""
159
    try:
1✔
160
        fn = entry_point.load()
1✔
161
        return PluginWrapper(entry_point.name, fn)
1✔
162
    except Exception as ex:
1✔
163
        raise ErrorLoadingPlugin(entry_point=entry_point) from ex
1✔
164

165

166
def load_from_multi_entry_point(
1✔
167
    entry_point: EntryPoint,
168
) -> Generator[StoredPlugin, None, None]:
169
    """Carefully load the plugin, raising a meaningful message in case of errors"""
170
    try:
1✔
171
        fn = entry_point.load()
1✔
172
        output = fn()
1✔
173
        id_ = f"{fn.__module__}.{fn.__name__}"
1✔
174
    except Exception as ex:
1✔
175
        raise ErrorLoadingPlugin(entry_point=entry_point) from ex
1✔
176

177
    priority = output.get("priority", _DEFAULT_MULTI_PRIORITY)
1✔
178
    for tool, schema in output["tools"].items():
1✔
179
        yield StoredPlugin(tool, schema, f"{id_}:{tool}", priority)
1✔
180
    for i, schema in enumerate(output.get("schemas", [])):
1✔
181
        yield StoredPlugin("", schema, f"{id_}:{i}", priority)
1✔
182

183

184
class _SortablePlugin(NamedTuple):
1✔
185
    name: str
1✔
186
    plugin: PluginWrapper | StoredPlugin
1✔
187

188
    def key(self) -> str:
1✔
189
        return self.plugin.tool or self.plugin.id
1✔
190

191
    def __lt__(self, other: Any) -> bool:
1✔
192
        # **Major concern**:
193
        # Consistency and reproducibility on which entry-points have priority
194
        # for a given environment.
195
        # The plugin with higher priority overwrites the schema definition.
196
        # The exact order that they are listed itself is not important for now.
197
        # **Implementation detail**:
198
        # By default, "single tool plugins" have priority 1 and "multi plugins"
199
        # have priority 0.
200
        # The order that the plugins will be listed is inverse to the priority.
201
        # If 2 plugins have the same numerical priority, the one whose
202
        # entry-point name is "higher alphabetically" wins.
203
        return (self.plugin.priority, self.name, self.key()) < (
1✔
204
            other.plugin.priority,
205
            other.name,
206
            other.key(),
207
        )
208

209

210
def list_from_entry_points(
1✔
211
    filtering: Callable[[EntryPoint], bool] = lambda _: True,
212
) -> list[PluginWrapper | StoredPlugin]:
213
    """Produces a list of plugin objects for each plugin registered
214
    via ``setuptools`` `entry point`_ mechanism.
215

216
    Args:
217
        filtering: function returning a boolean deciding if the entry point should be
218
            loaded and included (or not) in the final list. A ``True`` return means the
219
            plugin should be included.
220
    """
221
    tool_eps = (
1✔
222
        _SortablePlugin(e.name, load_from_entry_point(e))
223
        for e in iterate_entry_points("validate_pyproject.tool_schema")
224
        if filtering(e)
225
    )
226
    multi_eps = (
1✔
227
        _SortablePlugin(e.name, p)
228
        for e in iterate_entry_points("validate_pyproject.multi_schema")
229
        for p in load_from_multi_entry_point(e)
230
        if filtering(e)
231
    )
232
    eps = chain(tool_eps, multi_eps)
1✔
233
    dedup = {e.key(): e.plugin for e in sorted(eps)}
1✔
234
    return list(dedup.values())
1✔
235

236

237
class ErrorLoadingPlugin(RuntimeError):
1✔
238
    _DESC = """There was an error loading '{plugin}'.
1✔
239
    Please make sure you have installed a version of the plugin that is compatible
240
    with {package} {version}. You can also try uninstalling it.
241
    """
242
    __doc__ = _DESC
1✔
243

244
    def __init__(self, plugin: str = "", entry_point: EntryPoint | None = None):
1✔
245
        if entry_point and not plugin:
1!
246
            plugin = getattr(entry_point, "module", entry_point.name)
1✔
247

248
        sub = {"package": __package__, "version": __version__, "plugin": plugin}
1✔
249
        msg = dedent(self._DESC).format(**sub).splitlines()
1✔
250
        super().__init__(f"{msg[0]}\n{' '.join(msg[1:])}")
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc