Use BookState to break all the dependency cycles

This commit is contained in:
object-Object 2023-06-25 15:40:39 -04:00
parent b2fd0bf094
commit c9ba636309
36 changed files with 928 additions and 589 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

View 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

View file

@ -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.

View file

@ -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] = []

View file

@ -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
View 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)
}

View file

@ -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)
}

View file

@ -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]

View file

@ -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)

View file

View 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)

View 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

View 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

View file

@ -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):

View file

@ -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")

View file

View 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:

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: