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

abravalheri / validate-pyproject / 6173991897923584

11 Nov 2024 04:41PM CUT coverage: 97.859%. Remained the same
6173991897923584

Pull #218

cirrus-ci

pre-commit-ci[bot]
[pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci
Pull Request #218: [pre-commit.ci] pre-commit autoupdate

293 of 306 branches covered (95.75%)

Branch coverage included in aggregate %.

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

941 of 955 relevant lines covered (98.53%)

6.89 hits per line

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

99.19
/src/validate_pyproject/error_reporting.py
1
import io
7✔
2
import json
7✔
3
import logging
7✔
4
import os
7✔
5
import re
7✔
6
import typing
7✔
7
from contextlib import contextmanager
7✔
8
from textwrap import indent, wrap
7✔
9
from typing import Any, Dict, Generator, Iterator, List, Optional, Sequence, Union
7✔
10

11
from fastjsonschema import JsonSchemaValueException
7✔
12

13
if typing.TYPE_CHECKING:
14
    import sys
15

16
    if sys.version_info < (3, 11):
17
        from typing_extensions import Self
18
    else:
19
        from typing import Self
20

21
_logger = logging.getLogger(__name__)
7✔
22

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

30
_SKIP_DETAILS = (
7✔
31
    "must not be empty",
32
    "is always invalid",
33
    "must not be there",
34
)
35

36
_NEED_DETAILS = {"anyOf", "oneOf", "allOf", "contains", "propertyNames", "not", "items"}
7✔
37

38
_CAMEL_CASE_SPLITTER = re.compile(r"\W+|([A-Z][^A-Z\W]*)")
7✔
39
_IDENTIFIER = re.compile(r"^[\w_]+$", re.I)
7✔
40

41
_TOML_JARGON = {
7✔
42
    "object": "table",
43
    "property": "key",
44
    "properties": "keys",
45
    "property names": "keys",
46
}
47

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

53

54
class ValidationError(JsonSchemaValueException):
7✔
55
    """Report violations of a given JSON schema.
56

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

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

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

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

71
    summary = ""
7✔
72
    details = ""
7✔
73
    _original_message = ""
7✔
74

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

87

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

95

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

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

108
        return self.summary
7✔
109

110
    @property
7✔
111
    def summary(self) -> str:
7✔
112
        if not self._summary:
7✔
113
            self._summary = self._expand_summary()
7✔
114

115
        return self._summary
7✔
116

117
    @property
7✔
118
    def details(self) -> str:
7✔
119
        if not self._details:
7✔
120
            self._details = self._expand_details()
7✔
121

122
        return self._details
7✔
123

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

129
    def _expand_summary(self) -> str:
7✔
130
        msg = self._original_message
7✔
131

132
        for bad, repl in _MESSAGE_REPLACEMENTS.items():
7✔
133
            msg = msg.replace(bad, repl)
7✔
134

135
        if any(substring in msg for substring in _SKIP_DETAILS):
7✔
136
            return msg
7✔
137

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

143
        return msg
7✔
144

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

172

173
class _SummaryWriter:
7✔
174
    _IGNORE = frozenset(("description", "default", "title", "examples"))
7✔
175

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

210
    def _jargon(self, term: Union[str, List[str]]) -> Union[str, List[str]]:
7✔
211
        if isinstance(term, list):
7✔
212
            return [self.jargon.get(t, t) for t in term]
7✔
213
        return self.jargon.get(term, term)
7✔
214

215
    def __call__(
7✔
216
        self,
217
        schema: Union[dict, List[dict]],
218
        prefix: str = "",
219
        *,
220
        _path: Sequence[str] = (),
221
    ) -> str:
222
        if isinstance(schema, list):
7✔
223
            return self._handle_list(schema, prefix, _path)
7✔
224

225
        filtered = self._filter_unecessary(schema, _path)
7✔
226
        simple = self._handle_simple_dict(filtered, _path)
7✔
227
        if simple:
7✔
228
            return f"{prefix}{simple}"
7✔
229

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

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

263
    def _filter_unecessary(
7✔
264
        self, schema: Dict[str, Any], path: Sequence[str]
265
    ) -> Dict[str, Any]:
266
        return {
7✔
267
            key: value
268
            for key, value in schema.items()
269
            if not self._is_unecessary([*path, key])
270
        }
271

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

279
    def _handle_list(
7✔
280
        self, schemas: list, prefix: str = "", path: Sequence[str] = ()
281
    ) -> str:
282
        if self._is_unecessary(path):
7!
283
            return ""
×
284

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

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

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

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

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

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

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

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

327
    def _child_prefix(self, parent_prefix: str, child_prefix: str) -> str:
7✔
328
        return len(parent_prefix) * " " + child_prefix
7✔
329

330

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