Edit on GitHub

hexdoc.patchouli

 1__all__ = [
 2    "Book",
 3    "BookContext",
 4    "Category",
 5    "Entry",
 6    "FormatTree",
 7    "FormattingContext",
 8    "page",
 9]
10
11from . import page
12from .book import Book
13from .book_context import BookContext
14from .category import Category
15from .entry import Entry
16from .text import FormattingContext, FormatTree
class Book(hexdoc.model.base.HexdocModel):
 24class Book(HexdocModel):
 25    """Main Patchouli book class.
 26
 27    Includes all data from book.json, categories/entries/pages, and i18n.
 28
 29    You should probably not use this (or any other Patchouli types, eg. Category, Entry)
 30    to edit and re-serialize book.json, because this class sets all the default values
 31    as defined by the docs.
 32
 33    See: https://vazkiimods.github.io/Patchouli/docs/reference/book-json
 34    """
 35
 36    # not in book.json
 37    _categories: dict[ResourceLocation, Category] = PrivateAttr(default_factory=dict)
 38
 39    # required
 40    name: LocalizedStr
 41    landing_text: FormatTree
 42
 43    # required in 1.20 but optional in 1.19
 44    # so we'll make it optional and validate later
 45    use_resource_pack: AtLeast_1_20[Literal[True]] | Before_1_20[bool] = False
 46
 47    # optional
 48    book_texture: ResourceLocation = ResLoc("patchouli", "textures/gui/book_brown.png")
 49    filler_texture: ResourceLocation | None = None
 50    crafting_texture: ResourceLocation | None = None
 51    model: ResourceLocation = ResLoc("patchouli", "book_brown")
 52    text_color: Color = Color("000000")
 53    header_color: Color = Color("333333")
 54    nameplate_color: Color = Color("FFDD00")
 55    link_color: Color = Color("0000EE")
 56    link_hover_color: Color = Color("8800EE")
 57    progress_bar_color: Color = Color("FFFF55")
 58    progress_bar_background: Color = Color("DDDDDD")
 59    open_sound: ResourceLocation | None = None
 60    flip_sound: ResourceLocation | None = None
 61    index_icon: ResourceLocation | None = None
 62    pamphlet: bool = False
 63    show_progress: bool = True
 64    version: str | int = 0
 65    subtitle: LocalizedStr | None = None
 66    creative_tab: str | None = None
 67    advancements_tab: str | None = None
 68    dont_generate_book: bool = False
 69    custom_book_item: ItemStack | None = None
 70    show_toasts: bool = True
 71    use_blocky_font: bool = False
 72    i18n: bool = False
 73    macros: dict[str, str] = Field(default_factory=dict)
 74    pause_game: bool = False
 75    text_overflow_mode: Literal["overflow", "resize", "truncate"] | None = None
 76
 77    @property
 78    def categories(self):
 79        return self._categories
 80
 81    def _load_categories(self, context: ContextSource, book_ctx: BookContext):
 82        categories = Category.load_all(
 83            cast_context(context),
 84            book_ctx.book_id,
 85            self.use_resource_pack,
 86        )
 87
 88        if not categories:
 89            raise ValueError(
 90                "No categories found, are the paths in your properties file correct?"
 91            )
 92
 93        for id, category in categories.items():
 94            self._categories[id] = category
 95
 96            link_base = book_ctx.get_link_base(category.resource_dir)
 97            book_ctx.book_links[category.book_link_key] = link_base.with_fragment(
 98                category.fragment
 99            )
