From c9ba636309af8230512b9ab3764823a0063e2eab Mon Sep 17 00:00:00 2001 From: object-Object Date: Sun, 25 Jun 2023 15:40:39 -0400 Subject: [PATCH] Use BookState to break all the dependency cycles --- doc/requirements-dev.txt | 10 +- doc/requirements.txt | 4 +- doc/src/collate_data.py | 27 +- doc/src/common/__init__.py | 0 doc/src/common/dacite_patch.py | 94 ++++++- doc/src/common/deserialize.py | 63 ++--- doc/src/common/formatting.py | 22 +- doc/src/common/properties.py | 12 + doc/src/common/state.py | 163 +++++++++++++ doc/src/common/tagged_union.py | 124 ++++++++-- doc/src/common/toml_placeholders.py | 27 +- doc/src/common/types.py | 58 ++++- doc/src/hexcasting/__init__.py | 0 doc/src/hexcasting/hex_book.py | 57 +++++ doc/src/hexcasting/hex_pages.py | 118 +++++++++ doc/src/hexcasting/hex_recipes.py | 35 +++ .../hex_book.py => hexcasting/hex_state.py} | 11 +- doc/src/main.py | 2 +- doc/src/minecraft/__init__.py | 0 doc/src/minecraft/i18n.py | 32 +-- doc/src/minecraft/recipe/__init__.py | 19 +- doc/src/minecraft/recipe/abstract.py | 27 +- doc/src/minecraft/recipe/concrete.py | 21 +- doc/src/minecraft/recipe/ingredient.py | 15 +- doc/src/minecraft/recipe/result.py | 6 - doc/src/minecraft/resource.py | 4 +- doc/src/patchouli/__init__.py | 5 +- doc/src/patchouli/book.py | 230 +++++++----------- doc/src/patchouli/category.py | 33 +-- doc/src/patchouli/entry.py | 34 +-- doc/src/patchouli/page/__init__.py | 73 +++--- doc/src/patchouli/page/abstract.py | 89 ++----- doc/src/patchouli/page/concrete.py | 23 +- doc/src/patchouli/page/hexcasting.py | 72 ------ doc/test/common/test_formatting.py | 5 +- doc/test/test_snapshots.py | 2 +- 36 files changed, 928 insertions(+), 589 deletions(-) create mode 100644 doc/src/common/__init__.py create mode 100644 doc/src/common/state.py create mode 100644 doc/src/hexcasting/__init__.py create mode 100644 doc/src/hexcasting/hex_book.py create mode 100644 doc/src/hexcasting/hex_pages.py create mode 100644 doc/src/hexcasting/hex_recipes.py rename doc/src/{patchouli/hex_book.py => hexcasting/hex_state.py} (72%) create mode 100644 doc/src/minecraft/__init__.py delete mode 100644 doc/src/patchouli/page/hexcasting.py diff --git a/doc/requirements-dev.txt b/doc/requirements-dev.txt index b2c68bd3..f8591527 100644 --- a/doc/requirements-dev.txt +++ b/doc/requirements-dev.txt @@ -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 diff --git a/doc/requirements.txt b/doc/requirements.txt index 091dce49..eb3c4fc9 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -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 diff --git a/doc/src/collate_data.py b/doc/src/collate_data.py index c7950070..0df3fb1f 100644 --- a/doc/src/collate_data.py +++ b/doc/src/collate_data.py @@ -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) diff --git a/doc/src/common/__init__.py b/doc/src/common/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/doc/src/common/dacite_patch.py b/doc/src/common/dacite_patch.py index ddf031eb..3bfee59d 100644 --- a/doc/src/common/dacite_patch.py +++ b/doc/src/common/dacite_patch.py @@ -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 diff --git a/doc/src/common/deserialize.py b/doc/src/common/deserialize.py index 7b5a1cd6..d3bd8465 100644 --- a/doc/src/common/deserialize.py +++ b/doc/src/common/deserialize.py @@ -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. diff --git a/doc/src/common/formatting.py b/doc/src/common/formatting.py index 0b7d6966..978913bc 100644 --- a/doc/src/common/formatting.py +++ b/doc/src/common/formatting.py @@ -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)", + "$(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] = [] diff --git a/doc/src/common/properties.py b/doc/src/common/properties.py index 6499c0c1..83458f56 100644 --- a/doc/src/common/properties.py +++ b/doc/src/common/properties.py @@ -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] diff --git a/doc/src/common/state.py b/doc/src/common/state.py new file mode 100644 index 00000000..28a86070 --- /dev/null +++ b/doc/src/common/state.py @@ -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) + } diff --git a/doc/src/common/tagged_union.py b/doc/src/common/tagged_union.py index 448c716e..9a69324a 100644 --- a/doc/src/common/tagged_union.py +++ b/doc/src/common/tagged_union.py @@ -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) + } diff --git a/doc/src/common/toml_placeholders.py b/doc/src/common/toml_placeholders.py index 124540db..bd520ca5 100644 --- a/doc/src/common/toml_placeholders.py +++ b/doc/src/common/toml_placeholders.py @@ -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] diff --git a/doc/src/common/types.py b/doc/src/common/types.py index 9a1dae01..63099c54 100644 --- a/doc/src/common/types.py +++ b/doc/src/common/types.py @@ -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) diff --git a/doc/src/hexcasting/__init__.py b/doc/src/hexcasting/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/doc/src/hexcasting/hex_book.py b/doc/src/hexcasting/hex_book.py new file mode 100644 index 00000000..6c27351f --- /dev/null +++ b/doc/src/hexcasting/hex_book.py @@ -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) diff --git a/doc/src/hexcasting/hex_pages.py b/doc/src/hexcasting/hex_pages.py new file mode 100644 index 00000000..ff9f5938 --- /dev/null +++ b/doc/src/hexcasting/hex_pages.py @@ -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 diff --git a/doc/src/hexcasting/hex_recipes.py b/doc/src/hexcasting/hex_recipes.py new file mode 100644 index 00000000..2d71e25e --- /dev/null +++ b/doc/src/hexcasting/hex_recipes.py @@ -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 diff --git a/doc/src/patchouli/hex_book.py b/doc/src/hexcasting/hex_state.py similarity index 72% rename from doc/src/patchouli/hex_book.py rename to doc/src/hexcasting/hex_state.py index 7e73e1e9..9a9bfd3a 100644 --- a/doc/src/patchouli/hex_book.py +++ b/doc/src/hexcasting/hex_state.py @@ -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): diff --git a/doc/src/main.py b/doc/src/main.py index 298fe049..57e1ffea 100644 --- a/doc/src/main.py +++ b/doc/src/main.py @@ -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") diff --git a/doc/src/minecraft/__init__.py b/doc/src/minecraft/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/doc/src/minecraft/i18n.py b/doc/src/minecraft/i18n.py index 911afd8b..99008f8e 100644 --- a/doc/src/minecraft/i18n.py +++ b/doc/src/minecraft/i18n.py @@ -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: diff --git a/doc/src/minecraft/recipe/__init__.py b/doc/src/minecraft/recipe/__init__.py index f8ceab4a..5ed3a45a 100644 --- a/doc/src/minecraft/recipe/__init__.py +++ b/doc/src/minecraft/recipe/__init__.py @@ -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 diff --git a/doc/src/minecraft/recipe/abstract.py b/doc/src/minecraft/recipe/abstract.py index cdce6639..b33b5717 100644 --- a/doc/src/minecraft/recipe/abstract.py +++ b/doc/src/minecraft/recipe/abstract.py @@ -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) diff --git a/doc/src/minecraft/recipe/concrete.py b/doc/src/minecraft/recipe/concrete.py index 1b8e40d8..ebb02b35 100644 --- a/doc/src/minecraft/recipe/concrete.py +++ b/doc/src/minecraft/recipe/concrete.py @@ -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 diff --git a/doc/src/minecraft/recipe/ingredient.py b/doc/src/minecraft/recipe/ingredient.py index 46b4324c..c9bbf956 100644 --- a/doc/src/minecraft/recipe/ingredient.py +++ b/doc/src/minecraft/recipe/ingredient.py @@ -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 diff --git a/doc/src/minecraft/recipe/result.py b/doc/src/minecraft/recipe/result.py index 88251c49..dcf4410f 100644 --- a/doc/src/minecraft/recipe/result.py +++ b/doc/src/minecraft/recipe/result.py @@ -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 diff --git a/doc/src/minecraft/resource.py b/doc/src/minecraft/resource.py index acc74f01..140c88bb 100644 --- a/doc/src/minecraft/resource.py +++ b/doc/src/minecraft/resource.py @@ -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: diff --git a/doc/src/patchouli/__init__.py b/doc/src/patchouli/__init__.py index bdf32343..93a66912 100644 --- a/doc/src/patchouli/__init__.py +++ b/doc/src/patchouli/__init__.py @@ -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 diff --git a/doc/src/patchouli/book.py b/doc/src/patchouli/book.py index 0d49b9aa..47891497 100644 --- a/doc/src/patchouli/book.py +++ b/doc/src/patchouli/book.py @@ -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)", - "$(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 diff --git a/doc/src/patchouli/category.py b/doc/src/patchouli/category.py index 92f0438b..6684233f 100644 --- a/doc/src/patchouli/category.py +++ b/doc/src/patchouli/category.py @@ -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, ) diff --git a/doc/src/patchouli/entry.py b/doc/src/patchouli/entry.py index 1f2e3e3a..0d876538 100644 --- a/doc/src/patchouli/entry.py +++ b/doc/src/patchouli/entry.py @@ -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 diff --git a/doc/src/patchouli/page/__init__.py b/doc/src/patchouli/page/__init__.py index cd9448c9..b8120b82 100644 --- a/doc/src/patchouli/page/__init__.py +++ b/doc/src/patchouli/page/__init__.py @@ -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 diff --git a/doc/src/patchouli/page/abstract.py b/doc/src/patchouli/page/abstract.py index 986a20f8..a1448211 100644 --- a/doc/src/patchouli/page/abstract.py +++ b/doc/src/patchouli/page/abstract.py @@ -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) diff --git a/doc/src/patchouli/page/concrete.py b/doc/src/patchouli/page/concrete.py index 7b52d042..543efae3 100644 --- a/doc/src/patchouli/page/concrete.py +++ b/doc/src/patchouli/page/concrete.py @@ -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 diff --git a/doc/src/patchouli/page/hexcasting.py b/doc/src/patchouli/page/hexcasting.py deleted file mode 100644 index 5e1446e9..00000000 --- a/doc/src/patchouli/page/hexcasting.py +++ /dev/null @@ -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 diff --git a/doc/test/common/test_formatting.py b/doc/test/common/test_formatting.py index 867db321..bc2f2df9 100644 --- a/doc/test/common/test_formatting.py +++ b/doc/test/common/test_formatting.py @@ -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 diff --git a/doc/test/test_snapshots.py b/doc/test/test_snapshots.py index 1cc4d7ba..b8d26e33 100644 --- a/doc/test/test_snapshots.py +++ b/doc/test/test_snapshots.py @@ -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: