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

abravalheri / validate-pyproject / 4900349245390848

pending completion
4900349245390848

Pull #82

cirrus-ci

Anderson Bravalheri
Add test case for extra keys in authors
Pull Request #82: Error-out when extra keys are added to project.authos/maintainers

255 of 261 branches covered (97.7%)

Branch coverage included in aggregate %.

736 of 746 relevant lines covered (98.66%)

10.85 hits per line

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

95.43
/src/validate_pyproject/api.py
1
"""
2
Retrieve JSON schemas for validating dicts representing a ``pyproject.toml`` file.
3
"""
4
import json
11✔
5
import logging
11✔
6
import sys
11✔
7
from enum import Enum
11✔
8
from functools import partial, reduce
11✔
9
from itertools import chain
11✔
10
from types import MappingProxyType, ModuleType
11✔
11
from typing import (
11✔
12
    TYPE_CHECKING,
13
    Callable,
14
    Dict,
15
    Iterator,
16
    Mapping,
17
    Optional,
18
    Sequence,
19
    Tuple,
20
    TypeVar,
21
    Union,
22
    cast,
23
)
24

25
from . import errors, formats
11✔
26
from ._vendor import fastjsonschema as FJS
11✔
27
from .error_reporting import detailed_errors
11✔
28
from .extra_validations import EXTRA_VALIDATIONS
11✔
29
from .types import FormatValidationFn, Schema, ValidationFn
11✔
30

31
_logger = logging.getLogger(__name__)
11✔
32
_chain_iter = chain.from_iterable
11✔
33

34
if TYPE_CHECKING:  # pragma: no cover
35
    from .plugins import PluginWrapper  # noqa
36

37

38
try:  # pragma: no cover
39
    if sys.version_info[:2] < (3, 7) or TYPE_CHECKING:  # See #22
40
        from importlib_resources import files
41
    else:
42
        from importlib.resources import files
43

44
    def read_text(package: Union[str, ModuleType], resource) -> str:
45
        return files(package).joinpath(resource).read_text(encoding="utf-8")
46

47
except ImportError:  # pragma: no cover
48
    from importlib.resources import read_text
49

50

51
T = TypeVar("T", bound=Mapping)
11✔
52
AllPlugins = Enum("AllPlugins", "ALL_PLUGINS")
11✔
53
ALL_PLUGINS = AllPlugins.ALL_PLUGINS
11✔
54

55
TOP_LEVEL_SCHEMA = "pyproject_toml"
11✔
56
PROJECT_TABLE_SCHEMA = "project_metadata"
11✔
57

58

59
def _get_public_functions(module: ModuleType) -> Mapping[str, FormatValidationFn]:
11✔
60
    return {
11✔
61
        fn.__name__.replace("_", "-"): fn
62
        for fn in module.__dict__.values()
63
        if callable(fn) and not fn.__name__.startswith("_")
64
    }
65

66

67
FORMAT_FUNCTIONS = MappingProxyType(_get_public_functions(formats))
11✔
68

69

70
def load(name: str, package: str = __package__, ext: str = ".schema.json") -> Schema:
11✔
71
    """Load the schema from a JSON Schema file.
72
    The returned dict-like object is immutable.
73
    """
74
    return Schema(json.loads(read_text(package, f"{name}{ext}")))
11✔
75

76

77
def load_builtin_plugin(name: str) -> Schema:
11✔
78
    return load(name, f"{__package__}.plugins")
11✔
79

80

81
class SchemaRegistry(Mapping[str, Schema]):
11✔
82
    """Repository of parsed JSON Schemas used for validating a ``pyproject.toml``.
83

84
    During instantiation the schemas equivalent to PEP 517, PEP 518 and PEP 621
85
    will be combined with the schemas for the ``tool`` subtables provided by the
86
    plugins.
87

88
    Since this object work as a mapping between each schema ``$id`` and the schema
89
    itself, all schemas provided by plugins **MUST** have a top level ``$id``.
90
    """
