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

Note: includes external entries as well (not just internal ones)

def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
358def init_private_attributes(self: BaseModel, context: Any, /) -> None:
359    """This function is meant to behave like a BaseModel method to initialise private attributes.
360
361    It takes context as an argument since that's what pydantic-core passes when calling it.
362
363    Args:
364        self: The BaseModel instance.
365        context: The context.
366    """
367    if getattr(self, '__pydantic_private__', None) is None:
368        pydantic_private = {}
369        for name, private_attr in self.__private_attributes__.items():
370            default = private_attr.get_default()
371            if default is not PydanticUndefined:
372                pydantic_private[name] = default
373        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    flags: dict[str, bool]
19
20    def get_link_base(self, resource_dir: PathResourceDir) -> URL:
21        modid = resource_dir.modid
22        if resource_dir.internal or modid is None or modid == self.modid:
23            return URL()
24
25        book_url = self.all_metadata[modid].book_url
26        if book_url is None:
27            raise ValueError(f"Mod {modid} does not export a book url")
28
29        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]
flags: dict[str, bool]
class Category(hexdoc.model.id.IDModel, hexdoc.utils.types.Sortable, hexdoc.patchouli.utils.Flagged):
 23class Category(IDModel, Sortable, Flagged):
 24    """Category with pages and localizations.
 25
 26    See: https://vazkiimods.github.io/Patchouli/docs/reference/category-json
 27    """
 28
 29    entries: SkipJsonSchema[dict[ResourceLocation, Entry]] = Field(default_factory=dict)
 30    is_spoiler: SkipJsonSchema[bool] = False
 31
 32    # required
 33    name: LocalizedStr
 34    description: FormatTree
 35    icon: ItemWithTexture | NamedTexture
 36
 37    # optional
 38    parent_id: ResourceLocation | None = Field(default=None, alias="parent")
 39    _parent_cmp_key: tuple[int, ...] | None = None
 40    sortnum: int = 0
 41    secret: bool = False
 42
 43    @classmethod
 44    def load_all(
 45        cls,
 46        context: dict[str, Any],
 47        book_id: ResourceLocation,
 48        use_resource_pack: bool,
 49    ) -> dict[ResourceLocation, Self]:
 50        loader = ModResourceLoader.of(context)
 51
 52        # load
 53        categories = dict[ResourceLocation, Self]()
 54        G = TypedDiGraph[ResourceLocation]()
 55
 56        for resource_dir, id, data in loader.load_book_assets(
 57            book_id,
 58            "categories",
 59            use_resource_pack,
 60        ):
 61            # Patchouli checks flags before resolving category parents
 62            # https://github.com/VazkiiMods/Patchouli/blob/abd6d03a08c37bcf116730021fda9f477412b31f/Xplat/src/main/java/vazkii/patchouli/client/book/BookContentsBuilder.java#L151
 63            category = cls.load(resource_dir, id, data, context)
 64            if not category.is_flag_enabled:
 65                logger.info(
 66                    f"Skipping category {id} due to disabled flag {category.flag}"
 67                )
 68                continue
 69
 70            categories[id] = category
 71            if category.parent_id:
 72                G.add_edge(category.parent_id, category.id)
 73
 74        # if there's a cycle in the graph, we can't find a valid ordering
 75        # eg. two categories with each other as parents
 76        if cycle := G.find_cycle():
 77            raise ValueError(
 78                "Found cycle of category parents:\n  "
 79                + "\n  ".join(f"{u} -> {v}" for u, v in cycle)
 80            )
 81
 82        # late-init _parent_cmp_key
 83        for parent_id in G.topological_sort():
 84            parent = categories.get(parent_id)
 85            if parent is None:
 86                children = ", ".join(str(v) for _, v in G.iter_out_edges(parent_id))
 87                raise ValueError(
 88                    f"Parent category {parent_id} required by {children} does not exist"
 89                )
 90
 91            for _, child_id in G.iter_out_edges(parent_id):
 92                categories[child_id]._parent_cmp_key = parent._cmp_key
 93
 94        # return sorted by sortnum, which requires parent to be initialized
 95        return sorted_dict(categories)
 96
 97    @property
 98    def book_link_key(self):
 99        """Key to look up this category in `BookContext.book_links`."""