100
101    def _load_entries(
102        self,
103        context: ContextSource,
104        book_ctx: BookContext,
105        loader: ModResourceLoader,
106    ):
107        internal_entries = defaultdict[ResLoc, dict[ResLoc, Entry]](dict)
108        spoilered_categories = dict[ResLoc, bool]()
109
110        for resource_dir, id, data in loader.load_book_assets(
111            parent_book_id=book_ctx.book_id,
112            folder="entries",
113            use_resource_pack=self.use_resource_pack,
114        ):
115            entry = Entry.load(resource_dir, id, data, cast_context(context))
116
117            spoilered_categories[entry.category_id] = (
118                entry.is_spoiler and spoilered_categories.get(entry.category_id, True)
119            )
120
121            # i used the entry to insert the entry (pretty sure thanos said that)
122            if resource_dir.internal:
123                internal_entries[entry.category_id][entry.id] = entry
124
125            link_base = book_ctx.get_link_base(resource_dir)
126            book_ctx.book_links[entry.book_link_key] = link_base.with_fragment(
127                entry.fragment
128            )
129
130            for page in entry.pages:
131                page_key = page.book_link_key(entry.book_link_key)
132                page_fragment = page.fragment(entry.fragment)
133                if page_key is not None and page_fragment is not None:
134                    book_ctx.book_links[page_key] = link_base.with_fragment(
135                        page_fragment
136                    )
137
138        if not internal_entries:
139            raise ValueError(
140                f"No internal entries found for book {book_ctx.book_id}, is this the correct id?"
141            )
142
143        for category_id, new_entries in internal_entries.items():
144            category = self._categories[category_id]
145            category.entries = sorted_dict(category.entries | new_entries)
146            if is_spoiler := spoilered_categories.get(category.id):
147                category.is_spoiler = is_spoiler
148
149    @model_validator(mode="before")
150    @classmethod
151    def _pre_root(cls, data: dict[Any, Any] | Any):
152        if isinstance(data, dict) and "index_icon" not in data:
153            data["index_icon"] = data.get("model")
154        return data
155
156    @model_validator(mode="after")
157    def _post_root(self, info: ValidationInfo):
158        if not info.context:
159            return self
160
161        # make the macros accessible when rendering the template
162        self.macros |= FormattingContext.of(info).macros
163
164        return self

Main Patchouli book class.

Includes all data from book.json, categories/entries/pages, and i18n.

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.

See: https://vazkiimods.github.io/Patchouli/docs/reference/book-json

landing_text: FormatTree
use_resource_pack: Union[Annotated[Literal[True], IsVersion(version_spec='>=1.20', version_source=<class 'hexdoc.core.MinecraftVersion'>)], Annotated[bool, IsVersion(version_spec='<1.20', version_source=<class 'hexdoc.core.MinecraftVersion'>)]]
filler_texture: hexdoc.core.ResourceLocation | None
crafting_texture: hexdoc.core.ResourceLocation | None
text_color: hexdoc.model.Color
header_color: hexdoc.model.Color
nameplate_color: hexdoc.model.Color
progress_bar_color: hexdoc.model.Color
progress_bar_background: hexdoc.model.Color
open_sound: hexdoc.core.ResourceLocation | None
flip_sound: hexdoc.core.ResourceLocation | None
index_icon: hexdoc.core.ResourceLocation | None
pamphlet: bool
show_progress: bool
version: str | int
creative_tab: str | None
advancements_tab: str | None
dont_generate_book: bool
custom_book_item: hexdoc.core.ItemStack | None
show_toasts: bool
use_blocky_font: bool
i18n: bool
macros: dict[str, str]
pause_game: bool
text_overflow_mode: Optional[Literal['overflow', 'resize', 'truncate']]
categories
77    @property
78    def categories(self):
79        return self._categories
def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
337def init_private_attributes(self: BaseModel, context: Any, /) -> None:
338    """This function is meant to behave like a BaseModel method to initialise private attributes.
339
340    It takes context as an argument since that's what pydantic-core passes when calling it.
341
342    Args:
343        self: The BaseModel instance.
344        context: The context.
345    """
346    if getattr(self, '__pydantic_private__', None) is None:
347        pydantic_private = {}
348        for name, private_attr in self.__private_attributes__.items():
349            default = private_attr.get_default()
350            if default is not PydanticUndefined:
351                pydantic_private[name] = default
352        object_setattr(self, '__pydantic_private__', pydantic_private)

This function is meant to behave like a BaseModel method to initialise private attributes.

It takes context as an argument since that's what pydantic-core passes when calling it.

Args: self: The BaseModel instance. context: The context.

class BookContext(hexdoc.model.base.ValidationContextModel):
12class BookContext(ValidationContextModel):
13    modid: str
14    book_id: ResourceLocation
15    book_links: BookLinks = Field(default_factory=dict)
16    spoilered_advancements: set[ResourceLocation]
17    all_metadata: dict[str, HexdocMetadata]
18
19    def get_link_base(self, resource_dir: PathResourceDir) -> URL:
20        modid = resource_dir.modid
21        if resource_dir.internal or modid is None or modid == self.modid:
22            return URL()
23
24        book_url = self.all_metadata[modid].book_url
25        if book_url is None:
26            raise ValueError(f"Mod {modid} does not export a book url")
27
28        return book_url

Base class for all Pydantic models in hexdoc.

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

modid: str
spoilered_advancements: set[hexdoc.core.ResourceLocation]
all_metadata: dict[str, hexdoc.data.HexdocMetadata]
class Category(hexdoc.model.id.IDModel, hexdoc.utils.types.Sortable):
 19class Category(IDModel, Sortable):
 20    """Category with pages and localizations.
 21
 22    See: https://vazkiimods.github.io/Patchouli/docs/reference/category-json
 23    """
 24
 25    entries: SkipJsonSchema[dict[ResourceLocation, Entry]] = Field(default_factory=dict)
 26    is_spoiler: SkipJsonSchema[bool] = False
 27
 28    # required
 29    name: LocalizedStr
 30    description: FormatTree
 31    icon: ItemWithTexture | NamedTexture
 32
 33    # optional
 34    parent_id: ResourceLocation | None = Field(default=None, alias="parent")
 35    _parent_cmp_key: tuple[int, ...] | None = None
 36    flag: str | None = None
 37    sortnum: int = 0
 38    secret: bool = False
 39
 40    @classmethod
 41    def load_all(
 42        cls,
 43        context: dict[str, Any],
 44        book_id: ResourceLocation,
 45        use_resource_pack: bool,
 46    ) -> dict[ResourceLocation, Self]:
 47        loader = ModResourceLoader.of(context)
 48
 49        # load
 50        categories = dict[ResourceLocation, Self]()
 51        G = TypedDiGraph[ResourceLocation]()
 52
 53        for resource_dir, id, data in loader.load_book_assets(
 54            book_id,
 55            "categories",
 56            use_resource_pack,
 57        ):
 58            category = categories[id] = cls.load(resource_dir, id, data, context)
 59            if category.parent_id:
 60                G.add_edge(category.parent_id, category.id)
 61
 62        # if there's a cycle in the graph, we can't find a valid ordering
 63        # eg. two categories with each other as parents
 64        if cycle := G.find_cycle():
 65            raise ValueError(
 66                "Found cycle of category parents:\n  "
 67                + "\n  ".join(f"{u} -> {v}" for u, v in cycle)
 68            )
 69
 70        # late-init _parent_cmp_key
 71        for parent_id in G.topological_sort():
 72            parent = categories[parent_id]
 73            for _, child_id in G.iter_out_edges(parent_id):
 74                categories[child_id]._parent_cmp_key = parent._cmp_key
 75
 76        # return sorted by sortnum, which requires parent to be initialized
 77        return sorted_dict(categories)
 78
 79    @property
 80    def book_link_key(self):
 81        """Key to look up this category in `BookContext.book_links`."""
 82        return str(self.id)
 83
 84    @property
 85    def fragment(self):
 86        """URL fragment for this category in `BookContext.book_links`."""
 87        return self.id.path
 88
 89    @property
 90    def redirect_path(self):
 91        """Path to this category when generating redirect pages."""
 92        return self.id.path
 93
 94    @property
 95    def _is_cmp_key_ready(self) -> bool:
 96        return self.parent_id is None or self._parent_cmp_key is not None
 97
 98    @property
 99    def _cmp_key(self) -> tuple[int, ...]:
