Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move validation to jsonschema #12

Merged
merged 6 commits into from
Sep 10, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,8 @@ After a readme is assembled out of fragments, it's possible to run an arbitrary
```toml
[[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
replacement = 'It was a '\1'!'
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
117 changes: 91 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,12 @@
from dataclasses import dataclass
from typing import Any

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

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


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


SCHEMA = {
"$schema": CustomValidator.META_SCHEMA["$id"],
"type": "object",
hynek marked this conversation as resolved.
Show resolved Hide resolved
"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", "regex": True},
"replacement": {"type": "string"},
"ignore-case": {"type": "boolean"},
},
"required": ["pattern", "replacement"],
"additionalProperties": False,
},
},
},
"required": ["content-type", "fragments"],
"additionalProperties": False,
}


def load_and_validate_config(config: dict[str, Any]) -> Config:
errs = []
errs = sorted(
hynek marked this conversation as resolved.
Show resolved Hide resolved
CustomValidator(SCHEMA).iter_errors(config),
key=jsonschema.exceptions.relevance,
)
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 = sorted(
frag.validator.iter_errors(frag_cfg),
key=jsonschema.exceptions.relevance,
)
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
113 changes: 46 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,47 @@
from pathlib import Path
from typing import ClassVar, Iterable

from jsonschema import 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 ._validators import CustomValidator
from .exceptions import ConfigurationError

if errs:
raise ConfigurationError(errs)

return frags
TEXT_V = CustomValidator(
{
"$schema": CustomValidator.META_SCHEMA["$id"],
"type": "object",
"properties": {"text": {"type": "string", "minLength": 1}},
"required": ["text"],
"additionalProperties": False,
}
)

FILE_V = CustomValidator(
{
"$schema": CustomValidator.META_SCHEMA["$id"],
"type": "object",
"properties": {
"path": {"type": "string", "minLength": 1},
"start-after": {"type": "string", "minLength": 1},
"end-before": {"type": "string", "minLength": 1},
"pattern": {"type": "string", "regex": True},
},
"required": ["path"],
"additionalProperties": False,
}
)


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

@classmethod
def from_config(self, cfg: dict[str, str]) -> Fragment:
Expand All @@ -73,24 +68,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 +87,7 @@ class FileFragment:
"""

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

_contents: str

Expand All @@ -114,12 +99,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 +122,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 +143,4 @@ def render(self) -> str:
return self._contents


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