100        return str(self.id)
101
102    @property
103    def fragment(self):
104        """URL fragment for this category in `BookContext.book_links`."""
105        return self.id.path
106
107    @property
108    def redirect_path(self):
109        """Path to this category when generating redirect pages."""
110        return self.id.path
111
112    @property
113    def _is_cmp_key_ready(self) -> bool:
114        return self.parent_id is None or self._parent_cmp_key is not None
115
116    @property
117    def _cmp_key(self) -> tuple[int, ...]:
118        # implement Sortable
119        if parent_cmp_key := self._parent_cmp_key:
120            return parent_cmp_key + (self.sortnum,)
121        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
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]:
43    @classmethod
44    def load_all(
45        cls,
46        context: dict[str, Any],
47        book_id: ResourceLocation,
48        use_resource_pack: bool,
49    ) -> dict[ResourceLocation, Self]:
50        loader = ModResourceLoader.of(context)
51
52        # load
53        categories = dict[ResourceLocation, Self]()
54        G = TypedDiGraph[ResourceLocation]()
55
56        for resource_dir, id, data in loader.load_book_assets(
57            book_id,
58            "categories",
59            use_resource_pack,
60        ):
61            # Patchouli checks flags before resolving category parents
62            # https://github.com/VazkiiMods/Patchouli/blob/abd6d03a08c37bcf116730021fda9f477412b31f/Xplat/src/main/java/vazkii/patchouli/client/book/BookContentsBuilder.java#L151
63            category = cls.load(resource_dir, id, data, context)
64            if not category.is_flag_enabled:
65                logger.info(
66                    f"Skipping category {id} due to disabled flag {category.flag}"
67                )
68                continue
69
70            categories[id] = category
71            if category.parent_id:
72                G.add_edge(category.parent_id, category.id)
73
74        # if there's a cycle in the graph, we can't find a valid ordering
75        # eg. two categories with each other as parents
76        if cycle := G.find_cycle():
77            raise ValueError(
78                "Found cycle of category parents:\n  "
79                + "\n  ".join(f"{u} -> {v}" for u, v in cycle)
80            )
81
82        # late-init _parent_cmp_key
83        for parent_id in G.topological_sort():
84            parent = categories.get(parent_id)
85            if parent is None:
86                children = ", ".join(str(v) for _, v in G.iter_out_edges(parent_id))
87                raise ValueError(
88                    f"Parent category {parent_id} required by {children} does not exist"
89                )
90
91            for _, child_id in G.iter_out_edges(parent_id):
92                categories[child_id]._parent_cmp_key = parent._cmp_key
93
94        # return sorted by sortnum, which requires parent to be initialized
95        return sorted_dict(categories)
fragment
102    @property
103    def fragment(self):
104        """URL fragment for this category in `BookContext.book_links`."""
105        return self.id.path

URL fragment for this category in BookContext.book_links.

redirect_path
107    @property
108    def redirect_path(self):
109        """Path to this category when generating redirect pages."""
110        return self.id.path

Path to this category when generating redirect pages.

def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
358def init_private_attributes(self: BaseModel, context: Any, /) -> None:
359    """This function is meant to behave like a BaseModel method to initialise private attributes.
360
361    It takes context as an argument since that's what pydantic-core passes when calling it.
362
363    Args:
364        self: The BaseModel instance.
365        context: The context.
366    """
367    if getattr(self, '__pydantic_private__', None) is None:
368        pydantic_private = {}
369        for name, private_attr in self.__private_attributes__.items():
370            default = private_attr.get_default()
371            if default is not PydanticUndefined:
372                pydantic_private[name] = default
373        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, hexdoc.patchouli.utils.Flagged):
 20class Entry(IDModel, Sortable, AdvancementSpoilered, Flagged):
 21    """Entry json file, with pages and localizations.
 22
 23    See: https://vazkiimods.github.io/Patchouli/docs/reference/entry-json
 24    """
 25
 26    # required (entry.json)
 27    name: LocalizedStr
 28    category_id: ResourceLocation = Field(alias="category")
 29    icon: ItemWithTexture | NamedTexture
 30    pages: list[Page]
 31
 32    # optional (entry.json)
 33    advancement: ResourceLocation | None = None
 34    priority: bool = False
 35    secret: bool = False
 36    read_by_default: bool = False
 37    sortnum: int = 0
 38    turnin: ResourceLocation | None = None
 39    extra_recipe_mappings: dict[ItemStack, int] | None = None
 40    entry_color: Color | None = None  # this is undocumented lmao
 41
 42    @property
 43    def _cmp_key(self) -> tuple[bool, int, LocalizedStr]:
 44        # implement Sortable
 45        # note: python sorts false before true, so we invert priority
 46        return (not self.priority, self.sortnum, self.name)
 47
 48    @property
 49    def anchors(self) -> Iterable[str]:
 50        for page in self.pages:
 51            if page.anchor is not None:
 52                yield page.anchor
 53
 54    @property
 55    def book_link_key(self):
 56        """Key to look up this entry in `BookContext.book_links`."""
 57        return str(self.id)
 58
 59    @property
 60    def fragment(self):
 61        """URL fragment for this entry in `BookContext.book_links`."""
 62        return self.id.path
 63
 64    @property
 65    def redirect_path(self):
 66        """Path to this entry when generating redirect pages."""
 67        return self.id.path
 68
 69    @property
 70    def first_text_page(self):
 71        for page in self.pages:
 72            if getattr(page, "text", None):
 73                return page
 74
 75    def preprocess_pages(self) -> Iterator[Page]:
 76        """Combines adjacent PageWithAccumulator recipes as much as possible."""
 77        acc: AccumulatorPage[Any] | None = None
 78
 79        for page in self.pages:
 80            if isinstance(page, PageWithAccumulator):
 81                if not (acc and acc.can_append(page)):
 82                    if acc and acc.has_content:
 83                        yield acc
 84                    acc = page.accumulator_type().from_page(page)
 85
 86                acc.append(page)
 87
 88                if not acc.can_append_more:
 89                    yield acc
 90                    acc = None
 91            else:
 92                if acc and acc.has_content:
 93                    yield acc
 94                acc = None
 95                yield page
 96
 97        if acc and acc.has_content:
 98            yield acc
 99
