Edit on GitHub

hexdoc.core

 1__all__ = [
 2    "METADATA_SUFFIX",
 3    "AssumeTag",
 4    "BaseProperties",
 5    "BaseResourceDir",
 6    "BaseResourceLocation",
 7    "BookFolder",
 8    "Entity",
 9    "ExportFn",
10    "IsVersion",
11    "ItemStack",
12    "MinecraftVersion",
13    "ModResourceLoader",
14    "PathResourceDir",
15    "PluginResourceDir",
16    "Properties",
17    "ResLoc",
18    "ResourceDir",
19    "ResourceLocation",
20    "ResourceType",
21    "ValueIfVersion",
22    "VersionSource",
23    "Versioned",
24    "compat",
25    "properties",
26]
27
28from .compat import (
29    IsVersion,
30    MinecraftVersion,
31    ValueIfVersion,
32    Versioned,
33    VersionSource,
34)
35from .loader import (
36    METADATA_SUFFIX,
37    BookFolder,
38    ExportFn,
39    ModResourceLoader,
40)
41from .properties import BaseProperties, Properties
42from .resource import (
43    AssumeTag,
44    BaseResourceLocation,
45    Entity,
46    ItemStack,
47    ResLoc,
48    ResourceLocation,
49    ResourceType,
50)
51from .resource_dir import (
52    BaseResourceDir,
53    PathResourceDir,
54    PluginResourceDir,
55    ResourceDir,
56)
METADATA_SUFFIX = '.hexdoc.json'
AssumeTag = typing.Annotated[~_T, BeforeValidator(func=<function _add_hashtag_to_tag>, json_schema_input_type=PydanticUndefined)]
class BaseProperties(hexdoc.model.strip_hidden.StripHiddenModel, hexdoc.utils.context.ValidationContext):
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(  # pyright: ignore[reportIncompatibleMethodOverride]
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.

env: typing.Annotated[hexdoc.core.properties.EnvironmentVariableProps, SkipJsonSchema()]
props_dir: typing.Annotated[pathlib.Path, SkipJsonSchema()]
@classmethod
def load(cls, path: pathlib.Path) -> Self:
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        )
@classmethod
def load_data(cls, props_dir: pathlib.Path, data: dict[str, typing.Any]) -> Self:
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
@override
@classmethod
def model_json_schema( cls, by_alias: bool = True, ref_template: str = '#/$defs/{model}', schema_generator: type[pydantic.json_schema.GenerateJsonSchema] = <class 'hexdoc.utils.deserialize.toml.GenerateJsonSchemaTOML'>, mode: Literal['validation', 'serialization'] = 'validation') -> dict[str, typing.Any]:
204    @override
205    @classmethod
206    def model_json_schema(  # pyright: ignore[reportIncompatibleMethodOverride]
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. union_format: The format to use when combining schemas from unions together. Can be one of:

    - `'any_of'`: Use the [`anyOf`](https://json-schema.org/understanding-json-schema/reference/combining#anyOf)
    keyword to combine schemas (the default).
    - `'primitive_type_array'`: Use the [`type`](https://json-schema.org/understanding-json-schema/reference/type)
    keyword as an array of strings, containing each type of the combination. If any of the schemas is not a primitive
    type (`string`, `boolean`, `null`, `integer` or `number`) or contains constraints/metadata, falls back to
    `any_of`.
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.

class BaseResourceDir(hexdoc.model.base.HexdocModel, abc.ABC):
26class BaseResourceDir(HexdocModel, ABC):
27    @staticmethod
28    def _json_schema_extra(schema: dict[str, Any]):
29        properties = schema.pop("properties")
30        new_schema = {
31            "anyOf": [
32                schema | {"properties": properties | {key: value}}
33                for key, value in {
34                    "external": properties.pop("external"),
35                    "internal": {
36                        "type": "boolean",
37                        "default": True,
38                        "title": "Internal",
39                    },
40                }.items()
41            ],
42        }
43        schema.clear()
44        schema.update(new_schema)
45
46    model_config = DEFAULT_CONFIG | {
47        "json_schema_extra": _json_schema_extra,
48    }
49
50    external: bool
51    reexport: bool
52    """If not set, the default value will be `not self.external`.
53
54    Must be defined AFTER `external` in the Pydantic model.
55    """
56
57    @abstractmethod
58    def load(
59        self,
60        pm: PluginManager,
61    ) -> ContextManager[Iterable[PathResourceDir]]: ...
62
63    @property
64    def internal(self):
65        return not self.external
66
67    @model_validator(mode="before")
68    @classmethod
69    def _default_reexport(cls, data: JSONDict | Any):
70        if not isinstance(data, dict):
71            return data
72
73        external = cls._get_external(data)
74        if external is None:
75            return data
76
77        if "reexport" not in data:
78            data["reexport"] = not external
79
80        return data
81
82    @classmethod
83    def _get_external(cls, data: JSONDict | Any):
84        match data:
85            case {"external": bool(), "internal": bool()}:
86                raise ValueError(f"Expected internal OR external, got both: {data}")
87            case {"external": bool(external)}:
88                return external
89            case {"internal": bool(internal)}:
90                data.pop("internal")
91                external = data["external"] = not internal
92                return external
93            case _:
94                return None

Base class for all Pydantic models in hexdoc.

Sets the default model config, and overrides __init__ to allow using the init_context context manager to set validation context for constructors.

external: bool
reexport: bool

If not set, the default value will be not self.external.

Must be defined AFTER external in the Pydantic model.

@abstractmethod
def load( self, pm: hexdoc.plugin.PluginManager) -> ContextManager[Iterable[PathResourceDir]]:
57    @abstractmethod
58    def load(
59        self,
60        pm: PluginManager,
61    ) -> ContextManager[Iterable[PathResourceDir]]: ...
internal
63    @property
64    def internal(self):
65        return not self.external
@dataclass(frozen=True, repr=False, config=DEFAULT_CONFIG | ConfigDict(json_schema_extra=resloc_json_schema_extra, arbitrary_types_allowed=True))
class BaseResourceLocation:
 92@dataclass(
 93    frozen=True,
 94    repr=False,
 95    config=DEFAULT_CONFIG
 96    | ConfigDict(
 97        json_schema_extra=resloc_json_schema_extra,
 98        arbitrary_types_allowed=True,
 99    ),
100)
101class BaseResourceLocation:
102    namespace: str
103    path: str
104
105    _from_str_regex: ClassVar[re.Pattern[str]]
106
107    def __init_subclass__(cls, regex: re.Pattern[str] | None) -> None:
108        if regex:
109            cls._from_str_regex = regex
110
111    @classmethod
112    def from_str(cls, raw: str) -> Self:
113        match = cls._from_str_regex.fullmatch(raw)
114        if match is None:
115            raise ValueError(f"Invalid {cls.__name__} string: {raw}")
116
117        return cls(**match.groupdict())
118
119    @classmethod
120    def model_validate(cls, value: Any, *, context: Any = None):
121        ta = TypeAdapter(cls)
122        return ta.validate_python(value, context=context)
123
124    @model_validator(mode="wrap")
125    @classmethod
126    def _pre_root(cls, values: Any, handler: ModelWrapValidatorHandler[Self]):
127        # before validating the fields, if it's a string instead of a dict, convert it
128        logger.log(TRACE, f"Convert {values} to {cls.__name__}")
129        if isinstance(values, str):
130            return cls.from_str(values)
131        return handler(values)
132
133    @field_validator("namespace", mode="before")
134    def _default_namespace(cls, value: Any):
135        match value:
136            case str():
137                return value.lower()
138            case None:
139                return "minecraft"
140            case _:
141                return value
142
143    @field_validator("path")
144    def _validate_path(cls, value: str):
145        return value.lower().rstrip("/")
146
147    @model_serializer
148    def _ser_model(self) -> str:
149        return str(self)
150
151    @property
152    def id(self) -> ResourceLocation:
153        return ResourceLocation(self.namespace, self.path)
154
155    def i18n_key(self, root: str) -> str:
156        # TODO: is this how i18n works????? (apparently, because it's working)
157        return f"{root}.{self.namespace}.{self.path.replace('/', '.')}"
158
159    def __repr__(self) -> str:
160        return f"{self.namespace}:{self.path}"
BaseResourceLocation(*args: Any, **kwargs: Any)
119    def __init__(__dataclass_self__: PydanticDataclass, *args: Any, **kwargs: Any) -> None:
120        __tracebackhide__ = True
121        s = __dataclass_self__
122        s.__pydantic_validator__.validate_python(ArgsKwargs(args, kwargs), self_instance=s)
namespace: str
path: str
@classmethod
def from_str(cls, raw: str) -> Self:
111    @classmethod
112    def from_str(cls, raw: str) -> Self:
113        match = cls._from_str_regex.fullmatch(raw)
114        if match is None:
115            raise ValueError(f"Invalid {cls.__name__} string: {raw}")
116
117        return cls(**match.groupdict())
@classmethod
def model_validate(cls, value: Any, *, context: Any = None):
119    @classmethod
120    def model_validate(cls, value: Any, *, context: Any = None):
121        ta = TypeAdapter(cls)
122        return ta.validate_python(value, context=context)
id: ResourceLocation
151    @property
152    def id(self) -> ResourceLocation:
153        return ResourceLocation(self.namespace, self.path)
def i18n_key(self, root: str) -> str:
155    def i18n_key(self, root: str) -> str:
156        # TODO: is this how i18n works????? (apparently, because it's working)
157        return f"{root}.{self.namespace}.{self.path.replace('/', '.')}"
BookFolder = typing.Literal['categories', 'entries', 'templates']
@dataclass(frozen=True, repr=False)
class Entity(hexdoc.core.BaseResourceLocation):
317@dataclass(frozen=True, repr=False)
318class Entity(BaseResourceLocation, regex=_make_regex(nbt=True)):
319    """Represents an entity with optional NBT.
320
321    Inherits from BaseResourceLocation, not ResourceLocation.
322    """
323
324    nbt: str | None = None
325
326    def __repr__(self) -> str:
327        s = super().__repr__()
328        if self.nbt is not None:
329            s += self.nbt
330        return s

Represents an entity with optional NBT.

Inherits from BaseResourceLocation, not ResourceLocation.

Entity(*args: Any, **kwargs: Any)
119    def __init__(__dataclass_self__: PydanticDataclass, *args: Any, **kwargs: Any) -> None:
120        __tracebackhide__ = True
121        s = __dataclass_self__
122        s.__pydantic_validator__.validate_python(ArgsKwargs(args, kwargs), self_instance=s)
nbt: str | None = None
ExportFn = collections.abc.Callable[[~_T, typing.Optional[~_T]], str]
@dataclass(frozen=True)
class IsVersion(hexdoc.core.Versioned):
 65@dataclass(frozen=True)
 66class IsVersion(Versioned):
 67    """Instances of this class are truthy if version_spec matches version_source, which
 68    defaults to MinecraftVersion.
 69
 70    Can be used as a Pydantic validator annotation, which raises ValueError if
 71    version_spec doesn't match the current version. Use it like this:
 72
 73    `Annotated[str, IsVersion(">=1.20")] | Annotated[None, IsVersion("<1.20")]`
 74
 75    Can also be used as a class decorator for Pydantic models, which raises ValueError
 76    when validating the model if version_spec doesn't match the current version.
 77    Decorated classes must subclass HexdocModel (or HexdocBaseModel).
 78    """
 79
 80    def __bool__(self):
 81        return self.is_current
 82
 83    def __call__(self, cls: _T_ModelType) -> _T_ModelType:
 84        cls.__hexdoc_before_validator__ = self._model_validator
 85        return cls
 86
 87    def __get_pydantic_core_schema__(
 88        self,
 89        source_type: type[Any],
 90        handler: GetCoreSchemaHandler,
 91    ) -> core_schema.CoreSchema:
 92        return core_schema.no_info_before_validator_function(
 93            self._schema_validator,
 94            schema=handler(source_type),
 95        )
 96
 97    def _schema_validator(self, value: Any):
 98        if self.is_current:
 99            return value
100        raise ValueError(
101            f"Expected version {self.version_spec}, got {self.version_source.get()}"
102        )
103
104    def _model_validator(self, cls: Any, value: Any, info: ValidationInfo):
105        return self._schema_validator(value)

Instances of this class are truthy if version_spec matches version_source, which defaults to MinecraftVersion.

Can be used as a Pydantic validator annotation, which raises ValueError if version_spec doesn't match the current version. Use it like this:

Annotated[str, IsVersion(">=1.20")] | Annotated[None, IsVersion("<1.20")]

Can also be used as a class decorator for Pydantic models, which raises ValueError when validating the model if version_spec doesn't match the current version. Decorated classes must subclass HexdocModel (or HexdocBaseModel).

IsVersion( version_spec: str, *, version_source: VersionSource = <class 'MinecraftVersion'>)
@dataclass(frozen=True, repr=False)
class ItemStack(hexdoc.core.BaseResourceLocation):
260@dataclass(frozen=True, repr=False)
261class ItemStack(BaseResourceLocation, regex=_make_regex(count=True, nbt=True)):
262    """Represents an item with optional count and NBT.
263
264    Inherits from BaseResourceLocation, not ResourceLocation.
265    """
266
267    count: int | None = None
268    nbt: str | None = None
269
270    _data: SkipJsonSchema[Compound | None] = None
271
272    def __init_subclass__(cls, **kwargs: Any):
273        super().__init_subclass__(regex=cls._from_str_regex, **kwargs)
274
275    def __post_init__(self):
276        object.__setattr__(self, "_data", _parse_nbt(self.nbt))
277
278    @property
279    def data(self):
280        return self._data
281
282    def get_name(self) -> str | None:
283        if self.data is None:
284            return None
285
286        component_json = self.data.get(NBTPath("display.Name"))  # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
287        if not isinstance(component_json, str):
288            return None
289
290        try:
291            component: JsonValue = json.loads(component_json)
292        except ValueError:
293            return None
294
295        if not isinstance(component, dict):
296            return None
297
298        name = component.get("text")
299        if not isinstance(name, str):
300            return None
301
302        return name
303
304    @override
305    def i18n_key(self, root: str = "item") -> str:
306        return super().i18n_key(root)
307
308    def __repr__(self) -> str:
309        s = super().__repr__()
310        if self.count is not None:
311            s += f"#{self.count}"
312        if self.nbt is not None:
313            s += self.nbt
314        return s

Represents an item with optional count and NBT.

Inherits from BaseResourceLocation, not ResourceLocation.

ItemStack(*args: Any, **kwargs: Any)
119    def __init__(__dataclass_self__: PydanticDataclass, *args: Any, **kwargs: Any) -> None:
120        __tracebackhide__ = True
121        s = __dataclass_self__
122        s.__pydantic_validator__.validate_python(ArgsKwargs(args, kwargs), self_instance=s)
count: int | None = None
nbt: str | None = None
data
278    @property
279    def data(self):
280        return self._data
def get_name(self) -> str | None:
282    def get_name(self) -> str | None:
283        if self.data is None:
284            return None
285
286        component_json = self.data.get(NBTPath("display.Name"))  # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
287        if not isinstance(component_json, str):
288            return None
289
290        try:
291            component: JsonValue = json.loads(component_json)
292        except ValueError:
293            return None
294
295        if not isinstance(component, dict):
296            return None
297
298        name = component.get("text")
299        if not isinstance(name, str):
300            return None
301
302        return name
@override
def i18n_key(self, root: str = 'item') -> str:
304    @override
305    def i18n_key(self, root: str = "item") -> str:
306        return super().i18n_key(root)
class MinecraftVersion(hexdoc.core.VersionSource):
34class MinecraftVersion(VersionSource):
35    MINECRAFT_VERSION: ClassVar[str | None] = None
36
37    @override
38    @classmethod
39    def get(cls) -> str | None:
40        return cls.MINECRAFT_VERSION
41
42    @override
43    @classmethod
44    def matches(cls, specifier: str | SpecifierSet) -> bool:
45        if isinstance(specifier, str):
46            specifier = SpecifierSet(specifier)
47        if (version := cls.get()) is None:
48            return True
49        return version in specifier

Base class for protocol classes.

Protocol classes are defined as::

class Proto(Protocol):
    def meth(self) -> int:
        ...

Such classes are primarily used with static type checkers that recognize structural subtyping (static duck-typing).

For example::

class C:
    def meth(self) -> int:
        return 0

def func(x: Proto) -> int:
    return x.meth()

func(C())  # Passes static type check

See PEP 544 for details. Protocol classes decorated with @typing.runtime_checkable act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures. Protocol classes can be generic, they are defined as::

class GenProto(Protocol[T]):
    def meth(self) -> T:
        ...
MINECRAFT_VERSION: ClassVar[str | None] = None
@override
@classmethod
def get(cls) -> str | None:
37    @override
38    @classmethod
39    def get(cls) -> str | None:
40        return cls.MINECRAFT_VERSION

Returns the current version.

@override
@classmethod
def matches(cls, specifier: str | packaging.specifiers.SpecifierSet) -> bool:
42    @override
43    @classmethod
44    def matches(cls, specifier: str | SpecifierSet) -> bool:
45        if isinstance(specifier, str):
46            specifier = SpecifierSet(specifier)
47        if (version := cls.get()) is None:
48            return True
49        return version in specifier

Returns True if the current version matches the version_spec.

@dataclass(config=DEFAULT_CONFIG | {'arbitrary_types_allowed': True}, kw_only=True)
class ModResourceLoader(hexdoc.utils.context.ValidationContext):
 49@dataclass(config=DEFAULT_CONFIG | {"arbitrary_types_allowed": True}, kw_only=True)
 50class ModResourceLoader(ValidationContext):
 51    props: Properties
 52    export_dir: Path | None
 53    resource_dirs: Sequence[PathResourceDir]
 54    _stack: SkipValidation[ExitStack]
 55
 56    @classmethod
 57    def clean_and_load_all(
 58        cls,
 59        props: Properties,
 60        pm: PluginManager,
 61        *,
 62        export: bool = False,
 63    ):
 64        # clear the export dir so we start with a clean slate
 65        if props.export_dir and export:
 66            subprocess.run(
 67                ["git", "clean", "-fdX", props.export_dir],
 68                cwd=props.props_dir,
 69            )
 70
 71            write_to_path(
 72                props.export_dir / "__init__.py",
 73                dedent(
 74                    """\
 75                    # This directory is auto-generated by hexdoc.
 76                    # Do not edit or commit these files.
 77                    """
 78                ),
 79            )
 80
 81        return cls.load_all(
 82            props,
 83            pm,
 84            export=export,
 85        )
 86
 87    @classmethod
 88    def load_all(
 89        cls,
 90        props: Properties,
 91        pm: PluginManager,
 92        *,
 93        export: bool = False,
 94    ) -> Self:
 95        export_dir = props.export_dir if export else None
 96        stack = ExitStack()
 97
 98        with relative_path_root(Path()):
 99            resource_dirs = [
100                path_resource_dir
101                for resource_dir in props.resource_dirs
102                for path_resource_dir in stack.enter_context(resource_dir.load(pm))
103            ]
104
105        return cls(
106            props=props,
107            export_dir=export_dir,
108            resource_dirs=resource_dirs,
109            _stack=stack,
110        )
111
112    def __enter__(self):
113        return self
114
115    def __exit__(self, *exc_details: Any):
116        return self._stack.__exit__(*exc_details)
117
118    def close(self):
119        self._stack.close()
120
121    def _map_own_assets(self, folder: str, *, root: str | Path):
122        return {
123            id: path.resolve().relative_to(root)
124            for _, id, path in self.find_resources(
125                "assets",
126                namespace=self.props.modid,
127                folder="",
128                glob=f"{folder}/**/*.*",
129                allow_missing=True,
130            )
131        }
132
133    @property
134    def should_export(self):
135        return self.export_dir is not None
136
137    def load_metadata(
138        self,
139        *,
140        name_pattern: str = "{modid}",
141        model_type: type[_T_Model],
142        allow_missing: bool = False,
143    ) -> dict[str, _T_Model]:
144        """eg. `"{modid}.patterns"`"""
145        metadata = dict[str, _T_Model]()
146
147        # TODO: refactor
148        cached_metadata = self.props.cache_dir / (
149            name_pattern.format(modid=self.props.modid) + METADATA_SUFFIX
150        )
151        if cached_metadata.is_file():
152            metadata[self.props.modid] = model_type.model_validate_json(
153                cached_metadata.read_bytes()
154            )
155
156        for resource_dir in self.resource_dirs:
157            # skip if the resource dir has no metadata set, because we're only loading
158            # this for external mods (TODO: this feels flawed)
159            modid = resource_dir.modid
160            if modid is None or modid in metadata:
161                continue
162
163            try:
164                _, metadata[modid] = self.load_resource(
165                    Path(name_pattern.format(modid=modid) + METADATA_SUFFIX),
166                    decode=model_type.model_validate_json,
167                    export=False,
168                )
169            except FileNotFoundError:
170                if allow_missing:
171                    continue
172                raise
173
174        return metadata
175
176    @must_yield_something
177    def load_book_assets(
178        self,
179        parent_book_id: ResourceLocation,
180        folder: BookFolder,
181        use_resource_pack: bool,
182        lang: str | None = None,
183    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, JSONDict]]:
184        if self.props.book_id is None:
185            raise TypeError("Can't load book assets because props.book_id is None")
186
187        if lang is None:
188            lang = self.props.default_lang
189
190        # use ordered set to be deterministic but avoid duplicate ids
191        books_to_check = PydanticOrderedSet[ResourceLocation].collect(
192            parent_book_id,
193            self.props.book_id,
194            *self.props.extra_books,
195        )
196
197        for book_id in books_to_check:
198            yield from self.load_resources_with_decoders(
199                type="assets" if use_resource_pack else "data",
200                folder=Path("patchouli_books") / book_id.path / lang / folder,
201                namespace=book_id.namespace,
202                glob=[
203                    "**/*.json",
204                    "**/*.json5",
205                    "**/*.yml",
206                    "**/*.yaml",
207                ],
208                decoders={
209                    (".json", ".json5"): decode_json_dict,
210                    (".yml", ".yaml"): decode_yaml_dict,
211                },
212                allow_missing=True,
213            )
214
215    @overload
216    def load_resource(
217        self,
218        type: ResourceType,
219        folder: str | Path,
220        id: ResourceLocation,
221        *,
222        decode: Callable[[str], _T] = decode_json_dict,
223        export: ExportFn[_T] | Literal[False] | None = None,
224    ) -> tuple[PathResourceDir, _T]: ...
225
226    @overload
227    def load_resource(
228        self,
229        path: Path,
230        /,
231        *,
232        decode: Callable[[str], _T] = decode_json_dict,
233        export: ExportFn[_T] | Literal[False] | None = None,
234    ) -> tuple[PathResourceDir, _T]: ...
235
236    def load_resource(
237        self,
238        *args: Any,
239        decode: Callable[[str], _T] = decode_json_dict,
240        export: ExportFn[_T] | Literal[False] | None = None,
241        **kwargs: Any,
242    ) -> tuple[PathResourceDir, _T]:
243        """Find the first file with this resource location in `resource_dirs`.
244
245        If no file extension is provided, `.json` is assumed.
246
247        Raises FileNotFoundError if the file does not exist.
248        """
249
250        resource_dir, path = self.find_resource(*args, **kwargs)
251        return resource_dir, self._load_path(
252            resource_dir,
253            path,
254            decode=decode,
255            export=export,
256        )
257
258    @overload
259    def find_resource(
260        self,
261        type: ResourceType,
262        folder: str | Path,
263        id: ResourceLocation,
264    ) -> tuple[PathResourceDir, Path]: ...
265
266    @overload
267    def find_resource(
268        self,
269        path: Path,
270        /,
271    ) -> tuple[PathResourceDir, Path]: ...
272
273    def find_resource(
274        self,
275        type: ResourceType | Path,
276        folder: str | Path | None = None,
277        id: ResourceLocation | None = None,
278    ) -> tuple[PathResourceDir, Path]:
279        """Find the first file with this resource location in `resource_dirs`.
280
281        If no file extension is provided, `.json` / `.json5` / `.yml` / `.yaml` is assumed.
282
283        Raises FileNotFoundError if the file does not exist.
284        """
285        if isinstance(type, Path):
286            path_stubs = [type]
287        else:
288            assert folder is not None and id is not None
289            if not Path(id.path).suffix:
290                path_stubs = [
291                    (id + ".json").file_path_stub(type, folder, False),
292                    (id + ".json5").file_path_stub(type, folder, False),
293                    (id + ".yml").file_path_stub(type, folder, False),
294                    (id + ".yaml").file_path_stub(type, folder, False),
295                ]
296            else:
297                path_stubs = [
298                    id.file_path_stub(type, folder, False),
299                ]
300
301        # print(path_stubs)
302        # check by descending priority, return the first that exists
303        for path_stub in path_stubs:
304            for resource_dir in self.resource_dirs:
305                path = resource_dir.path / path_stub
306                if path.is_file():
307                    return resource_dir, path
308
309        raise FileNotFoundError(f"Path {path_stub} not found in any resource dir")
310
311    @overload
312    def load_resources(
313        self,
314        type: ResourceType,
315        *,
316        namespace: str,
317        folder: str | Path,
318        glob: str | list[str] = "**/*",
319        allow_missing: bool = False,
320        internal_only: bool = False,
321        decode: Callable[[str], _T] = decode_json_dict,
322        export: ExportFn[_T] | Literal[False] | None = None,
323    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, _T]]: ...
324
325    @overload
326    def load_resources(
327        self,
328        type: ResourceType,
329        *,
330        folder: str | Path,
331        id: ResourceLocation,
332        allow_missing: bool = False,
333        internal_only: bool = False,
334        decode: Callable[[str], _T] = decode_json_dict,
335        export: ExportFn[_T] | Literal[False] | None = None,
336    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, _T]]: ...
337
338    def load_resources(
339        self,
340        type: ResourceType,
341        *,
342        decode: Callable[[str], _T] = decode_json_dict,
343        export: ExportFn[_T] | Literal[False] | None = None,
344        **kwargs: Any,
345    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, _T]]:
346        """Like `find_resources`, but also loads the file contents and reexports it."""
347        for resource_dir, value_id, path in self.find_resources(type, **kwargs):
348            value = self._load_path(
349                resource_dir,
350                path,
351                decode=decode,
352                export=export,
353            )
354            yield resource_dir, value_id, value
355
356    def load_resources_with_decoders(
357        self,
358        type: ResourceType,
359        *,
360        decoders: Mapping[tuple[str, ...], Callable[[str], _T]] = {
361            tuple([".json*"]): decode_json_dict
362        },
363        export: ExportFn[_T] | Literal[False] | None = None,
364        **kwargs: Any,
365    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, _T]]:
366        """Like `find_resources`, but also loads the file contents and reexports it."""
367        for resource_dir, value_id, path in self.find_resources(type, **kwargs):
368            decoder = pick_decoder(str(path.suffix), decoders)
369
370            value = self._load_path(
371                resource_dir,
372                path,
373                decode=decoder,
374                export=export,
375            )
376            yield resource_dir, value_id, value
377
378    @overload
379    def find_resources(
380        self,
381        type: ResourceType,
382        *,
383        namespace: str,
384        folder: str | Path,
385        glob: str | list[str] = "**/*",
386        allow_missing: bool = False,
387        internal_only: bool = False,
388    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, Path]]: ...
389
390    @overload
391    def find_resources(
392        self,
393        type: ResourceType,
394        *,
395        folder: str | Path,
396        id: ResourceLocation,
397        allow_missing: bool = False,
398        internal_only: bool = False,
399    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, Path]]: ...
400
401    def find_resources(
402        self,
403        type: ResourceType,
404        *,
405        folder: str | Path,
406        id: ResourceLocation | None = None,
407        namespace: str | None = None,
408        glob: str | list[str] = "**/*",
409        allow_missing: bool = False,
410        internal_only: bool = False,
411    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, Path]]:
412        """Search for a glob under a given resource location in all of `resource_dirs`.
413
414        Files are returned from lowest to highest priority in the load order, ie. later
415        files should overwrite earlier ones.
416
417        If no file extension is provided for glob, `.json` is assumed.
418
419        Raises FileNotFoundError if no files were found in any resource dir.
420
421        For example:
422        ```py
423        props.find_resources(
424            "assets",
425            "lang/subdir",
426            namespace="*",
427            glob="*.flatten.json5",
428        )
429
430        # [(hexcasting:en_us, .../resources/assets/hexcasting/lang/subdir/en_us.json)]
431        ```
432        """
433
434        if id is not None:
435            namespace = id.namespace
436            glob = id.path
437
438        # eg. assets/*/lang/subdir
439        if namespace is not None:
440            base_path_stub = Path(type) / namespace / folder
441        else:
442            raise RuntimeError(
443                "No overload matches the specified arguments (expected id or namespace)"
444            )
445
446        # glob for json files if not provided
447        globs = [glob] if isinstance(glob, str) else glob
448        for i in range(len(globs)):
449            if not Path(globs[i]).suffix:
450                globs.append(globs[i] + ".json5")
451                globs[i] += ".json"
452
453        # find all files matching the resloc
454        found_any = False
455        for resource_dir in reversed(self.resource_dirs):
456            if internal_only and not resource_dir.internal:
457                continue
458
459            # eg. .../resources/assets/*/lang/subdir
460            for base_path in resource_dir.path.glob(base_path_stub.as_posix()):
461                for glob_ in globs:
462                    # eg. .../resources/assets/hexcasting/lang/subdir/*.flatten.json5
463                    for path in base_path.glob(glob_):
464                        # only strip json/json5, not eg. png
465                        id_path = path.relative_to(base_path)
466                        if path.name.endswith((".yaml", ".yml", ".json", ".json5")):
467                            id_path = strip_suffixes(id_path)
468
469                        id = ResourceLocation(
470                            # eg. ["assets", "hexcasting", "lang", ...][1]
471                            namespace=path.relative_to(resource_dir.path).parts[1],
472                            path=id_path.as_posix(),
473                        )
474
475                        if path.is_file():
476                            found_any = True
477                            yield resource_dir, id, path
478
479        # if we never yielded any files, raise an error
480        if not allow_missing and not found_any:
481            raise FileNotFoundError(
482                f"No files found under {base_path_stub / repr(globs)} in any resource dir"
483            )
484
485    def _load_path(
486        self,
487        resource_dir: PathResourceDir,
488        path: Path,
489        *,
490        decode: Callable[[str], _T] = decode_json_dict,
491        export: ExportFn[_T] | Literal[False] | None = None,
492    ) -> _T:
493        if not path.is_file():
494            raise FileNotFoundError(path)
495
496        logger.debug(f"Loading {path}")
497
498        data = path.read_text("utf-8")
499        value = decode(data)
500
501        if resource_dir.reexport and export is not False:
502            self.export(
503                path.relative_to(resource_dir.path),
504                data,
505                value,
506                decode=decode,
507                export=export,
508            )
509
510        return value
511
512    @overload
513    def export(self, /, path: Path, data: str, *, cache: bool = False) -> None: ...
514
515    @overload
516    def export(
517        self,
518        /,
519        path: Path,
520        data: str,
521        value: _T,
522        *,
523        decode: Callable[[str], _T] = decode_json_dict,
524        export: ExportFn[_T] | None = None,
525        cache: bool = False,
526    ) -> None: ...
527
528    def export(
529        self,
530        path: Path,
531        data: str,
532        value: _T = None,
533        *,
534        decode: Callable[[str], _T] = decode_json_dict,
535        export: ExportFn[_T] | None = None,
536        cache: bool = False,
537    ) -> None:
538        if not self.export_dir:
539            return
540        out_path = self.export_dir / path
541
542        logger.log(TRACE, f"Exporting {path} to {out_path}")
543        if export is None:
544            out_data = data
545        else:
546            try:
547                old_value = decode(out_path.read_text("utf-8"))
548            except FileNotFoundError:
549                old_value = None
550
551            out_data = export(value, old_value)
552
553        write_to_path(out_path, out_data)
554
555        if cache:
556            write_to_path(self.props.cache_dir / path, out_data)
557
558    def export_raw(self, path: Path, data: bytes):
559        if not self.export_dir:
560            return
561        out_path = self.export_dir / path
562
563        logger.log(TRACE, f"Exporting {path} to {out_path}")
564        write_to_path(out_path, data)
565
566    def __repr__(self):
567        return f"{self.__class__.__name__}(...)"
ModResourceLoader(*args: Any, **kwargs: Any)
119    def __init__(__dataclass_self__: PydanticDataclass, *args: Any, **kwargs: Any) -> None:
120        __tracebackhide__ = True
121        s = __dataclass_self__
122        s.__pydantic_validator__.validate_python(ArgsKwargs(args, kwargs), self_instance=s)
props: Properties
export_dir: pathlib.Path | None
resource_dirs: Sequence[PathResourceDir]
@classmethod
def clean_and_load_all( cls, props: Properties, pm: hexdoc.plugin.PluginManager, *, export: bool = False):
56    @classmethod
57    def clean_and_load_all(
58        cls,
59        props: Properties,
60        pm: PluginManager,
61        *,
62        export: bool = False,
63    ):
64        # clear the export dir so we start with a clean slate
65        if props.export_dir and export:
66            subprocess.run(
67                ["git", "clean", "-fdX", props.export_dir],
68                cwd=props.props_dir,
69            )
70
71            write_to_path(
72                props.export_dir / "__init__.py",
73                dedent(
74                    """\
75                    # This directory is auto-generated by hexdoc.
76                    # Do not edit or commit these files.
77                    """
78                ),
79            )
80
81        return cls.load_all(
82            props,
83            pm,
84            export=export,
85        )
@classmethod
def load_all( cls, props: Properties, pm: hexdoc.plugin.PluginManager, *, export: bool = False) -> Self:
 87    @classmethod
 88    def load_all(
 89        cls,
 90        props: Properties,
 91        pm: PluginManager,
 92        *,
 93        export: bool = False,
 94    ) -> Self:
 95        export_dir = props.export_dir if export else None
 96        stack = ExitStack()
 97
 98        with relative_path_root(Path()):
 99            resource_dirs = [
100                path_resource_dir
101                for resource_dir in props.resource_dirs
102                for path_resource_dir in stack.enter_context(resource_dir.load(pm))
103            ]
104
105        return cls(
106            props=props,
107            export_dir=export_dir,
108            resource_dirs=resource_dirs,
109            _stack=stack,
110        )
def close(self):
118    def close(self):
119        self._stack.close()
should_export
133    @property
134    def should_export(self):
135        return self.export_dir is not None
def load_metadata( self, *, name_pattern: str = '{modid}', model_type: type[~_T_Model], allow_missing: bool = False) -> dict[str, ~_T_Model]:
137    def load_metadata(
138        self,
139        *,
140        name_pattern: str = "{modid}",
141        model_type: type[_T_Model],
142        allow_missing: bool = False,
143    ) -> dict[str, _T_Model]:
144        """eg. `"{modid}.patterns"`"""
145        metadata = dict[str, _T_Model]()
146
147        # TODO: refactor
148        cached_metadata = self.props.cache_dir / (
149            name_pattern.format(modid=self.props.modid) + METADATA_SUFFIX
150        )
151        if cached_metadata.is_file():
152            metadata[self.props.modid] = model_type.model_validate_json(
153                cached_metadata.read_bytes()
154            )
155
156        for resource_dir in self.resource_dirs:
157            # skip if the resource dir has no metadata set, because we're only loading
158            # this for external mods (TODO: this feels flawed)
159            modid = resource_dir.modid
160            if modid is None or modid in metadata:
161                continue
162
163            try:
164                _, metadata[modid] = self.load_resource(
165                    Path(name_pattern.format(modid=modid) + METADATA_SUFFIX),
166                    decode=model_type.model_validate_json,
167                    export=False,
168                )
169            except FileNotFoundError:
170                if allow_missing:
171                    continue
172                raise
173
174        return metadata

