diff --git a/doc/src/hexdoc/hexcasting/hex_book.py b/doc/src/hexdoc/hexcasting/hex_book.py index d9568e87..015ccf23 100644 --- a/doc/src/hexdoc/hexcasting/hex_book.py +++ b/doc/src/hexdoc/hexcasting/hex_book.py @@ -1,6 +1,5 @@ import logging from pathlib import Path -from typing import Self from pydantic import Field, model_validator @@ -28,7 +27,7 @@ class HexContext(BookContext): patterns: dict[ResourceLocation, PatternInfo] = Field(default_factory=dict) @model_validator(mode="after") - def _post_root_load_patterns(self) -> Self: + def _load_patterns(self): # load the tag that specifies which patterns are random per world per_world = Tag.load( registry="action", @@ -44,11 +43,12 @@ class HexContext(BookContext): self._add_pattern(pattern, signatures) # export patterns so addons can use them + pattern_metadata = PatternMetadata( + patterns=self.patterns, + ) self.loader.export( path=PatternMetadata.path(self.props.modid), - data=PatternMetadata( - patterns=self.patterns, - ).model_dump_json(warnings=False), + data=pattern_metadata.model_dump_json(warnings=False), ) # add external patterns AFTER exporting so we don't reexport them diff --git a/doc/src/hexdoc/hexdoc.py b/doc/src/hexdoc/hexdoc.py index b848a634..941fd4e9 100644 --- a/doc/src/hexdoc/hexdoc.py +++ b/doc/src/hexdoc/hexdoc.py @@ -5,17 +5,16 @@ import sys from argparse import ArgumentParser from dataclasses import dataclass from pathlib import Path -from typing import Any, Self, Sequence +from typing import Self, Sequence from jinja2 import ChoiceLoader, FileSystemLoader, PackageLoader, StrictUndefined from jinja2.sandbox import SandboxedEnvironment from hexdoc.hexcasting.hex_book import HexContext -from hexdoc.minecraft.i18n import I18n from hexdoc.patchouli.book import Book from hexdoc.utils import Properties from hexdoc.utils.cd import cd -from hexdoc.utils.deserialize import cast_or_raise +from hexdoc.utils.model import init_context from hexdoc.utils.resource_loader import ModResourceLoader from .jinja_extensions import IncludeRawExtension, hexdoc_block, hexdoc_wrap @@ -89,53 +88,44 @@ def main(args: Args | None = None) -> None: props = Properties.load(args.properties_file) with ModResourceLoader.load_all(props) as loader: _, book_data = Book.load_book_json(loader, props.book) - book = Book.load_all( - book_data, - HexContext( - props=props, - loader=loader, - i18n=I18n( - props=props, - loader=loader, - enabled=cast_or_raise(book_data["i18n"], bool), - ), - macros=cast_or_raise(book_data["macros"], dict[Any, Any]), - ), - ) - # set up Jinja environment - env = SandboxedEnvironment( - # search order: template_dirs, template_packages, built-in hexdoc templates - loader=ChoiceLoader( - [FileSystemLoader(props.template_dirs)] - + [ - PackageLoader(name, str(path)) - for name, path in props.template_packages - ] - ), - undefined=StrictUndefined, - lstrip_blocks=True, - trim_blocks=True, - autoescape=True, - extensions=[ - IncludeRawExtension, - ], - ) - env.filters |= { # type: ignore - "hexdoc_block": hexdoc_block, - "hexdoc_wrap": hexdoc_wrap, - } + with init_context(book_data): + context = HexContext(loader=loader) - # load and render template - template = env.get_template(props.template) - docs = strip_empty_lines( - template.render( - **props.template_args, - book=book, - props=props, - mod_metadata=loader.mod_metadata, - ) + book = Book.model_validate(book_data, context=context) + + # set up Jinja environment + env = SandboxedEnvironment( + # search order: template_dirs, template_packages + loader=ChoiceLoader( + [FileSystemLoader(props.template_dirs)] + + [ + PackageLoader(name, str(path)) + for name, path in props.template_packages + ] + ), + undefined=StrictUndefined, + lstrip_blocks=True, + trim_blocks=True, + autoescape=True, + extensions=[ + IncludeRawExtension, + ], + ) + env.filters |= { # type: ignore + "hexdoc_block": hexdoc_block, + "hexdoc_wrap": hexdoc_wrap, + } + + # load and render template + template = env.get_template(props.template) + docs = strip_empty_lines( + template.render( + **props.template_args, + book=book, + props=props, ) + ) # if there's an output file specified, write to it # otherwise just print the generated docs diff --git a/doc/src/hexdoc/minecraft/i18n.py b/doc/src/hexdoc/minecraft/i18n.py index a2251047..39ede665 100644 --- a/doc/src/hexdoc/minecraft/i18n.py +++ b/doc/src/hexdoc/minecraft/i18n.py @@ -5,7 +5,7 @@ from dataclasses import InitVar from functools import total_ordering from typing import Any, Callable, Self -from pydantic import ValidationInfo, model_validator +from pydantic import Field, ValidationInfo, model_validator from pydantic.dataclasses import dataclass from pydantic.functional_validators import ModelWrapValidatorHandler @@ -22,7 +22,7 @@ from hexdoc.utils.deserialize import ( decode_and_flatten_json_dict, isinstance_or_raise, ) -from hexdoc.utils.model import ValidationContext +from hexdoc.utils.resource_loader import LoaderContext @total_ordering @@ -201,5 +201,16 @@ class I18n: return self.localize(f"key.{key}") -class I18nContext(ValidationContext): - i18n: I18n +class I18nContext(LoaderContext): + i18n: I18n = Field(default=None) + + @model_validator(mode="after") + def _init_i18n(self, info: ValidationInfo): + if self.i18n is None: # pyright: ignore[reportUnnecessaryComparison] + context = cast_or_raise(info.context, dict) + self.i18n = I18n( + props=self.props, + loader=self.loader, + enabled=cast_or_raise(context["i18n"], bool), + ) + return self diff --git a/doc/src/hexdoc/patchouli/text/formatting.py b/doc/src/hexdoc/patchouli/text/formatting.py index 35a4e826..d0aaed62 100644 --- a/doc/src/hexdoc/patchouli/text/formatting.py +++ b/doc/src/hexdoc/patchouli/text/formatting.py @@ -8,14 +8,14 @@ from contextlib import nullcontext from enum import Enum, auto from typing import Any, Literal, Self -from pydantic import ValidationInfo, field_validator, model_validator +from pydantic import Field, ValidationInfo, model_validator from pydantic.dataclasses import dataclass from pydantic.functional_validators import ModelWrapValidatorHandler from hexdoc.minecraft import LocalizedStr from hexdoc.minecraft.i18n import I18nContext from hexdoc.patchouli.text.html import HTMLElement, HTMLStream -from hexdoc.utils import DEFAULT_CONFIG, HexDocModel, PropsContext +from hexdoc.utils import DEFAULT_CONFIG, HexDocModel from hexdoc.utils.deserialize import cast_or_raise from hexdoc.utils.properties import Properties from hexdoc.utils.resource import ResourceLocation @@ -69,16 +69,17 @@ _COLORS = { class FormattingContext( I18nContext, - PropsContext, LoaderContext, arbitrary_types_allowed=True, ): - macros: dict[str, str] + macros: dict[str, str] = Field(default_factory=dict) - @field_validator("macros") - @classmethod - def _add_default_macros(cls, macros: dict[str, str]) -> dict[str, str]: - return DEFAULT_MACROS | macros + @model_validator(mode="after") + def _add_macros(self, info: ValidationInfo) -> Self: + # precedence: ctx arguments, book macros, default macros + context = cast_or_raise(info.context, dict) + self.macros = DEFAULT_MACROS | context["macros"] | self.macros + return self class BookLink(HexDocModel): diff --git a/doc/src/hexdoc/utils/__init__.py b/doc/src/hexdoc/utils/__init__.py index 83e63587..6c2ea959 100644 --- a/doc/src/hexdoc/utils/__init__.py +++ b/doc/src/hexdoc/utils/__init__.py @@ -8,7 +8,6 @@ __all__ = [ "NoValueType", "TagValue", "Properties", - "PropsContext", "Entity", "ItemStack", "ResLoc", @@ -19,7 +18,7 @@ __all__ = [ ] from .model import DEFAULT_CONFIG, HexDocModel, ValidationContext -from .properties import Properties, PropsContext +from .properties import Properties from .resource import Entity, ItemStack, ResLoc, ResourceLocation from .resource_loader import LoaderContext, ModResourceLoader from .tagged_union import ( diff --git a/doc/src/hexdoc/utils/model.py b/doc/src/hexdoc/utils/model.py index 7533e608..0a4164c4 100644 --- a/doc/src/hexdoc/utils/model.py +++ b/doc/src/hexdoc/utils/model.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Self, dataclass_transform +from contextlib import contextmanager +from contextvars import ContextVar +from typing import TYPE_CHECKING, Any, Iterator, Self, dataclass_transform from pydantic import BaseModel, ConfigDict, model_validator from pydantic.config import ConfigDict @@ -9,16 +11,48 @@ DEFAULT_CONFIG = ConfigDict( extra="forbid", ) +_init_context_var = ContextVar[Any]("_init_context_var", default=None) + + +@contextmanager +def init_context(value: Any) -> Iterator[None]: + """https://docs.pydantic.dev/latest/usage/validators/#using-validation-context-with-basemodel-initialization""" + token = _init_context_var.set(value) + try: + yield + finally: + _init_context_var.reset(token) + @dataclass_transform() -class ValidationContext(BaseModel): - """Base class for Pydantic validation context for `HexDocModel`.""" +class HexDocBaseModel(BaseModel): + """Base class for all Pydantic models in hexdoc. You should probably use + `HexDocModel` or `ValidationContext` instead. + + Sets the default model config, and overrides __init__ to allow using the + `init_context` context manager to set validation context for constructors. + """ model_config = DEFAULT_CONFIG + def __init__(__pydantic_self__, **data: Any) -> None: # type: ignore + __tracebackhide__ = True + __pydantic_self__.__pydantic_validator__.validate_python( + data, + self_instance=__pydantic_self__, + context=_init_context_var.get(), + ) + + __init__.__pydantic_base_init__ = True # type: ignore + @dataclass_transform() -class HexDocModel(BaseModel): +class ValidationContext(HexDocBaseModel): + """Base class for Pydantic validation context for `HexDocModel`.""" + + +@dataclass_transform() +class HexDocModel(HexDocBaseModel): """Base class for most Pydantic models in hexdoc. Includes type overrides to require using subclasses of `ValidationContext` for diff --git a/doc/src/hexdoc/utils/properties.py b/doc/src/hexdoc/utils/properties.py index f2177838..09e0b8c6 100644 --- a/doc/src/hexdoc/utils/properties.py +++ b/doc/src/hexdoc/utils/properties.py @@ -6,7 +6,7 @@ from typing import Annotated, Any, Self from pydantic import AfterValidator, HttpUrl -from .model import HexDocModel, StripHiddenModel, ValidationContext +from .model import HexDocModel, StripHiddenModel from .resource import ResourceDir, ResourceLocation from .toml_placeholders import load_toml_with_placeholders @@ -79,7 +79,3 @@ class Properties(StripHiddenModel): def get_asset_url(self, id: ResourceLocation) -> str: base_url = self.base_asset_urls[id.namespace] return f"{base_url}/{id.file_path_stub('assets').as_posix()}" - - -class PropsContext(ValidationContext): - props: Properties diff --git a/doc/src/hexdoc/utils/resource_loader.py b/doc/src/hexdoc/utils/resource_loader.py index 93db2c99..291577bf 100644 --- a/doc/src/hexdoc/utils/resource_loader.py +++ b/doc/src/hexdoc/utils/resource_loader.py @@ -345,3 +345,7 @@ class ModResourceLoader: class LoaderContext(ValidationContext): loader: ModResourceLoader + + @property + def props(self): + return self.loader.props