100    def _get_advancement(self):
101        # implements AdvancementSpoilered
102        return self.advancement
103
104    @model_validator(mode="after")
105    def _skip_disabled_pages(self):
106        new_pages = list[Page]()
107        for i, page in enumerate(self.pages):
108            if not page.is_flag_enabled:
109                logger.info(
110                    f"Skipping page {i} of entry {self.id} due to disabled flag {page.flag}"
111                )
112                continue
113            new_pages.append(page)
114        if not new_pages:
115            logger.warning(
116                f"Entry has no pages{' after applying flags' if self.pages else ''}: {self.id}"
117            )
118        self.pages = new_pages
119        return self

Entry json file, with pages and localizations.

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

advancement: hexdoc.core.ResourceLocation | 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]
48    @property
49    def anchors(self) -> Iterable[str]:
50        for page in self.pages:
51            if page.anchor is not None:
52                yield page.anchor
fragment
59    @property
60    def fragment(self):
61        """URL fragment for this entry in `BookContext.book_links`."""
62        return self.id.path

URL fragment for this entry in BookContext.book_links.

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

Path to this entry when generating redirect pages.

first_text_page
69    @property
70    def first_text_page(self):
71        for page in self.pages:
72            if getattr(page, "text", None):
73                return page
def preprocess_pages(self) -> Iterator[hexdoc.patchouli.page.Page]:
75    def preprocess_pages(self) -> Iterator[Page]:
76        """Combines adjacent PageWithAccumulator recipes as much as possible."""
77        acc: AccumulatorPage[Any] | None = None
78
79        for page in self.pages:
80            if isinstance(page, PageWithAccumulator):
81                if not (acc and acc.can_append(page)):
82                    if acc and acc.has_content:
83                        yield acc
84                    acc = page.accumulator_type().from_page(page)
85
86                acc.append(page)
87
88                if not acc.can_append_more:
89                    yield acc
90                    acc = None
91            else:
92                if acc and acc.has_content:
93                    yield acc
94                acc = None
95                yield page
96
97        if acc and acc.has_content:
98            yield acc

Combines adjacent PageWithAccumulator recipes as much as possible.

def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
358def init_private_attributes(self: BaseModel, context: Any, /) -> None:
359    """This function is meant to behave like a BaseModel method to initialise private attributes.
360
361    It takes context as an argument since that's what pydantic-core passes when calling it.
362
363    Args:
364        self: The BaseModel instance.
365        context: The context.
366    """
367    if getattr(self, '__pydantic_private__', None) is None:
368        pydantic_private = {}
369        for name, private_attr in self.__private_attributes__.items():
370            default = private_attr.get_default()
371            if default is not PydanticUndefined:
372                pydantic_private[name] = default
373        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)
119    def __init__(__dataclass_self__: PydanticDataclass, *args: Any, **kwargs: Any) -> None:
120        __tracebackhide__ = True
121        s = __dataclass_self__
122        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]