91

92
    def __init__(self, plugins: Sequence["PluginWrapper"] = ()):
11✔
93
        self._schemas: Dict[str, Tuple[str, str, Schema]] = {}
11✔
94
        # (which part of the TOML, who defines, schema)
95

96
        top_level = cast(dict, load(TOP_LEVEL_SCHEMA))  # Make it mutable
11✔
97
        self._spec_version = top_level["$schema"]
11✔
98
        top_properties = top_level["properties"]
11✔
99
        tool_properties = top_properties["tool"].setdefault("properties", {})
11✔
100

101
        # Add PEP 621
102
        project_table_schema = load(PROJECT_TABLE_SCHEMA)
11✔
103
        self._ensure_compatibility(PROJECT_TABLE_SCHEMA, project_table_schema)
11✔
104
        sid = project_table_schema["$id"]
11✔
105
        top_level["project"] = {"$ref": sid}
11✔
106
        origin = f"{__name__} - project metadata"
11✔
107
        self._schemas = {sid: ("project", origin, project_table_schema)}
11✔
108

109
        # Add tools using Plugins
110

111
        for plugin in plugins:
11✔
112
            pid, tool, schema = plugin.id, plugin.tool, plugin.schema
11✔
113
            if plugin.tool in tool_properties:
11✔
114
                _logger.warning(f"{plugin.id} overwrites `tool.{plugin.tool}` schema")
11✔
115
            else:
116
                _logger.info(f"{pid} defines `tool.{tool}` schema")
11✔
117
            sid = self._ensure_compatibility(tool, schema)["$id"]
11✔
118
            tool_properties[tool] = {"$ref": sid}
11✔
119
            self._schemas[sid] = (f"tool.{tool}", pid, schema)
11✔
120

121
        self._main_id = sid = top_level["$id"]
11✔
122
        main_schema = Schema(top_level)
11✔
123
        origin = f"{__name__} - build metadata"
11✔
124
        self._schemas[sid] = ("<$ROOT>", origin, main_schema)
11✔
125

126
    @property
11✔
127
    def spec_version(self) -> str:
11✔
128
        """Version of the JSON Schema spec in use"""
129
        return self._spec_version
11✔
130

131
    @property
11✔
132
    def main(self) -> str:
11✔
133
        """Top level schema for validating a ``pyproject.toml`` file"""
134
        return self._main_id
11✔
135

136
    def _ensure_compatibility(self, reference: str, schema: Schema) -> Schema:
11✔
137
        if "$id" not in schema:
11✔
138
            raise errors.SchemaMissingId(reference)
11✔
139
        sid = schema["$id"]
11✔
140
        if sid in self._schemas:
11✔
141
            raise errors.SchemaWithDuplicatedId(sid)
11✔
142
        version = schema.get("$schema")
11✔
143
        if version and version != self.spec_version:
11✔
144
            raise errors.InvalidSchemaVersion(reference, version, self.spec_version)
11✔
145
        return schema
11✔
146

147
    def __getitem__(self, key: str) -> Schema:
11✔
148
        return self._schemas[key][-1]
11✔
149

150
    def __iter__(self) -> Iterator[str]:
11✔
151
        return iter(self._schemas)
×
152

153
    def __len__(self) -> int:
11✔
154
        return len(self._schemas)
×
155

156

157
class RefHandler(Mapping[str, Callable[[str], Schema]]):
11✔
158
    """:mod:`fastjsonschema` allows passing a dict-like object to load external schema
159
    ``$ref``s. Such objects map the URI schema (e.g. ``http``, ``https``, ``ftp``)
160
    into a function that receives the schema URI and returns the schema (as parsed JSON)
161
    (otherwise :mod:`urllib` is used and the URI is assumed to be a valid URL).
162
    This class will ensure all the URIs are loaded from the local registry.
163
    """
164

