mdforge 0.2.3__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {mdforge-0.2.3 → mdforge-0.3.0}/PKG-INFO +47 -22
  2. {mdforge-0.2.3 → mdforge-0.3.0}/README.md +46 -21
  3. mdforge-0.3.0/pyproject.toml +109 -0
  4. mdforge-0.2.3/pyproject.toml → mdforge-0.3.0/pyproject.toml.orig +4 -4
  5. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/__init__.py +8 -2
  6. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/_utils.py +36 -3
  7. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/container.py +85 -55
  8. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/document.py +7 -11
  9. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/element.py +112 -32
  10. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/__init__.py +6 -2
  11. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/_image.py +5 -6
  12. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/__init__.py +9 -2
  13. mdforge-0.3.0/src/mdforge/elements/block/basic.py +74 -0
  14. mdforge-0.3.0/src/mdforge/elements/block/container.py +68 -0
  15. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/lists/definition.py +23 -10
  16. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/lists/item.py +73 -70
  17. mdforge-0.3.0/src/mdforge/elements/block/section.py +122 -0
  18. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/_context.py +8 -4
  19. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/_flavors/flavors.py +5 -3
  20. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/_flavors/frame/context.py +6 -4
  21. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/cell.py +9 -7
  22. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/table.py +12 -6
  23. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/text.py +4 -5
  24. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/inline/basic.py +8 -9
  25. mdforge-0.3.0/src/mdforge/elements/inline/text.py +198 -0
  26. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/exceptions.py +8 -0
  27. mdforge-0.2.3/src/mdforge/elements/block/basic.py +0 -105
  28. mdforge-0.2.3/src/mdforge/elements/block/section.py +0 -47
  29. mdforge-0.2.3/src/mdforge/elements/inline/text.py +0 -149
  30. {mdforge-0.2.3 → mdforge-0.3.0}/LICENSE +0 -0
  31. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/_norm.py +0 -0
  32. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/lists/__init__.py +0 -0
  33. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/__init__.py +0 -0
  34. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/_flavors/frame/__init__.py +0 -0
  35. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/_flavors/frame/variant.py +0 -0
  36. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/_flavors/frame/variants.py +0 -0
  37. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/_flavors/list/__init__.py +0 -0
  38. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/_params.py +0 -0
  39. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/_variant.py +0 -0
  40. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/block/table/_vcell.py +0 -0
  41. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/elements/inline/__init__.py +0 -0
  42. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/py.typed +0 -0
  43. {mdforge-0.2.3 → mdforge-0.3.0}/src/mdforge/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdforge
3
- Version: 0.2.3
3
+ Version: 0.3.0
4
4
  Summary: Pythonic multi-flavor Markdown generator
5
5
  Author: mm21
6
6
  Author-email: mm21 <mm21.dev@gmail.com>
@@ -64,17 +64,17 @@ pip install mdforge
64
64
  Create your first Markdown document:
65
65
 