eg. "{modid}.patterns"

@must_yield_something
def load_book_assets( self, parent_book_id: ResourceLocation, folder: Literal['categories', 'entries', 'templates'], use_resource_pack: bool, lang: str | None = None) -> Iterator[tuple[PathResourceDir, ResourceLocation, dict[str, JsonValue]]]:
176    @must_yield_something
177    def load_book_assets(
178        self,
179        parent_book_id: ResourceLocation,
180        folder: BookFolder,
181        use_resource_pack: bool,
182        lang: str | None = None,
183    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, JSONDict]]:
184        if self.props.book_id is None:
185            raise TypeError("Can't load book assets because props.book_id is None")
186
187        if lang is None:
188            lang = self.props.default_lang
189
190        # use ordered set to be deterministic but avoid duplicate ids
191        books_to_check = PydanticOrderedSet[ResourceLocation].collect(
192            parent_book_id,
193            self.props.book_id,
194            *self.props.extra_books,
195        )
196
197        for book_id in books_to_check:
198            yield from self.load_resources_with_decoders(
199                type="assets" if use_resource_pack else "data",
200                folder=Path("patchouli_books") / book_id.path / lang / folder,
201                namespace=book_id.namespace,
202                glob=[
203                    "**/*.json",
204                    "**/*.json5",
205                    "**/*.yml",
206                    "**/*.yaml",
207                ],
208                decoders={
209                    (".json", ".json5"): decode_json_dict,
210                    (".yml", ".yaml"): decode_yaml_dict,
211                },
212                allow_missing=True,
213            )
def load_resource( self, *args: Any, decode: Callable[[str], ~_T] = <function decode_json_dict>, export: Union[Callable[[~_T, Optional[~_T]], str], Literal[False], NoneType] = None, **kwargs: Any) -> tuple[PathResourceDir, ~_T]:
236    def load_resource(
237        self,
238        *args: Any,
239        decode: Callable[[str], _T] = decode_json_dict,
240        export: ExportFn[_T] | Literal[False] | None = None,
241        **kwargs: Any,
242    ) -> tuple[PathResourceDir, _T]:
243        """Find the first file with this resource location in `resource_dirs`.
244
245        If no file extension is provided, `.json` is assumed.
246
247        Raises FileNotFoundError if the file does not exist.
248        """
249
250        resource_dir, path = self.find_resource(*args, **kwargs)
251        return resource_dir, self._load_path(
252            resource_dir,
253            path,
254            decode=decode,
255            export=export,
256        )

Find the first file with this resource location in resource_dirs.

If no file extension is provided, .json is assumed.

Raises FileNotFoundError if the file does not exist.

def find_resource( self, type: Union[Literal['assets', 'data', ''], pathlib.Path], folder: str | pathlib.Path | None = None, id: ResourceLocation | None = None) -> tuple[PathResourceDir, pathlib.Path]:
273    def find_resource(
274        self,
275        type: ResourceType | Path,
276        folder: str | Path | None = None,
277        id: ResourceLocation | None = None,
278    ) -> tuple[PathResourceDir, Path]:
279        """Find the first file with this resource location in `resource_dirs`.
280
281        If no file extension is provided, `.json` / `.json5` / `.yml` / `.yaml` is assumed.
282
283        Raises FileNotFoundError if the file does not exist.
284        """
285        if isinstance(type, Path):
286            path_stubs = [type]
287        else:
288            assert folder is not None and id is not None
289            if not Path(id.path).suffix:
290                path_stubs = [
291                    (id + ".json").file_path_stub(type, folder, False),
292                    (id + ".json5").file_path_stub(type, folder, False),
293                    (id + ".yml").file_path_stub(type, folder, False),
294                    (id + ".yaml").file_path_stub(type, folder, False),
295                ]
296            else:
297                path_stubs = [
298                    id.file_path_stub(type, folder, False),
299                ]
300
301        # print(path_stubs)
302        # check by descending priority, return the first that exists
303        for path_stub in path_stubs:
304            for resource_dir in self.resource_dirs:
305                path = resource_dir.path / path_stub
306                if path.is_file():
307                    return resource_dir, path
308
309        raise FileNotFoundError(f"Path {path_stub} not found in any resource dir")