165
    def __init__(self, registry: Mapping[str, Schema]):
11✔
166
        self._uri_schemas = ["http", "https"]
11✔
167
        self._registry = registry
11✔
168

169
    def __contains__(self, key) -> bool:
11✔
170
        return_val = isinstance(key, str)
11✔
171
        if return_val and key not in self._uri_schemas:
11!
172
            self._uri_schemas.append(key)
×
173
        return return_val
11✔
174

175
    def __iter__(self) -> Iterator[str]:
11✔
176
        return iter(self._uri_schemas)
×
177

178
    def __len__(self):
11✔
179
        return len(self._uri_schemas)
×
180

181
    def __getitem__(self, key: str) -> Callable[[str], Schema]:
11✔
182
        """All the references should be retrieved from the registry"""
183
        return self._registry.__getitem__
11✔
184

185

186
class Validator:
11✔
187
    def __init__(
11✔
188
        self,
189
        plugins: Union[Sequence["PluginWrapper"], AllPlugins] = ALL_PLUGINS,
190
        format_validators: Mapping[str, FormatValidationFn] = FORMAT_FUNCTIONS,
191
        extra_validations: Sequence[ValidationFn] = EXTRA_VALIDATIONS,
192
    ):
193
        self._code_cache: Optional[str] = None
11✔
194
        self._cache: Optional[ValidationFn] = None
11✔
195
        self._schema: Optional[Schema] = None
11✔
196

197
        # Let's make the following options readonly
198
        self._format_validators = MappingProxyType(format_validators)
11✔
199
        self._extra_validations = tuple(extra_validations)
11✔
200

201
        if plugins is ALL_PLUGINS:
11✔
202
            from .plugins import list_from_entry_points
11✔
203

204
            self._plugins = tuple(list_from_entry_points())
11✔
205
        else:
206
            self._plugins = tuple(plugins)  # force immutability / read only
11✔
207

208
        self._schema_registry = SchemaRegistry(self._plugins)
11✔
209
        self.handlers = RefHandler(self._schema_registry)
11✔
210

211
    @property
11✔
212
    def registry(self) -> SchemaRegistry:
11✔
213
        return self._schema_registry
11✔
214

215
    @property
11✔
216
    def schema(self) -> Schema:
11✔
217
        """Top level ``pyproject.toml`` JSON Schema"""
218
        return Schema({"$ref": self._schema_registry.main})
11✔
219

220
    @property
11✔
221
    def extra_validations(self) -> Sequence[ValidationFn]:
11✔
222
        """List of extra validation functions that run after the JSON Schema check"""
223
        return self._extra_validations
11✔
224

225
    @property
11✔
226
    def formats(self) -> Mapping[str, FormatValidationFn]:
11✔
227
        """Mapping between JSON Schema formats and functions that validates them"""
228
        return self._format_validators
11✔
229

230
    @property
11✔
231
    def generated_code(self) -> str:
11✔
232
        if self._code_cache is None:
11!
233
            fmts = dict(self.formats)
11✔
234
            self._code_cache = FJS.compile_to_code(self.schema, self.handlers, fmts)
11✔
235

236
        return self._code_cache
11✔
237

238
    def __getitem__(self, schema_id: str) -> Schema:
11✔
239
        """Retrieve a schema from registry"""
240
        return self._schema_registry[schema_id]
×
241

242
    def __call__(self, pyproject: T) -> T:
11✔
243
        if self._cache is None:
11✔
244
            compiled = FJS.compile(self.schema, self.handlers, dict(self.formats))
11✔
245
            fn = partial(compiled, custom_formats=self._format_validators)
11✔
246
            self._cache = cast(ValidationFn, fn)
11✔
247

248
        with detailed_errors():
11✔
249
            self._cache(pyproject)
11✔
250
        return reduce(lambda acc, fn: fn(acc), self.extra_validations, pyproject)
11✔
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

© 2025 Coveralls, Inc