66
66
  ```python
67
- from mdforge import Document, Heading, Paragraph, Strong
67
+ from mdforge import Document, Paragraph, Section, Strong
68
68
  from pathlib import Path
69
69
 
70
70
  # create a document
71
71
  doc = Document()
72
72
 
73
73
  # add elements
74
- doc += [
75
- Heading("My first MDForge document"),
76
- Paragraph("Hello, ", Strong("world"), "!"),
77
- ]
74
+ doc += Section(
75
+ "My first MDForge document",
76
+ elements=Paragraph("Hello, ", Strong("world"), "!"),
77
+ )
78
78
 
79
79
  # render pandoc-flavored Markdown document to a file
80
80
  doc.render_file("doc.md", flavor="pandoc")
@@ -164,11 +164,33 @@ Elaboration on point 3.
164
164
 
165
165
  Note the following observations:
166
166
 
167
- - Sections can be nested in other sections, with optional headings
168
- - Heading levels for sections are automatically set by their placement in the hierarchy
167
+ - Headings are created from section titles; content belonging to a heading is placed in its section
168
+ - Sections can be nested in other sections, with optional titles
169
+ - Heading levels for sections are automatically set by their placement in the hierarchy; see [Heading levels](#heading-levels)
169
170
  - Strings added to a document or section via `+=` are treated as raw text
170
171
  - A comment is placed at the end of each list; this disambiguates the end of one list and the start of the next, in case there are no other elements in between
171
172
 
173
+ ### Heading levels
174
+
175
+ By default (`level=None`), the level of a section is relative to the render root: the element on which `render()` is called. The render root is level 1 and each nested section is one level deeper.
176
+
177
+ As a result, a section rendered by itself is a self-contained tree starting at level 1, even if it's also placed in a document.
178
+
179
+ To generate a fragment to be placed in a larger document, pass an explicit `level`. Explicit levels are invariant of placement, and nested sections are relative to it:
180
+
181
+ ```python
182
+ from mdforge import Section
183
+
184
+ fragment = Section("Fragment", level=3, elements=Section("Subsection"))
185
+
186
+ assert fragment.render(flavor="pandoc") == "### Fragment\n\n#### Subsection"
187
+ ```
188
+
189
+ When an element with an explicit level is rendered as part of a larger tree, its level is checked against its placement:
190
+
191
+ - A level not nested in the containing level, e.g. `level=1` in a level-1 section, raises `RenderError`
192
+ - A level which skips levels, e.g. `level=3` in a level-1 section, issues `RenderWarning`
193
+
172
194
  ## Pythonic Markdown elements
173
195
 
174
196
  MDForge provides a set of Markdown elements that can be composed to create rich documents. Not all common elements are implemented yet, e.g. code blocks. Nonetheless, you can insert raw text (which may be pre-formatted as Markdown) using `+=`.
@@ -557,32 +579,33 @@ assert element.render(flavor="pandoc") == "This is a **test element**"
557
579
 
558
580
  ### HTML attributes
559
581
 
560
- For pandoc flavor, HTML attributes can be added to various elements:
582
+ For pandoc flavor, HTML attributes can be added to various elements, including section headings:
561
583
 
562
584
  ```python
563
- from mdforge import Attributes, Document, Heading, Ref, Span
585
+ from mdforge import Attributes, Document, Ref, Section, Span
564
586
 
565
587
  doc = Document()
566
588
 
