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

abravalheri / validate-pyproject / 27592220927

15 Jun 2026 06:39PM UTC coverage: 97.735% (+0.09%) from 97.647%
27592220927

push

github

web-flow
fix: add timeout to network requests (#319)

Without a timeout, a stalled connection to PyPI or a schema host hangs
indefinitely. This is especially problematic when running as a pre-commit
hook. Set a 10-second timeout via a module-level constant.

Assisted-by: ClaudeCode:claude-sonnet-4-6

88 of 96 branches covered (91.67%)

Branch coverage included in aggregate %.

4 of 4 new or added lines in 2 files covered. (100.0%)

11 existing lines in 2 files now uncovered.

1077 of 1096 relevant lines covered (98.27%)

5.88 hits per line

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

99.48
/src/validate_pyproject/error_reporting.py
1
from __future__ import annotations
6✔
2

3
import io
6✔
4
import json
6✔
5
import logging
6✔
6
import os
6✔
7
import re
6✔
8
import typing
6✔
9
from contextlib import contextmanager
6✔
10
from textwrap import indent, wrap
6✔
11
from typing import Any
6✔
12

13
from fastjsonschema import JsonSchemaValueException
6✔
14

15
if typing.TYPE_CHECKING:
16
    import sys
17
    from collections.abc import Generator, Iterator, Sequence
18

19
    if sys.version_info < (3, 11):
20
        from typing_extensions import Self
21
    else:
22
        from typing import Self
23

24
_logger = logging.getLogger(__name__)
6✔
25

26
_MESSAGE_REPLACEMENTS = {
6✔
27
    "must be named by propertyName definition": "keys must be named by",
28
    "one of contains definition": "at least one item that matches",
29
    " same as const definition:": "",
30
    "only specified items": "only items matching the definition",
31
}
32

33
_SKIP_DETAILS = (
6✔
34
    "must not be empty",
35
    "is always invalid",
36
    "must not be there",
37
)
38

39
_NEED_DETAILS = {"anyOf", "oneOf", "allOf", "contains", "propertyNames", "not", "items"}
6✔
40

41
_CAMEL_CASE_SPLITTER = re.compile(r"\W+|([A-Z][^A-Z\W]*)")
6✔
42

43
_TOML_JARGON = {
6✔
44
    "object": "table",
45
    "property": "key",
46
    "properties": "keys",
47
    "property names": "keys",
48
}
49

50
_FORMATS_HELP = """
6✔
51
For more details about `format` see
52
https://validate-pyproject.readthedocs.io/en/latest/api/validate_pyproject.formats.html
53
"""
54

55

56
class ValidationError(JsonSchemaValueException):
6✔
57
    """Report violations of a given JSON schema.
58

59
    This class extends :exc:`~fastjsonschema.JsonSchemaValueException`
60
    by adding the following properties:
61

62
    - ``summary``: an improved version of the ``JsonSchemaValueException`` error message
63
      with only the necessary information)
64

65
    - ``details``: more contextual information about the error like the failing schema
66
      itself and the value that violates the schema.
67

68
    Depending on the level of the verbosity of the ``logging`` configuration
69
    the exception message will be only ``summary`` (default) or a combination of
70
    ``summary`` and ``details`` (when the logging level is set to :obj:`logging.DEBUG`).
71
    """
72

73
    summary = ""
6✔
74
    details = ""
6✔
75
    _original_message = ""
6✔
76

77
    @classmethod
6✔
78
    def _from_jsonschema(cls, ex: JsonSchemaValueException) -> Self:
6✔
79
        formatter = _ErrorFormatting(ex)
6✔
80
        obj = cls(str(formatter), ex.value, formatter.name, ex.definition, ex.rule)
6✔
81
        debug_code = os.getenv("JSONSCHEMA_DEBUG_CODE_GENERATION", "false").lower()
6✔
82
        if debug_code != "false":  # pragma: no cover
83
            obj.__cause__, obj.__traceback__ = ex.__cause__, ex.__traceback__
84
        obj._original_message = ex.message
6✔
85
        obj.summary = formatter.summary
6✔
86
        obj.details = formatter.details
6✔
87
        return obj
6✔
88

89

90
@contextmanager
6✔
91
def detailed_errors() -> Generator[None, None, None]:
6✔
92
    try:
6✔
93
        yield
6✔
94
    except JsonSchemaValueException as ex:
6✔
95
        raise ValidationError._from_jsonschema(ex) from None
6✔
96

97

98
class _ErrorFormatting:
6✔
99
    def __init__(self, ex: JsonSchemaValueException):
6✔
100
        self.ex = ex
6✔
101
        self.name = f"`{self._simplify_name(ex.name)}`"
6✔
102
        self._original_message: str = self.ex.message.replace(ex.name, self.name)
6✔
103
        self._summary = ""
6✔
104
        self._details = ""
6✔
105

106
    def __str__(self) -> str:
6✔
107
        if _logger.getEffectiveLevel() <= logging.DEBUG and self.details:
6✔
108
            return f"{self.summary}\n\n{self.details}"
6✔
109

110
        return self.summary
6✔
111

112
    @property
6✔
113
    def summary(self) -> str:
6✔
114
        if not self._summary:
6✔
115
            self._summary = self._expand_summary()
6✔
116

117
        return self._summary
6✔
118

119
    @property
6✔
120
    def details(self) -> str:
6✔
121
        if not self._details:
6✔
122
            self._details = self._expand_details()
6✔
123

124
        return self._details
6✔
125

126
    @staticmethod
6✔
127
    def _simplify_name(name: str) -> str:
6✔
128
        x = len("data.")
6✔
129
        return name[x:] if name.startswith("data.") else name
6✔
130

131
    def _expand_summary(self) -> str:
6✔
132
        msg = self._original_message
6✔
133

134
        for bad, repl in _MESSAGE_REPLACEMENTS.items():
6✔
135
            msg = msg.replace(bad, repl)
6✔
136

137
        if any(substring in msg for substring in _SKIP_DETAILS):
6✔
138
            return msg
6✔
139

140
        schema = self.ex.rule_definition
6✔
141
        if self.ex.rule in _NEED_DETAILS and schema:
6✔
142
            summary = _SummaryWriter(_TOML_JARGON)
6✔
143
            return f"{msg}:\n\n{indent(summary(schema), '    ')}"
6✔
144

145
        return msg
6✔
146

147
    def _expand_details(self) -> str:
6✔
148
        optional = []
6✔
149
        definition = dict(self.ex.definition) if self.ex.definition else {}
6✔
150
        desc_lines = definition.pop("$$description", [])
6✔
151
        desc = definition.pop("description", None) or " ".join(desc_lines)
6✔
152
        if desc:
6✔
153
            description = "\n".join(
6✔
154
                wrap(
155
                    desc,
156
                    width=80,
157
                    initial_indent="    ",
158
                    subsequent_indent="    ",
159
                    break_long_words=False,
160
                )
161
            )
162
            optional.append(f"DESCRIPTION:\n{description}")
6✔
163
        schema = json.dumps(definition, indent=4)
6✔
164
        value = json.dumps(self.ex.value, indent=4)
6✔
165
        defaults = [
6✔
166
            f"GIVEN VALUE:\n{indent(value, '    ')}",
167
            f"OFFENDING RULE: {self.ex.rule!r}",
168
            f"DEFINITION:\n{indent(schema, '    ')}",
169
        ]
170
        msg = "\n\n".join(optional + defaults)
6✔
171
        epilog = f"\n{_FORMATS_HELP}" if "format" in msg.lower() else ""
6✔
172
        return msg + epilog
6✔
173

174

175
class _SummaryWriter:
6✔
176
    _IGNORE = frozenset(("description", "default", "title", "examples"))
6✔
177

178
    def __init__(self, jargon: dict[str, str] | None = None):
6✔
179
        self.jargon: dict[str, str] = jargon or {}
6✔
180
        # Clarify confusing terms
181
        self._terms = {
6✔
182
            "anyOf": "at least one of the following",
183
            "oneOf": "exactly one of the following",
184
            "allOf": "all of the following",
185
            "not": "(*NOT* the following)",
186
            "prefixItems": f"{self._jargon('items')} (in order)",
187
            "items": "items",
188
            "contains": "contains at least one of",
189
            "propertyNames": (
190
                f"non-predefined acceptable {self._jargon('property names')}"
191
            ),
192
            "patternProperties": f"{self._jargon('properties')} named via pattern",
193
            "const": "predefined value",
194
            "enum": "one of",
195
        }
196
        # Attributes that indicate that the definition is easy and can be done
197
        # inline (e.g. string and number)
198
        self._guess_inline_defs = [
6✔
199
            "enum",
200
            "const",
201
            "maxLength",
202
            "minLength",
203
            "pattern",
204
            "format",
205
            "minimum",
206
            "maximum",
207
            "exclusiveMinimum",
208
            "exclusiveMaximum",
209
            "multipleOf",
210
        ]
211

212
    def _jargon(self, term: str | list[str]) -> str | list[str]:
6✔
213
        if isinstance(term, list):
6✔
214
            return [self.jargon.get(t, t) for t in term]
6✔
215
        return self.jargon.get(term, term)
6✔
216

217
    def __call__(
6✔
218
        self,
219
        schema: dict | list[dict],
220
        prefix: str = "",
221
        *,
222
        _path: Sequence[str] = (),
223
    ) -> str:
224
        if isinstance(schema, list):
6✔
225
            return self._handle_list(schema, prefix, _path)
6✔
226

227
        filtered = self._filter_unnecessary(schema, _path)
6✔
228
        simple = self._handle_simple_dict(filtered, _path)
6✔
229
        if simple:
6✔
230
            return f"{prefix}{simple}"
6✔
231

232
        child_prefix = self._child_prefix(prefix, "  ")
6✔
233
        item_prefix = self._child_prefix(prefix, "- ")
6✔
234
        indent = len(prefix) * " "
6✔
235
        with io.StringIO() as buffer:
6✔
236
            for i, (key, value) in enumerate(filtered.items()):
6✔
237
                child_path = [*_path, key]
6✔
238
                line_prefix = prefix if i == 0 else indent
6✔
239
                buffer.write(f"{line_prefix}{self._label(child_path)}:")
6✔
240
                # ^  just the first item should receive the complete prefix
241
                if isinstance(value, dict):
6✔
242
                    filtered = self._filter_unnecessary(value, child_path)
6✔
243
                    simple = self._handle_simple_dict(filtered, child_path)
6✔
244
                    buffer.write(
6✔
245
                        f" {simple}"
246
                        if simple
247
                        else f"\n{self(value, child_prefix, _path=child_path)}"
248
                    )
249
                elif isinstance(value, list) and (
6✔
250
                    key != "type" or self._is_property(child_path)
251
                ):
252
                    children = self._handle_list(value, item_prefix, child_path)
6✔
253
                    sep = " " if children.startswith("[") else "\n"
6✔
254
                    buffer.write(f"{sep}{children}")
6✔
255
                else:
256
                    buffer.write(f" {self._value(value, child_path)}\n")
6✔
257
            return buffer.getvalue()
6✔
258

259
    def _is_unnecessary(self, path: Sequence[str]) -> bool:
6✔
260
        if self._is_property(path) or not path:  # empty path => instruction @ root
6✔
261
            return False
6✔
262
        key = path[-1]
6✔
263
        return any(key.startswith(k) for k in "$_") or key in self._IGNORE
6✔
264

265
    def _filter_unnecessary(
6✔
266
        self, schema: dict[str, Any], path: Sequence[str]
267
    ) -> dict[str, Any]:
268
        return {
6✔
269
            key: value
270
            for key, value in schema.items()
271
            if not self._is_unnecessary([*path, key])
272
        }
273

274
    def _handle_simple_dict(self, value: dict, path: Sequence[str]) -> str | None:
6✔
275
        inline = any(p in value for p in self._guess_inline_defs)
6✔
276
        simple = not any(isinstance(v, (list, dict)) for v in value.values())
6✔
277
        if inline or simple:
6✔
278
            return f"{{{', '.join(self._inline_attrs(value, path))}}}\n"
6✔
279
        return None
6✔
280

281
    def _handle_list(
6✔
282
        self, schemas: list, prefix: str = "", path: Sequence[str] = ()
283
    ) -> str:
284
        if self._is_unnecessary(path):
6✔
UNCOV
285
            return ""
×
286

287
        repr_ = repr(schemas)
6✔
288
        if all(not isinstance(e, (dict, list)) for e in schemas) and len(repr_) < 60:
6✔
289
            return f"{repr_}\n"
6✔
290

291
        item_prefix = self._child_prefix(prefix, "- ")
6✔
292
        return "".join(
6✔
293
            self(v, item_prefix, _path=[*path, f"[{i}]"]) for i, v in enumerate(schemas)
294
        )
295

296
    def _is_property(self, path: Sequence[str]) -> bool:
6✔
297
        """Check if the given path can correspond to an arbitrarily named property"""
298
        counter = 0
6✔
299
        for key in path[-2::-1]:
6✔
300
            if key not in {"properties", "patternProperties"}:
6✔
301
                break
6✔
302
            counter += 1
6✔
303

304
        # If the counter if even, the path correspond to a JSON Schema keyword
305
        # otherwise it can be any arbitrary string naming a property
306
        return counter % 2 == 1
6✔
307

308
    def _label(self, path: Sequence[str]) -> str:
6✔
309
        *parents, key = path
6✔
310
        if not self._is_property(path):
6✔
311
            norm_key = _separate_terms(key)
6✔
312
            return self._terms.get(key) or " ".join(self._jargon(norm_key))
6✔
313

314
        if parents[-1] == "patternProperties":
6✔
315
            return f"(regex {key!r})"
6✔
316
        return repr(key)  # property name
6✔
317

318
    def _value(self, value: Any, path: Sequence[str]) -> str:
6✔
319
        if path[-1] == "type" and not self._is_property(path):
6✔
320
            type_ = self._jargon(value)
6✔
321
            return f"[{', '.join(type_)}]" if isinstance(type_, list) else type_
6✔
322
        return repr(value)
6✔
323

324
    def _inline_attrs(self, schema: dict, path: Sequence[str]) -> Iterator[str]:
6✔
325
        for key, value in schema.items():
6✔
326
            child_path = [*path, key]
6✔
327
            yield f"{self._label(child_path)}: {self._value(value, child_path)}"
6✔
328

329
    def _child_prefix(self, parent_prefix: str, child_prefix: str) -> str:
6✔
330
        return len(parent_prefix) * " " + child_prefix
6✔
331

332

333
def _separate_terms(word: str) -> list[str]:
6✔
334
    """
335
    >>> _separate_terms("FooBar-foo")
336
    ['foo', 'bar', 'foo']
337
    """
338
    return [w.lower() for w in _CAMEL_CASE_SPLITTER.split(word) if w]
6✔
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