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
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
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.
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.
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
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
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)
79 @property 80 def book_link_key(self): 81 """Key to look up this category in `BookContext.book_links`.""" 82 return str(self.id)
Key to look up this category in BookContext.book_links
.
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
.
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.
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.
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
53 @property 54 def book_link_key(self): 55 """Key to look up this entry in `BookContext.book_links`.""" 56 return str(self.id)
Key to look up this entry in BookContext.book_links
.
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
.
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.
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.
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.
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 )
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
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.