Find the first file with this resource location in resource_dirs.

If no file extension is provided, .json / .json5 / .yml / .yaml is assumed.

Raises FileNotFoundError if the file does not exist.

def load_resources( self, type: Literal['assets', 'data', ''], *, decode: Callable[[str], ~_T] = <function decode_json_dict>, export: Union[Callable[[~_T, Optional[~_T]], str], Literal[False], NoneType] = None, **kwargs: Any) -> Iterator[tuple[PathResourceDir, ResourceLocation, ~_T]]:
338    def load_resources(
339        self,
340        type: ResourceType,
341        *,
342        decode: Callable[[str], _T] = decode_json_dict,
343        export: ExportFn[_T] | Literal[False] | None = None,
344        **kwargs: Any,
345    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, _T]]:
346        """Like `find_resources`, but also loads the file contents and reexports it."""
347        for resource_dir, value_id, path in self.find_resources(type, **kwargs):
348            value = self._load_path(
349                resource_dir,
350                path,
351                decode=decode,
352                export=export,
353            )
354            yield resource_dir, value_id, value

Like find_resources, but also loads the file contents and reexports it.

def load_resources_with_decoders( self, type: Literal['assets', 'data', ''], *, decoders: Mapping[tuple[str, ...], Callable[[str], ~_T]] = {('.json*',): <function decode_json_dict>}, export: Union[Callable[[~_T, Optional[~_T]], str], Literal[False], NoneType] = None, **kwargs: Any) -> Iterator[tuple[PathResourceDir, ResourceLocation, ~_T]]:
356    def load_resources_with_decoders(
357        self,
358        type: ResourceType,
359        *,
360        decoders: Mapping[tuple[str, ...], Callable[[str], _T]] = {
361            tuple([".json*"]): decode_json_dict
362        },
363        export: ExportFn[_T] | Literal[False] | None = None,
364        **kwargs: Any,
365    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, _T]]:
366        """Like `find_resources`, but also loads the file contents and reexports it."""
367        for resource_dir, value_id, path in self.find_resources(type, **kwargs):
368            decoder = pick_decoder(str(path.suffix), decoders)
369
370            value = self._load_path(
371                resource_dir,
372                path,
373                decode=decoder,
374                export=export,
375            )
376            yield resource_dir, value_id, value

Like find_resources, but also loads the file contents and reexports it.

def find_resources( self, type: Literal['assets', 'data', ''], *, folder: str | pathlib.Path, id: ResourceLocation | None = None, namespace: str | None = None, glob: str | list[str] = '**/*', allow_missing: bool = False, internal_only: bool = False) -> Iterator[tuple[PathResourceDir, ResourceLocation, pathlib.Path]]:
401    def find_resources(
402        self,
403        type: ResourceType,
404        *,
405        folder: str | Path,
406        id: ResourceLocation | None = None,
407        namespace: str | None = None,
408        glob: str | list[str] = "**/*",
409        allow_missing: bool = False,
410        internal_only: bool = False,
411    ) -> Iterator[tuple[PathResourceDir, ResourceLocation, Path]]:
412        """Search for a glob under a given resource location in all of `resource_dirs`.
413
414        Files are returned from lowest to highest priority in the load order, ie. later
415        files should overwrite earlier ones.
416
417        If no file extension is provided for glob, `.json` is assumed.
418
419        Raises FileNotFoundError if no files were found in any resource dir.
420
421        For example:
422        ```py
423        props.find_resources(
424            "assets",
425            "lang/subdir",
426            namespace="*",
427            glob="*.flatten.json5",
428        )
429
430        # [(hexcasting:en_us, .../resources/assets/hexcasting/lang/subdir/en_us.json)]
431        ```
432        """
433
434        if id is not None:
435            namespace = id.namespace
436            glob = id.path
437
438        # eg. assets/*/lang/subdir
439        if namespace is not None:
440            base_path_stub = Path(type) / namespace / folder
441        else:
442            raise RuntimeError(
443                "No overload matches the specified arguments (expected id or namespace)"
444            )
445
446        # glob for json files if not provided
447        globs = [glob] if isinstance(glob, str) else glob
448        for i in range(len(globs)):
449            if not Path(globs[i]).suffix:
450                globs.append(globs[i] + ".json5")
451                globs[i] += ".json"
452
453        # find all files matching the resloc
454        found_any = False
455        for resource_dir in reversed(self.resource_dirs):
456            if internal_only and not resource_dir.internal:
457                continue
458
459            # eg. .../resources/assets/*/lang/subdir
460            for base_path in resource_dir.path.glob(base_path_stub.as_posix()):
461                for glob_ in globs:
462                    # eg. .../resources/assets/hexcasting/lang/subdir/*.flatten.json5
463                    for path in base_path.glob(glob_):
464                        # only strip json/json5, not eg. png
465                        id_path = path.relative_to(base_path)
466                        if path.name.endswith((".yaml", ".yml", ".json", ".json5")):
467                            id_path = strip_suffixes(id_path)
468
469                        id = ResourceLocation(
470                            # eg. ["assets", "hexcasting", "lang", ...][1]
471                            namespace=path.relative_to(resource_dir.path).parts[1],
472                            path=id_path.as_posix(),
473                        )
474
475                        if path.is_file():
476                            found_any = True
477                            yield resource_dir, id, path
478
479        # if we never yielded any files, raise an error
480        if not allow_missing and not found_any:
481            raise FileNotFoundError(
482                f"No files found under {base_path_stub / repr(globs)} in any resource dir"
483            )

Search for a glob under a given resource location in all of resource_dirs.

Files are returned from lowest to highest priority in the load order, ie. later files should overwrite earlier ones.

If no file extension is provided for glob, .json is assumed.

Raises FileNotFoundError if no files were found in any resource dir.

For example:

props.find_resources(
    "assets",
    "lang/subdir",
    namespace="*",
    glob="*.flatten.json5",
)

# [(hexcasting:en_us, .../resources/assets/hexcasting/lang/subdir/en_us.json)]
def export( self, path: pathlib.Path, data: str, value: ~_T = None, *, decode: Callable[[str], ~_T] = <function decode_json_dict>, export: Callable[[~_T, typing.Optional[~_T]], str] | None = None, cache: bool = False) -> None:
528    def export(
529        self,
530        path: Path,
531        data: str,
532        value: _T = None,
533        *,
534        decode: Callable[[str], _T] = decode_json_dict,
535        export: ExportFn[_T] | None = None,
536        cache: bool = False,
537    ) -> None:
538        if not self.export_dir:
539            return
540        out_path = self.export_dir / path
541
542        logger.log(TRACE, f"Exporting {path} to {out_path}")
543        if export is None:
544            out_data = data
545        else:
546            try:
547                old_value = decode(out_path.read_text("utf-8"))
548            except FileNotFoundError:
549                old_value = None
550
551            out_data = export(value, old_value)
552
553        write_to_path(out_path, out_data)
554
555        if cache:
556            write_to_path(self.props.cache_dir / path, out_data)
def export_raw(self, path: pathlib.Path, data: bytes):
558    def export_raw(self, path: Path, data: bytes):
559        if not self.export_dir:
560            return
561        out_path = self.export_dir / path
562
563        logger.log(TRACE, f"Exporting {path} to {out_path}")
564        write_to_path(out_path, data)
class PathResourceDir(hexdoc.core.resource_dir.BasePathResourceDir):
123class PathResourceDir(BasePathResourceDir):
124    """Represents a path to a resources directory or a mod's `.jar` file."""
125
126    @staticmethod
127    def _json_schema_extra(schema: dict[str, Any]):
128        BaseResourceDir._json_schema_extra(schema)
129        new_schema = {
130            "anyOf": [
131                {
132                    "type": "string",
133                    "format": "path",
134                },
135                *schema["anyOf"],
136            ]
137        }
138        schema.clear()
139        schema.update(new_schema)
140
141    model_config = DEFAULT_CONFIG | {
142        "json_schema_extra": _json_schema_extra,
143    }
144
145    path: RelativePath
146    """A path relative to `hexdoc.toml`."""
147
148    archive: bool = Field(default=None, validate_default=False)  # type: ignore
149    """If true, treat this path as a zip archive (eg. a mod's `.jar` file).
150
151    If `path` ends with `.jar` or `.zip`, defaults to `True`.
152    """
153
154    # not a props field
155    _modid: str | None = None
156
157    @property
158    def modid(self):
159        return self._modid
160
161    @property
162    @override
163    def _paths(self):
164        return [self.path]
165
166    def set_modid(self, modid: str) -> Self:
167        self._modid = modid
168        return self
169
170    @contextmanager
171    @override
172    def load(self, pm: PluginManager):
173        if self.archive:
174            with self._extract_archive() as path:
175                update = {
176                    "path": path,
177                    "archive": False,
178                }
179                yield [self.model_copy(update=update)]
180        else:
181            yield [self]
182
183    @contextmanager
184    def _extract_archive(self) -> Iterator[Path]:
185        with (
186            ZipFile(self.path, "r") as zf,
187            TemporaryDirectory(suffix=self.path.name) as tempdir,
188        ):
189            # extract root-level files and *useful* sub-directories
190            # ie. avoid extracting classes etc
191            for info in zf.filelist:
192                path = info.filename
193                if path.startswith(("assets/", "data/")) or "/" not in path:
194                    zf.extract(info, tempdir)
195
196            yield Path(tempdir)
197
198    @model_validator(mode="before")
199    def _pre_root(cls: Any, value: Any):
200        # treat plain strings as paths
201        if isinstance(value, str):
202            return {"path": value}
203        return value
204
205    @model_validator(mode="after")
206    def _post_root(self):
207        if cast_nullable(self.archive) is None:
208            self.archive = self.path.suffix in {".jar", ".zip"}
209        return self

Represents a path to a resources directory or a mod's .jar file.

path: typing.Annotated[pathlib.Path, AfterValidator(func=<function <lambda> at 0x7f93887fe020>)]

A path relative to hexdoc.toml.

archive: bool

