Skip to content

Commit 791aba9

Browse files
committed
Move validation to jsonschema
1 parent eaada13 commit 791aba9

13 files changed

+444
-272
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ After a readme is assembled out of fragments, it's possible to run an arbitrary
183183
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
184184
pattern = "This is a (.*) that we'll replace later."
185185
replacement = "It was a '\\1'!"
186-
ignore_case = true # optional; false by default
186+
ignore-case = true # optional; false by default
187187
```
188188

189189
---

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ classifiers = [
2929
]
3030
dependencies = [
3131
"hatchling",
32+
"jsonschema",
3233
"tomli; python_version<'3.11'",
3334
"typing-extensions; python_version<'3.8'",
3435
]
@@ -116,9 +117,13 @@ profile = "attrs"
116117

117118

118119
[tool.mypy]
120+
show_error_codes = true
121+
enable_error_code = ["ignore-without-code"]
119122
strict = true
120123
follow_imports = "normal"
121124
warn_no_return = true
125+
ignore_missing_imports = true
126+
122127

123128
[[tool.mypy.overrides]]
124129
module = "tests.*"

src/hatch_fancy_pypi_readme/_config.py

Lines changed: 90 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
from dataclasses import dataclass
88
from typing import Any
99

10-
from ._fragments import Fragment, load_fragments
11-
from ._substitutions import Substituter, load_substitutions
10+
from jsonschema import Draft202012Validator
11+
12+
from ._fragments import VALID_FRAGMENTS, Fragment
13+
from ._humanize_validation_errors import errors_to_human_strings
14+
from ._substitutions import Substituter
1215
from .exceptions import ConfigurationError
1316

1417

@@ -19,36 +22,97 @@ class Config:
1922
substitutions: list[Substituter]
2023

2124

25+
SCHEMA = {
26+
"type": "object",
27+
"properties": {
28+
"content-type": {
29+
"type": "string",
30+
"enum": ["text/markdown", "text/x-rst"],
31+
},
32+
"fragments": {
33+
"type": "array",
34+
"minItems": 1,
35+
# Items are validated separately for better error messages.
36+
"items": {"type": "object"},
37+
},
38+
"substitutions": {
39+
"type": "array",
40+
"items": {
41+
"type": "object",
42+
"properties": {
43+
"pattern": {"type": "string", "format": "regex"},
44+
"replacement": {"type": "string"},
45+
"ignore-case": {"type": "boolean"},
46+
},
47+
"required": ["pattern", "replacement"],
48+
"additionalProperties": False,
49+
},
50+
},
51+
},
52+
"required": ["content-type", "fragments"],
53+
"additionalProperties": False,
54+
}
55+
56+
V = Draft202012Validator(
57+
SCHEMA, format_checker=Draft202012Validator.FORMAT_CHECKER
58+
)
59+
60+
2261
def load_and_validate_config(config: dict[str, Any]) -> Config:
23-
errs = []
62+
errs = sorted(
63+
V.iter_errors(config),
64+
key=lambda e: e.path, # type: ignore[no-any-return]
65+
)
66+
if errs:
67+
raise ConfigurationError(errors_to_human_strings(errs))
68+
69+
return Config(
70+
config["content-type"],
71+
_load_fragments(config["fragments"]),
72+
[
73+
Substituter.from_config(sub_cfg)
74+
for sub_cfg in config.get("substitutions", [])
75+
],
76+
)
77+
78+
79+
def _load_fragments(config: list[dict[str, str]]) -> list[Fragment]:
80+
"""
81+
Load fragments from *config*.
82+
83+
This is a bit more complicated because validating the fragments field using
84+
`oneOf` leads to unhelpful error messages that are difficult to convert
85+
into something humanly meaningful.
86+
87+
So we detect first, validate using jsonschema and try to load them. They
88+
still may fail loading if they refer to files and lack markers / the
89+
pattern doesn't match.
90+
"""
2491
frags = []
92+
errs = []
2593

26-
if "content-type" not in config:
27-
errs.append(
28-
"Missing tool.hatch.metadata.hooks.fancy-pypi-readme.content-type "
29-
"setting."
30-
)
31-
32-
try:
33-
try:
34-
frag_cfg_list = config["fragments"]
35-
except KeyError:
36-
errs.append(
37-
"Missing tool.hatch.metadata.hooks.fancy-pypi-readme.fragments"
38-
" setting."
39-
)
40-
else:
41-
frags = load_fragments(frag_cfg_list)
94+
for i, frag_cfg in enumerate(config):
95+
for frag in VALID_FRAGMENTS:
96+
if frag.key not in frag_cfg:
97+
continue
4298

43-
except ConfigurationError as e:
44-
errs.extend(e.errors)
99+
try:
100+
ves = tuple(frag.validator.iter_errors(frag_cfg))
101+
if ves:
102+
raise ConfigurationError(
103+
errors_to_human_strings(ves, ("fragments", i))
104+
)
105+
frags.append(frag.from_config(frag_cfg))
106+
except ConfigurationError as e:
107+
errs.extend(e.errors)
45108

46-
try:
47-
subs = load_substitutions(config.get("substitutions", []))
48-
except ConfigurationError as e:
49-
errs.extend(e.errors)
109+
# We have either detecte and added or detected and errored, but in
110+
# any case we're done with this fragment.
111+
break
112+
else:
113+
errs.append(f"Unknown fragment type {frag_cfg!r}.")
50114

51115
if errs:
52116
raise ConfigurationError(errs)
53117

54-
return Config(config["content-type"], frags, subs)
118+
return frags

src/hatch_fancy_pypi_readme/_fragments.py

Lines changed: 41 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -11,52 +11,46 @@
1111
from pathlib import Path
1212
from typing import ClassVar, Iterable
1313

14+
from jsonschema import Draft202012Validator, Validator
15+
1416

1517
if sys.version_info >= (3, 8):
1618
from typing import Protocol
1719
else:
1820
from typing_extensions import Protocol
19-
from .exceptions import ConfigurationError
20-
21-
22-
def load_fragments(config: list[dict[str, str]]) -> list[Fragment]:
23-
"""
24-
Load all fragments from the fragments config list.
25-
26-
Raise ConfigurationError on unknown or misconfigured ones.
27-
"""
28-
if not config:
29-
raise ConfigurationError(
30-
[
31-
"tool.hatch.metadata.hooks.fancy-pypi-readme.fragments must "
32-
"not be empty."
33-
]
34-
)
35-
36-
frags = []
37-
errs = []
38-
for frag_cfg in config:
39-
for frag in _VALID_FRAGMENTS:
40-
if frag.key not in frag_cfg:
41-
continue
42-
43-
try:
44-
frags.append(frag.from_config(frag_cfg))
45-
except ConfigurationError as e:
46-
errs.extend(e.errors)
4721

48-
break
49-
else:
50-
errs.append(f"Unknown fragment type {frag_cfg!r}.")
22+
from .exceptions import ConfigurationError
5123

52-
if errs:
53-
raise ConfigurationError(errs)
5424

55-
return frags
25+
TEXT_V = Draft202012Validator(
26+
{
27+
"type": "object",
28+
"properties": {"text": {"type": "string", "pattern": ".+"}},
29+
"required": ["text"],
30+
"additionalProperties": False,
31+
},
32+
format_checker=Draft202012Validator.FORMAT_CHECKER,
33+
)
34+
35+
FILE_V = Draft202012Validator(
36+
{
37+
"type": "object",
38+
"properties": {
39+
"path": {"type": "string", "pattern": ".+"},
40+
"start-after": {"type": "string", "pattern": ".+"},
41+
"end-before": {"type": "string", "pattern": ".+"},
42+
"pattern": {"type": "string", "format": "regex"},
43+
},
44+
"required": ["path"],
45+
"additionalProperties": False,
46+
},
47+
format_checker=Draft202012Validator.FORMAT_CHECKER,
48+
)
5649

5750

5851
class Fragment(Protocol):
5952
key: ClassVar[str]
53+
validator: ClassVar[Validator]
6054

6155
@classmethod
6256
def from_config(self, cfg: dict[str, str]) -> Fragment:
@@ -73,24 +67,13 @@ class TextFragment:
7367
"""
7468

7569
key: ClassVar[str] = "text"
70+
validator: ClassVar[Validator] = TEXT_V
7671

7772
_text: str
7873

7974
@classmethod
8075
def from_config(cls, cfg: dict[str, str]) -> Fragment:
81-
contents = cfg.pop(cls.key)
82-
83-
if not contents:
84-
raise ConfigurationError(
85-
[f"text fragment: {cls.key} can't be empty."]
86-
)
87-
88-
if cfg:
89-
raise ConfigurationError(
90-
[f"text fragment: unknown option: {o}" for o in cfg.keys()]
91-
)
92-
93-
return cls(contents)
76+
return cls(cfg[cls.key])
9477

9578
def render(self) -> str:
9679
return self._text
@@ -103,6 +86,7 @@ class FileFragment:
10386
"""
10487

10588
key: ClassVar[str] = "path"
89+
validator: ClassVar[Validator] = FILE_V
10690

10791
_contents: str
10892

@@ -114,11 +98,6 @@ def from_config(cls, cfg: dict[str, str]) -> Fragment:
11498
pattern = cfg.pop("pattern", None)
11599

116100
errs: list[str] = []
117-
if cfg:
118-
errs.extend(
119-
f"file fragment: unknown option: {o!r}" for o in cfg.keys()
120-
)
121-
122101
contents = path.read_text(encoding="utf-8")
123102

124103
if start_after is not None:
@@ -138,22 +117,17 @@ def from_config(cls, cfg: dict[str, str]) -> Fragment:
138117
)
139118

140119
if pattern:
141-
try:
142-
m = re.search(pattern, contents, re.DOTALL)
143-
if not m:
120+
m = re.search(pattern, contents, re.DOTALL)
121+
if not m:
122+
errs.append(f"file fragment: pattern {pattern!r} not found.")
123+
else:
124+
try:
125+
contents = m.group(1)
126+
except IndexError:
144127
errs.append(
145-
f"file fragment: pattern {pattern!r} not found."
128+
"file fragment: pattern matches, but no group "
129+
"defined."
146130
)
147-
else:
148-
try:
149-
contents = m.group(1)
150-
except IndexError:
151-
errs.append(
152-
"file fragment: pattern matches, but no group "
153-
"defined."
154-
)
155-
except re.error as e:
156-
errs.append(f"file fragment: invalid pattern {pattern!r}: {e}")
157131

158132
if errs:
159133
raise ConfigurationError(errs)
@@ -164,4 +138,4 @@ def render(self) -> str:
164138
return self._contents
165139

166140

167-
_VALID_FRAGMENTS: Iterable[type[Fragment]] = (TextFragment, FileFragment)
141+
VALID_FRAGMENTS: Iterable[type[Fragment]] = (TextFragment, FileFragment)

0 commit comments

Comments
 (0)