100        # implement Sortable
101        if parent_cmp_key := self._parent_cmp_key:
102            return parent_cmp_key + (self.sortnum,)
103        return (self.sortnum,)

Category with pages and localizations.

See: https://vazkiimods.github.io/Patchouli/docs/reference/category-json

entries: typing.Annotated[dict[hexdoc.core.ResourceLocation, Entry], SkipJsonSchema()]
is_spoiler: typing.Annotated[bool, SkipJsonSchema()]
description: FormatTree
parent_id: hexdoc.core.ResourceLocation | None
flag: str | None
sortnum: int
secret: bool
@classmethod
def load_all( cls, context: dict[str, typing.Any], book_id: hexdoc.core.ResourceLocation, use_resource_pack: bool) -> dict[hexdoc.core.ResourceLocation, typing.Self]:
40    @classmethod
41    def load_all(
42        cls,
43        context: dict[str, Any],
44        book_id: ResourceLocation,
45        use_resource_pack: bool,
46    ) -> dict[ResourceLocation, Self]:
47        loader = ModResourceLoader.of(context)
48
49        # load
50        categories = dict[ResourceLocation, Self]()
51        G = TypedDiGraph[ResourceLocation]()
52
53        for resource_dir, id, data in loader.load_book_assets(
54            book_id,
55            "categories",
56            use_resource_pack,
57        ):
58            category = categories[id] = cls.load(resource_dir, id, data, context)
59            if category.parent_id:
60                G.add_edge(category.parent_id, category.id)
61
62        # if there's a cycle in the graph, we can't find a valid ordering
63        # eg. two categories with each other as parents
64        if cycle := G.find_cycle():
65            raise ValueError(
66                "Found cycle of category parents:\n  "
67                + "\n  ".join(f"{u} -> {v}" for u, v in cycle)
68            )
69
70        # late-init _parent_cmp_key
71        for parent_id in G.topological_sort():
72            parent = categories[parent_id]
73            for _, child_id in G.iter_out_edges(parent_id):
74                categories[child_id]._parent_cmp_key = parent._cmp_key
75
76        # return sorted by sortnum, which requires parent to be initialized
77        return sorted_dict(categories)
fragment
84    @property
85    def fragment(self):
86        """URL fragment for this category in `BookContext.book_links`."""
87        return self.id.path

URL fragment for this category in BookContext.book_links.

redirect_path
89    @property
90    def redirect_path(self):
91        """Path to this category when generating redirect pages."""
92        return self.id.path

Path to this category when generating redirect pages.

def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
337def init_private_attributes(self: BaseModel, context: Any, /) -> None:
338    """This function is meant to behave like a BaseModel method to initialise private attributes.
339
340    It takes context as an argument since that's what pydantic-core passes when calling it.
341
342    Args:
343        self: The BaseModel instance.
344        context: The context.
345    """
346    if getattr(self, '__pydantic_private__', None) is None:
347        pydantic_private = {}
348        for name, private_attr in self.__private_attributes__.items():
349            default = private_attr.get_default()
350            if default is not PydanticUndefined:
351                pydantic_private[name] = default
352        object_setattr(self, '__pydantic_private__', pydantic_private)

This function is meant to behave like a BaseModel method to initialise private attributes.

It takes context as an argument since that's what pydantic-core passes when calling it.

Args: self: The BaseModel instance. context: The context.