If true, treat this path as a zip archive (eg. a mod's .jar file).

If path ends with .jar or .zip, defaults to True.

modid
157    @property
158    def modid(self):
159        return self._modid
def set_modid(self, modid: str) -> Self:
166    def set_modid(self, modid: str) -> Self:
167        self._modid = modid
168        return self
@contextmanager
@override
def load(self, pm: hexdoc.plugin.PluginManager):
170    @contextmanager
171    @override
172    def load(self, pm: PluginManager):
173        if self.archive:
174            with self._extract_archive() as path:
175                update = {
176                    "path": path,
177                    "archive": False,
178                }
179                yield [self.model_copy(update=update)]
180        else:
181            yield [self]
def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
365def init_private_attributes(self: BaseModel, context: Any, /) -> None:
366    """This function is meant to behave like a BaseModel method to initialize private attributes.
367
368    It takes context as an argument since that's what pydantic-core passes when calling it.
369
370    Args:
371        self: The BaseModel instance.
372        context: The context.
373    """
374    if getattr(self, '__pydantic_private__', None) is None:
375        pydantic_private = {}
376        for name, private_attr in self.__private_attributes__.items():
377            # Avoid needlessly creating a new dict for the validated data:
378            if private_attr.default_factory_takes_validated_data:
379                default = private_attr.get_default(
380                    call_default_factory=True, validated_data={**self.__dict__, **pydantic_private}
381                )
382            else:
383                default = private_attr.get_default(call_default_factory=True)
384            if default is not PydanticUndefined:
385                pydantic_private[name] = default
386        object_setattr(self, '__pydantic_private__', pydantic_private)

This function is meant to behave like a BaseModel method to initialize 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.

class PluginResourceDir(hexdoc.core.BaseResourceDir):
304class PluginResourceDir(BaseResourceDir):
305    modid: str
306
307    # if we're specifying a modid, it's probably from some other mod/package
308    external: bool = True
309    reexport: bool = False
310
311    @contextmanager
312    @override
313    def load(self, pm: PluginManager):
314        with ExitStack() as stack:
315            yield list(self._load_all(pm, stack))  # NOT "yield from"
316
317    def _load_all(self, pm: PluginManager, stack: ExitStack):
318        for module in pm.load_resources(self.modid):
319            traversable = resources.files(module)
320            path = stack.enter_context(resources.as_file(traversable))
321
322            yield PathResourceDir(
323                path=path,
324                external=self.external,
325                reexport=self.reexport,
326            ).set_modid(self.modid)  # setting _modid directly causes a validation error

Base class for all Pydantic models in hexdoc.

Sets the default model config, and overrides __init__ to allow using the init_context context manager to set validation context for constructors.

modid: str
external: bool
reexport: bool

If not set, the default value will be not self.external.

Must be defined AFTER external in the Pydantic model.

@contextmanager
@override
def load(self, pm: hexdoc.plugin.PluginManager):
311    @contextmanager
312    @override
313    def load(self, pm: PluginManager):
314        with ExitStack() as stack:
315            yield list(self._load_all(pm, stack))  # NOT "yield from"
class Properties(hexdoc.core.BaseProperties):
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    flags: dict[str, bool] = Field(default_factory=dict)
246    """Local Patchouli flag overrides.
247
248    This has the final say over built-in defaults and flags exported by other mods.
249    """
250
251    template: TemplateProps | None = None
252
253    lang: defaultdict[
254        str,
255        Annotated[LangProps, Field(default_factory=LangProps)],
256    ] = Field(default_factory=lambda: defaultdict(LangProps))
257    """Per-language configuration. The key should be the language code, eg. `en_us`."""
258
259    extra: dict[str, Any] = Field(default_factory=dict)
260
261    def mod_loc(self, path: str) -> ResourceLocation:
262        """Returns a ResourceLocation with self.modid as the namespace."""
263        return ResourceLocation(self.modid, path)
264
265    @property
266    def prerender_dir(self):
267        return self.cache_dir / "prerender"
268
269    @property
270    def cache_dir(self):
271        return self.repo_root / ".hexdoc"
272
273    @cached_property
274    def repo_root(self):
275        return git_root(self.props_dir)

Pydantic model for hexdoc.toml / properties.toml.

modid: str
book_type: str

Modid of the hexdoc.plugin.BookPlugin to use when loading this book.

book_id: ResourceLocation | None
extra_books: list[ResourceLocation]
default_lang: str
default_branch: str
is_0_black: bool

If true, the style $(0) changes the text color to black; otherwise it resets the text color to the default.

resource_dirs: Sequence[PathResourceDir | hexdoc.core.resource_dir.PatchouliBooksResourceDir | PluginResourceDir | hexdoc.core.resource_dir.GlobResourceDir]
export_dir: Optional[Annotated[pathlib.Path, AfterValidator(func=<function <lambda> at 0x7f93887fe020>)]]
entry_id_blacklist: set[ResourceLocation]
macros: dict[str, str]
flags: dict[str, bool]

Local Patchouli flag overrides.

This has the final say over built-in defaults and flags exported by other mods.

lang: collections.defaultdict[str, typing.Annotated[hexdoc.core.properties.LangProps, FieldInfo(annotation=NoneType, required=False, default_factory=LangProps)]]

Per-language configuration. The key should be the language code, eg. en_us.

extra: dict[str, typing.Any]
def mod_loc(self, path: str) -> ResourceLocation:
261    def mod_loc(self, path: str) -> ResourceLocation:
262        """Returns a ResourceLocation with self.modid as the namespace."""
263        return ResourceLocation(self.modid, path)

Returns a ResourceLocation with self.modid as the namespace.

prerender_dir
265    @property
266    def prerender_dir(self):
267        return self.cache_dir / "prerender"
cache_dir
269    @property
270    def cache_dir(self):
271        return self.repo_root / ".hexdoc"
repo_root
273    @cached_property
274    def repo_root(self):
275        return git_root(self.props_dir)
ResLoc = <class 'ResourceLocation'>
ResourceDir = PathResourceDir | hexdoc.core.resource_dir.PatchouliBooksResourceDir | PluginResourceDir | hexdoc.core.resource_dir.GlobResourceDir
@dataclass(frozen=True, repr=False)
class ResourceLocation(hexdoc.core.BaseResourceLocation):
163@dataclass(frozen=True, repr=False)
164class ResourceLocation(BaseResourceLocation, regex=_make_regex()):
165    """Represents a Minecraft resource location / namespaced ID."""
166
167    is_tag: bool = False
168
169    @classmethod
170    def from_str(cls, raw: str) -> Self:
171        id = super().from_str(raw.removeprefix("#"))
172        if raw.startswith("#"):
173            object.__setattr__(id, "is_tag", True)
174        return id
175
176    @classmethod
177    def from_file(cls, modid: str, base_dir: Path, path: Path) -> Self:
178        resource_path = path.relative_to(base_dir).with_suffix("").as_posix()
179        return cls(modid, resource_path)
180
181    @classmethod
182    def from_model_path(cls, model_path: str | Path) -> Self:
183        match = MODEL_PATH_REGEX.search(Path(model_path).as_posix())
184        if not match:
185            raise ValueError(f"Failed to match model path: {model_path}")
186        return cls(match["namespace"], match["path"])
187
188    @property
189    def href(self) -> str:
190        return f"#{self.path}"
191
192    @property
193    def css_class(self) -> str:
194        stripped_path = re.sub(r"[\*\/\.]", "-", self.path)
195        return f"texture-{self.namespace}-{stripped_path}"
196
197    def with_namespace(self, namespace: str) -> Self:
198        """Returns a copy of this ResourceLocation with the given namespace."""
199        return self.__class__(namespace, self.path)
200
201    def with_path(self, path: str | Path) -> Self:
202        """Returns a copy of this ResourceLocation with the given path."""
203        if isinstance(path, Path):
204            path = path.as_posix()
205        return self.__class__(self.namespace, path)
206
207    def match(self, pattern: Self) -> bool:
208        return fnmatch(str(self), str(pattern))
209
210    def template_path(self, type: str, folder: str = "") -> str:
211        return self.file_path_stub(type, folder, assume_json=False).as_posix()
212
213    def file_path_stub(
214        self,
215        type: ResourceType | str,
216        folder: str | Path = "",
217        assume_json: bool = True,
218    ) -> Path:
219        """Returns the path to find this resource within a resource directory.
220
221        If `assume_json` is True and no file extension is provided, `.json` is assumed.
222
223        For example:
224        ```py
225        ResLoc("hexcasting", "thehexbook/book").file_path_stub("data", "patchouli_books")
226        # data/hexcasting/patchouli_books/thehexbook/book.json
227        ```
228        """
229        # if folder is an empty string, Path won't add an extra slash
230        path = Path(type) / self.namespace / folder / self.path
231        if assume_json and not path.suffix:
232            return path.with_suffix(".json")
233        return path
234
235    def removeprefix(self, prefix: str) -> Self:
236        return self.with_path(self.path.removeprefix(prefix))
237
238    def __truediv__(self, other: str) -> Self:
239        return self.with_path(f"{self.path}/{other}")
240
241    def __rtruediv__(self, other: str) -> Self:
242        return self.with_path(f"{other}/{self.path}")
243
244    def __add__(self, other: str) -> Self:
245        return self.with_path(self.path + other)
246
247    def __repr__(self) -> str:
248        s = super().__repr__()
249        if self.is_tag:
250            return f"#{s}"
251        return s

Represents a Minecraft resource location / namespaced ID.

ResourceLocation(*args: Any, **kwargs: Any)
119    def __init__(__dataclass_self__: PydanticDataclass, *args: Any, **kwargs: Any) -> None:
120        __tracebackhide__ = True
121        s = __dataclass_self__
122        s.__pydantic_validator__.validate_python(ArgsKwargs(args, kwargs), self_instance=s)
is_tag: bool = False
@classmethod
def from_str(cls, raw: str) -> Self:
169    @classmethod
170    def from_str(cls, raw: str) -> Self:
171        id = super().from_str(raw.removeprefix("#"))
172        if raw.startswith("#"):
173            object.__setattr__(id, "is_tag", True)
174        return id
@classmethod
def from_file(cls, modid: str, base_dir: pathlib.Path, path: pathlib.Path) -> Self:
176    @classmethod
177    def from_file(cls, modid: str, base_dir: Path, path: Path) -> Self:
178        resource_path = path.relative_to(base_dir).with_suffix("").as_posix()
179        return cls(modid, resource_path)
@classmethod
def from_model_path(cls, model_path: str | pathlib.Path) -> Self:
181    @classmethod
182    def from_model_path(cls, model_path: str | Path) -> Self:
183        match = MODEL_PATH_REGEX.search(Path(model_path).as_posix())
184        if not match:
185            raise ValueError(f"Failed to match model path: {model_path}")
186        return cls(match["namespace"], match["path"])
href: str
188    @property
189    def href(self) -> str:
190        return f"#{self.path}"
css_class: str
192    @property
193    def css_class(self) -> str:
194        stripped_path = re.sub(r"[\*\/\.]", "-", self.path)
195        return f"texture-{self.namespace}-{stripped_path}"
def with_namespace(self, namespace: str) -> Self:
197    def with_namespace(self, namespace: str) -> Self:
198        """Returns a copy of this ResourceLocation with the given namespace."""
199        return self.__class__(namespace, self.path)

Returns a copy of this ResourceLocation with the given namespace.

def with_path(self, path: str | pathlib.Path) -> Self:
201    def with_path(self, path: str | Path) -> Self:
202        """Returns a copy of this ResourceLocation with the given path."""
203        if isinstance(path, Path):
204            path = path.as_posix()
205        return self.__class__(self.namespace, path)

Returns a copy of this ResourceLocation with the given path.

def match(self, pattern: Self) -> bool:
207    def match(self, pattern: Self) -> bool:
208        return fnmatch(str(self), str(pattern))
def template_path(self, type: str, folder: str = '') -> str:
210    def template_path(self, type: str, folder: str = "") -> str:
211        return self.file_path_stub(type, folder, assume_json=False).as_posix()
def file_path_stub( self, type: Union[Literal['assets', 'data', ''], str], folder: str | pathlib.Path = '', assume_json: bool = True) -> pathlib.Path:
213    def file_path_stub(
214        self,
215        type: ResourceType | str,
216        folder: str | Path = "",
217        assume_json: bool = True,
218    ) -> Path:
219        """Returns the path to find this resource within a resource directory.
220
221        If `assume_json` is True and no file extension is provided, `.json` is assumed.
222
223        For example:
224        ```py
225        ResLoc("hexcasting", "thehexbook/book").file_path_stub("data", "patchouli_books")
226        # data/hexcasting/patchouli_books/thehexbook/book.json
227        ```
228        """
229        # if folder is an empty string, Path won't add an extra slash
230        path = Path(type) / self.namespace / folder / self.path
231        if assume_json and not path.suffix:
232            return path.with_suffix(".json")
233        return path

Returns the path to find this resource within a resource directory.

If assume_json is True and no file extension is provided, .json is assumed.

For example:

ResLoc("hexcasting", "thehexbook/book").file_path_stub("data", "patchouli_books")
# data/hexcasting/patchouli_books/thehexbook/book.json
def removeprefix(self, prefix: str) -> Self:
235    def removeprefix(self, prefix: str) -> Self:
236        return self.with_path(self.path.removeprefix(prefix))
ResourceType = typing.Literal['assets', 'data', '']
@dataclass(frozen=True)
class ValueIfVersion(hexdoc.core.Versioned, typing.Generic[~_If, ~_Else]):
123@dataclass(frozen=True)
124class ValueIfVersion(Versioned, Generic[_If, _Else]):
125    value_if: _If
126    value_else: _Else
127
128    def __call__(self) -> _If | _Else:
129        if self.is_current:
130            return self.value_if
131        return self.value_else
value_if: ~_If
value_else: ~_Else
class VersionSource(typing.Protocol):
22class VersionSource(Protocol):
23    @classmethod
24    def get(cls) -> str | None:
25        """Returns the current version."""
26        ...
27
28    @classmethod
29    def matches(cls, specifier: str | SpecifierSet) -> bool:
30        """Returns True if the current version matches the version_spec."""
31        ...

Base class for protocol classes.

Protocol classes are defined as::

class Proto(Protocol):
    def meth(self) -> int:
        ...

Such classes are primarily used with static type checkers that recognize structural subtyping (static duck-typing).

For example::

class C:
    def meth(self) -> int:
        return 0

def func(x: Proto) -> int:
    return x.meth()

func(C())  # Passes static type check

See PEP 544 for details. Protocol classes decorated with @typing.runtime_checkable act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures. Protocol classes can be generic, they are defined as::

class GenProto(Protocol[T]):
    def meth(self) -> T:
        ...
VersionSource(*args, **kwargs)
1953def _no_init_or_replace_init(self, *args, **kwargs):
1954    cls = type(self)
1955
1956    if cls._is_protocol:
1957        raise TypeError('Protocols cannot be instantiated')
1958
1959    # Already using a custom `__init__`. No need to calculate correct
1960    # `__init__` to call. This can lead to RecursionError. See bpo-45121.
1961    if cls.__init__ is not _no_init_or_replace_init:
1962        return
1963
1964    # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`.
1965    # The first instantiation of the subclass will call `_no_init_or_replace_init` which
1966    # searches for a proper new `__init__` in the MRO. The new `__init__`
1967    # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent
1968    # instantiation of the protocol subclass will thus use the new
1969    # `__init__` and no longer call `_no_init_or_replace_init`.
1970    for base in cls.__mro__:
1971        init = base.__dict__.get('__init__', _no_init_or_replace_init)
1972        if init is not _no_init_or_replace_init:
1973            cls.__init__ = init
1974            break
1975    else:
1976        # should not happen
1977        cls.__init__ = object.__init__
1978
1979    cls.__init__(self, *args, **kwargs)
@classmethod
def get(cls) -> str | None:
23    @classmethod
24    def get(cls) -> str | None:
25        """Returns the current version."""
26        ...

Returns the current version.

@classmethod
def matches(cls, specifier: str | packaging.specifiers.SpecifierSet) -> bool:
28    @classmethod
29    def matches(cls, specifier: str | SpecifierSet) -> bool:
30        """Returns True if the current version matches the version_spec."""
31        ...

Returns True if the current version matches the version_spec.

@dataclass(frozen=True)
class Versioned:
52@dataclass(frozen=True)
53class Versioned:
54    """Base class for types which can behave differently based on a version source,
55    which defaults to MinecraftVersion."""
56
57    version_spec: str
58    version_source: VersionSource = field(default=MinecraftVersion, kw_only=True)
59
60    @property
61    def is_current(self):
62        return self.version_source.matches(self.version_spec)

Base class for types which can behave differently based on a version source, which defaults to MinecraftVersion.

Versioned( version_spec: str, *, version_source: VersionSource = <class 'MinecraftVersion'>)
version_spec: str
version_source: VersionSource = <class 'MinecraftVersion'>
is_current
60    @property
61    def is_current(self):
62        return self.version_source.matches(self.version_spec)