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)
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.
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( # 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.
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.
If not set, the default value will be not self.external.
Must be defined AFTER external in the Pydantic model.
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}"
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.
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).
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.
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
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:
...
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.
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__}(...)"
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 )
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 )
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"
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 )
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.
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.
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.
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.
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)]
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)
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.
A path relative to hexdoc.toml.
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.
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.
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.
If not set, the default value will be not self.external.
Must be defined AFTER external in the Pydantic model.
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.
If true, the style $(0) changes the text color to black; otherwise it resets
the text color to the default.
Local Patchouli flag overrides.
This has the final say over built-in defaults and flags exported by other mods.
Per-language configuration. The key should be the language code, eg. en_us.
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.
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.
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.
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
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:
...
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)
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.
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.