Skip to content

Commit

Permalink
Move validation to jsonschema
Browse files Browse the repository at this point in the history
  • Loading branch information
hynek committed Sep 10, 2022
1 parent eaada13 commit 7ef5b37
Show file tree
Hide file tree
Showing 13 changed files with 462 additions and 272 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ After a readme is assembled out of fragments, it's possible to run an arbitrary
[[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]]
pattern = "This is a (.*) that we'll replace later."
replacement = "It was a '\\1'!"
ignore_case = true # optional; false by default
ignore-case = true # optional; false by default
```

---
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ classifiers = [
]
dependencies = [
"hatchling",
"jsonschema",
"tomli; python_version<'3.11'",
"typing-extensions; python_version<'3.8'",
]
Expand Down Expand Up @@ -116,9 +117,13 @@ profile = "attrs"


[tool.mypy]
show_error_codes = true
enable_error_code = ["ignore-without-code"]
strict = true
follow_imports = "normal"
warn_no_return = true
ignore_missing_imports = true


[[tool.mypy.overrides]]
module = "tests.*"
Expand Down
116 changes: 90 additions & 26 deletions src/hatch_fancy_pypi_readme/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
from dataclasses import dataclass
from typing import Any

from ._fragments import Fragment, load_fragments
from ._substitutions import Substituter, load_substitutions
from jsonschema import Draft202012Validator

from ._fragments import VALID_FRAGMENTS, Fragment
from ._humanize_validation_errors import errors_to_human_strings
from ._substitutions import Substituter
from .exceptions import ConfigurationError


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


SCHEMA = {
"type": "object",
"properties": {
"content-type": {
"type": "string",
"enum": ["text/markdown", "text/x-rst"],
},
"fragments": {
"type": "array",
"minItems": 1,
# Items are validated separately for better error messages.
"items": {"type": "object"},
},
"substitutions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"pattern": {"type": "string", "format": "regex"},
"replacement": {"type": "string"},
"ignore-case": {"type": "boolean"},
},
"required": ["pattern", "replacement"],
"additionalProperties": False,
},
},
},
"required": ["content-type", "fragments"],
"additionalProperties": False,
}

V = Draft202012Validator(
SCHEMA, format_checker=Draft202012Validator.FORMAT_CHECKER
)


def load_and_validate_config(config: dict[str, Any]) -> Config:
errs = []
errs = sorted(
V.iter_errors(config),
key=lambda e: e.path, # type: ignore[no-any-return]
)
if errs:
raise ConfigurationError(errors_to_human_strings(errs))

return Config(
config["content-type"],
_load_fragments(config["fragments"]),
[
Substituter.from_config(sub_cfg)
for sub_cfg in config.get("substitutions", [])
],
)


def _load_fragments(config: list[dict[str, str]]) -> list[Fragment]:
"""
Load fragments from *config*.
This is a bit more complicated because validating the fragments field using
`oneOf` leads to unhelpful error messages that are difficult to convert
into something humanly meaningful.
So we detect first, validate using jsonschema and try to load them. They
still may fail loading if they refer to files and lack markers / the
pattern doesn't match.
"""
frags = []
errs = []

if "content-type" not in config:
errs.append(
"Missing tool.hatch.metadata.hooks.fancy-pypi-readme.content-type "
"setting."
)

try:
try:
frag_cfg_list = config["fragments"]
except KeyError:
errs.append(
"Missing tool.hatch.metadata.hooks.fancy-pypi-readme.fragments"
" setting."
)
else:
frags = load_fragments(frag_cfg_list)
for i, frag_cfg in enumerate(config):
for frag in VALID_FRAGMENTS:
if frag.key not in frag_cfg:
continue

except ConfigurationError as e:
errs.extend(e.errors)
try:
ves = tuple(frag.validator.iter_errors(frag_cfg))
if ves:
raise ConfigurationError(
errors_to_human_strings(ves, ("fragments", i))
)
frags.append(frag.from_config(frag_cfg))
except ConfigurationError as e:
errs.extend(e.errors)

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

if errs:
raise ConfigurationError(errs)

return Config(config["content-type"], frags, subs)
return frags
112 changes: 45 additions & 67 deletions src/hatch_fancy_pypi_readme/_fragments.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,52 +11,46 @@
from pathlib import Path
from typing import ClassVar, Iterable

from jsonschema import Draft202012Validator, Validator


if sys.version_info >= (3, 8):
from typing import Protocol
else:
from typing_extensions import Protocol
from .exceptions import ConfigurationError


def load_fragments(config: list[dict[str, str]]) -> list[Fragment]:
"""
Load all fragments from the fragments config list.
Raise ConfigurationError on unknown or misconfigured ones.
"""
if not config:
raise ConfigurationError(
[
"tool.hatch.metadata.hooks.fancy-pypi-readme.fragments must "
"not be empty."
]
)

frags = []
errs = []
for frag_cfg in config:
for frag in _VALID_FRAGMENTS:
if frag.key not in frag_cfg:
continue

try:
frags.append(frag.from_config(frag_cfg))
except ConfigurationError as e:
errs.extend(e.errors)

break
else:
errs.append(f"Unknown fragment type {frag_cfg!r}.")
from .exceptions import ConfigurationError

if errs:
raise ConfigurationError(errs)

return frags
TEXT_V = Draft202012Validator(
{
"type": "object",
"properties": {"text": {"type": "string", "pattern": ".+"}},
"required": ["text"],
"additionalProperties": False,
},
format_checker=Draft202012Validator.FORMAT_CHECKER,
)

FILE_V = Draft202012Validator(
{
"type": "object",
"properties": {
"path": {"type": "string", "pattern": ".+"},
"start-after": {"type": "string", "pattern": ".+"},
"end-before": {"type": "string", "pattern": ".+"},
"pattern": {"type": "string", "format": "regex"},
},
"required": ["path"],
"additionalProperties": False,
},
format_checker=Draft202012Validator.FORMAT_CHECKER,
)


class Fragment(Protocol):
key: ClassVar[str]
validator: ClassVar[Validator]

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

key: ClassVar[str] = "text"
validator: ClassVar[Validator] = TEXT_V

_text: str

@classmethod
def from_config(cls, cfg: dict[str, str]) -> Fragment:
contents = cfg.pop(cls.key)

if not contents:
raise ConfigurationError(
[f"text fragment: {cls.key} can't be empty."]
)

if cfg:
raise ConfigurationError(
[f"text fragment: unknown option: {o}" for o in cfg.keys()]
)

return cls(contents)
return cls(cfg[cls.key])

def render(self) -> str:
return self._text
Expand All @@ -103,6 +86,7 @@ class FileFragment:
"""

key: ClassVar[str] = "path"
validator: ClassVar[Validator] = FILE_V

_contents: str

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

errs: list[str] = []
if cfg:
errs.extend(
f"file fragment: unknown option: {o!r}" for o in cfg.keys()
)

contents = path.read_text(encoding="utf-8")
try:
contents = path.read_text(encoding="utf-8")
except FileNotFoundError:
raise ConfigurationError([f"Fragment file '{path}' not found."])

if start_after is not None:
try:
Expand All @@ -138,22 +121,17 @@ def from_config(cls, cfg: dict[str, str]) -> Fragment:
)

if pattern:
try:
m = re.search(pattern, contents, re.DOTALL)
if not m:
m = re.search(pattern, contents, re.DOTALL)
if not m:
errs.append(f"file fragment: pattern {pattern!r} not found.")
else:
try:
contents = m.group(1)
except IndexError:
errs.append(
f"file fragment: pattern {pattern!r} not found."
"file fragment: pattern matches, but no group "
"defined."
)
else:
try:
contents = m.group(1)
except IndexError:
errs.append(
"file fragment: pattern matches, but no group "
"defined."
)
except re.error as e:
errs.append(f"file fragment: invalid pattern {pattern!r}: {e}")

if errs:
raise ConfigurationError(errs)
Expand All @@ -164,4 +142,4 @@ def render(self) -> str:
return self._contents


_VALID_FRAGMENTS: Iterable[type[Fragment]] = (TextFragment, FileFragment)
VALID_FRAGMENTS: Iterable[type[Fragment]] = (TextFragment, FileFragment)
Loading

0 comments on commit 7ef5b37

Please sign in to comment.