Skip to content

Commit

Permalink
Add script to generate JSON schema for hexdoc.toml
Browse files Browse the repository at this point in the history
  • Loading branch information
object-Object committed Apr 30, 2024
1 parent fb416da commit 7b3e7a3
Show file tree
Hide file tree
Showing 12 changed files with 271 additions and 13 deletions.
19 changes: 17 additions & 2 deletions noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ def docs(session: nox.Session):
hexdoc_version = get_hexdoc_version()
commit = run_silent_external(session, "git", "rev-parse", "--short", "HEAD")

static_generated = "web/docusaurus/static-generated"
rmtree(session, static_generated)

session.run(
"pdoc",
"hexdoc",
Expand All @@ -136,10 +139,22 @@ def docs(session: nox.Session):
"--logo=https://github.com/hexdoc-dev/hexdoc/raw/main/media/hexdoc.svg",
"--edit-url=hexdoc=https://github.com/hexdoc-dev/hexdoc/blob/main/src/hexdoc/",
f"--footer-text=Version: {hexdoc_version} ({commit})",
"--output-directory=web/docusaurus/static-generated/docs/api",
f"--output-directory={static_generated}/docs/api",
)

shutil.copytree("media", "web/docusaurus/static-generated/img", dirs_exist_ok=True)
shutil.copytree("media", f"{static_generated}/img", dirs_exist_ok=True)

for model_type in [
"hexdoc.core.Properties",
]:
session.run(
"python",
"-m",
"_scripts.json_schema",
model_type,
"--output",
f"{static_generated}/schema/{model_type.replace('.', '/')}.json",
)

with session.cd("web/docusaurus"):
session.run_install("npm", ("ci" if is_ci() else "install"), external=True)
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ only-include = [
"src",
"vendor",
]
exclude = [
"src/_scripts",
]

[tool.hatch.build.targets.wheel]
packages = [
Expand Down
1 change: 1 addition & 0 deletions src/_scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Internal hexdoc helper scripts. This package is not published to PyPI.
37 changes: 37 additions & 0 deletions src/_scripts/json_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import json
from pathlib import Path
from typing import Annotated, Any, Optional

import rich
import typer
from hexdoc.cli.utils import DefaultTyper
from hexdoc.cli.utils.args import parse_import_class
from pydantic import BaseModel, TypeAdapter
from typer import Argument, Option

app = DefaultTyper()


@app.command()
def main(
model_type: Annotated[type[Any], Argument(parser=parse_import_class)],
*,
output_path: Annotated[Optional[Path], Option("--output", "-o")] = None,
):
if issubclass(model_type, BaseModel):
schema = model_type.model_json_schema()
else:
schema = TypeAdapter(model_type).json_schema()

if output_path:
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8") as f:
json.dump(schema, f, indent=2)
typer.echo(f"Successfully wrote schema for {model_type} to {output_path}.")
else:
typer.echo(f"Schema for {model_type}:")
rich.print(schema)


if __name__ == "__main__":
app()
Empty file added src/_scripts/py.typed
Empty file.
24 changes: 24 additions & 0 deletions src/hexdoc/cli/utils/args.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import importlib
import logging
from pathlib import Path
from typing import Annotated

from click import BadParameter
from typer import Argument, Option, Typer

from hexdoc.utils import commands
from hexdoc.utils.tracebacks import get_message_with_hints
from hexdoc.utils.types import typed_partial

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -69,3 +72,24 @@ def get_current_commit() -> str:
default_factory=get_current_commit,
),
]


def parse_import_class(value: str):
if "." not in value:
raise BadParameter("import path must contain at least one '.' character.")
module_name, attr_name = value.rsplit(".", 1)

try:
module = importlib.import_module(module_name, package="hexdoc")
except ModuleNotFoundError as e:
raise BadParameter(str(e))

try:
type_ = getattr(module, attr_name)
except AttributeError as e:
raise BadParameter(get_message_with_hints(e))

if not isinstance(type_, type):
raise BadParameter(f"{type_} is not a class.")

return type_
24 changes: 21 additions & 3 deletions src/hexdoc/core/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@
from collections import defaultdict
from functools import cached_property
from pathlib import Path
from typing import Annotated, Any, Self, Sequence
from typing import Annotated, Any, Literal, Self, Sequence

from pydantic import Field, PrivateAttr, field_validator, model_validator
from pydantic.json_schema import (
DEFAULT_REF_TEMPLATE,
GenerateJsonSchema,
SkipJsonSchema,
)
from typing_extensions import override
from yarl import URL

from hexdoc.model.base import HexdocSettings
Expand All @@ -20,6 +26,7 @@
load_toml_with_placeholders,
relative_path_root,
)
from hexdoc.utils.deserialize.toml import GenerateJsonSchemaTOML
from hexdoc.utils.types import PydanticURL

from .resource import ResourceLocation
Expand Down Expand Up @@ -156,8 +163,8 @@ class LangProps(StripHiddenModel):


class BaseProperties(StripHiddenModel, ValidationContext):
env: EnvironmentVariableProps
props_dir: Path
env: SkipJsonSchema[EnvironmentVariableProps]
props_dir: SkipJsonSchema[Path]

@classmethod
def load(cls, path: Path) -> Self:
Expand All @@ -183,6 +190,17 @@ def load_data(cls, props_dir: Path, data: dict[str, Any]) -> Self:
logger.log(TRACE, props)
return props

@override
@classmethod
def model_json_schema(
cls,
by_alias: bool = True,
ref_template: str = DEFAULT_REF_TEMPLATE,
schema_generator: type[GenerateJsonSchema] = GenerateJsonSchemaTOML,
mode: Literal["validation", "serialization"] = "validation",
) -> dict[str, Any]:
return super().model_json_schema(by_alias, ref_template, schema_generator, mode)


