Use BookState to break all the dependency cycles
This commit is contained in:
parent
b2fd0bf094
commit
c9ba636309
36 changed files with 928 additions and 589 deletions
|
@ -1,6 +1,6 @@
|
|||
-r requirements.txt
|
||||
black==22.10.0 # formatting
|
||||
isort==5.12.0 # formatting 2
|
||||
pytest==7.3.1 # testing framework
|
||||
syrupy==4.0.2 # snapshot tests
|
||||
beautifulsoup4==4.12.2 # html pretty print so the snapshot diffs are actually usable
|
||||
black==22.10.0 # formatting
|
||||
isort==5.12.0 # formatting 2
|
||||
pytest==7.3.1 # testing framework
|
||||
syrupy==4.0.2 # snapshot tests
|
||||
beautifulsoup4==4.12.2 # html pretty print so the snapshot diffs are actually usable
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
typed-argument-parser==1.8.0 # better argument parsing
|
||||
git+https://github.com/mciszczon/dacite@f298260c6aedc1097c7567b1b0a61298a0ddf2a8 # book deserialization
|
||||
typed-argument-parser==1.8.0 # better argument parsing
|
||||
git+https://github.com/mciszczon/dacite@f298260c6aedc1097c7567b1b0a61298a0ddf2a8 # book deserialization
|
||||
|
|
|
@ -7,19 +7,22 @@ from typing import IO, Any
|
|||
|
||||
from common.formatting import FormatTree
|
||||
from common.types import LocalizedStr
|
||||
from patchouli import Category, Entry, HexBook
|
||||
from patchouli.page import (
|
||||
from hexcasting.hex_book import HexBook
|
||||
from hexcasting.hex_pages import (
|
||||
BrainsweepPage,
|
||||
CraftingMultiPage,
|
||||
LookupPatternPage,
|
||||
PageWithPattern,
|
||||
)
|
||||
from patchouli import Category, Entry
|
||||
from patchouli.page import (
|
||||
CraftingPage,
|
||||
EmptyPage,
|
||||
ImagePage,
|
||||
LinkPage,
|
||||
Page,
|
||||
PageWithPattern,
|
||||
PageWithText,
|
||||
PageWithTitle,
|
||||
PatternPage,
|
||||
SpotlightPage,
|
||||
TextPage,
|
||||
)
|
||||
|
@ -122,7 +125,7 @@ def get_format(out: Stream, ty: str, value: Any):
|
|||
def entry_spoilered(root_info: HexBook, entry: Entry):
|
||||
if entry.advancement is None:
|
||||
return False
|
||||
return str(entry.advancement) in root_info.spoilers
|
||||
return str(entry.advancement) in root_info.state.spoilers
|
||||
|
||||
|
||||
def category_spoilered(root_info: HexBook, category: Category):
|
||||
|
@ -161,7 +164,7 @@ def permalink(out: Stream, link: str):
|
|||
out.empty_pair_tag("i", clazz="bi bi-link-45deg")
|
||||
|
||||
|
||||
def write_page(out: Stream, pageid: str, page: Page):
|
||||
def write_page(out: Stream, pageid: str, page: Page[Any]):
|
||||
if anchor := page.anchor:
|
||||
anchor_id = pageid + "@" + anchor
|
||||
else:
|
||||
|
@ -172,7 +175,9 @@ def write_page(out: Stream, pageid: str, page: Page):
|
|||
if isinstance(page, PageWithTitle) and page.title is not None:
|
||||
# gross
|
||||
_kwargs = (
|
||||
{"clazz": "pattern-title"} if isinstance(page, PatternPage) else {}
|
||||
{"clazz": "pattern-title"}
|
||||
if isinstance(page, LookupPatternPage)
|
||||
else {}
|
||||
)
|
||||
with out.pair_tag("h4", **_kwargs):
|
||||
out.text(page.title)
|
||||
|
@ -264,7 +269,7 @@ def write_page(out: Stream, pageid: str, page: Page):
|
|||
with out.pair_tag("p", clazz="todo-note"):
|
||||
out.text(f"TODO: Missing processor for type: {type(page)}")
|
||||
if isinstance(page, PageWithText):
|
||||
write_block(out, page.text or page.book.format(LocalizedStr("")))
|
||||
write_block(out, page.text or page.state.format(LocalizedStr("")))
|
||||
out.tag("br")
|
||||
|
||||
|
||||
|
@ -290,7 +295,7 @@ def write_category(out: Stream, book: HexBook, category: Category):
|
|||
permalink(out, category.id.href)
|
||||
write_block(out, category.description)
|
||||
for entry in category.entries:
|
||||
if entry.id.path not in book.blacklist:
|
||||
if entry.id.path not in book.state.blacklist:
|
||||
write_entry(out, book, entry)
|
||||
|
||||
|
||||
|
@ -347,11 +352,11 @@ def generate_docs(book: HexBook, template: str) -> str:
|
|||
for line in template.splitlines(True):
|
||||
if line.startswith("#DO_NOT_RENDER"):
|
||||
_, *blacklist = line.split()
|
||||
book.blacklist.update(blacklist)
|
||||
book.state.blacklist.update(blacklist)
|
||||
|
||||
if line.startswith("#SPOILER"):
|
||||
_, *spoilers = line.split()
|
||||
book.spoilers.update(spoilers)
|
||||
book.state.spoilers.update(spoilers)
|
||||
elif line == "#DUMP_BODY_HERE\n":
|
||||
write_book(Stream(output), book)
|
||||
print("", file=output)
|
||||
|
|
0
doc/src/common/__init__.py
Normal file
0
doc/src/common/__init__.py
Normal file
|
@ -5,11 +5,31 @@
|
|||
import copy
|
||||
import traceback
|
||||
from itertools import zip_longest
|
||||
from typing import Any, Collection, Mapping, Type
|
||||
from typing import (
|
||||
Any,
|
||||
ClassVar,
|
||||
Collection,
|
||||
Mapping,
|
||||
Type,
|
||||
TypeVar,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
)
|
||||
|
||||
import dacite.core
|
||||
from dacite import Config, DaciteError, StrictUnionMatchError, UnionMatchError
|
||||
import dacite.types
|
||||
from dacite import (
|
||||
Config,
|
||||
DaciteError,
|
||||
StrictUnionMatchError,
|
||||
UnionMatchError,
|
||||
from_dict as _original_from_dict,
|
||||
)
|
||||
from dacite.cache import cache
|
||||
from dacite.core import _build_value
|
||||
from dacite.data import Data
|
||||
from dacite.dataclasses import get_fields
|
||||
from dacite.types import extract_generic, is_instance, is_optional, is_subclass
|
||||
|
||||
|
||||
|
@ -18,6 +38,39 @@ class UnionSkip(Exception):
|
|||
match their type."""
|
||||
|
||||
|
||||
def handle_metadata_inplace(data_class: Type[Any], data: dict[str, Any]) -> None:
|
||||
"""Applies our custom metadata. Currently this just renames fields."""
|
||||
# only transform a dict once, in case this is called multiple times
|
||||
if data.get("__metadata_handled"): # mischief managed?
|
||||
return
|
||||
data["__metadata_handled"] = True
|
||||
|
||||
for field in get_fields(data_class):
|
||||
try:
|
||||
key_name = field.metadata["rename"]
|
||||
if not isinstance(key_name, str):
|
||||
# TODO: raise?
|
||||
continue
|
||||
|
||||
if field.name in data:
|
||||
# TODO: could instead keep a set of renamed fields, skip writing from a shadowed field
|
||||
raise ValueError(
|
||||
f"Can't rename key '{key_name}' to field '{field.name}' because the key '{field.name}' also exists in the dict\n{data}"
|
||||
)
|
||||
data[field.name] = data.pop(key_name)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
def handle_metadata_inplace_final(data_class: Type[Any], data: dict[str, Any]) -> None:
|
||||
"""As `handle_metadata_inplace`, but removes the key marking data as handled.
|
||||
|
||||
Should only be used within a custom from_dict implementation.
|
||||
"""
|
||||
handle_metadata_inplace(data_class, data)
|
||||
data.pop("__metadata_handled")
|
||||
|
||||
|
||||
# fixes https://github.com/konradhalas/dacite/issues/234
|
||||
# workaround for https://github.com/konradhalas/dacite/issues/218
|
||||
# this code is, like, really bad. but to be fair dacite's isn't a whole lot better
|
||||
|
@ -107,6 +160,43 @@ def _patched_build_value_for_collection(
|
|||
return data
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def _patched_from_dict(
|
||||
data_class: Type[_T],
|
||||
data: Data,
|
||||
config: Config | None = None,
|
||||
) -> _T:
|
||||
if isinstance(data, data_class):
|
||||
return data
|
||||
data = dict(data)
|
||||
handle_metadata_inplace_final(data_class, data)
|
||||
return _original_from_dict(data_class, data, config)
|
||||
|
||||
|
||||
def _patched_is_valid_generic_class(value: Any, type_: Type[Any]) -> bool:
|
||||
origin = get_origin(type_)
|
||||
if not (origin and isinstance(value, origin)):
|
||||
return False
|
||||
type_args = get_args(type_)
|
||||
type_hints = cache(get_type_hints)(type(value))
|
||||
for field_name, field_type in type_hints.items():
|
||||
field_value = getattr(value, field_name, None)
|
||||
if isinstance(field_type, TypeVar):
|
||||
# TODO: this will fail to detect incorrect type in some cases
|
||||
# see comments on https://github.com/konradhalas/dacite/pull/209
|
||||
if not any(is_instance(field_value, arg) for arg in type_args):
|
||||
return False
|
||||
elif get_origin(field_type) is not ClassVar:
|
||||
if not is_instance(field_value, field_type):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# we do a bit of monkeypatching
|
||||
dacite.from_dict = _patched_from_dict
|
||||
dacite.core.from_dict = _patched_from_dict
|
||||
dacite.core._build_value_for_union = _patched_build_value_for_union
|
||||
dacite.core._build_value_for_collection = _patched_build_value_for_collection
|
||||
dacite.types.is_valid_generic_class = _patched_is_valid_generic_class
|
||||
|
|
|
@ -9,28 +9,28 @@ from pathlib import Path
|
|||
from typing import Any, Callable, Type, TypeVar
|
||||
|
||||
from dacite import Config, from_dict
|
||||
from dacite.dataclasses import get_fields
|
||||
|
||||
from common.toml_placeholders import TOMLTable, fill_placeholders
|
||||
from common.dacite_patch import handle_metadata_inplace
|
||||
from common.toml_placeholders import TOMLDict, fill_placeholders
|
||||
from common.types import Castable, JSONDict, JSONValue, isinstance_or_raise
|
||||
|
||||
_T_Input = TypeVar("_T_Input")
|
||||
|
||||
class Castable:
|
||||
"""Abstract base class for types with a constructor in the form `C(value) -> C`.
|
||||
_T_Dataclass = TypeVar("_T_Dataclass")
|
||||
|
||||
Subclassing this ABC allows for automatic deserialization using Dacite.
|
||||
"""
|
||||
TypeHook = Callable[[_T_Dataclass | Any], _T_Dataclass | dict[str, Any]]
|
||||
|
||||
TypeHooks = dict[Type[_T_Dataclass], TypeHook[_T_Dataclass]]
|
||||
|
||||
TypeFn = Callable[..., Any]
|
||||
TypeHooks = dict[Type[Any], TypeFn]
|
||||
TypeHookMaker = Callable[[_T_Input], TypeHooks[_T_Dataclass]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class TypedConfig(Config):
|
||||
"""Dacite config, but with proper type hints and sane defaults."""
|
||||
|
||||
type_hooks: TypeHooks = field(default_factory=dict)
|
||||
cast: list[TypeFn] = field(default_factory=list)
|
||||
type_hooks: TypeHooks[Any] = field(default_factory=dict)
|
||||
cast: list[TypeHook[Any]] = field(default_factory=list)
|
||||
check_types: bool = True
|
||||
strict: bool = True
|
||||
strict_unions_match: bool = True
|
||||
|
@ -55,32 +55,9 @@ def rename(rename: str) -> dict[str, Any]:
|
|||
return metadata(rename=rename)
|
||||
|
||||
|
||||
def handle_metadata_inplace(data_class: Type[Any], data: dict[str, Any]) -> None:
|
||||
"""Applies our custom metadata. Currently this just renames fields."""
|
||||
for field in get_fields(data_class):
|
||||
try:
|
||||
key_name = field.metadata["rename"]
|
||||
if not isinstance(key_name, str):
|
||||
# TODO: raise?
|
||||
continue
|
||||
|
||||
if field.name in data:
|
||||
# TODO: could instead keep a set of renamed fields, skip writing from a shadowed field
|
||||
raise ValueError(
|
||||
f"Can't rename key '{key_name}' to field '{field.name}' because the key '{field.name}' also exists in the dict\n{data}"
|
||||
)
|
||||
data[field.name] = data.pop(key_name)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
_T_json = TypeVar("_T_json", list[Any], dict[str, Any], str, int, bool, None)
|
||||
|
||||
|
||||
def load_json(path: Path, _cls: Type[_T_json] = dict) -> _T_json:
|
||||
data: _T_json | Any = json.loads(path.read_text("utf-8"))
|
||||
if not isinstance(data, _cls):
|
||||
raise TypeError(f"Expected to load {_cls} from {path}, but got {type(data)}")
|
||||
def load_json_object(path: Path) -> JSONDict:
|
||||
data: JSONValue = json.loads(path.read_text("utf-8"))
|
||||
assert isinstance_or_raise(data, dict)
|
||||
return data
|
||||
|
||||
|
||||
|
@ -90,28 +67,24 @@ def load_json_data(
|
|||
extra_data: dict[str, Any] = {},
|
||||
) -> dict[str, Any]:
|
||||
"""Load a dict from a JSON file and apply metadata transformations to it."""
|
||||
data = load_json(path)
|
||||
data = load_json_object(path)
|
||||
handle_metadata_inplace(data_class, data)
|
||||
data.update(extra_data)
|
||||
return data
|
||||
return data | extra_data
|
||||
|
||||
|
||||
def load_toml_data(data_class: Type[Any], path: Path) -> TOMLTable:
|
||||
def load_toml_data(data_class: Type[Any], path: Path) -> TOMLDict:
|
||||
data = tomllib.loads(path.read_text("utf-8"))
|
||||
fill_placeholders(data)
|
||||
handle_metadata_inplace(data_class, data)
|
||||
return data
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
def from_dict_checked(
|
||||
data_class: Type[_T],
|
||||
data_class: Type[_T_Dataclass],
|
||||
data: dict[str, Any],
|
||||
config: TypedConfig,
|
||||
path: Path | None = None,
|
||||
) -> _T:
|
||||
) -> _T_Dataclass:
|
||||
"""Convert a dict to a dataclass.
|
||||
|
||||
path is currently just used for error messages.
|
||||
|
|
|
@ -2,7 +2,23 @@ import re
|
|||
from dataclasses import dataclass
|
||||
from typing import NamedTuple, Self
|
||||
|
||||
from common.types import LocalizedStr
|
||||
DEFAULT_MACROS = {
|
||||
"$(obf)": "$(k)",
|
||||
"$(bold)": "$(l)",
|
||||
"$(strike)": "$(m)",
|
||||
"$(italic)": "$(o)",
|
||||
"$(italics)": "$(o)",
|
||||
"$(list": "$(li",
|
||||
"$(reset)": "$()",
|
||||
"$(clear)": "$()",
|
||||
"$(2br)": "$(br2)",
|
||||
"$(p)": "$(br2)",
|
||||
"/$": "$()",
|
||||
"<br>": "$(br)",
|
||||
"$(nocolor)": "$(0)",
|
||||
"$(item)": "$(#b0b)",
|
||||
"$(thing)": "$(#490)",
|
||||
}
|
||||
|
||||
_COLORS: dict[str, str | None] = {
|
||||
"0": None,
|
||||
|
@ -107,14 +123,14 @@ class FormatTree:
|
|||
return cls(Style("base", None), [])
|
||||
|
||||
@classmethod
|
||||
def format(cls, macros: dict[str, str], string: LocalizedStr) -> Self:
|
||||
def format(cls, macros: dict[str, str], string: str) -> Self:
|
||||
# resolve macros
|
||||
# TODO: use ahocorasick? this feels inefficient
|
||||
old_string = None
|
||||
while old_string != string:
|
||||
old_string = string
|
||||
for macro, replace in macros.items():
|
||||
string = LocalizedStr(string.replace(macro, replace))
|
||||
string = string.replace(macro, replace)
|
||||
|
||||
# lex out parsed styles
|
||||
text_nodes: list[str] = []
|
||||
|
|
|
@ -70,6 +70,18 @@ class Properties:
|
|||
/ self.book_name
|
||||
)
|
||||
|
||||
@property
|
||||
def categories_dir(self) -> Path:
|
||||
return self.book_dir / self.lang / "categories"
|
||||
|
||||
@property
|
||||
def entries_dir(self) -> Path:
|
||||
return self.book_dir / self.lang / "entries"
|
||||
|
||||
@property
|
||||
def templates_dir(self) -> Path:
|
||||
return self.book_dir / self.lang / "templates"
|
||||
|
||||
@property
|
||||
def platforms(self) -> list[PlatformProps]:
|
||||
platforms = [self.common]
|
||||
|
|
163
doc/src/common/state.py
Normal file
163
doc/src/common/state.py
Normal file
|
@ -0,0 +1,163 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import InitVar, dataclass
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from re import sub
|
||||
from typing import Any, Collection, Generic, Iterable, Mapping, Self, Type, TypeVar
|
||||
|
||||
from common.deserialize import (
|
||||
TypedConfig,
|
||||
TypeHook,
|
||||
TypeHooks,
|
||||
from_dict_checked,
|
||||
load_json_data,
|
||||
)
|
||||
from common.formatting import DEFAULT_MACROS, FormatTree
|
||||
from common.pattern import Direction
|
||||
from common.properties import Properties
|
||||
from common.tagged_union import InternallyTaggedUnion
|
||||
from common.types import LocalizedItem, LocalizedStr, isinstance_or_raise
|
||||
from minecraft.i18n import I18n
|
||||
from minecraft.resource import ItemStack, ResourceLocation
|
||||
|
||||
|
||||
@dataclass
|
||||
class BookState:
|
||||
"""Stores data which needs to be accessible/mutable from many different places.
|
||||
|
||||
This helps us avoid some *really* ugly circular dependencies in the book tree.
|
||||
"""
|
||||
|
||||
props: Properties
|
||||
i18n: I18n
|
||||
macros: InitVar[dict[str, str]]
|
||||
"""Extra formatting macros to be merged with the provided defaults.
|
||||
|
||||
These should generally come from `book.json`.
|
||||
"""
|
||||
type_hooks: InitVar[TypeHooks[Any] | None] = None
|
||||
"""Extra Dacite type hooks to be merged with the provided defaults.
|
||||
|
||||
This should only be used if necessary to avoid a circular dependency. In general,
|
||||
you should add hooks by subclassing BookState and adding them in __post_init__ after
|
||||
calling super.
|
||||
|
||||
Hooks are added in the following order. In case of conflict, later values will
|
||||
override earlier ones.
|
||||
* `state._default_hooks()`
|
||||
* `type_hooks`
|
||||
* `type_hook_maker`
|
||||
"""
|
||||
# oh my god
|
||||
stateful_unions: InitVar[StatefulUnions[Self] | None] = None
|
||||
|
||||
def __post_init__(
|
||||
self,
|
||||
macros: dict[str, str],
|
||||
type_hooks: TypeHooks[Any] | None,
|
||||
stateful_unions: StatefulUnions[Self] | None,
|
||||
):
|
||||
# macros (TODO: order of operations?)
|
||||
self._macros: dict[str, str] = macros | DEFAULT_MACROS
|
||||
|
||||
# type conversion hooks
|
||||
self._type_hooks: TypeHooks[Any] = {
|
||||
ResourceLocation: ResourceLocation.from_str,
|
||||
ItemStack: ItemStack.from_str,
|
||||
Direction: Direction.__getitem__,
|
||||
LocalizedStr: self.i18n.localize,
|
||||
LocalizedItem: self.i18n.localize_item,
|
||||
FormatTree: self.format,
|
||||
}
|
||||
if type_hooks:
|
||||
self._type_hooks |= type_hooks
|
||||
if stateful_unions:
|
||||
for base, subtypes in stateful_unions.items():
|
||||
self._type_hooks |= make_stateful_union_hooks(base, subtypes, self)
|
||||
|
||||
def format(self, text: str | LocalizedStr) -> FormatTree:
|
||||
"""Converts the given string into a FormatTree, localizing it if necessary."""
|
||||
# we use this as a type hook
|
||||
assert isinstance_or_raise(text, (str, LocalizedStr))
|
||||
|
||||
if not isinstance(text, LocalizedStr):
|
||||
text = self.i18n.localize(text)
|
||||
return FormatTree.format(self._macros, text)
|
||||
|
||||
@property
|
||||
def config(self) -> TypedConfig:
|
||||
"""Creates a Dacite config."""
|
||||
return TypedConfig(type_hooks=self._type_hooks)
|
||||
|
||||
|
||||
AnyState = TypeVar("AnyState", bound=BookState)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Stateful(Generic[AnyState]):
|
||||
"""Base for dataclasses with a BookState object.
|
||||
|
||||
Provides some helper properties to make the state more ergonomic to use.
|
||||
"""
|
||||
|
||||
state: AnyState
|
||||
|
||||
@property
|
||||
def props(self):
|
||||
return self.state.props
|
||||
|
||||
@property
|
||||
def i18n(self):
|
||||
return self.state.i18n
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class StatefulFile(Stateful[AnyState]):
|
||||
"""Base for dataclasses which can be loaded from a JSON file given a path and the
|
||||
shared state. Extends Stateful."""
|
||||
|
||||
path: Path
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path, state: AnyState) -> Self:
|
||||
# load the raw data from json, and add our extra fields
|
||||
data = load_json_data(cls, path, {"path": path, "state": state})
|
||||
return from_dict_checked(cls, data, state.config, path)
|
||||
|
||||
|
||||
class StatefulInternallyTaggedUnion(
|
||||
Stateful[AnyState],
|
||||
InternallyTaggedUnion,
|
||||
tag=None,
|
||||
value=None,
|
||||
):
|
||||
@classmethod
|
||||
def resolve_union_with_state(
|
||||
cls,
|
||||
data: Self | dict[str, Any] | Any,
|
||||
state: AnyState,
|
||||
) -> Self | dict[str, Any]:
|
||||
if isinstance(data, dict):
|
||||
data["state"] = state
|
||||
return cls.resolve_union(data, state.config)
|
||||
|
||||
@classmethod
|
||||
def make_type_hook(cls, state: AnyState) -> TypeHook[Self]:
|
||||
return lambda data: cls.resolve_union_with_state(data, state)
|
||||
|
||||
|
||||
StatefulUnions = Mapping[
|
||||
Type[StatefulInternallyTaggedUnion[AnyState]],
|
||||
Collection[Type[StatefulInternallyTaggedUnion[AnyState]]],
|
||||
]
|
||||
|
||||
|
||||
def make_stateful_union_hooks(
|
||||
base: Type[StatefulInternallyTaggedUnion[AnyState]],
|
||||
subtypes: Iterable[Type[StatefulInternallyTaggedUnion[AnyState]]],
|
||||
state: AnyState,
|
||||
) -> TypeHooks[StatefulInternallyTaggedUnion[AnyState]]:
|
||||
return {
|
||||
subtype: subtype.make_type_hook(state) for subtype in chain([base], subtypes)
|
||||
}
|
|
@ -2,13 +2,23 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from typing import Any, ClassVar, Generator, Type, TypeVar
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
from itertools import chain
|
||||
from typing import Any, ClassVar, Iterable, Mapping, Self, Type, TypeVar, overload
|
||||
|
||||
from dacite.types import extract_generic
|
||||
from dacite import StrictUnionMatchError, UnionMatchError, from_dict
|
||||
|
||||
from common.dacite_patch import UnionSkip
|
||||
from common.deserialize import handle_metadata_inplace
|
||||
from common.deserialize import TypedConfig, TypeHooks
|
||||
from common.types import JSONValue, isinstance_or_raise
|
||||
|
||||
|
||||
class WrongTag(UnionSkip):
|
||||
def __init__(self, union_type: Type[InternallyTaggedUnion], tag_value: str) -> None:
|
||||
super().__init__(
|
||||
f"Expected {union_type._tag_key}={union_type._expected_tag_value}, got {tag_value}"
|
||||
)
|
||||
|
||||
|
||||
class InternallyTaggedUnion(ABC):
|
||||
|
@ -19,40 +29,102 @@ class InternallyTaggedUnion(ABC):
|
|||
See: https://serde.rs/enum-representations.html#internally-tagged
|
||||
"""
|
||||
|
||||
_tag_name: ClassVar[str | None]
|
||||
_tag_value: ClassVar[str | None]
|
||||
_tag_key: ClassVar[str | None] = None
|
||||
_expected_tag_value: ClassVar[str | None] = None
|
||||
_union_types: ClassVar[defaultdict[str, list[Type[Self]]]]
|
||||
|
||||
def __init_subclass__(cls, tag: str | None, value: str | None) -> None:
|
||||
cls._tag_name = tag
|
||||
cls._tag_value = value
|
||||
cls._tag_key = tag
|
||||
cls._expected_tag_value = value
|
||||
cls._union_types = defaultdict(list)
|
||||
|
||||
# if cls is a concrete union type, add it to all the lookups of its parents
|
||||
# also add it to its own lookup so it can resolve itself
|
||||
if tag is not None and value is not None:
|
||||
cls._union_types[value].append(cls)
|
||||
for base in cls._union_bases():
|
||||
base._union_types[value].append(cls)
|
||||
|
||||
@classmethod
|
||||
def assert_tag(cls, data: dict[str, Any] | Any) -> dict[str, Any]:
|
||||
# tag and value should only be None for base classes
|
||||
if cls._tag_name is None or cls._tag_value is None:
|
||||
def _union_bases(cls) -> list[Type[InternallyTaggedUnion]]:
|
||||
union_bases: list[Type[InternallyTaggedUnion]] = []
|
||||
for base in cls.__bases__:
|
||||
if (
|
||||
issubclass(base, InternallyTaggedUnion)
|
||||
and base._tag_key is not None
|
||||
and base._tag_key == cls._tag_key
|
||||
):
|
||||
union_bases += [base] + base._union_bases()
|
||||
return union_bases
|
||||
|
||||
@classmethod
|
||||
def resolve_union(cls, data: Self | Any, config: TypedConfig) -> Self:
|
||||
if cls._tag_key is None:
|
||||
raise NotImplementedError
|
||||
|
||||
# this is a type hook, so check the input type
|
||||
if not isinstance(data, dict):
|
||||
raise TypeError(f"Expected dict, got {type(data)}: {data}")
|
||||
# if it's already instantiated, just return it; otherwise ensure it's a dict
|
||||
if isinstance(data, InternallyTaggedUnion):
|
||||
assert isinstance_or_raise(data, cls)
|
||||
return data
|
||||
assert isinstance_or_raise(data, dict[str, Any])
|
||||
|
||||
# raise if data doesn't have that key or if the value is wrong
|
||||
tag_value = data[cls._tag_name]
|
||||
if tag_value != cls._tag_value:
|
||||
raise UnionSkip(
|
||||
f"Expected {cls._tag_name}={cls._tag_value}, got {tag_value}"
|
||||
# get the class objects for this type
|
||||
tag_value = data.get(cls._tag_key)
|
||||
if tag_value is None:
|
||||
raise KeyError(cls._tag_key, data)
|
||||
|
||||
tag_types = cls._union_types.get(tag_value)
|
||||
if tag_types is None:
|
||||
raise TypeError(
|
||||
f"Unhandled tag: {cls._tag_key}={tag_value} for {cls}: {data}"
|
||||
)
|
||||
|
||||
# convenient spot to put it, i guess
|
||||
handle_metadata_inplace(cls, data)
|
||||
return data
|
||||
# try all the types
|
||||
exceptions: list[Exception] = []
|
||||
union_matches: dict[Type[InternallyTaggedUnion], InternallyTaggedUnion] = {}
|
||||
|
||||
for inner_type in tag_types:
|
||||
try:
|
||||
value = from_dict(inner_type, data, config)
|
||||
if not config.strict_unions_match:
|
||||
return value
|
||||
union_matches[inner_type] = value
|
||||
except UnionSkip:
|
||||
pass
|
||||
except Exception as e:
|
||||
exceptions.append(e)
|
||||
|
||||
# ensure we only matched one
|
||||
match len(union_matches):
|
||||
case 1:
|
||||
return union_matches.popitem()[1]
|
||||
case x if x > 1 and config.strict_unions_match:
|
||||
exceptions.append(StrictUnionMatchError(union_matches))
|
||||
case _:
|
||||
exceptions.append(UnionMatchError(tag_types, data))
|
||||
|
||||
# oopsies
|
||||
raise ExceptionGroup(
|
||||
f"Failed to match {cls} with {cls._tag_key}={tag_value} to any of {tag_types}: {data}",
|
||||
exceptions,
|
||||
)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _tag_value(self) -> str:
|
||||
...
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
_T_Union = TypeVar("_T_Union", bound=InternallyTaggedUnion)
|
||||
|
||||
|
||||
def get_union_types(*unions: Type[_T]) -> Generator[Type[_T], None, None]:
|
||||
for union in unions:
|
||||
yield from extract_generic(union)
|
||||
def make_internally_tagged_hooks(
|
||||
base: Type[_T_Union],
|
||||
subtypes: Iterable[Type[_T_Union]],
|
||||
config: TypedConfig,
|
||||
) -> TypeHooks[_T_Union]:
|
||||
"""Creates type hooks for an internally tagged union."""
|
||||
return {
|
||||
subtype: lambda data: subtype.resolve_union(data, config)
|
||||
for subtype in chain([base], subtypes)
|
||||
}
|
||||
|
|
|
@ -2,9 +2,11 @@ import datetime
|
|||
import re
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from common.types import isinstance_or_raise
|
||||
|
||||
# TODO: there's (figuratively) literally no comments in this file
|
||||
|
||||
TOMLTable = dict[str, "TOMLValue"]
|
||||
TOMLDict = dict[str, "TOMLValue"]
|
||||
|
||||
TOMLValue = (
|
||||
str
|
||||
|
@ -15,21 +17,21 @@ TOMLValue = (
|
|||
| datetime.date
|
||||
| datetime.time
|
||||
| list["TOMLValue"]
|
||||
| TOMLTable
|
||||
| TOMLDict
|
||||
)
|
||||
|
||||
|
||||
def fill_placeholders(data: TOMLTable):
|
||||
def fill_placeholders(data: TOMLDict):
|
||||
_fill_placeholders(data, [data], set())
|
||||
|
||||
|
||||
def _expand_placeholder(
|
||||
data: TOMLTable,
|
||||
stack: list[TOMLTable],
|
||||
data: TOMLDict,
|
||||
stack: list[TOMLDict],
|
||||
expanded: set[tuple[int, str | int]],
|
||||
placeholder: str,
|
||||
) -> str:
|
||||
tmp_stack = stack[:]
|
||||
tmp_stack: list[TOMLDict] = stack[:]
|
||||
|
||||
key = "UNBOUND"
|
||||
keys = placeholder.split(".")
|
||||
|
@ -38,14 +40,15 @@ def _expand_placeholder(
|
|||
tmp_stack = tmp_stack[:-n]
|
||||
key = key.replace("^", "")
|
||||
if key and i < len(keys) - 1:
|
||||
assert isinstance(new := tmp_stack[-1][key], dict)
|
||||
# TODO: does this work?
|
||||
assert isinstance_or_raise(new := tmp_stack[-1][key], TOMLDict)
|
||||
tmp_stack.append(new)
|
||||
|
||||
table = tmp_stack[-1]
|
||||
if (id(table), key) not in expanded:
|
||||
_handle_child(data, tmp_stack, expanded, key, table[key], table.__setitem__)
|
||||
|
||||
assert isinstance(value := table[key], str)
|
||||
assert isinstance_or_raise(value := table[key], str)
|
||||
return value
|
||||
|
||||
|
||||
|
@ -55,8 +58,8 @@ _PLACEHOLDER_RE = re.compile(r"\{(.+?)\}")
|
|||
|
||||
|
||||
def _handle_child(
|
||||
data: TOMLTable,
|
||||
stack: list[TOMLTable],
|
||||
data: TOMLDict,
|
||||
stack: list[TOMLDict],
|
||||
expanded: set[tuple[int, str | int]],
|
||||
key: _T_key,
|
||||
value: TOMLValue,
|
||||
|
@ -100,8 +103,8 @@ def _handle_child(
|
|||
|
||||
|
||||
def _fill_placeholders(
|
||||
data: TOMLTable,
|
||||
stack: list[TOMLTable],
|
||||
data: TOMLDict,
|
||||
stack: list[TOMLDict],
|
||||
expanded: set[tuple[int, str | int]],
|
||||
):
|
||||
table = stack[-1]
|
||||
|
|
|
@ -2,9 +2,48 @@ from __future__ import annotations
|
|||
|
||||
import string
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Mapping, Protocol, Self, TypeVar
|
||||
from typing import Any, Mapping, Protocol, Self, Type, TypeGuard, TypeVar
|
||||
|
||||
from common.deserialize import Castable
|
||||
JSONDict = dict[str, "JSONValue"]
|
||||
|
||||
JSONValue = JSONDict | list["JSONValue"] | str | int | float | bool | None
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
_DEFAULT_MESSAGE = "Expected type {expected}, got {actual}: {value}"
|
||||
|
||||
|
||||
# there may well be a better way to do this but i don't know what it is
|
||||
def isinstance_or_raise(
|
||||
val: Any,
|
||||
class_or_tuple: Type[_T] | tuple[Type[_T], ...],
|
||||
message: str = _DEFAULT_MESSAGE,
|
||||
) -> TypeGuard[_T]:
|
||||
"""Usage: `assert isinstance_or_raise(val, str)`
|
||||
|
||||
message placeholders: `{expected}`, `{actual}`, `{value}`
|
||||
"""
|
||||
|
||||
# convert generic types into the origin type
|
||||
if not isinstance(class_or_tuple, tuple):
|
||||
class_or_tuple = (class_or_tuple,)
|
||||
ungenericed_classes = tuple(getattr(t, "__origin__", t) for t in class_or_tuple)
|
||||
|
||||
if not isinstance(val, ungenericed_classes):
|
||||
# just in case the caller messed up the message formatting
|
||||
subs = dict(expected=class_or_tuple, actual=type(val), value=val)
|
||||
try:
|
||||
raise TypeError(message.format(subs))
|
||||
except Exception:
|
||||
raise TypeError(_DEFAULT_MESSAGE.format(subs))
|
||||
return True
|
||||
|
||||
|
||||
class Castable:
|
||||
"""Abstract base class for types with a constructor in the form `C(value) -> C`.
|
||||
|
||||
Subclassing this ABC allows for automatic deserialization using Dacite.
|
||||
"""
|
||||
|
||||
|
||||
class Color(str, Castable):
|
||||
|
@ -25,10 +64,11 @@ class Color(str, Castable):
|
|||
|
||||
__slots__ = ()
|
||||
|
||||
def __new__(cls, s: str) -> Self:
|
||||
assert isinstance(s, str), f"Expected str, got {type(s)}"
|
||||
def __new__(cls, value: str) -> Self:
|
||||
# this is a castable type hook but we hint str for usability
|
||||
assert isinstance_or_raise(value, str)
|
||||
|
||||
color = s.removeprefix("#").lower()
|
||||
color = value.removeprefix("#").lower()
|
||||
|
||||
# 012 -> 001122
|
||||
if len(color) == 3:
|
||||
|
@ -36,7 +76,7 @@ class Color(str, Castable):
|
|||
|
||||
# length and character check
|
||||
if len(color) != 6 or any(c not in string.hexdigits for c in color):
|
||||
raise ValueError(f"invalid color code: {s}")
|
||||
raise ValueError(f"invalid color code: {value}")
|
||||
|
||||
return str.__new__(cls, color)
|
||||
|
||||
|
@ -46,8 +86,8 @@ class LocalizedStr(str):
|
|||
"""Represents a string which has been localized."""
|
||||
|
||||
def __new__(cls, value: str) -> Self:
|
||||
# check the type because we use this while deserializing the i18n dict
|
||||
assert isinstance(value, str), f"Expected str, got {type(value)}"
|
||||
# this is a castable type hook but we hint str for usability
|
||||
assert isinstance_or_raise(value, str)
|
||||
return str.__new__(cls, value)
|
||||
|
||||
|
||||
|
@ -69,8 +109,6 @@ class Sortable(ABC):
|
|||
return NotImplemented
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
_T_Sortable = TypeVar("_T_Sortable", bound=Sortable)
|
||||
|
||||
_T_covariant = TypeVar("_T_covariant", covariant=True)
|
||||
|
|
0
doc/src/hexcasting/__init__.py
Normal file
0
doc/src/hexcasting/__init__.py
Normal file
57
doc/src/hexcasting/hex_book.py
Normal file
57
doc/src/hexcasting/hex_book.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from common.deserialize import TypeHooks
|
||||
from common.properties import Properties
|
||||
from common.state import BookState, StatefulUnions
|
||||
from minecraft.i18n import I18n
|
||||
from minecraft.recipe import Recipe
|
||||
from patchouli import Book, Page
|
||||
|
||||
from .hex_pages import (
|
||||
BrainsweepPage,
|
||||
CraftingMultiPage,
|
||||
LookupPatternPage,
|
||||
ManualOpPatternPage,
|
||||
ManualPatternNosigPage,
|
||||
ManualRawPatternPage,
|
||||
)
|
||||
from .hex_recipes import BrainsweepRecipe
|
||||
from .hex_state import HexBookState
|
||||
|
||||
_PAGES = [
|
||||
LookupPatternPage,
|
||||
ManualPatternNosigPage,
|
||||
ManualOpPatternPage,
|
||||
ManualRawPatternPage,
|
||||
CraftingMultiPage,
|
||||
BrainsweepPage,
|
||||
]
|
||||
|
||||
_RECIPES = [
|
||||
BrainsweepRecipe,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HexBook(Book):
|
||||
"""Main docgen dataclass."""
|
||||
|
||||
state: HexBookState
|
||||
|
||||
@classmethod
|
||||
def _init_state(
|
||||
cls,
|
||||
data: dict[str, Any],
|
||||
props: Properties,
|
||||
i18n: I18n,
|
||||
macros: dict[str, str],
|
||||
type_hooks: TypeHooks[Any],
|
||||
stateful_unions: StatefulUnions[Any],
|
||||
) -> BookState:
|
||||
# we know it's this type, but the type checker doesn't
|
||||
stateful_unions = dict(stateful_unions) | {
|
||||
Page[HexBookState]: _PAGES,
|
||||
Recipe[HexBookState]: _RECIPES,
|
||||
}
|
||||
return HexBookState(props, i18n, macros, type_hooks, stateful_unions)
|
118
doc/src/hexcasting/hex_pages.py
Normal file
118
doc/src/hexcasting/hex_pages.py
Normal file
|
@ -0,0 +1,118 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from common.deserialize import rename
|
||||
from common.pattern import RawPatternInfo
|
||||
from common.types import LocalizedStr
|
||||
from minecraft.recipe import CraftingRecipe
|
||||
from minecraft.resource import ResourceLocation
|
||||
from patchouli.page import PageWithCraftingRecipes, PageWithText, PageWithTitle
|
||||
|
||||
from .hex_recipes import BrainsweepRecipe
|
||||
from .hex_state import HexBookState
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class PageWithPattern(PageWithTitle[HexBookState], ABC, type=None):
|
||||
_patterns: RawPatternInfo | list[RawPatternInfo] = field(
|
||||
metadata=rename("patterns")
|
||||
)
|
||||
op_id: ResourceLocation | None = None
|
||||
header: LocalizedStr | None = None
|
||||
input: str | None = None
|
||||
output: str | None = None
|
||||
hex_size: int | None = None
|
||||
|
||||
_title: None = None
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> LocalizedStr:
|
||||
...
|
||||
|
||||
@property
|
||||
def args(self) -> str | None:
|
||||
inp = self.input or ""
|
||||
oup = self.output or ""
|
||||
if inp or oup:
|
||||
return f"{inp} \u2192 {oup}".strip()
|
||||
return None
|
||||
|
||||
@property
|
||||
def title(self) -> LocalizedStr:
|
||||
suffix = f" ({self.args})" if self.args else ""
|
||||
return LocalizedStr(self.name + suffix)
|
||||
|
||||
@property
|
||||
def patterns(self) -> list[RawPatternInfo]:
|
||||
if isinstance(self._patterns, list):
|
||||
return self._patterns
|
||||
return [self._patterns]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LookupPatternPage(PageWithPattern, type="hexcasting:pattern"):
|
||||
state: HexBookState
|
||||
|
||||
_patterns: list[RawPatternInfo] = field(init=False)
|
||||
op_id: ResourceLocation
|
||||
header: None
|
||||
|
||||
def __post_init__(self):
|
||||
self._patterns = [self.state.patterns[self.op_id]]
|
||||
|
||||
@property
|
||||
def name(self) -> LocalizedStr:
|
||||
return self.i18n.localize_pattern(self.op_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManualPatternNosigPage(PageWithPattern, type="hexcasting:manual_pattern_nosig"):
|
||||
header: LocalizedStr
|
||||
op_id: None
|
||||
input: None
|
||||
output: None
|
||||
|
||||
@property
|
||||
def name(self) -> LocalizedStr:
|
||||
return self.header
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManualOpPatternPage(PageWithPattern, type="hexcasting:manual_pattern"):
|
||||
op_id: ResourceLocation
|
||||
header: None
|
||||
|
||||
@property
|
||||
def name(self) -> LocalizedStr:
|
||||
return self.i18n.localize_pattern(self.op_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManualRawPatternPage(PageWithPattern, type="hexcasting:manual_pattern"):
|
||||
op_id: None
|
||||
header: LocalizedStr
|
||||
|
||||
@property
|
||||
def name(self) -> LocalizedStr:
|
||||
return self.header
|
||||
|
||||
|
||||
@dataclass
|
||||
class CraftingMultiPage(
|
||||
PageWithCraftingRecipes[HexBookState],
|
||||
type="hexcasting:crafting_multi",
|
||||
):
|
||||
heading: LocalizedStr # ...heading?
|
||||
_recipes: list[CraftingRecipe] = field(metadata=rename("recipes"))
|
||||
|
||||
@property
|
||||
def recipes(self) -> list[CraftingRecipe]:
|
||||
return self._recipes
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrainsweepPage(PageWithText[HexBookState], type="hexcasting:brainsweep"):
|
||||
recipe: BrainsweepRecipe
|
35
doc/src/hexcasting/hex_recipes.py
Normal file
35
doc/src/hexcasting/hex_recipes.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from common.types import LocalizedItem
|
||||
from minecraft.recipe import Recipe
|
||||
from minecraft.resource import ResourceLocation
|
||||
|
||||
from .hex_state import HexBookState
|
||||
|
||||
|
||||
@dataclass
|
||||
class VillagerIngredient:
|
||||
minLevel: int
|
||||
profession: ResourceLocation | None = None
|
||||
biome: ResourceLocation | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockStateIngredient:
|
||||
# TODO: StateIngredient should also be a TypeTaggedUnion, probably
|
||||
type: Literal["block"]
|
||||
block: ResourceLocation
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BlockState:
|
||||
name: LocalizedItem
|
||||
properties: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrainsweepRecipe(Recipe[HexBookState], type="hexcasting:brainsweep"):
|
||||
blockIn: BlockStateIngredient
|
||||
villagerIn: VillagerIngredient
|
||||
result: BlockState
|
|
@ -1,21 +1,24 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import patchouli
|
||||
from common.pattern import PatternInfo
|
||||
from common.state import BookState
|
||||
from minecraft.resource import ResourceLocation
|
||||
|
||||
|
||||
@dataclass
|
||||
class HexBook(patchouli.Book):
|
||||
"""Main docgen dataclass."""
|
||||
class HexBookState(BookState):
|
||||
def __post_init__(self, *args: Any, **kwargs: Any):
|
||||
super().__post_init__(*args, **kwargs)
|
||||
|
||||
def __post_init_pre_categories__(self) -> None:
|
||||
# mutable state
|
||||
self.blacklist: set[str] = set()
|
||||
self.spoilers: set[str] = set()
|
||||
|
||||
# patterns
|
||||
self.patterns: dict[ResourceLocation, PatternInfo] = {}
|
||||
for stub in self.props.pattern_stubs:
|
||||
# for each stub, load all the patterns in the file
|
||||
for pattern in stub.load_patterns(self.props.modid, self.props.pattern_re):
|
||||
# check for key clobbering, because why not
|
||||
if duplicate := self.patterns.get(pattern.id):
|
|
@ -13,7 +13,7 @@ from tap import Tap
|
|||
|
||||
from collate_data import generate_docs
|
||||
from common.properties import Properties
|
||||
from patchouli.hex_book import HexBook
|
||||
from hexcasting.hex_book import HexBook
|
||||
|
||||
if sys.version_info < (3, 11):
|
||||
raise RuntimeError("Minimum Python version: 3.11")
|
||||
|
|
0
doc/src/minecraft/__init__.py
Normal file
0
doc/src/minecraft/__init__.py
Normal file
|
@ -1,13 +1,11 @@
|
|||
from dataclasses import InitVar, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from common.deserialize import load_json
|
||||
from common.deserialize import load_json_object
|
||||
from common.properties import Properties
|
||||
from common.types import LocalizedItem, LocalizedStr
|
||||
from common.types import LocalizedItem, LocalizedStr, isinstance_or_raise
|
||||
from minecraft.resource import ItemStack, ResourceLocation
|
||||
|
||||
I18nLookup = dict[str, LocalizedStr]
|
||||
|
||||
|
||||
@dataclass
|
||||
class I18n:
|
||||
|
@ -18,28 +16,22 @@ class I18n:
|
|||
|
||||
def __post_init__(self, enabled: bool):
|
||||
# skip loading the files if we don't need to
|
||||
self._lookup: I18nLookup | None = None
|
||||
self._lookup: dict[str, LocalizedStr] | None = None
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
# load, deserialize, validate
|
||||
# load and deserialize
|
||||
# TODO: load ALL of the i18n files, return dict[str, _Lookup] | None
|
||||
# or maybe dict[(str, str), LocalizedStr]
|
||||
# we could also use that to ensure all i18n files have the same set of keys
|
||||
path = self.dir / self.props.i18n.filename
|
||||
_lookup = load_json(path)
|
||||
if self.props.i18n.extra:
|
||||
_lookup.update(self.props.i18n.extra)
|
||||
raw_lookup = load_json_object(path) | (self.props.i18n.extra or {})
|
||||
|
||||
# validate fields
|
||||
# TODO: there's probably a library we can use to do this for us
|
||||
for k, v in _lookup.items():
|
||||
assert isinstance(k, str), f"Unexpected key type `{type(k)}` in {path}: {k}"
|
||||
assert isinstance(
|
||||
v, str
|
||||
), f"Unexpected value type `{type(v)}` in {path}: {v}"
|
||||
|
||||
self._lookup = _lookup
|
||||
# validate and insert
|
||||
self._lookup = {}
|
||||
for key, raw_value in raw_lookup.items():
|
||||
assert isinstance_or_raise(raw_value, str)
|
||||
self._lookup[key] = LocalizedStr(raw_value)
|
||||
|
||||
@property
|
||||
def dir(self) -> Path:
|
||||
|
@ -62,7 +54,7 @@ class I18n:
|
|||
corresponding localized value.
|
||||
"""
|
||||
|
||||
assert isinstance(key, (str, list, tuple))
|
||||
assert isinstance_or_raise(key, (str, list[str], tuple[str, ...]))
|
||||
|
||||
if self._lookup is None:
|
||||
# if i18n is disabled, just return the key
|
||||
|
@ -81,7 +73,7 @@ class I18n:
|
|||
else:
|
||||
# for a list/tuple of keys, return the first one that matches (by recursing)
|
||||
for current_key in key[:-1]:
|
||||
assert isinstance(current_key, str)
|
||||
assert isinstance_or_raise(current_key, str)
|
||||
try:
|
||||
return self.localize(current_key)
|
||||
except KeyError:
|
||||
|
|
|
@ -1,32 +1,21 @@
|
|||
__all__ = [
|
||||
"BaseRecipe",
|
||||
"BrainsweepRecipe",
|
||||
"Recipe",
|
||||
"CraftingRecipe",
|
||||
"CraftingShapedRecipe",
|
||||
"CraftingShapelessRecipe",
|
||||
"Recipe",
|
||||
"BlockStateIngredient",
|
||||
"ItemIngredient",
|
||||
"ItemIngredientData",
|
||||
"ModConditionalIngredient",
|
||||
"VillagerIngredient",
|
||||
"BlockState",
|
||||
"ItemResult",
|
||||
]
|
||||
|
||||
from .abstract import BaseRecipe
|
||||
from .abstract import Recipe
|
||||
from .concrete import (
|
||||
BrainsweepRecipe,
|
||||
CraftingRecipe,
|
||||
CraftingShapedRecipe,
|
||||
CraftingShapelessRecipe,
|
||||
Recipe,
|
||||
)
|
||||
from .ingredient import (
|
||||
BlockStateIngredient,
|
||||
ItemIngredient,
|
||||
ItemIngredientData,
|
||||
ModConditionalIngredient,
|
||||
VillagerIngredient,
|
||||
)
|
||||
from .result import BlockState, ItemResult
|
||||
from .ingredient import ItemIngredient, ItemIngredientData, ModConditionalIngredient
|
||||
from .result import ItemResult
|
||||
|
|
|
@ -1,44 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Self
|
||||
from typing import Any, Self, TypeVar
|
||||
|
||||
import patchouli
|
||||
from common.deserialize import TypeFn, load_json_data
|
||||
from common.tagged_union import InternallyTaggedUnion
|
||||
from common.deserialize import TypeHook, load_json_data
|
||||
from common.state import BookState, StatefulInternallyTaggedUnion
|
||||
from minecraft.resource import ResourceLocation
|
||||
|
||||
_T_State = TypeVar("_T_State", bound=BookState)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BaseRecipe(InternallyTaggedUnion, tag="type", value=None):
|
||||
class Recipe(StatefulInternallyTaggedUnion[_T_State], tag="type", value=None):
|
||||
id: ResourceLocation
|
||||
|
||||
type: ResourceLocation = field(init=False)
|
||||
group: str | None = None
|
||||
|
||||
def __init_subclass__(cls, type: str) -> None:
|
||||
super().__init_subclass__(__class__._tag_name, type)
|
||||
super().__init_subclass__(__class__._tag_key, type)
|
||||
cls.type = ResourceLocation.from_str(type)
|
||||
|
||||
@classmethod
|
||||
def make_type_hook(cls, book: patchouli.Book) -> TypeFn:
|
||||
def make_type_hook(cls, state: BookState) -> TypeHook[Self]:
|
||||
"""Creates a type hook which, given a stringified ResourceLocation, loads and
|
||||
returns the recipe json at that location."""
|
||||
|
||||
def type_hook(raw_id: str | Any) -> Self | dict[str, Any]:
|
||||
# FIXME: this should use isinstance_or_raise but I'm probably redoing it
|
||||
if isinstance(raw_id, cls):
|
||||
return raw_id
|
||||
id = ResourceLocation.from_str(raw_id)
|
||||
|
||||
# FIXME: hack
|
||||
# the point of this is to ensure the recipe exists on all platforms
|
||||
# because we've had issues with that in the past, eg. in Hexal
|
||||
id = ResourceLocation.from_str(raw_id)
|
||||
data: dict[str, Any] = {}
|
||||
for recipe_dir in book.props.recipe_dirs:
|
||||
for recipe_dir in state.props.recipe_dirs:
|
||||
# TODO: should this use id.namespace somewhere?
|
||||
data = load_json_data(cls, recipe_dir / f"{id.path}.json")
|
||||
|
||||
data["id"] = id
|
||||
return data
|
||||
return data | {"id": id, "state": state}
|
||||
|
||||
return type_hook
|
||||
|
||||
@property
|
||||
def _tag_value(self) -> str:
|
||||
return str(self.type)
|
||||
|
|
|
@ -1,30 +1,23 @@
|
|||
from dataclasses import dataclass
|
||||
|
||||
from .abstract import BaseRecipe
|
||||
from .ingredient import BlockStateIngredient, ItemIngredient, VillagerIngredient
|
||||
from .result import BlockState, ItemResult
|
||||
from common.state import BookState
|
||||
|
||||
from .abstract import Recipe
|
||||
from .ingredient import ItemIngredient
|
||||
from .result import ItemResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class CraftingShapedRecipe(BaseRecipe, type="minecraft:crafting_shaped"):
|
||||
class CraftingShapedRecipe(Recipe[BookState], type="minecraft:crafting_shaped"):
|
||||
pattern: list[str]
|
||||
key: dict[str, ItemIngredient]
|
||||
result: ItemResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class CraftingShapelessRecipe(BaseRecipe, type="minecraft:crafting_shapeless"):
|
||||
class CraftingShapelessRecipe(Recipe[BookState], type="minecraft:crafting_shapeless"):
|
||||
ingredients: list[ItemIngredient]
|
||||
result: ItemResult
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrainsweepRecipe(BaseRecipe, type="hexcasting:brainsweep"):
|
||||
blockIn: BlockStateIngredient
|
||||
villagerIn: VillagerIngredient
|
||||
result: BlockState
|
||||
|
||||
|
||||
CraftingRecipe = CraftingShapedRecipe | CraftingShapelessRecipe
|
||||
|
||||
Recipe = CraftingShapedRecipe | CraftingShapelessRecipe | BrainsweepRecipe
|
||||
|
|
|
@ -10,6 +10,7 @@ class ItemIngredientData:
|
|||
tag: ResourceLocation | None = None
|
||||
|
||||
|
||||
# TODO: should be in hex but idk how
|
||||
# TODO: tagged union~!
|
||||
@dataclass
|
||||
class ModConditionalIngredient:
|
||||
|
@ -22,17 +23,3 @@ class ModConditionalIngredient:
|
|||
ItemIngredient = (
|
||||
ItemIngredientData | ModConditionalIngredient | list[ItemIngredientData]
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VillagerIngredient:
|
||||
minLevel: int
|
||||
profession: ResourceLocation | None = None
|
||||
biome: ResourceLocation | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BlockStateIngredient:
|
||||
# TODO: StateIngredient should also be a TypeTaggedUnion, probably
|
||||
type: Literal["block"]
|
||||
block: ResourceLocation
|
||||
|
|
|
@ -4,12 +4,6 @@ from typing import Any
|
|||
from common.types import LocalizedItem
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BlockState:
|
||||
name: LocalizedItem
|
||||
properties: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ItemResult:
|
||||
item: LocalizedItem
|
||||
|
|
|
@ -7,6 +7,8 @@ from dataclasses import dataclass
|
|||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
|
||||
from common.types import isinstance_or_raise
|
||||
|
||||
_RESOURCE_LOCATION_RE = re.compile(r"(?:([0-9a-z_\-.]+):)?([0-9a-z_\-./]+)")
|
||||
_ITEM_STACK_SUFFIX_RE = re.compile(r"(?:#([0-9]+))?({.*})?")
|
||||
_ENTITY_SUFFIX_RE = re.compile(r"({.*})?")
|
||||
|
@ -36,7 +38,7 @@ class BaseResourceLocation:
|
|||
raw: str,
|
||||
fullmatch: bool = True,
|
||||
) -> tuple[tuple[Any, ...], re.Match[str]]:
|
||||
assert isinstance(raw, str), f"Expected str, got {type(raw)}"
|
||||
assert isinstance_or_raise(raw, str)
|
||||
|
||||
match = _match(_RESOURCE_LOCATION_RE, fullmatch, raw)
|
||||
if match is None:
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
__all__ = ["Book", "BookHelpers", "Category", "Entry", "HexBook", "Page"]
|
||||
__all__ = ["Book", "Category", "Entry", "Page"]
|
||||
|
||||
from .book import Book, BookHelpers
|
||||
from .book import Book
|
||||
from .category import Category
|
||||
from .entry import Entry
|
||||
from .hex_book import HexBook
|
||||
from .page import Page
|
||||
|
|
|
@ -1,79 +1,73 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, Self
|
||||
from typing import Any, Literal, Self, Type
|
||||
|
||||
import patchouli
|
||||
from common.deserialize import (
|
||||
TypedConfig,
|
||||
TypeHooks,
|
||||
from_dict_checked,
|
||||
load_json_data,
|
||||
rename,
|
||||
)
|
||||
from common.deserialize import TypeHooks, from_dict_checked, load_json_data, rename
|
||||
from common.formatting import FormatTree
|
||||
from common.pattern import Direction
|
||||
from common.properties import Properties
|
||||
from common.tagged_union import get_union_types
|
||||
from common.types import Color, IProperty, LocalizedItem, LocalizedStr, sorted_dict
|
||||
from common.state import BookState, Stateful, StatefulUnions
|
||||
from common.types import Color, LocalizedStr
|
||||
from minecraft.i18n import I18n
|
||||
from minecraft.recipe import Recipe
|
||||
from minecraft.recipe.concrete import CraftingShapedRecipe, CraftingShapelessRecipe
|
||||
from minecraft.resource import ItemStack, ResLoc, ResourceLocation
|
||||
from patchouli.page.concrete import (
|
||||
CraftingPage,
|
||||
EmptyPage,
|
||||
EntityPage,
|
||||
ImagePage,
|
||||
LinkPage,
|
||||
MultiblockPage,
|
||||
QuestPage,
|
||||
RelationsPage,
|
||||
SmeltingPage,
|
||||
SpotlightPage,
|
||||
TextPage,
|
||||
)
|
||||
|
||||
_DEFAULT_MACROS = {
|
||||
"$(obf)": "$(k)",
|
||||
"$(bold)": "$(l)",
|
||||
"$(strike)": "$(m)",
|
||||
"$(italic)": "$(o)",
|
||||
"$(italics)": "$(o)",
|
||||
"$(list": "$(li",
|
||||
"$(reset)": "$()",
|
||||
"$(clear)": "$()",
|
||||
"$(2br)": "$(br2)",
|
||||
"$(p)": "$(br2)",
|
||||
"/$": "$()",
|
||||
"<br>": "$(br)",
|
||||
"$(nocolor)": "$(0)",
|
||||
"$(item)": "$(#b0b)",
|
||||
"$(thing)": "$(#490)",
|
||||
}
|
||||
from .category import Category
|
||||
from .entry import Entry
|
||||
from .page import Page
|
||||
|
||||
_DEFAULT_TYPE_HOOKS: TypeHooks = {
|
||||
ResourceLocation: ResourceLocation.from_str,
|
||||
ItemStack: ItemStack.from_str,
|
||||
Direction: Direction.__getitem__,
|
||||
}
|
||||
_PAGES: list[Type[Page[BookState]]] = [
|
||||
TextPage,
|
||||
ImagePage,
|
||||
CraftingPage,
|
||||
SmeltingPage,
|
||||
MultiblockPage,
|
||||
EntityPage,
|
||||
SpotlightPage,
|
||||
LinkPage,
|
||||
RelationsPage,
|
||||
QuestPage,
|
||||
EmptyPage,
|
||||
]
|
||||
|
||||
|
||||
def _format(text: str | LocalizedStr, i18n: I18n, macros: dict[str, str]):
|
||||
if not isinstance(text, LocalizedStr):
|
||||
assert isinstance(text, str), f"Expected str, got {type(text)}: {text}"
|
||||
text = i18n.localize(text)
|
||||
return FormatTree.format(macros, text)
|
||||
_RECIPES: list[Type[Recipe[BookState]]] = [
|
||||
CraftingShapedRecipe,
|
||||
CraftingShapelessRecipe,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Book:
|
||||
class Book(Stateful[BookState]):
|
||||
"""Main Patchouli book class.
|
||||
|
||||
Includes all data from book.json, categories/entries/pages, and i18n.
|
||||
|
||||
You should probably not use this to edit and re-serialize book.json, because this sets
|
||||
all the default values as defined by the docs. (TODO: superclass which doesn't do that)
|
||||
You should probably not use this (or any other Patchouli types, eg. Category, Entry)
|
||||
to edit and re-serialize book.json, because this class sets all the default values
|
||||
as defined by the docs. (TODO: superclass which doesn't do that)
|
||||
|
||||
See: https://vazkiimods.github.io/Patchouli/docs/reference/book-json
|
||||
"""
|
||||
|
||||
props: Properties
|
||||
i18n: I18n
|
||||
|
||||
# required from book.json
|
||||
# required
|
||||
name: LocalizedStr
|
||||
landing_text: FormatTree
|
||||
|
||||
# optional from book.json
|
||||
# optional
|
||||
book_texture: ResourceLocation = ResLoc("patchouli", "textures/gui/book_brown.png")
|
||||
filler_texture: ResourceLocation | None = None
|
||||
crafting_texture: ResourceLocation | None = None
|
||||
|
@ -108,114 +102,70 @@ class Book:
|
|||
"""NOTE: currently this WILL NOT load values from the target book!"""
|
||||
allow_extensions: bool = True
|
||||
|
||||
@property
|
||||
def index_icon(self) -> ResourceLocation:
|
||||
# default value as defined by patchouli, apparently
|
||||
return self.model if self._index_icon is None else self._index_icon
|
||||
|
||||
@classmethod
|
||||
def load(cls, props: Properties) -> Self:
|
||||
"""Sets up i18n, macros, and the book.json data."""
|
||||
"""Loads `book.json`, and sets up shared state with `cls._make_state()`.
|
||||
|
||||
Subclasses should generally not override this. To customize state creation,
|
||||
override `_make_state()`. To add type hooks which require a state instance,
|
||||
override `_make_type_hooks()` (remember to call and extend super).
|
||||
"""
|
||||
|
||||
# read the raw dict from the json file
|
||||
path = props.book_dir / "book.json"
|
||||
data = load_json_data(cls, path)
|
||||
|
||||
data["props"] = props
|
||||
|
||||
i18n = I18n(props, data["do_i18n"])
|
||||
data["i18n"] = i18n
|
||||
|
||||
# TODO: order of operations: should default macros really override book macros?
|
||||
# this does make a difference - the snapshot tests fail if the order is reversed
|
||||
data["macros"].update(_DEFAULT_MACROS)
|
||||
# construct the shared state object
|
||||
state = cls._init_state(
|
||||
data=data,
|
||||
props=props,
|
||||
i18n=I18n(props, data["do_i18n"]),
|
||||
macros=data["macros"],
|
||||
type_hooks={},
|
||||
stateful_unions={
|
||||
Page[BookState]: _PAGES,
|
||||
Recipe[BookState]: _RECIPES,
|
||||
},
|
||||
)
|
||||
data["state"] = state
|
||||
|
||||
# NOW we can convert the actual book data
|
||||
initial_type_hooks: TypeHooks = {
|
||||
LocalizedStr: i18n.localize,
|
||||
FormatTree: lambda s: _format(s, i18n, data["macros"]),
|
||||
}
|
||||
config = TypedConfig(type_hooks=_DEFAULT_TYPE_HOOKS | initial_type_hooks)
|
||||
return from_dict_checked(cls, data, config, path)
|
||||
return from_dict_checked(cls, data, state.config, path)
|
||||
|
||||
def __post_init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
# type hooks which need a Book instance
|
||||
# Dacite fails to type-check TypeHooks, so this CANNOT be a dataclass field
|
||||
self.type_hooks: TypeHooks = _DEFAULT_TYPE_HOOKS | {
|
||||
LocalizedStr: self.i18n.localize,
|
||||
LocalizedItem: self.i18n.localize_item,
|
||||
FormatTree: self.format,
|
||||
**patchouli.page.make_page_hooks(self),
|
||||
**{cls: cls.make_type_hook(self) for cls in get_union_types(Recipe)},
|
||||
}
|
||||
@classmethod
|
||||
def _init_state(
|
||||
cls,
|
||||
data: dict[str, Any],
|
||||
props: Properties,
|
||||
i18n: I18n,
|
||||
macros: dict[str, str],
|
||||
type_hooks: TypeHooks[Any],
|
||||
stateful_unions: StatefulUnions[Any],
|
||||
) -> BookState:
|
||||
"""Constructs the shared state object for this book.
|
||||
|
||||
# best name ever tbh
|
||||
self.__post_init_pre_categories__(*args, **kwargs)
|
||||
You can add Page or Recipe types here, to `stateful_unions`.
|
||||
|
||||
Subclasses need not call super if they're instantiating a subclass of BookState.
|
||||
"""
|
||||
return BookState(props, i18n, macros, type_hooks, stateful_unions)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Loads categories and entries."""
|
||||
# categories
|
||||
self.categories = patchouli.Category.load_all(self)
|
||||
self.categories = Category.load_all(self.state)
|
||||
|
||||
# entries
|
||||
# must be after categories, since Entry uses book.categories to get the parent
|
||||
for path in self.entries_dir.rglob("*.json"):
|
||||
# i used the entry to insert the entry
|
||||
# pretty sure thanos said that
|
||||
entry = patchouli.Entry.load(path, self)
|
||||
entry.category.entries.append(entry)
|
||||
for path in self.props.entries_dir.rglob("*.json"):
|
||||
# i used the entry to insert the entry (pretty sure thanos said that)
|
||||
entry = Entry.load(path, self.state)
|
||||
self.categories[entry.category_id].entries.append(entry)
|
||||
|
||||
# we inserted a bunch of entries in no particular order, so sort each category
|
||||
for category in self.categories.values():
|
||||
category.entries.sort()
|
||||
|
||||
def __post_init_pre_categories__(self) -> None:
|
||||
"""Subclasses may override this method to run code just before categories are
|
||||
loaded and deserialized.
|
||||
|
||||
Type hooks are initialized before this, so you can add more here if needed.
|
||||
"""
|
||||
|
||||
@property
|
||||
def _dir_with_lang(self) -> Path:
|
||||
return self.props.book_dir / self.props.i18n.lang
|
||||
|
||||
@property
|
||||
def categories_dir(self) -> Path:
|
||||
return self._dir_with_lang / "categories"
|
||||
|
||||
@property
|
||||
def entries_dir(self) -> Path:
|
||||
return self._dir_with_lang / "entries"
|
||||
|
||||
@property
|
||||
def templates_dir(self) -> Path:
|
||||
return self._dir_with_lang / "templates"
|
||||
|
||||
def format(
|
||||
self,
|
||||
text: str | LocalizedStr,
|
||||
skip_errors: bool = False,
|
||||
) -> FormatTree:
|
||||
"""Converts the given string into a FormatTree, localizing it if necessary."""
|
||||
return _format(text, self.i18n, self.macros)
|
||||
|
||||
def config(self, extra_type_hooks: TypeHooks = {}) -> TypedConfig:
|
||||
"""Creates a Dacite config with strict mode and some preset type hooks.
|
||||
|
||||
If passed, extra_type_hooks will be merged with the default hooks. In case of
|
||||
conflict, extra_type_hooks will be preferred.
|
||||
"""
|
||||
return TypedConfig(type_hooks={**self.type_hooks, **extra_type_hooks})
|
||||
|
||||
|
||||
class BookHelpers(ABC):
|
||||
"""Shortcuts for types with a book field."""
|
||||
|
||||
book: Book | IProperty[Book]
|
||||
|
||||
@property
|
||||
def props(self):
|
||||
return self.book.props
|
||||
|
||||
@property
|
||||
def i18n(self):
|
||||
return self.book.i18n
|
||||
def index_icon(self) -> ResourceLocation:
|
||||
# default value as defined by patchouli, apparently
|
||||
return self.model if self._index_icon is None else self._index_icon
|
||||
|
|
|
@ -1,33 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Self
|
||||
|
||||
import patchouli
|
||||
from common.deserialize import from_dict_checked, load_json_data, rename
|
||||
from common.deserialize import rename
|
||||
from common.formatting import FormatTree
|
||||
from common.state import BookState, StatefulFile
|
||||
from common.types import LocalizedStr, Sortable, sorted_dict
|
||||
from minecraft.resource import ItemStack, ResourceLocation
|
||||
|
||||
from .entry import Entry
|
||||
|
||||
|
||||
@dataclass
|
||||
class Category(Sortable, patchouli.BookHelpers):
|
||||
class Category(StatefulFile[BookState], Sortable):
|
||||
"""Category with pages and localizations.
|
||||
|
||||
See: https://vazkiimods.github.io/Patchouli/docs/reference/category-json
|
||||
"""
|
||||
|
||||
# non-json fields
|
||||
path: Path
|
||||
book: patchouli.Book
|
||||
|
||||
# required (category.json)
|
||||
# required
|
||||
name: LocalizedStr
|
||||
description: FormatTree
|
||||
icon: ItemStack
|
||||
|
||||
# optional (category.json)
|
||||
# optional
|
||||
_parent_id: ResourceLocation | None = field(default=None, metadata=rename("parent"))
|
||||
parent: Category | None = field(default=None, init=False)
|
||||
flag: str | None = None
|
||||
|
@ -35,21 +32,15 @@ class Category(Sortable, patchouli.BookHelpers):
|
|||
secret: bool = False
|
||||
|
||||
def __post_init__(self):
|
||||
self.entries: list[patchouli.Entry] = []
|
||||
self.entries: list[Entry] = []
|
||||
|
||||
@classmethod
|
||||
def _load(cls, path: Path, book: patchouli.Book) -> Self:
|
||||
# load the raw data from json, and add our extra fields
|
||||
data = load_json_data(cls, path, {"path": path, "book": book})
|
||||
return from_dict_checked(cls, data, book.config(), path)
|
||||
|
||||
@classmethod
|
||||
def load_all(cls, book: patchouli.Book):
|
||||
def load_all(cls, state: BookState):
|
||||
categories: dict[ResourceLocation, Self] = {}
|
||||
|
||||
# load
|
||||
for path in book.categories_dir.rglob("*.json"):
|
||||
category = cls._load(path, book)
|
||||
for path in state.props.categories_dir.rglob("*.json"):
|
||||
category = cls.load(path, state)
|
||||
categories[category.id] = category
|
||||
|
||||
# late-init parent
|
||||
|
@ -64,7 +55,7 @@ class Category(Sortable, patchouli.BookHelpers):
|
|||
def id(self) -> ResourceLocation:
|
||||
return ResourceLocation.from_file(
|
||||
self.props.modid,
|
||||
self.book.categories_dir,
|
||||
self.props.categories_dir,
|
||||
self.path,
|
||||
)
|
||||
|
||||
|
|
|
@ -1,31 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Self
|
||||
|
||||
import patchouli
|
||||
from common.deserialize import from_dict_checked, load_json_data, rename
|
||||
from common.deserialize import rename
|
||||
from common.state import BookState, StatefulFile
|
||||
from common.types import Color, LocalizedStr, Sortable
|
||||
from minecraft.resource import ItemStack, ResourceLocation
|
||||
|
||||
from .page import Page
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entry(Sortable, patchouli.BookHelpers):
|
||||
class Entry(StatefulFile[BookState], Sortable):
|
||||
"""Entry json file, with pages and localizations.
|
||||
|
||||
See: https://vazkiimods.github.io/Patchouli/docs/reference/entry-json
|
||||
"""
|
||||
|
||||
# non-json fields
|
||||
path: Path
|
||||
category: patchouli.Category = field(init=False)
|
||||
|
||||
# required (entry.json)
|
||||
name: LocalizedStr
|
||||
_category_id: ResourceLocation = field(metadata=rename("category"))
|
||||
category_id: ResourceLocation = field(metadata=rename("category"))
|
||||
icon: ItemStack
|
||||
pages: list[patchouli.Page]
|
||||
pages: list[Page[BookState]]
|
||||
|
||||
# optional (entry.json)
|
||||
advancement: ResourceLocation | None = None
|
||||
|
@ -38,24 +34,10 @@ class Entry(Sortable, patchouli.BookHelpers):
|
|||
extra_recipe_mappings: dict[ItemStack, int] | None = None
|
||||
entry_color: Color | None = None # this is undocumented lmao
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path, book: patchouli.Book) -> Self:
|
||||
# load and convert the raw data from json
|
||||
data = load_json_data(cls, path, {"path": path})
|
||||
entry = from_dict_checked(cls, data, book.config(), path)
|
||||
|
||||
# now that it's been type hooked, use the id to get the category
|
||||
entry.category = book.categories[entry._category_id]
|
||||
return entry
|
||||
|
||||
@property
|
||||
def book(self) -> patchouli.Book:
|
||||
return self.category.book
|
||||
|
||||
@property
|
||||
def id(self) -> ResourceLocation:
|
||||
return ResourceLocation.from_file(
|
||||
self.props.modid, self.book.entries_dir, self.path
|
||||
self.props.modid, self.props.entries_dir, self.path
|
||||
)
|
||||
|
||||
@property
|
||||
|
|
|
@ -1,45 +1,32 @@
|
|||
import patchouli
|
||||
from common.deserialize import TypeHooks
|
||||
from common.tagged_union import get_union_types
|
||||
__all__ = [
|
||||
"Page",
|
||||
"PageWithCraftingRecipes",
|
||||
"PageWithText",
|
||||
"PageWithTitle",
|
||||
"CraftingPage",
|
||||
"EmptyPage",
|
||||
"EntityPage",
|
||||
"ImagePage",
|
||||
"LinkPage",
|
||||
"MultiblockPage",
|
||||
"QuestPage",
|
||||
"RelationsPage",
|
||||
"SmeltingPage",
|
||||
"SpotlightPage",
|
||||
"TextPage",
|
||||
]
|
||||
|
||||
from .abstract import *
|
||||
from .concrete import *
|
||||
from .hexcasting import *
|
||||
|
||||
Page = (
|
||||
TextPage
|
||||
| ImagePage
|
||||
| CraftingPage
|
||||
| SmeltingPage
|
||||
| MultiblockPage
|
||||
| EntityPage
|
||||
| SpotlightPage
|
||||
| LinkPage
|
||||
| RelationsPage
|
||||
| QuestPage
|
||||
| EmptyPage
|
||||
| PatternPage
|
||||
| ManualPatternNosigPage
|
||||
| ManualOpPatternPage
|
||||
| ManualRawPatternPage
|
||||
| CraftingMultiPage
|
||||
| BrainsweepPage
|
||||
from .abstract import Page, PageWithCraftingRecipes, PageWithText, PageWithTitle
|
||||
from .concrete import (
|
||||
CraftingPage,
|
||||
EmptyPage,
|
||||
EntityPage,
|
||||
ImagePage,
|
||||
LinkPage,
|
||||
MultiblockPage,
|
||||
QuestPage,
|
||||
RelationsPage,
|
||||
SmeltingPage,
|
||||
SpotlightPage,
|
||||
TextPage,
|
||||
)
|
||||
|
||||
|
||||
def _raw_page_hook(data: dict[str, Any] | str) -> dict[str, Any]:
|
||||
if isinstance(data, str):
|
||||
# special case, thanks patchouli
|
||||
return {"type": "patchouli:text", "text": data}
|
||||
return data
|
||||
|
||||
|
||||
def make_page_hooks(book: patchouli.Book) -> TypeHooks:
|
||||
"""Creates type hooks for deserializing Page types."""
|
||||
|
||||
type_hooks: TypeHooks = {Page: _raw_page_hook}
|
||||
|
||||
for cls_ in get_union_types(Page):
|
||||
type_hooks[cls_] = cls_.make_type_hook(book)
|
||||
|
||||
return type_hooks
|
||||
|
|
|
@ -1,55 +1,56 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Self
|
||||
from typing import Any, Self
|
||||
|
||||
import patchouli
|
||||
from common.deserialize import TypeFn, rename
|
||||
from common.deserialize import TypeHook, rename
|
||||
from common.formatting import FormatTree
|
||||
from common.pattern import RawPatternInfo
|
||||
from common.tagged_union import InternallyTaggedUnion
|
||||
from common.state import AnyState, StatefulInternallyTaggedUnion
|
||||
from common.types import LocalizedStr
|
||||
from minecraft.recipe import CraftingRecipe
|
||||
from minecraft.recipe.concrete import CraftingRecipe
|
||||
from minecraft.resource import ResourceLocation
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class BasePage(InternallyTaggedUnion, patchouli.BookHelpers, tag="type", value=None):
|
||||
"""Fields shared by all Page types.
|
||||
class Page(StatefulInternallyTaggedUnion[AnyState], tag="type", value=None):
|
||||
"""Base class for Patchouli page types.
|
||||
|
||||
See: https://vazkiimods.github.io/Patchouli/docs/patchouli-basics/page-types
|
||||
"""
|
||||
|
||||
book: patchouli.Book
|
||||
|
||||
type: ResourceLocation = field(init=False)
|
||||
advancement: ResourceLocation | None = None
|
||||
flag: str | None = None
|
||||
anchor: str | None = None
|
||||
|
||||
def __init_subclass__(cls, type: str | None) -> None:
|
||||
super().__init_subclass__(__class__._tag_name, type)
|
||||
super().__init_subclass__("type", type)
|
||||
if type is not None:
|
||||
cls.type = ResourceLocation.from_str(type)
|
||||
|
||||
@classmethod
|
||||
def make_type_hook(cls, book: patchouli.Book) -> TypeFn:
|
||||
def type_hook(data: Self | dict[str, Any]) -> Self | dict[str, Any]:
|
||||
if isinstance(data, cls):
|
||||
return data
|
||||
data = cls.assert_tag(data)
|
||||
data["book"] = book
|
||||
return data
|
||||
def make_type_hook(cls, state: AnyState) -> TypeHook[Self]:
|
||||
super_hook = super().make_type_hook(state)
|
||||
|
||||
def type_hook(data: Self | Any) -> Self | dict[str, Any]:
|
||||
# special case, thanks patchouli
|
||||
if isinstance(data, str):
|
||||
data = {"type": "patchouli:text", "text": data}
|
||||
return super_hook(data)
|
||||
|
||||
return type_hook
|
||||
|
||||
@property
|
||||
def _tag_value(self) -> str:
|
||||
return str(self.type)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class PageWithText(BasePage, type=None):
|
||||
class PageWithText(Page[AnyState], type=None):
|
||||
text: FormatTree | None = None
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class PageWithTitle(PageWithText, type=None):
|
||||
class PageWithTitle(PageWithText[AnyState], type=None):
|
||||
_title: LocalizedStr | None = field(default=None, metadata=rename("title"))
|
||||
|
||||
@property
|
||||
|
@ -58,54 +59,8 @@ class PageWithTitle(PageWithText, type=None):
|
|||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class PageWithCraftingRecipes(PageWithText, ABC, type=None):
|
||||
class PageWithCraftingRecipes(PageWithText[AnyState], ABC, type=None):
|
||||
@property
|
||||
@abstractmethod
|
||||
def recipes(self) -> list[CraftingRecipe]:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class PageWithPattern(PageWithTitle, ABC, type=None):
|
||||
book: patchouli.HexBook
|
||||
|
||||
patterns: list[RawPatternInfo]
|
||||
op_id: ResourceLocation | None = None
|
||||
header: LocalizedStr | None = None
|
||||
input: str | None = None
|
||||
output: str | None = None
|
||||
hex_size: int | None = None
|
||||
|
||||
_title: None = None
|
||||
|
||||
@classmethod
|
||||
def make_type_hook(cls, book: patchouli.Book) -> TypeFn:
|
||||
super_hook = super().make_type_hook(book)
|
||||
|
||||
def type_hook(data: dict[str, Any]) -> dict[str, Any]:
|
||||
# convert a single pattern to a list
|
||||
data = super_hook(data)
|
||||
patterns = data.get("patterns")
|
||||
if patterns is not None and not isinstance(patterns, list):
|
||||
data["patterns"] = [patterns]
|
||||
return data
|
||||
|
||||
return type_hook
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> LocalizedStr:
|
||||
...
|
||||
|
||||
@property
|
||||
def args(self) -> str | None:
|
||||
inp = self.input or ""
|
||||
oup = self.output or ""
|
||||
if inp or oup:
|
||||
return f"{inp} \u2192 {oup}".strip()
|
||||
return None
|
||||
|
||||
@property
|
||||
def title(self) -> LocalizedStr:
|
||||
suffix = f" ({self.args})" if self.args else ""
|
||||
return LocalizedStr(self.name + suffix)
|
||||
|
|
|
@ -5,26 +5,27 @@ from typing import Any
|
|||
|
||||
from common.deserialize import rename
|
||||
from common.formatting import FormatTree
|
||||
from common.state import BookState
|
||||
from common.types import LocalizedItem, LocalizedStr
|
||||
from minecraft.recipe import CraftingRecipe
|
||||
from minecraft.resource import Entity, ItemStack, ResourceLocation
|
||||
|
||||
from .abstract import BasePage, PageWithCraftingRecipes, PageWithText, PageWithTitle
|
||||
from .abstract import Page, PageWithCraftingRecipes, PageWithText, PageWithTitle
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class TextPage(PageWithTitle, type="patchouli:text"):
|
||||
class TextPage(PageWithTitle[BookState], type="patchouli:text"):
|
||||
text: FormatTree
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImagePage(PageWithTitle, type="patchouli:image"):
|
||||
class ImagePage(PageWithTitle[BookState], type="patchouli:image"):
|
||||
images: list[ResourceLocation]
|
||||
border: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CraftingPage(PageWithCraftingRecipes, type="patchouli:crafting"):
|
||||
class CraftingPage(PageWithCraftingRecipes[BookState], type="patchouli:crafting"):
|
||||
recipe: CraftingRecipe
|
||||
recipe2: CraftingRecipe | None = None
|
||||
|
||||
|
@ -38,13 +39,13 @@ class CraftingPage(PageWithCraftingRecipes, type="patchouli:crafting"):
|
|||
|
||||
# TODO: this should probably inherit PageWithRecipes too
|
||||
@dataclass
|
||||
class SmeltingPage(PageWithTitle, type="patchouli:smelting"):
|
||||
class SmeltingPage(PageWithTitle[BookState], type="patchouli:smelting"):
|
||||
recipe: ItemStack
|
||||
recipe2: ItemStack | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MultiblockPage(PageWithText, type="patchouli:multiblock"):
|
||||
class MultiblockPage(PageWithText[BookState], type="patchouli:multiblock"):
|
||||
name: LocalizedStr
|
||||
multiblock_id: ResourceLocation | None = None
|
||||
# TODO: https://vazkiimods.github.io/Patchouli/docs/patchouli-basics/multiblocks/
|
||||
|
@ -58,7 +59,7 @@ class MultiblockPage(PageWithText, type="patchouli:multiblock"):
|
|||
|
||||
|
||||
@dataclass
|
||||
class EntityPage(PageWithText, type="patchouli:entity"):
|
||||
class EntityPage(PageWithText[BookState], type="patchouli:entity"):
|
||||
entity: Entity
|
||||
scale: float = 1
|
||||
offset: float = 0
|
||||
|
@ -68,7 +69,7 @@ class EntityPage(PageWithText, type="patchouli:entity"):
|
|||
|
||||
|
||||
@dataclass
|
||||
class SpotlightPage(PageWithTitle, type="patchouli:spotlight"):
|
||||
class SpotlightPage(PageWithTitle[BookState], type="patchouli:spotlight"):
|
||||
item: LocalizedItem # TODO: patchi says this is an ItemStack, so this might break
|
||||
link_recipe: bool = False
|
||||
|
||||
|
@ -80,7 +81,7 @@ class LinkPage(TextPage, type="patchouli:link"):
|
|||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class RelationsPage(PageWithTitle, type="patchouli:relations"):
|
||||
class RelationsPage(PageWithTitle[BookState], type="patchouli:relations"):
|
||||
entries: list[ResourceLocation]
|
||||
_title: LocalizedStr = field(
|
||||
default=LocalizedStr("Related Chapters"), metadata=rename("title")
|
||||
|
@ -88,7 +89,7 @@ class RelationsPage(PageWithTitle, type="patchouli:relations"):
|
|||
|
||||
|
||||
@dataclass
|
||||
class QuestPage(PageWithTitle, type="patchouli:quest"):
|
||||
class QuestPage(PageWithTitle[BookState], type="patchouli:quest"):
|
||||
trigger: ResourceLocation | None = None
|
||||
_title: LocalizedStr = field(
|
||||
default=LocalizedStr("Objective"), metadata=rename("title")
|
||||
|
@ -96,5 +97,5 @@ class QuestPage(PageWithTitle, type="patchouli:quest"):
|
|||
|
||||
|
||||
@dataclass
|
||||
class EmptyPage(BasePage, type="patchouli:empty"):
|
||||
class EmptyPage(Page[BookState], type="patchouli:empty"):
|
||||
draw_filler: bool = True
|
||||
|
|
|
@ -1,72 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from common.deserialize import rename
|
||||
from common.pattern import RawPatternInfo
|
||||
from common.types import LocalizedStr
|
||||
from minecraft.recipe import BrainsweepRecipe, CraftingRecipe
|
||||
from minecraft.resource import ResourceLocation
|
||||
|
||||
from .abstract import PageWithCraftingRecipes, PageWithPattern, PageWithText
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatternPage(PageWithPattern, type="hexcasting:pattern"):
|
||||
patterns: list[RawPatternInfo] = field(init=False)
|
||||
op_id: ResourceLocation
|
||||
header: None
|
||||
|
||||
def __post_init__(self):
|
||||
self.patterns = [self.book.patterns[self.op_id]]
|
||||
|
||||
@property
|
||||
def name(self) -> LocalizedStr:
|
||||
return self.i18n.localize_pattern(self.op_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManualPatternNosigPage(PageWithPattern, type="hexcasting:manual_pattern_nosig"):
|
||||
header: LocalizedStr
|
||||
op_id: None
|
||||
input: None
|
||||
output: None
|
||||
|
||||
@property
|
||||
def name(self) -> LocalizedStr:
|
||||
return self.header
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManualOpPatternPage(PageWithPattern, type="hexcasting:manual_pattern"):
|
||||
op_id: ResourceLocation
|
||||
header: None
|
||||
|
||||
@property
|
||||
def name(self) -> LocalizedStr:
|
||||
return self.i18n.localize_pattern(self.op_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManualRawPatternPage(PageWithPattern, type="hexcasting:manual_pattern"):
|
||||
op_id: None
|
||||
header: LocalizedStr
|
||||
|
||||
@property
|
||||
def name(self) -> LocalizedStr:
|
||||
return self.header
|
||||
|
||||
|
||||
@dataclass
|
||||
class CraftingMultiPage(PageWithCraftingRecipes, type="hexcasting:crafting_multi"):
|
||||
heading: LocalizedStr # ...heading?
|
||||
_recipes: list[CraftingRecipe] = field(metadata=rename("recipes"))
|
||||
|
||||
@property
|
||||
def recipes(self) -> list[CraftingRecipe]:
|
||||
return self._recipes
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrainsweepPage(PageWithText, type="hexcasting:brainsweep"):
|
||||
recipe: BrainsweepRecipe
|
|
@ -1,7 +1,6 @@
|
|||
# pyright: reportPrivateUsage=false
|
||||
from common.formatting import FormatTree, Style
|
||||
from common.formatting import DEFAULT_MACROS, FormatTree, Style
|
||||
from common.types import LocalizedStr
|
||||
from patchouli.book import _DEFAULT_MACROS
|
||||
|
||||
|
||||
def test_format_string():
|
||||
|
@ -9,7 +8,7 @@ def test_format_string():
|
|||
test_str = "Write the given iota to my $(l:patterns/readwrite#hexcasting:write/local)$(#490)local$().$(br)The $(l:patterns/readwrite#hexcasting:write/local)$(#490)local$() is a lot like a $(l:items/focus)$(#b0b)Focus$(). It's cleared when I stop casting a Hex, starts with $(l:casting/influences)$(#490)Null$() in it, and is preserved between casts of $(l:patterns/meta#hexcasting:for_each)$(#fc77be)Thoth's Gambit$(). "
|
||||
|
||||
# act
|
||||
tree = FormatTree.format(_DEFAULT_MACROS, LocalizedStr(test_str))
|
||||
tree = FormatTree.format(DEFAULT_MACROS, LocalizedStr(test_str))
|
||||
|
||||
# assert
|
||||
# TODO: possibly make this less lazy
|
||||
|
|
|
@ -13,8 +13,8 @@ from syrupy.types import SerializedData
|
|||
from common.formatting import FormatTree
|
||||
from common.properties import Properties
|
||||
from common.types import LocalizedStr
|
||||
from hexcasting.hex_book import HexBook
|
||||
from main import Args, main
|
||||
from patchouli.hex_book import HexBook
|
||||
|
||||
|
||||
def prettify(data: SerializedData) -> str:
|
||||
|
|
Loading…
Reference in a new issue