class Entry(hexdoc.model.id.IDModel, hexdoc.utils.types.Sortable, hexdoc.patchouli.utils.AdvancementSpoilered):
 18class Entry(IDModel, Sortable, AdvancementSpoilered):
 19    """Entry json file, with pages and localizations.
 20
 21    See: https://vazkiimods.github.io/Patchouli/docs/reference/entry-json
 22    """
 23
 24    # required (entry.json)
 25    name: LocalizedStr
 26    category_id: ResourceLocation = Field(alias="category")
 27    icon: ItemWithTexture | NamedTexture
 28    pages: list[Page]
 29
 30    # optional (entry.json)
 31    advancement: ResourceLocation | None = None
 32    flag: str | None = None
 33    priority: bool = False
 34    secret: bool = False
 35    read_by_default: bool = False
 36    sortnum: int = 0
 37    turnin: ResourceLocation | None = None
 38    extra_recipe_mappings: dict[ItemStack, int] | None = None
 39    entry_color: Color | None = None  # this is undocumented lmao
 40
 41    @property
 42    def _cmp_key(self) -> tuple[bool, int, LocalizedStr]:
 43        # implement Sortable
 44        # note: python sorts false before true, so we invert priority
 45        return (not self.priority, self.sortnum, self.name)
 46
 47    @property
 48    def anchors(self) -> Iterable[str]:
 49        for page in self.pages:
 50            if page.anchor is not None:
 51                yield page.anchor
 52
 53    @property
 54    def book_link_key(self):
 55        """Key to look up this entry in `BookContext.book_links`."""
 56        return str(self.id)
 57
 58    @property
 59    def fragment(self):
 60        """URL fragment for this entry in `BookContext.book_links`."""
 61        return self.id.path
 62
 63    @property
 64    def redirect_path(self):
 65        """Path to this entry when generating redirect pages."""
 66        return self.id.path
 67
 68    @property
 69    def first_text_page(self):
 70        for page in self.pages:
 71            if getattr(page, "text", None):
 72                return page
 73
 74    def preprocess_pages(self) -> Iterator[Page]:
 75        """Combines adjacent CraftingPage recipes as much as possible."""
 76        accumulator = _CraftingPageAccumulator.blank()
 77
 78        for page in self.pages:
 79            match page:
 80                case CraftingPage(
 81                    recipes=list(recipes),
 82                    text=None,
 83                    title=None,
 84                    anchor=None,
 85                ):
 86                    accumulator.recipes += recipes
 87                case CraftingPage(
 88                    recipes=list(recipes),
 89                    title=LocalizedStr() as title,
 90                    text=None,
 91                    anchor=None,
 92                ):
 93                    if accumulator.recipes:
 94                        yield accumulator
 95                    accumulator = _CraftingPageAccumulator.blank()
 96                    accumulator.recipes += recipes
 97                    accumulator.title = title
 98                case CraftingPage(
 99                    recipes=list(recipes),
100                    title=None,
101                    text=FormatTree() as text,
102                    anchor=None,
103                ):
104                    accumulator.title = None
105                    accumulator.text = text
106                    accumulator.recipes += recipes
107                    yield accumulator
108                    accumulator = _CraftingPageAccumulator.blank()
109                case _:
110                    if accumulator.recipes:
111                        yield accumulator
112                        accumulator = _CraftingPageAccumulator.blank()
113                    yield page
114
115        if accumulator.recipes:
116            yield accumulator
117
118    def _get_advancement(self):
119        # implements AdvancementSpoilered
120        return self.advancement

Entry json file, with pages and localizations.

See: https://vazkiimods.github.io/Patchouli/docs/reference/entry-json

advancement: hexdoc.core.ResourceLocation | None
flag: str | None
priority: bool
secret: bool
read_by_default: bool
sortnum: int
extra_recipe_mappings: dict[hexdoc.core.ItemStack, int] | None
entry_color: hexdoc.model.Color | None
anchors: Iterable[str]
47    @property
48    def anchors(self) -> Iterable[str]:
49        for page in self.pages:
50            if page.anchor is not None:
51                yield page.anchor
fragment
58    @property
59    def fragment(self):
60        """URL fragment for this entry in `BookContext.book_links`."""
61        return self.id.path

