hexdoc.core.properties
1from __future__ import annotations 2 3import logging 4from collections import defaultdict 5from functools import cached_property 6from pathlib import Path 7from typing import Annotated, Any, Literal, Self, Sequence 8 9from pydantic import Field, PrivateAttr, field_validator, model_validator 10from pydantic.json_schema import ( 11 DEFAULT_REF_TEMPLATE, 12 GenerateJsonSchema, 13 SkipJsonSchema, 14) 15from typing_extensions import override 16from yarl import URL 17 18from hexdoc.model.base import HexdocSettings 19from hexdoc.model.strip_hidden import StripHiddenModel 20from hexdoc.utils import ( 21 TRACE, 22 PydanticOrderedSet, 23 RelativePath, 24 ValidationContext, 25 git_root, 26 load_toml_with_placeholders, 27 relative_path_root, 28) 29from hexdoc.utils.deserialize.toml import GenerateJsonSchemaTOML 30from hexdoc.utils.types import PydanticURL 31 32from .resource import ResourceLocation 33from .resource_dir import ResourceDir 34 35logger = logging.getLogger(__name__) 36 37JINJA_NAMESPACE_ALIASES = { 38 "patchouli": "hexdoc", 39} 40 41 42class EnvironmentVariableProps(HexdocSettings): 43 # default Actions environment variables 44 github_repository: str 45 github_sha: str 46 47 # set by CI 48 github_pages_url: PydanticURL 49 50 # for putting books somewhere other than the site root 51 hexdoc_subdirectory: str | None = None 52 53 # optional for debugging 54 debug_githubusercontent: PydanticURL | None = None 55 56 @property 57 def asset_url(self) -> URL: 58 if self.debug_githubusercontent is not None: 59 return URL(str(self.debug_githubusercontent)) 60 61 return ( 62 URL("https://raw.githubusercontent.com") 63 / self.github_repository 64 / self.github_sha 65 ) 66 67 @property 68 def source_url(self) -> URL: 69 return ( 70 URL("https://github.com") 71 / self.github_repository 72 / "tree" 73 / self.github_sha 74 ) 75 76 @property 77 def repo_owner(self): 78 return self._github_repository_parts[0] 79 80 @property 81 def repo_name(self): 82 return self._github_repository_parts[1] 83 84 @property 85 def _github_repository_parts(self): 86 owner, repo_name = self.github_repository.split("/", maxsplit=1) 87 return owner, repo_name 88 89 @model_validator(mode="after") 90 def _append_subdirectory(self) -> Self: 91 if self.hexdoc_subdirectory: 92 self.github_pages_url /= self.hexdoc_subdirectory 93 return self 94 95 96class TemplateProps(StripHiddenModel, validate_assignment=True): 97 static_dir: RelativePath | None = None 98 icon: RelativePath | None = None 99 include: PydanticOrderedSet[str] 100 101 render_from: PydanticOrderedSet[str] = Field(None, validate_default=False) # type: ignore 102 """List of modids to include default rendered templates from. 103 104 If not provided, defaults to `self.include`. 105 """ 106 render: dict[Path, str] = Field(default_factory=dict) 107 extend_render: dict[Path, str] = Field(default_factory=dict) 108 109 redirect: tuple[Path, str] | None = (Path("index.html"), "redirect.html.jinja") 110 """filename, template""" 111 112 args: dict[str, Any] 113 114 _was_render_set: bool = PrivateAttr(False) 115 116 @property 117 def override_default_render(self): 118 return self._was_render_set 119 120 @field_validator("include", "render_from", mode="after") 121 @classmethod 122 def _resolve_aliases(cls, values: PydanticOrderedSet[str] | None): 123 if values: 124 for alias, replacement in JINJA_NAMESPACE_ALIASES.items(): 125 if alias in values: 126 values.remove(alias) 127 values.add(replacement) 128 return values 129 130 @model_validator(mode="after") 131 def _set_default_render_from(self): 132 if self.render_from is None: # pyright: ignore[reportUnnecessaryComparison] 133 self.render_from = self.include 134 return self 135 136 137# TODO: support item/block override 138class PNGTextureOverride(StripHiddenModel): 139 url: PydanticURL 140 pixelated: bool 141 142 143class TextureTextureOverride(StripHiddenModel): 144 texture: ResourceLocation 145 """The id of an image texture (eg. `minecraft:textures/item/stick.png`).""" 146 147 148class TexturesProps(StripHiddenModel): 149 enabled: bool = True 150 """Set to False to disable texture rendering.""" 151 strict: bool = True 152 """Set to False to print some errors instead of throwing them.""" 153 missing: set[ResourceLocation] | Literal["*"] = Field(default_factory=set) 154 override: dict[ 155 ResourceLocation, 156 PNGTextureOverride | TextureTextureOverride, 157 ] = Field(default_factory=dict) 158 159 160class LangProps(StripHiddenModel): 161 """Configuration for a specific book language.""" 162 163 quiet: bool = False 164 """If `True`, do not log warnings for missing translations. 165 166 Using this option for the default language is not recommended. 167 """ 168 ignore_errors: bool = False 169 """If `True`, log fatal errors for this language instead of failing entirely. 170 171 Using this option for the default language is not recommended. 172 """ 173 174 175class BaseProperties(StripHiddenModel, ValidationContext): 176 env: SkipJsonSchema[EnvironmentVariableProps] 177 props_dir: SkipJsonSchema[Path] 178 179 @classmethod 180 def load(cls, path: Path) -> Self: 181 return cls.load_data( 182 props_dir=path.parent, 183 data=load_toml_with_placeholders(path), 184 ) 185 186 @classmethod 187 def load_data(cls, props_dir: Path, data: dict[str, Any]) -> Self: 188 props_dir = props_dir.resolve() 189 190 with relative_path_root(props_dir): 191 env = EnvironmentVariableProps.model_getenv() 192 props = cls.model_validate( 193 data 194 | { 195 "env": env, 196 "props_dir": props_dir, 197 }, 198 ) 199 200 logger.log(TRACE, props) 201 return props 202 203 @override 204 @classmethod 205 def model_json_schema( 206 cls, 207 by_alias: bool = True, 208 ref_template: str = DEFAULT_REF_TEMPLATE, 209 schema_generator: type[GenerateJsonSchema] = GenerateJsonSchemaTOML, 210 mode: Literal["validation", "serialization"] = "validation", 211 ) -> dict[str, Any]: 212 return super().model_json_schema(by_alias, ref_template, schema_generator, mode) 213 214 215class Properties(BaseProperties): 216 """Pydantic model for `hexdoc.toml` / `properties.toml`.""" 217 218 modid: str 219 220 book_type: str = "patchouli" 221 """Modid of the `hexdoc.plugin.BookPlugin` to use when loading this book.""" 222 223 # TODO: make another properties type without book_id 224 book_id: ResourceLocation | None = Field(alias="book", default=None) 225 extra_books: list[ResourceLocation] = Field(default_factory=list) 226 227 default_lang: str = "en_us" 228 default_branch: str = "main" 229 230 is_0_black: bool = False 231 """If true, the style `$(0)` changes the text color to black; otherwise it resets 232 the text color to the default.""" 233 234 resource_dirs: Sequence[ResourceDir] 235 export_dir: RelativePath | None = None 236 237 entry_id_blacklist: set[ResourceLocation] = Field(default_factory=set) 238 239 macros: dict[str, str] = Field(default_factory=dict) 240 link_overrides: dict[str, str] = Field(default_factory=dict) 241 242 textures: TexturesProps = Field(default_factory=TexturesProps) 243 244 template: TemplateProps | None = None 245 246 lang: defaultdict[ 247 str, 248 Annotated[LangProps, Field(default_factory=LangProps)], 249 ] = Field(default_factory=lambda: defaultdict(LangProps)) 250 """Per-language configuration. The key should be the language code, eg. `en_us`.""" 251 252 extra: dict[str, Any] = Field(default_factory=dict) 253 254 def mod_loc(self, path: str) -> ResourceLocation: 255 """Returns a ResourceLocation with self.modid as the namespace.""" 256 return ResourceLocation(self.modid, path) 257 258 @property 259 def prerender_dir(self): 260 return self.cache_dir / "prerender" 261 262 @property 263 def cache_dir(self): 264 return self.repo_root / ".hexdoc" 265 266 @cached_property 267 def repo_root(self): 268 return git_root(self.props_dir)
43class EnvironmentVariableProps(HexdocSettings): 44 # default Actions environment variables 45 github_repository: str 46 github_sha: str 47 48 # set by CI 49 github_pages_url: PydanticURL 50 51 # for putting books somewhere other than the site root 52 hexdoc_subdirectory: str | None = None 53 54 # optional for debugging 55 debug_githubusercontent: PydanticURL | None = None 56 57 @property 58 def asset_url(self) -> URL: 59 if self.debug_githubusercontent is not None: 60 return URL(str(self.debug_githubusercontent)) 61 62 return ( 63 URL("https://raw.githubusercontent.com") 64 / self.github_repository 65 / self.github_sha 66 ) 67 68 @property 69 def source_url(self) -> URL: 70 return ( 71 URL("https://github.com") 72 / self.github_repository 73 / "tree" 74 / self.github_sha 75 ) 76 77 @property 78 def repo_owner(self): 79 return self._github_repository_parts[0] 80 81 @property 82 def repo_name(self): 83 return self._github_repository_parts[1] 84 85 @property 86 def _github_repository_parts(self): 87 owner, repo_name = self.github_repository.split("/", maxsplit=1) 88 return owner, repo_name 89 90 @model_validator(mode="after") 91 def _append_subdirectory(self) -> Self: 92 if self.hexdoc_subdirectory: 93 self.github_pages_url /= self.hexdoc_subdirectory 94 return self
Base class for settings, allowing values to be overridden by environment variables.
This is useful in production for secrets you do not wish to save in code, it plays nicely with docker(-compose), Heroku and any 12 factor app design.
All the below attributes can be set via model_config
.
Args:
_case_sensitive: Whether environment and CLI variable names should be read with case-sensitivity.
Defaults to None
.
_nested_model_default_partial_update: Whether to allow partial updates on nested model default object fields.
Defaults to False
.
_env_prefix: Prefix for all environment variables. Defaults to None
.
_env_file: The env file(s) to load settings values from. Defaults to Path('')
, which
means that the value from model_config['env_file']
should be used. You can also pass
None
to indicate that environment variables should not be loaded from an env file.
_env_file_encoding: The env file encoding, e.g. 'latin-1'
. Defaults to None
.
_env_ignore_empty: Ignore environment variables where the value is an empty string. Default to False
.
_env_nested_delimiter: The nested env values delimiter. Defaults to None
.
_env_nested_max_split: The nested env values maximum nesting. Defaults to None
, which means no limit.
_env_parse_none_str: The env string value that should be parsed (e.g. "null", "void", "None", etc.)
into None
type(None). Defaults to None
type(None), which means no parsing should occur.
_env_parse_enums: Parse enum field names to values. Defaults to None.
, which means no parsing should occur.
_cli_prog_name: The CLI program name to display in help text. Defaults to None
if _cli_parse_args is None
.
Otherwse, defaults to sys.argv[0].
_cli_parse_args: The list of CLI arguments to parse. Defaults to None.
If set to True
, defaults to sys.argv[1:].
_cli_settings_source: Override the default CLI settings source with a user defined instance. Defaults to None.
_cli_parse_none_str: The CLI string value that should be parsed (e.g. "null", "void", "None", etc.) into
None
type(None). Defaults to _env_parse_none_str value if set. Otherwise, defaults to "null" if
_cli_avoid_json is False
, and "None" if _cli_avoid_json is True
.
_cli_hide_none_type: Hide None
values in CLI help text. Defaults to False
.
_cli_avoid_json: Avoid complex JSON objects in CLI help text. Defaults to False
.
_cli_enforce_required: Enforce required fields at the CLI. Defaults to False
.
_cli_use_class_docs_for_groups: Use class docstrings in CLI group help text instead of field descriptions.
Defaults to False
.
_cli_exit_on_error: Determines whether or not the internal parser exits with error info when an error occurs.
Defaults to True
.
_cli_prefix: The root parser command line arguments prefix. Defaults to "".
_cli_flag_prefix_char: The flag prefix character to use for CLI optional arguments. Defaults to '-'.
_cli_implicit_flags: Whether bool
fields should be implicitly converted into CLI boolean flags.
(e.g. --flag, --no-flag). Defaults to False
.
_cli_ignore_unknown_args: Whether to ignore unknown CLI args and parse only known ones. Defaults to False
.
_cli_kebab_case: CLI args use kebab case. Defaults to False
.
_secrets_dir: The secret files directory or a sequence of directories. Defaults to None
.
Configuration for the model, should be a dictionary conforming to [ConfigDict
][pydantic.config.ConfigDict].
97class TemplateProps(StripHiddenModel, validate_assignment=True): 98 static_dir: RelativePath | None = None 99 icon: RelativePath | None = None 100 include: PydanticOrderedSet[str] 101 102 render_from: PydanticOrderedSet[str] = Field(None, validate_default=False) # type: ignore 103 """List of modids to include default rendered templates from. 104 105 If not provided, defaults to `self.include`. 106 """ 107 render: dict[Path, str] = Field(default_factory=dict) 108 extend_render: dict[Path, str] = Field(default_factory=dict) 109 110 redirect: tuple[Path, str] | None = (Path("index.html"), "redirect.html.jinja") 111 """filename, template""" 112 113 args: dict[str, Any] 114 115 _was_render_set: bool = PrivateAttr(False) 116 117 @property 118 def override_default_render(self): 119 return self._was_render_set 120 121 @field_validator("include", "render_from", mode="after") 122 @classmethod 123 def _resolve_aliases(cls, values: PydanticOrderedSet[str] | None): 124 if values: 125 for alias, replacement in JINJA_NAMESPACE_ALIASES.items(): 126 if alias in values: 127 values.remove(alias) 128 values.add(replacement) 129 return values 130 131 @model_validator(mode="after") 132 def _set_default_render_from(self): 133 if self.render_from is None: # pyright: ignore[reportUnnecessaryComparison] 134 self.render_from = self.include 135 return self
Base model which removes all keys starting with _ before validation.
List of modids to include default rendered templates from.
If not provided, defaults to self.include
.
337def init_private_attributes(self: BaseModel, context: Any, /) -> None: 338 """This function is meant to behave like a BaseModel method to initialise private attributes. 339 340 It takes context as an argument since that's what pydantic-core passes when calling it. 341 342 Args: 343 self: The BaseModel instance. 344 context: The context. 345 """ 346 if getattr(self, '__pydantic_private__', None) is None: 347 pydantic_private = {} 348 for name, private_attr in self.__private_attributes__.items(): 349 default = private_attr.get_default() 350 if default is not PydanticUndefined: 351 pydantic_private[name] = default 352 object_setattr(self, '__pydantic_private__', pydantic_private)
This function is meant to behave like a BaseModel method to initialise private attributes.
It takes context as an argument since that's what pydantic-core passes when calling it.
Args: self: The BaseModel instance. context: The context.
Base model which removes all keys starting with _ before validation.
144class TextureTextureOverride(StripHiddenModel): 145 texture: ResourceLocation 146 """The id of an image texture (eg. `minecraft:textures/item/stick.png`)."""
Base model which removes all keys starting with _ before validation.
The id of an image texture (eg. minecraft:textures/item/stick.png
).
149class TexturesProps(StripHiddenModel): 150 enabled: bool = True 151 """Set to False to disable texture rendering.""" 152 strict: bool = True 153 """Set to False to print some errors instead of throwing them.""" 154 missing: set[ResourceLocation] | Literal["*"] = Field(default_factory=set) 155 override: dict[ 156 ResourceLocation, 157 PNGTextureOverride | TextureTextureOverride, 158 ] = Field(default_factory=dict)
Base model which removes all keys starting with _ before validation.
161class LangProps(StripHiddenModel): 162 """Configuration for a specific book language.""" 163 164 quiet: bool = False 165 """If `True`, do not log warnings for missing translations. 166 167 Using this option for the default language is not recommended. 168 """ 169 ignore_errors: bool = False 170 """If `True`, log fatal errors for this language instead of failing entirely. 171 172 Using this option for the default language is not recommended. 173 """
Configuration for a specific book language.
176class BaseProperties(StripHiddenModel, ValidationContext): 177 env: SkipJsonSchema[EnvironmentVariableProps] 178 props_dir: SkipJsonSchema[Path] 179 180 @classmethod 181 def load(cls, path: Path) -> Self: 182 return cls.load_data( 183 props_dir=path.parent, 184 data=load_toml_with_placeholders(path), 185 ) 186 187 @classmethod 188 def load_data(cls, props_dir: Path, data: dict[str, Any]) -> Self: 189 props_dir = props_dir.resolve() 190 191 with relative_path_root(props_dir): 192 env = EnvironmentVariableProps.model_getenv() 193 props = cls.model_validate( 194 data 195 | { 196 "env": env, 197 "props_dir": props_dir, 198 }, 199 ) 200 201 logger.log(TRACE, props) 202 return props 203 204 @override 205 @classmethod 206 def model_json_schema( 207 cls, 208 by_alias: bool = True, 209 ref_template: str = DEFAULT_REF_TEMPLATE, 210 schema_generator: type[GenerateJsonSchema] = GenerateJsonSchemaTOML, 211 mode: Literal["validation", "serialization"] = "validation", 212 ) -> dict[str, Any]: 213 return super().model_json_schema(by_alias, ref_template, schema_generator, mode)
Base model which removes all keys starting with _ before validation.
187 @classmethod 188 def load_data(cls, props_dir: Path, data: dict[str, Any]) -> Self: 189 props_dir = props_dir.resolve() 190 191 with relative_path_root(props_dir): 192 env = EnvironmentVariableProps.model_getenv() 193 props = cls.model_validate( 194 data 195 | { 196 "env": env, 197 "props_dir": props_dir, 198 }, 199 ) 200 201 logger.log(TRACE, props) 202 return props
204 @override 205 @classmethod 206 def model_json_schema( 207 cls, 208 by_alias: bool = True, 209 ref_template: str = DEFAULT_REF_TEMPLATE, 210 schema_generator: type[GenerateJsonSchema] = GenerateJsonSchemaTOML, 211 mode: Literal["validation", "serialization"] = "validation", 212 ) -> dict[str, Any]: 213 return super().model_json_schema(by_alias, ref_template, schema_generator, mode)
Generates a JSON schema for a model class.
Args:
by_alias: Whether to use attribute aliases or not.
ref_template: The reference template.
schema_generator: To override the logic used to generate the JSON schema, as a subclass of
GenerateJsonSchema
with your desired modifications
mode: The mode in which to generate the schema.
Returns: The JSON schema for the given model class.
216class Properties(BaseProperties): 217 """Pydantic model for `hexdoc.toml` / `properties.toml`.""" 218 219 modid: str 220 221 book_type: str = "patchouli" 222 """Modid of the `hexdoc.plugin.BookPlugin` to use when loading this book.""" 223 224 # TODO: make another properties type without book_id 225 book_id: ResourceLocation | None = Field(alias="book", default=None) 226 extra_books: list[ResourceLocation] = Field(default_factory=list) 227 228 default_lang: str = "en_us" 229 default_branch: str = "main" 230 231 is_0_black: bool = False 232 """If true, the style `$(0)` changes the text color to black; otherwise it resets 233 the text color to the default.""" 234 235 resource_dirs: Sequence[ResourceDir] 236 export_dir: RelativePath | None = None 237 238 entry_id_blacklist: set[ResourceLocation] = Field(default_factory=set) 239 240 macros: dict[str, str] = Field(default_factory=dict) 241 link_overrides: dict[str, str] = Field(default_factory=dict) 242 243 textures: TexturesProps = Field(default_factory=TexturesProps) 244 245 template: TemplateProps | None = None 246 247 lang: defaultdict[ 248 str, 249 Annotated[LangProps, Field(default_factory=LangProps)], 250 ] = Field(default_factory=lambda: defaultdict(LangProps)) 251 """Per-language configuration. The key should be the language code, eg. `en_us`.""" 252 253 extra: dict[str, Any] = Field(default_factory=dict) 254 255 def mod_loc(self, path: str) -> ResourceLocation: 256 """Returns a ResourceLocation with self.modid as the namespace.""" 257 return ResourceLocation(self.modid, path) 258 259 @property 260 def prerender_dir(self): 261 return self.cache_dir / "prerender" 262 263 @property 264 def cache_dir(self): 265 return self.repo_root / ".hexdoc" 266 267 @cached_property 268 def repo_root(self): 269 return git_root(self.props_dir)
Pydantic model for hexdoc.toml
/ properties.toml
.
If true, the style $(0)
changes the text color to black; otherwise it resets
the text color to the default.
Per-language configuration. The key should be the language code, eg. en_us
.