diff --git a/src/hexdoc/core/properties.py b/src/hexdoc/core/properties.py deleted file mode 100644 index e4f299f1..00000000 --- a/src/hexdoc/core/properties.py +++ /dev/null @@ -1,302 +0,0 @@ -from __future__ import annotations - -import logging -from collections import defaultdict -from enum import StrEnum -from functools import cached_property -from pathlib import Path -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 -from hexdoc.model.strip_hidden import StripHiddenModel -from hexdoc.utils import ( - TRACE, - PydanticOrderedSet, - RelativePath, - ValidationContext, - git_root, - load_toml_with_placeholders, - relative_path_root, -) -from hexdoc.utils.deserialize.toml import GenerateJsonSchemaTOML -from hexdoc.utils.types import PydanticURL - -from .resource import ResourceLocation -from .resource_dir import ResourceDir - -logger = logging.getLogger(__name__) - -JINJA_NAMESPACE_ALIASES = { - "patchouli": "hexdoc", -} - - -# TODO: why is this a separate class? -class BaseProperties(StripHiddenModel, ValidationContext): - env: SkipJsonSchema[EnvironmentVariableProps] - props_dir: SkipJsonSchema[Path] - - @classmethod - def load(cls, path: Path) -> Self: - return cls.load_data( - props_dir=path.parent, - data=load_toml_with_placeholders(path), - ) - - @classmethod - def load_data(cls, props_dir: Path, data: dict[str, Any]) -> Self: - props_dir = props_dir.resolve() - - with relative_path_root(props_dir): - env = EnvironmentVariableProps.model_getenv() - props = cls.model_validate( - data - | { - "env": env, - "props_dir": props_dir, - }, - ) - - 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 EnvironmentVariableProps(HexdocSettings): - # default Actions environment variables - github_repository: str - github_sha: str - - # set by CI - github_pages_url: PydanticURL - - # for putting books somewhere other than the site root - hexdoc_subdirectory: str | None = None - - # optional for debugging - debug_githubusercontent: PydanticURL | None = None - - @property - def asset_url(self) -> URL: - if self.debug_githubusercontent is not None: - return URL(str(self.debug_githubusercontent)) - - return ( - URL("https://raw.githubusercontent.com") - / self.repo_owner - / self.repo_name - / self.github_sha - ) - - @property - def repo_owner(self): - return self._github_repository_parts[0] - - @property - def repo_name(self): - return self._github_repository_parts[1] - - @property - def _github_repository_parts(self): - owner, repo_name = self.github_repository.split("/", maxsplit=1) - return owner, repo_name - - @model_validator(mode="after") - def _append_subdirectory(self) -> Self: - if self.hexdoc_subdirectory: - self.github_pages_url /= self.hexdoc_subdirectory - return self - - -class Properties(BaseProperties): - """Pydantic model for `hexdoc.toml` / `properties.toml`.""" - - modid: str - - book_type: str = "patchouli" - """Modid of the `hexdoc.plugin.BookPlugin` to use when loading this book.""" - - # TODO: make another properties type without book_id - book_id: ResourceLocation | None = Field(alias="book", default=None) - extra_books: list[ResourceLocation] = Field(default_factory=list) - - default_lang: str = "en_us" - default_branch: str = "main" - - is_0_black: bool = False - """If true, the style `$(0)` changes the text color to black; otherwise it resets - the text color to the default.""" - - resource_dirs: Sequence[ResourceDir] - export_dir: RelativePath | None = None - - entry_id_blacklist: set[ResourceLocation] = Field(default_factory=set) - - macros: dict[str, str] = Field(default_factory=dict) - link_overrides: dict[str, str] = Field(default_factory=dict) - - textures: TexturesProps = Field(default_factory=lambda: TexturesProps()) - - template: TemplateProps | None = None - - lang: defaultdict[ - str, - Annotated[LangProps, Field(default_factory=lambda: LangProps())], - ] = Field(default_factory=lambda: defaultdict(LangProps)) - """Per-language configuration. The key should be the language code, eg. `en_us`.""" - - extra: dict[str, Any] = Field(default_factory=dict) - - def mod_loc(self, path: str) -> ResourceLocation: - """Returns a ResourceLocation with self.modid as the namespace.""" - return ResourceLocation(self.modid, path) - - @property - def prerender_dir(self): - return self.cache_dir / "prerender" - - @property - def cache_dir(self): - return self.repo_root / ".hexdoc" - - @cached_property - def repo_root(self): - return git_root(self.props_dir) - - -class TexturesProps(StripHiddenModel): - enabled: bool = True - """Set to False to disable texture rendering.""" - strict: bool = True - """Set to False to print some errors instead of throwing them.""" - - missing: set[ResourceLocation] | Literal["*"] = Field(default_factory=set) - override: dict[ - ResourceLocation, - PNGTextureOverride | TextureTextureOverride, - ] = Field(default_factory=dict) - - large_items: bool = True - """Controls whether flat item renders should be enlarged after rendering, or left at - the default size (usually 16x16). Defaults to `True`.""" - - animated: AnimatedTexturesProps = Field( - default_factory=lambda: AnimatedTexturesProps(), - ) - - -# TODO: support item/block override -class PNGTextureOverride(StripHiddenModel): - url: PydanticURL - pixelated: bool - - -class TextureTextureOverride(StripHiddenModel): - texture: ResourceLocation - """The id of an image texture (eg. `minecraft:textures/item/stick.png`).""" - - -class AnimationFormat(StrEnum): - APNG = "apng" - GIF = "gif" - - -class AnimatedTexturesProps(StripHiddenModel): - enabled: bool = True - """If set to `False`, animated textures will be rendered as a PNG with the first - frame of the animation.""" - format: AnimationFormat = AnimationFormat.APNG - """Animated image output format. - - `apng` (the default) is higher quality, but the file size is a bit larger. - - `gif` produces smaller but lower quality files, and interpolated textures may have - issues with flickering. - """ - max_frames: Annotated[int, Field(ge=0)] = 15 * 20 # 15 seconds * 20 tps - """Maximum number of frames for animated textures (1 frame = 1 tick = 1/20 seconds). - If a texture would have more frames than this, some frames will be dropped. - - This is mostly necessary because of prismarine, which is an interpolated texture - with a total length of 6600 frames or 5.5 minutes. - - The default value is 300 frames or 15 seconds, producing about a 3 MB animation for - prismarine. - - To disable the frame limit entirely, set this value to 0. - """ - - -class TemplateProps(StripHiddenModel, validate_assignment=True): - static_dir: RelativePath | None = None - icon: RelativePath | None = None - include: PydanticOrderedSet[str] - - render_from: PydanticOrderedSet[str] = Field(None, validate_default=False) - """List of modids to include default rendered templates from. - - If not provided, defaults to `self.include`. - """ - render: dict[Path, str] = Field(default_factory=dict) - extend_render: dict[Path, str] = Field(default_factory=dict) - - redirect: tuple[Path, str] | None = (Path("index.html"), "redirect.html.jinja") - """filename, template""" - - args: dict[str, Any] - - _was_render_set: bool = PrivateAttr(False) - - @property - def override_default_render(self): - return self._was_render_set - - @field_validator("include", "render_from", mode="after") - @classmethod - def _resolve_aliases(cls, values: PydanticOrderedSet[str] | None): - if values: - for alias, replacement in JINJA_NAMESPACE_ALIASES.items(): - if alias in values: - values.remove(alias) - values.add(replacement) - return values - - @model_validator(mode="after") - def _set_default_render_from(self): - if self.render_from is None: # pyright: ignore[reportUnnecessaryComparison] - self.render_from = self.include - return self - - -class LangProps(StripHiddenModel): - """Configuration for a specific book language.""" - - quiet: bool = False - """If `True`, do not log warnings for missing translations. - - Using this option for the default language is not recommended. - """ - ignore_errors: bool = False - """If `True`, log fatal errors for this language instead of failing entirely. - - Using this option for the default language is not recommended. - """ diff --git a/src/hexdoc/core/properties/__init__.py b/src/hexdoc/core/properties/__init__.py new file mode 100644 index 00000000..e3b63cc0 --- /dev/null +++ b/src/hexdoc/core/properties/__init__.py @@ -0,0 +1,30 @@ +__all__ = [ + "JINJA_NAMESPACE_ALIASES", + "AnimatedTexturesProps", + "AnimationFormat", + "BaseProperties", + "EnvironmentVariableProps", + "LangProps", + "PNGTextureOverride", + "Properties", + "TemplateProps", + "TextureTextureOverride", + "TexturesProps", + "env", + "lang", + "properties", + "template", + "textures", +] + +from .env import EnvironmentVariableProps +from .lang import LangProps +from .properties import BaseProperties, Properties +from .template import JINJA_NAMESPACE_ALIASES, TemplateProps +from .textures import ( + AnimatedTexturesProps, + AnimationFormat, + PNGTextureOverride, + TexturesProps, + TextureTextureOverride, +) diff --git a/src/hexdoc/core/properties/env.py b/src/hexdoc/core/properties/env.py new file mode 100644 index 00000000..4fd9cc7e --- /dev/null +++ b/src/hexdoc/core/properties/env.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Self + +from pydantic import model_validator +from yarl import URL + +from hexdoc.model.base import HexdocSettings +from hexdoc.utils.types import PydanticURL + + +class EnvironmentVariableProps(HexdocSettings): + # default Actions environment variables + github_repository: str + github_sha: str + + # set by CI + github_pages_url: PydanticURL + + # for putting books somewhere other than the site root + hexdoc_subdirectory: str | None = None + + # optional for debugging + debug_githubusercontent: PydanticURL | None = None + + @property + def asset_url(self) -> URL: + if self.debug_githubusercontent is not None: + return URL(str(self.debug_githubusercontent)) + + return ( + URL("https://raw.githubusercontent.com") + / self.repo_owner + / self.repo_name + / self.github_sha + ) + + @property + def repo_owner(self): + return self._github_repository_parts[0] + + @property + def repo_name(self): + return self._github_repository_parts[1] + + @property + def _github_repository_parts(self): + owner, repo_name = self.github_repository.split("/", maxsplit=1) + return owner, repo_name + + @model_validator(mode="after") + def _append_subdirectory(self) -> Self: + if self.hexdoc_subdirectory: + self.github_pages_url /= self.hexdoc_subdirectory + return self diff --git a/src/hexdoc/core/properties/lang.py b/src/hexdoc/core/properties/lang.py new file mode 100644 index 00000000..f4b0dc2f --- /dev/null +++ b/src/hexdoc/core/properties/lang.py @@ -0,0 +1,16 @@ +from hexdoc.model.strip_hidden import StripHiddenModel + + +class LangProps(StripHiddenModel): + """Configuration for a specific book language.""" + + quiet: bool = False + """If `True`, do not log warnings for missing translations. + + Using this option for the default language is not recommended. + """ + ignore_errors: bool = False + """If `True`, log fatal errors for this language instead of failing entirely. + + Using this option for the default language is not recommended. + """ diff --git a/src/hexdoc/core/properties/properties.py b/src/hexdoc/core/properties/properties.py new file mode 100644 index 00000000..f021c3a2 --- /dev/null +++ b/src/hexdoc/core/properties/properties.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import logging +from collections import defaultdict +from functools import cached_property +from pathlib import Path +from typing import Annotated, Any, Literal, Self, Sequence + +from pydantic import Field +from pydantic.json_schema import ( + DEFAULT_REF_TEMPLATE, + GenerateJsonSchema, + SkipJsonSchema, +) +from typing_extensions import override + +from hexdoc.model.strip_hidden import StripHiddenModel +from hexdoc.utils import ( + TRACE, + RelativePath, + ValidationContext, + git_root, + load_toml_with_placeholders, + relative_path_root, +) +from hexdoc.utils.deserialize.toml import GenerateJsonSchemaTOML + +from ..resource import ResourceLocation +from ..resource_dir import ResourceDir +from .env import EnvironmentVariableProps +from .lang import LangProps +from .template import TemplateProps +from .textures import TexturesProps + +logger = logging.getLogger(__name__) + + +# TODO: why is this a separate class? +class BaseProperties(StripHiddenModel, ValidationContext): + env: SkipJsonSchema[EnvironmentVariableProps] + props_dir: SkipJsonSchema[Path] + + @classmethod + def load(cls, path: Path) -> Self: + return cls.load_data( + props_dir=path.parent, + data=load_toml_with_placeholders(path), + ) + + @classmethod + def load_data(cls, props_dir: Path, data: dict[str, Any]) -> Self: + props_dir = props_dir.resolve() + + with relative_path_root(props_dir): + env = EnvironmentVariableProps.model_getenv() + props = cls.model_validate( + data + | { + "env": env, + "props_dir": props_dir, + }, + ) + + 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`.""" + + modid: str + + book_type: str = "patchouli" + """Modid of the `hexdoc.plugin.BookPlugin` to use when loading this book.""" + + # TODO: make another properties type without book_id + book_id: ResourceLocation | None = Field(alias="book", default=None) + extra_books: list[ResourceLocation] = Field(default_factory=list) + + default_lang: str = "en_us" + default_branch: str = "main" + + is_0_black: bool = False + """If true, the style `$(0)` changes the text color to black; otherwise it resets + the text color to the default.""" + + resource_dirs: Sequence[ResourceDir] + export_dir: RelativePath | None = None + + entry_id_blacklist: set[ResourceLocation] = Field(default_factory=set) + + macros: dict[str, str] = Field(default_factory=dict) + link_overrides: dict[str, str] = Field(default_factory=dict) + + textures: TexturesProps = Field(default_factory=lambda: TexturesProps()) + + template: TemplateProps | None = None + + lang: defaultdict[ + str, + Annotated[LangProps, Field(default_factory=lambda: LangProps())], + ] = Field(default_factory=lambda: defaultdict(LangProps)) + """Per-language configuration. The key should be the language code, eg. `en_us`.""" + + extra: dict[str, Any] = Field(default_factory=dict) + + def mod_loc(self, path: str) -> ResourceLocation: + """Returns a ResourceLocation with self.modid as the namespace.""" + return ResourceLocation(self.modid, path) + + @property + def prerender_dir(self): + return self.cache_dir / "prerender" + + @property + def cache_dir(self): + return self.repo_root / ".hexdoc" + + @cached_property + def repo_root(self): + return git_root(self.props_dir) diff --git a/src/hexdoc/core/properties/template.py b/src/hexdoc/core/properties/template.py new file mode 100644 index 00000000..0bb4ad12 --- /dev/null +++ b/src/hexdoc/core/properties/template.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from pydantic import Field, PrivateAttr, field_validator, model_validator + +from hexdoc.model.strip_hidden import StripHiddenModel +from hexdoc.utils import ( + PydanticOrderedSet, + RelativePath, +) + +logger = logging.getLogger(__name__) + + +JINJA_NAMESPACE_ALIASES = { + "patchouli": "hexdoc", +} + + +class TemplateProps(StripHiddenModel, validate_assignment=True): + static_dir: RelativePath | None = None + icon: RelativePath | None = None + include: PydanticOrderedSet[str] + + render_from: PydanticOrderedSet[str] = Field(None, validate_default=False) + """List of modids to include default rendered templates from. + + If not provided, defaults to `self.include`. + """ + render: dict[Path, str] = Field(default_factory=dict) + extend_render: dict[Path, str] = Field(default_factory=dict) + + redirect: tuple[Path, str] | None = (Path("index.html"), "redirect.html.jinja") + """filename, template""" + + args: dict[str, Any] + + _was_render_set: bool = PrivateAttr(False) + + @property + def override_default_render(self): + return self._was_render_set + + @field_validator("include", "render_from", mode="after") + @classmethod + def _resolve_aliases(cls, values: PydanticOrderedSet[str] | None): + if values: + for alias, replacement in JINJA_NAMESPACE_ALIASES.items(): + if alias in values: + values.remove(alias) + values.add(replacement) + return values + + @model_validator(mode="after") + def _set_default_render_from(self): + if self.render_from is None: # pyright: ignore[reportUnnecessaryComparison] + self.render_from = self.include + return self diff --git a/src/hexdoc/core/properties/textures.py b/src/hexdoc/core/properties/textures.py new file mode 100644 index 00000000..72bd2062 --- /dev/null +++ b/src/hexdoc/core/properties/textures.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import logging +from enum import StrEnum +from typing import Annotated, Literal + +from pydantic import Field + +from hexdoc.model.strip_hidden import StripHiddenModel +from hexdoc.utils.types import PydanticURL + +from ..resource import ResourceLocation + +logger = logging.getLogger(__name__) + + +# TODO: support item/block override +class PNGTextureOverride(StripHiddenModel): + url: PydanticURL + pixelated: bool + + +class TextureTextureOverride(StripHiddenModel): + texture: ResourceLocation + """The id of an image texture (eg. `minecraft:textures/item/stick.png`).""" + + +class AnimationFormat(StrEnum): + APNG = "apng" + GIF = "gif" + + +class AnimatedTexturesProps(StripHiddenModel): + enabled: bool = True + """If set to `False`, animated textures will be rendered as a PNG with the first + frame of the animation.""" + format: AnimationFormat = AnimationFormat.APNG + """Animated image output format. + + `apng` (the default) is higher quality, but the file size is a bit larger. + + `gif` produces smaller but lower quality files, and interpolated textures may have + issues with flickering. + """ + max_frames: Annotated[int, Field(ge=0)] = 15 * 20 # 15 seconds * 20 tps + """Maximum number of frames for animated textures (1 frame = 1 tick = 1/20 seconds). + If a texture would have more frames than this, some frames will be dropped. + + This is mostly necessary because of prismarine, which is an interpolated texture + with a total length of 6600 frames or 5.5 minutes. + + The default value is 300 frames or 15 seconds, producing about a 3 MB animation for + prismarine. + + To disable the frame limit entirely, set this value to 0. + """ + + +class OverridesProps(StripHiddenModel): + pass + + +class TexturesProps(StripHiddenModel): + enabled: bool = True + """Set to False to disable model rendering.""" + strict: bool = True + """Set to False to print some errors instead of throwing them.""" + large_items: bool = True + """Controls whether flat item renders should be enlarged after rendering, or left at + the default size (usually 16x16). Defaults to `True`.""" + + missing: set[ResourceLocation] | Literal["*"] = Field(default_factory=set) + + animated: AnimatedTexturesProps = Field(default_factory=AnimatedTexturesProps) + + override: dict[ + ResourceLocation, + PNGTextureOverride | TextureTextureOverride, + ] = Field(default_factory=dict)