URL fragment for this entry in BookContext.book_links.

redirect_path
63    @property
64    def redirect_path(self):
65        """Path to this entry when generating redirect pages."""
66        return self.id.path

Path to this entry when generating redirect pages.

first_text_page
68    @property
69    def first_text_page(self):
70        for page in self.pages:
71            if getattr(page, "text", None):
72                return page
def preprocess_pages(self) -> Iterator[hexdoc.patchouli.page.Page]:
 74    def preprocess_pages(self) -> Iterator[Page]:
 75        """Combines adjacent CraftingPage recipes as much as possible."""
 76        accumulator = _CraftingPageAccumulator.blank()
 77
 78        for page in self.pages:
 79            match page:
 80                case CraftingPage(
 81                    recipes=list(recipes),
 82                    text=None,
 83                    title=None,
 84                    anchor=None,
 85                ):
 86                    accumulator.recipes += recipes
 87                case CraftingPage(
 88                    recipes=list(recipes),
 89                    title=LocalizedStr() as title,
 90                    text=None,
 91                    anchor=None,
 92                ):
 93                    if accumulator.recipes:
 94                        yield accumulator
 95                    accumulator = _CraftingPageAccumulator.blank()
 96                    accumulator.recipes += recipes
 97                    accumulator.title = title
 98                case CraftingPage(
 99                    recipes=list(recipes),
100                    title=None,
101                    text=FormatTree() as text,
102                    anchor=None,
103                ):
104                    accumulator.title = None
105                    accumulator.text = text
106                    accumulator.recipes += recipes
107                    yield accumulator
108                    accumulator = _CraftingPageAccumulator.blank()
109                case _:
110                    if accumulator.recipes:
111                        yield accumulator
112                        accumulator = _CraftingPageAccumulator.blank()
113                    yield page
114
115        if accumulator.recipes:
116            yield accumulator

Combines adjacent CraftingPage recipes as much as possible.

def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
337def init_private_attributes(self: BaseModel, context: Any, /) -> None:
338    """This function is meant to behave like a BaseModel method to initialise private attributes.
339
340    It takes context as an argument since that's what pydantic-core passes when calling it.
341
342    Args:
343        self: The BaseModel instance.
344        context: The context.
345    """
346    if getattr(self, '__pydantic_private__', None) is None:
347        pydantic_private = {}
348        for name, private_attr in self.__private_attributes__.items():
349            default = private_attr.get_default()
350            if default is not PydanticUndefined:
351                pydantic_private[name] = default
352        object_setattr(self, '__pydantic_private__', pydantic_private)

This function is meant to behave like a BaseModel method to initialise private attributes.

It takes context as an argument since that's what pydantic-core passes when calling it.

Args: self: The BaseModel instance. context: The context.

@final
@dataclass(config=DEFAULT_CONFIG | json_schema_extra_config(type_str, inherited))
class FormatTree:
346@final
347@dataclass(config=DEFAULT_CONFIG | json_schema_extra_config(type_str, inherited))
348class FormatTree:
349    style: Style
350    children: list[FormatTree | str]  # this can't be Self, it breaks Pydantic
351    raw: str | None = None
352
353    @classmethod
354    def format(
355        cls,
356        string: str,
357        *,
358        book_id: ResourceLocation,
359        i18n: I18n,
360        macros: dict[str, str],
361        is_0_black: bool,
362        pm: PluginManager,
363        link_overrides: dict[str, str],
364    ) -> Self:
365        for macro, replace in macros.items():
366            if macro in replace:
367                raise RuntimeError(
368                    f"Recursive macro: replacement `{replace}` is matched by key `{macro}`"
369                )
370
371        working_string = resolve_macros(string, macros)
372
373        # lex out parsed styles
374        text_nodes: list[str] = []
375        styles: list[Style | _CloseTag] = []
376        text_since_prev_style: list[str] = []
377        last_end = 0
378
379        for match in re.finditer(STYLE_REGEX, working_string):
380            # get the text between the previous match and here
381            leading_text = working_string[last_end : match.start()]
382            text_since_prev_style.append(leading_text)
383            last_end = match.end()
384
385            match Style.parse(match[1], book_id, i18n, is_0_black, link_overrides):
386                case str(replacement):
387                    # str means "use this instead of the original value"
388                    text_since_prev_style.append(replacement)
389                case Style() | _CloseTag() as style:
390                    # add this style and collect the text since the previous one
391                    styles.append(style)
392                    text_nodes.append("".join(text_since_prev_style))
393                    text_since_prev_style.clear()
394
395        text_nodes.append("".join(text_since_prev_style) + working_string[last_end:])
396        first_node = text_nodes.pop(0)
397
398        # parse
399        style_stack = [
400            FormatTree(CommandStyle(type=SpecialStyleType.base), []),
401            FormatTree(ParagraphStyle.paragraph(), [first_node]),
402        ]
403        for style, text in zip(styles, text_nodes):
404            tmp_stylestack: list[Style] = []
405            if style.type == SpecialStyleType.base:
406                while style_stack[-1].style.type != SpecialStyleType.paragraph:
407                    last_node = style_stack.pop()
408                    style_stack[-1].children.append(last_node)
409            elif any(tree.style.type == style.type for tree in style_stack):
410                while len(style_stack) >= 2:
411                    last_node = style_stack.pop()
412                    style_stack[-1].children.append(last_node)
413                    if last_node.style.type == style.type:
414                        break
415                    tmp_stylestack.append(last_node.style)
416
417            for sty in tmp_stylestack:
418                style_stack.append(FormatTree(sty, []))
419
420            if isinstance(style, _CloseTag):
421                if text:
422                    style_stack[-1].children.append(text)
423            else:
424                style_stack.append(FormatTree(style, [text] if text else []))
425
426        while len(style_stack) >= 2:
427            last_node = style_stack.pop()
428            style_stack[-1].children.append(last_node)
429
430        unvalidated_tree = style_stack[0]
431        unvalidated_tree.raw = string
432
433        validated_tree = pm.validate_format_tree(
434            tree=unvalidated_tree,
435            macros=macros,
436            book_id=book_id,
437            i18n=i18n,
438            is_0_black=is_0_black,
439            link_overrides=link_overrides,
440        )
441        assert isinstance(validated_tree, cls)
442
443        return validated_tree
444
445    @model_validator(mode="wrap")
446    @classmethod
447    def _wrap_root(
448        cls,
449        value: str | LocalizedStr | Self,
450        handler: ModelWrapValidatorHandler[Self],
451        info: ValidationInfo,
452    ):
453        if not info.context or isinstance(value, FormatTree):
454            return handler(value)
455
456        context = FormattingContext.of(info)
457        i18n = I18n.of(info)
458        pm = PluginManager.of(info)
459        props = Properties.of(info)
460
461        if isinstance(value, str):
462            value = i18n.localize(value)
463
464        return cls.format(
465            value.value,
466            book_id=context.book_id,
467            i18n=i18n,
468            macros=context.macros,
469            is_0_black=props.is_0_black,
470            pm=pm,
471            link_overrides=props.link_overrides,
472        )
FormatTree(*args: Any, **kwargs: Any)
118    def __init__(__dataclass_self__: PydanticDataclass, *args: Any, **kwargs: Any) -> None:
119        __tracebackhide__ = True
120        s = __dataclass_self__
121        s.__pydantic_validator__.validate_python(ArgsKwargs(args, kwargs), self_instance=s)
style: hexdoc.patchouli.text.Style
children: list[FormatTree | str]
raw: str | None = None
@classmethod
def format( cls, string: str, *, book_id: hexdoc.core.ResourceLocation, i18n: hexdoc.minecraft.I18n, macros: dict[str, str], is_0_black: bool, pm: hexdoc.plugin.PluginManager, link_overrides: dict[str, str]) -> Self:
353    @classmethod
354    def format(
355        cls,
356        string: str,
357        *,
358        book_id: ResourceLocation,
359        i18n: I18n,
360        macros: dict[str, str],
361        is_0_black: bool,
362        pm: PluginManager,
363        link_overrides: dict[str, str],
364    ) -> Self:
365        for macro, replace in macros.items():
366            if macro in replace:
367                raise RuntimeError(
368                    f"Recursive macro: replacement `{replace}` is matched by key `{macro}`"
369                )
370
371        working_string = resolve_macros(string, macros)
372
373        # lex out parsed styles
374        text_nodes: list[str] = []
375        styles: list[Style | _CloseTag] = []
376        text_since_prev_style: list[str] = []
377        last_end = 0
378
379        for match in re.finditer(STYLE_REGEX, working_string):
380            # get the text between the previous match and here
381            leading_text = working_string[last_end : match.start()]
382            text_since_prev_style.append(leading_text)
383            last_end = match.end()
384
385            match Style.parse(match[1], book_id, i18n, is_0_black, link_overrides):
386                case str(replacement):
387                    # str means "use this instead of the original value"
388                    text_since_prev_style.append(replacement)
389                case Style() | _CloseTag() as style:
390                    # add this style and collect the text since the previous one
391                    styles.append(style)
392                    text_nodes.append("".join(text_since_prev_style))
393                    text_since_prev_style.clear()
394
395        text_nodes.append("".join(text_since_prev_style) + working_string[last_end:])
396        first_node = text_nodes.pop(0)
397
398        # parse
399        style_stack = [
400            FormatTree(CommandStyle(type=SpecialStyleType.base), []),
401            FormatTree(ParagraphStyle.paragraph(), [first_node]),
402        ]
403        for style, text in zip(styles, text_nodes):
404            tmp_stylestack: list[Style] = []
405            if style.type == SpecialStyleType.base:
406                while style_stack[-1].style.type != SpecialStyleType.paragraph:
407                    last_node = style_stack.pop()
408                    style_stack[-1].children.append(last_node)
409            elif any(tree.style.type == style.type for tree in style_stack):
410                while len(style_stack) >= 2:
411                    last_node = style_stack.pop()
412                    style_stack[-1].children.append(last_node)
413                    if last_node.style.type == style.type:
414                        break
415                    tmp_stylestack.append(last_node.style)
416
417            for sty in tmp_stylestack:
418                style_stack.append(FormatTree(sty, []))
419
420            if isinstance(style, _CloseTag):
421                if text:
422                    style_stack[-1].children.append(text)
423            else:
424                style_stack.append(FormatTree(style, [text] if text else []))
425
426        while len(style_stack) >= 2:
427            last_node = style_stack.pop()
428            style_stack[-1].children.append(last_node)
429
430        unvalidated_tree = style_stack[0]
431        unvalidated_tree.raw = string
432
433        validated_tree = pm.validate_format_tree(
434            tree=unvalidated_tree,
435            macros=macros,
436            book_id=book_id,
437            i18n=i18n,
438            is_0_black=is_0_black,
439            link_overrides=link_overrides,
440        )
441        assert isinstance(validated_tree, cls)
442
443        return validated_tree
class FormattingContext(hexdoc.model.base.ValidationContextModel):
73class FormattingContext(ValidationContextModel):
74    book_id: ResourceLocation
75    macros: dict[str, str]

Base class for all Pydantic models in hexdoc.

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

macros: dict[str, str]