567
- # heading with ID, classes, and custom attributes
568
- my_heading = Heading(
569
- "Heading with attributes",
589
+ # section with heading ID, classes, and custom attributes
590
+ my_section = Section(
591
+ "Section with attributes",
570
592
  attributes=Attributes(
571
- html_id="my-heading",
593
+ html_id="my-section",
572
594
  css_classes=["class1", "class2"],
573
595
  attrs={"style": "color: blue;"},
574
596
  ),
575
597
  )
576
- doc += my_heading
598
+ doc += my_section
577
599
 
578
600
  # span with attributes
579
- doc += Span(
601
+ my_section += Span(
580
602
  "Text with attributes",
581
603
  attributes=Attributes(html_id="my-span", css_classes="class1"),
582
604
  )
583
605
 
584
- # reference to heading by id
585
- doc += Ref(my_heading, "See previous heading")
606
+ # references to section, by object and by id
607
+ my_section += Ref(my_section, "See this section")
608
+ my_section += Ref("my-section")
586
609
 
587
610
  doc.render_file("doc.md", flavor="pandoc")
588
611
  ```
@@ -590,11 +613,13 @@ doc.render_file("doc.md", flavor="pandoc")
590
613
  This is rendered as:
591
614
 
592
615
  ```markdown
593
- # Heading with attributes {#my-heading .class1 .class2 style="color: blue;"}
616
+ # Section with attributes {#my-section .class1 .class2 style="color: blue;"}
594
617
 
595
618
  [Text with attributes]{#my-span .class1}
596
619
 
597
- [See previous heading](#my-heading)
620
+ [See this section](#my-section)
621
+
622
+ [Section with attributes](#my-section)
598
623
 
599
624
  ```
600
625
 
@@ -603,12 +628,12 @@ This is rendered as:
603
628
  As MDForge is designed with pandoc compatibility in mind, it automatically tracks required pandoc extensions:
604
629
 
605
630
  ```python
606
- from mdforge import Attributes, Document, Heading, Strikethrough
631
+ from mdforge import Attributes, Document, Section, Strikethrough
607
632
 
608
633
  doc = Document()
609
634
 
610
635
  # requires "header_attributes"
611
- doc += Heading("Heading 1", attributes=Attributes(html_id="heading-1"))
636
+ doc += Section("Section 1", attributes=Attributes(html_id="section-1"))
612
637
 
613
638
  # requires "strikeout"
614
639
  doc += Strikethrough("This text is struck through")
@@ -41,17 +41,17 @@ pip install mdforge
41
41
  Create your first Markdown document:
42
42
 
43
43
  ```python
44
- from mdforge import Document, Heading, Paragraph, Strong
44
+ from mdforge import Document, Paragraph, Section, Strong
45
45
  from pathlib import Path
46
46
 
47
47
  # create a document
48
48
  doc = Document()
49
49
 
50
50
  # add elements
51
- doc += [
52
- Heading("My first MDForge document"),
53
- Paragraph("Hello, ", Strong("world"), "!"),
54
- ]
51
+ doc += Section(
52
+ "My first MDForge document",
53
+ elements=Paragraph("Hello, ", Strong("world"), "!"),
54
+ )
55
55
 
56
56
  # render pandoc-flavored Markdown document to a file
57
57
  doc.render_file("doc.md", flavor="pandoc")
@@ -141,11 +141,33 @@ Elaboration on point 3.
141
141
 
142
142
  Note the following observations:
143
143
 
144
- - Sections can be nested in other sections, with optional headings
145
- - Heading levels for sections are automatically set by their placement in the hierarchy
144
+ - Headings are created from section titles; content belonging to a heading is placed in its section
145
+ - Sections can be nested in other sections, with optional titles
146
+ - Heading levels for sections are automatically set by their placement in the hierarchy; see [Heading levels](#heading-levels)
146
147
  - Strings added to a document or section via `+=` are treated as raw text
147
148
  - A comment is placed at the end of each list; this disambiguates the end of one list and the start of the next, in case there are no other elements in between
148
149
 
150
+ ### Heading levels
151
+
152
+ By default (`level=None`), the level of a section is relative to the render root: the element on which `render()` is called. The render root is level 1 and each nested section is one level deeper.
153
+
154
+ As a result, a section rendered by itself is a self-contained tree starting at level 1, even if it's also placed in a document.
155
+
156
+ To generate a fragment to be placed in a larger document, pass an explicit `level`. Explicit levels are invariant of placement, and nested sections are relative to it:
157
+
158
+ ```python
159
+ from mdforge import Section
160
+
161
+ fragment = Section("Fragment", level=3, elements=Section("Subsection"))
162
+
163
+ assert fragment.render(flavor="pandoc") == "### Fragment\n\n#### Subsection"
164
+ ```
165
+
166
+ When an element with an explicit level is rendered as part of a larger tree, its level is checked against its placement:
167
+
168
+ - A level not nested in the containing level, e.g. `level=1` in a level-1 section, raises `RenderError`
169
+ - A level which skips levels, e.g. `level=3` in a level-1 section, issues `RenderWarning`
170
+
149
171
  ## Pythonic Markdown elements
150
172
 
151
173
  MDForge provides a set of Markdown elements that can be composed to create rich documents. Not all common elements are implemented yet, e.g. code blocks. Nonetheless, you can insert raw text (which may be pre-formatted as Markdown) using `+=`.
@@ -534,32 +556,33 @@ assert element.render(flavor="pandoc") == "This is a **test element**"
534
556
 
535
557
  ### HTML attributes
536
558
 
537
- For pandoc flavor, HTML attributes can be added to various elements:
559
+ For pandoc flavor, HTML attributes can be added to various elements, including section headings:
538
560
 
539
561
  ```python
540
- from mdforge import Attributes, Document, Heading, Ref, Span
562
+ from mdforge import Attributes, Document, Ref, Section, Span
541
563
 
542
564
  doc = Document()
543
565
 
544
- # heading with ID, classes, and custom attributes
545
- my_heading = Heading(
546
- "Heading with attributes",
566
+ # section with heading ID, classes, and custom attributes
567
+ my_section = Section(
568
+ "Section with attributes",
547
569
  attributes=Attributes(
548
- html_id="my-heading",
570
+ html_id="my-section",
549
571
  css_classes=["class1", "class2"],
550
572
  attrs={"style": "color: blue;"},
551
573
  ),
552
574
  )
553
- doc += my_heading
575
+ doc += my_section
554
576
 
555
577
  # span with attributes
556
- doc += Span(
578
+ my_section += Span(
557
579
  "Text with attributes",
558
580
  attributes=Attributes(html_id="my-span", css_classes="class1"),
559
581
  )
560
582
 
561
- # reference to heading by id
562
- doc += Ref(my_heading, "See previous heading")
583
+ # references to section, by object and by id
584
+ my_section += Ref(my_section, "See this section")
585
+ my_section += Ref("my-section")
563
586
 
564
587
  doc.render_file("doc.md", flavor="pandoc")
565
588
  ```
@@ -567,11 +590,13 @@ doc.render_file("doc.md", flavor="pandoc")
567
590
  This is rendered as:
568
591
 
569
592
  ```markdown
570
- # Heading with attributes {#my-heading .class1 .class2 style="color: blue;"}
593
+ # Section with attributes {#my-section .class1 .class2 style="color: blue;"}
571
594
 
572
595
  [Text with attributes]{#my-span .class1}
573
596
 
574
- [See previous heading](#my-heading)
597
+ [See this section](#my-section)
598
+
599
+ [Section with attributes](#my-section)
575
600
 
576
601
  ```
577
602
 
@@ -580,12 +605,12 @@ This is rendered as:
580
605
  As MDForge is designed with pandoc compatibility in mind, it automatically tracks required pandoc extensions:
581
606
 
582
607
  ```python
583
- from mdforge import Attributes, Document, Heading, Strikethrough
608
+ from mdforge import Attributes, Document, Section, Strikethrough
584
609
 
585
610
  doc = Document()
586
611
 
587
612
  # requires "header_attributes"
588
- doc += Heading("Heading 1", attributes=Attributes(html_id="heading-1"))
613
+ doc += Section("Section 1", attributes=Attributes(html_id="section-1"))
589
614
 
590
615
  # requires "strikeout"
591
616
  doc += Strikethrough("This text is struck through")
@@ -0,0 +1,109 @@
1
+ [project]
2
+ classifiers = [
3
+ "Development Status :: 3 - Alpha",
4
+ "Natural Language :: English",
5
+ "Operating System :: OS Independent",
6
+ "Programming Language :: Python :: 3",
7
+ "Programming Language :: Python :: 3.12",
8
+ "Programming Language :: Python :: 3.13",
9
+ "Programming Language :: Python :: 3.14",
10
+ "Programming Language :: Python :: Implementation :: CPython",
11
+ "Topic :: Software Development :: Libraries :: Python Modules",
12
+ "Typing :: Typed",
13
+ ]
14
+ dependencies = ["pyyaml>=6.0.2,<7"]
15
+ description = "Pythonic multi-flavor Markdown generator"
16
+ license = "MIT"
17
+ license-files = ["LICENSE"]
18
+ name = "mdforge"
19
+ readme = "README.md"
20
+ requires-python = ">=3.12,<4.0"
21
+ version = "0.3.0"
22
+
23
+ [[project.authors]]
24
+ name = "mm21"
25
+ email = "mm21.dev@gmail.com"
26
+
27
+ [project.urls]
28
+ homepage = "https://github.com/mm21/mdforge"
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pyprojkit[all]>=0.9.0,<0.10.0",
33
+ "pytest-powerpack>=0.1.5,<0.2",
34
+ "types-pyyaml>=6.0.12,<7",
35
+ ]
36
+
37
+ [build-system]
38
+ build-backend = "uv_build"
39
+ requires = ["uv_build>=0.13.5,<0.14.0"]
40
+
41
+ [tool.autoflake]
42
+ in_place = true
43
+ recursive = true
44
+ remove-all-unused-imports = true
45
+ remove-unused-variables = true
46
+
47
+ [tool.black]
48
+ quiet = true
49
+ target-version = [
50
+ "py312",
51
+ "py313",
52
+ "py314",
53
+ ]
54
+
55
+ [tool.coverage.report]
56
+ exclude_lines = [
57
+ "if TYPE_CHECKING:",
58
+ '\.\.\.$',
59
+ ]
60
+
61
+ [tool.coverage.run]
62
+ data_file = "__cache__/.coverage"
63
+
64
+ [tool.docfmt]
65
+ force-reflow = true
66
+ in-place = true
67
+ recursive = true
68
+ summary-on-own-line = true
69
+
70
+ [tool.doit]
71
+ dep_file = "__cache__/.doit.db"
72
+ verbosity = 2
73
+
74
+ [tool.isort]
75
+ profile = "black"
76
+ quiet = true
77
+
78
+ [tool.mypy]
79
+ check_untyped_defs = true
80
+ python_version = "3.12"
81
+
82
+ [[tool.mypy.overrides]]
83
+ ignore_missing_imports = true
84
+ module = [
85
+ "pytest_powerpack",
86
+ "pytest_powerpack.*",
87
+ ]
88
+
89
+ [tool.nox]
90
+ default_venv_backend = "uv"
91
+ envdir = "__cache__/nox"
92
+
93
+ [tool.pyright]
94
+ pythonVersion = "3.12"
95
+
96
+ [tool.pytest.ini_options]
97
+ addopts = "--import-mode=importlib -s -v -rA"
98
+ cache_dir = "__cache__/pytest"
99
+ powerpack_underline = true
100
+ testpaths = ["test"]
101
+
102
+ [tool.tomlsort]
103
+ sort_first = [
104
+ "project",
105
+ "dependency-groups",
106
+ "build-system",
107
+ ]
108
+ sort_table_keys = true
109
+ spaces_before_inline_comment = 2
@@ -23,21 +23,21 @@ license-files = ["LICENSE"]
23
23
  name = "mdforge"
24
24
  readme = "README.md"
25
25
  requires-python = ">=3.12,<4.0" # pyprojkit-managed
26
- version = "0.2.3"
26
+ version = "0.3.0"
27
27
 
28
28
  [project.urls]
29
29
  homepage = "https://github.com/mm21/mdforge"
30
30
 
31
31
  [dependency-groups]
32
32
  dev = [
33
- "pyprojkit[all]>=0.8.1,<0.9.0",
33
+ "pyprojkit[all]>=0.9.0,<0.10.0",
34
34
  "pytest-powerpack>=0.1.5,<0.2",
35
35
  "types-pyyaml>=6.0.12,<7"
36
36
  ]
37
37
 
38
38
  [build-system]
39
- build-backend = "uv_build"
40
- requires = ["uv_build>=0.11.6,<0.12.0"]
39
+ build-backend = "uv_build" # pyprojkit-tracked
40
+ requires = ["uv_build>=0.13.5,<0.14.0"] # pyprojkit-tracked
41
41
 
42
42
  [tool.autoflake]
43
43
  in_place = true # pyprojkit-managed
@@ -32,8 +32,10 @@ from .container import (
32
32
  )
33
33
  from .elements import (
34
34
  Section,
35
- Heading,
36
35
  BlockImage,
36
+ HorizontalRule,
37
+ Quote,
38
+ AlignedBlock,
37
39
  Paragraph,
38
40
  BlockText,
39
41
  ListItemType,
@@ -61,6 +63,7 @@ from .elements import (
61
63
  from .exceptions import (
62
64
  ValidationError,
63
65
  RenderError,
66
+ RenderWarning,
64
67
  )
65
68
 
66
69
  __all__ = [
@@ -74,8 +77,10 @@ __all__ = [
74
77
  "InlineContainer",
75
78
  "BlockContainer",
76
79
  "Section",
77
- "Heading",
78
80
  "BlockImage",
81
+ "HorizontalRule",
82
+ "Quote",
83
+ "AlignedBlock",
79
84
  "Paragraph",
80
85
  "BlockText",
81
86
  "ListItemType",
@@ -101,5 +106,6 @@ __all__ = [
101
106
  "InlineImage",
102
107
  "ValidationError",
103
108
  "RenderError",
109
+ "RenderWarning",
104
110
  ]
105
111
  # </AUTOGEN_INIT>
@@ -1,12 +1,21 @@
1
+ import warnings
2
+
1
3
  from .element import BaseElement, BaseInlineElement
2
- from .exceptions import ValidationError
4
+ from .exceptions import RenderError, RenderWarning, ValidationError
3
5
 
4
6
  __all__ = [
5
7
  "coerce_text",
6
8
  "coerce_inline_text",
7
9
  "wrap_para_cond",
10
+ "validate_level",
11
+ "check_level",
8
12
  ]
9
13
 
14
+ MAX_LEVEL: int = 6
15
+ """
16
+ Maximum heading level supported by markdown.
17
+ """
18
+
10
19
 
11
20
  def coerce_text(obj: str) -> BaseElement:
12
21
  """
@@ -29,8 +38,8 @@ def coerce_inline_text(obj: str, allow_multiline: bool = False) -> BaseInlineEle
29
38
  # get lines
30
39
  lines = obj.split("\n")
31
40
 
32
- # check for blank lines
33
- if any(line.strip() == "" for line in lines):
41
+ # check for blank lines, allowing single-line text to be only whitespace
42
+ if len(lines) > 1 and any(line.strip() == "" for line in lines):
34
43
  raise ValidationError(f"Input text cannot contain blank lines: {obj}")
35
44
 
36
45
  if len(lines) > 1 and allow_multiline:
@@ -58,3 +67,27 @@ def wrap_para_cond(lines: list[str]):
58
67
 
59
68
  lines[0] = f"<p>{lines[0]}"
60
69
  lines[-1] = f"{lines[-1]}</p>"
70
+
71
+
72
+ def validate_level(level: int | None):
73
+ """
74
+ Ensure the explicit level, if given, is supported by markdown.
75
+ """
76
+ if level is not None and not 1 <= level <= MAX_LEVEL:
77
+ raise ValidationError(f"Level must be between 1 and {MAX_LEVEL}, got {level}")
78
+
79
+
80
+ def check_level(desc: str, level: int, expected: int):
81
+ """
82
+ Check the level of an element against the level expected from its placement: fail if
83
+ it's not nested in its containing level, or warn if it skips levels.
84
+ """
85
+ if level < expected:
86
+ raise RenderError(
87
+ f"Level {level} of {desc} must be at least {expected} based on its placement"
88
+ )
89
+ if level > expected:
90
+ warnings.warn(
91
+ f"Level {level} of {desc} skips levels, expected {expected} based on its placement",
92
+ RenderWarning,
93
+ )