class Properties(BaseProperties):
"""Pydantic model for `hexdoc.toml` / `properties.toml`."""
Expand Down
43 changes: 39 additions & 4 deletions src/hexdoc/core/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@

from pydantic import (
BeforeValidator,
ConfigDict,
TypeAdapter,
field_validator,
model_serializer,
model_validator,
)
from pydantic.config import JsonDict
from pydantic.dataclasses import dataclass
from pydantic.functional_validators import ModelWrapValidatorHandler
from typing_extensions import override
Expand Down Expand Up @@ -52,7 +54,40 @@ def _make_regex(count: bool = False, nbt: bool = False) -> re.Pattern[str]:
return re.compile(pattern)


@dataclass(config=DEFAULT_CONFIG, frozen=True, repr=False)
def _json_schema_extra(schema: JsonDict, model_type: type[BaseResourceLocation]):
object_schema = schema.copy()

regex = model_type._from_str_regex # pyright: ignore[reportPrivateUsage]
pattern = re.sub(
r"\(\?P<(.+?)>(.+?)\)",
r"(?<\1>\2)",
f"^{regex.pattern}$",
)
string_schema: JsonDict = {
"type": "string",
"pattern": pattern,
}

schema.clear()

for key in ["title", "description"]:
if value := object_schema.pop(key, None):
schema[key] = value

schema["anyOf"] = [
object_schema,
string_schema,
]


@dataclass(
frozen=True,
repr=False,
config=DEFAULT_CONFIG
| ConfigDict(
json_schema_extra=_json_schema_extra,
),
)
class BaseResourceLocation:
namespace: str
path: str
Expand Down Expand Up @@ -115,7 +150,7 @@ def __repr__(self) -> str:
return f"{self.namespace}:{self.path}"


@dataclass(config=DEFAULT_CONFIG, frozen=True, repr=False)
@dataclass(frozen=True, repr=False)
class ResourceLocation(BaseResourceLocation, regex=_make_regex()):
"""Represents a Minecraft resource location / namespaced ID."""

Expand Down Expand Up @@ -210,7 +245,7 @@ def __repr__(self) -> str:
ResLoc = ResourceLocation


@dataclass(config=DEFAULT_CONFIG, frozen=True, repr=False)
@dataclass(frozen=True, repr=False)
class ItemStack(BaseResourceLocation, regex=_make_regex(count=True, nbt=True)):
"""Represents an item with optional count and NBT.
Expand All @@ -236,7 +271,7 @@ def __repr__(self) -> str:
return s


@dataclass(config=DEFAULT_CONFIG, frozen=True, repr=False)
@dataclass(frozen=True, repr=False)
class Entity(BaseResourceLocation, regex=_make_regex(nbt=True)):
"""Represents an entity with optional NBT.
Expand Down
42 changes: 42 additions & 0 deletions src/hexdoc/core/resource_dir.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,35 @@
from typing_extensions import override

from hexdoc.model import HexdocModel
from hexdoc.model.base import DEFAULT_CONFIG
from hexdoc.plugin import PluginManager
from hexdoc.utils import JSONDict, RelativePath, relative_path_root


class BaseResourceDir(HexdocModel, ABC):
@staticmethod
def _json_schema_extra(schema: dict[str, Any]):
properties = schema.pop("properties")
new_schema = {
"anyOf": [
schema | {"properties": properties | {key: value}}
for key, value in {
"external": properties.pop("external"),
"internal": {
"type": "boolean",
"default": True,
"title": "Internal",
},
}.items()
],
}
schema.clear()
schema.update(new_schema)

model_config = DEFAULT_CONFIG | {
"json_schema_extra": _json_schema_extra,
}

external: bool
reexport: bool
"""If not set, the default value will be `not self.external`.
Expand Down Expand Up @@ -90,6 +114,24 @@ def _assert_path_exists(self):


class PathResourceDir(BasePathResourceDir):
@staticmethod
def _json_schema_extra(schema: dict[str, Any]):
BaseResourceDir._json_schema_extra(schema)
new_schema = {
"anyOf": [
*schema["anyOf"],
{
"type": "string",
},
]
}
schema.clear()
schema.update(new_schema)

model_config = DEFAULT_CONFIG | {
"json_schema_extra": _json_schema_extra,
}

# input is relative to the props file
path: RelativePath

Expand Down
21 changes: 18 additions & 3 deletions src/hexdoc/model/strip_hidden.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
from typing import Any, dataclass_transform

from pydantic import model_validator
from pydantic import ConfigDict, model_validator
from pydantic.config import JsonDict

from .base import HexdocModel
from hexdoc.utils.deserialize.assertions import cast_or_raise

from .base import DEFAULT_CONFIG, HexdocModel


def _json_schema_extra(schema: JsonDict):
cast_or_raise(schema.setdefault("patternProperties", {}), JsonDict).update(
{
r"^\_": {},
},
)


@dataclass_transform()
class StripHiddenModel(HexdocModel):
"""Base model which removes all keys starting with _ before validation."""

model_config = DEFAULT_CONFIG | ConfigDict(
json_schema_extra=_json_schema_extra,
)

@model_validator(mode="before")
def _pre_root_strip_hidden(cls, values: dict[Any, Any] | Any) -> Any:
if not isinstance(values, dict):
Expand All @@ -17,5 +32,5 @@ def _pre_root_strip_hidden(cls, values: dict[Any, Any] | Any) -> Any:
return {
key: value
for key, value in values.items()
if not (isinstance(key, str) and key.startswith("_"))
if not (isinstance(key, str) and (key.startswith("_") or key == "$schema"))
}
Loading

0 comments on commit 7b3e7a3

Please sign in to comment.