markdown-extractor 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,150 @@
1
+ """Render the parsed block tree to HTML.
2
+
3
+ Pure-Python, zero-dependency renderer for the same Markdown subset that
4
+ :mod:`markdown_extractor.blocks` understands: paragraphs, ordered/unordered
5
+ lists with nesting, code fences, blockquotes — plus the common inline
6
+ constructs (``**bold**``, ``*em*``, ``` `code` ```, ``[text](url)``,
7
+ ``![alt](url)``).
8
+
9
+ The output is plain HTML5 with no styling. It is intentionally minimal:
10
+ ``markdown-extractor``'s job is structural extraction, not pretty rendering.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from html import escape
17
+ from typing import Iterable, List
18
+
19
+ from markdown_extractor.blocks import Block
20
+
21
+
22
+ def render(blocks: Iterable[Block]) -> str:
23
+ """Render a sequence of blocks to an HTML fragment."""
24
+ return "\n".join(_render_block(b) for b in blocks)
25
+
26
+
27
+ def _render_block(block: Block) -> str:
28
+ if block.kind == "paragraph":
29
+ return f"<p>{_inline(block.text)}</p>"
30
+ if block.kind == "code":
31
+ cls = f' class="language-{escape(block.info)}"' if block.info else ""
32
+ return f"<pre><code{cls}>{escape(block.text)}</code></pre>"
33
+ if block.kind == "blockquote":
34
+ inner = render(block.children) if block.children else f"<p>{_inline(block.text)}</p>"
35
+ return f"<blockquote>\n{inner}\n</blockquote>"
36
+ if block.kind in ("list", "ordered_list"):
37
+ tag = "ol" if block.kind == "ordered_list" else "ul"
38
+ items = "\n".join(_render_block(item) for item in block.children)
39
+ return f"<{tag}>\n{items}\n</{tag}>"
40
+ if block.kind == "list_item":
41
+ body = _inline(block.text)
42
+ if block.children:
43
+ nested = render(block.children)
44
+ return f"<li>{body}\n{nested}\n</li>"
45
+ return f"<li>{body}</li>"
46
+ return f"<div>{_inline(block.text)}</div>"
47
+
48
+
49
+ # ---------------------------------------------------------------- inline
50
+
51
+ # Order matters: replace inline code first (so its contents are not further
52
+ # transformed), then images, links, bold, em.
53
+ _CODE_RE = re.compile(r"`([^`]+)`")
54
+ _IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)\)")
55
+ _LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
56
+ _BOLD_RE = re.compile(r"\*\*([^*]+)\*\*")
57
+ _EM_RE = re.compile(r"(?<![*\w])\*([^*\n]+?)\*(?!\w)")
58
+ _EM_UNDER_RE = re.compile(r"(?<![\w_])_([^_\n]+?)_(?!\w)")
59
+
60
+
61
+ def _inline(text: str) -> str:
62
+ """Convert the inline-formatting subset to HTML.
63
+
64
+ Inline ``code`` content is escaped and stashed before any other rule
65
+ runs, so backticks shield their contents from bold/em/link parsing.
66
+ """
67
+ if not text:
68
+ return ""
69
+
70
+ placeholders: List[str] = []
71
+
72
+ def stash(html: str) -> str:
73
+ placeholders.append(html)
74
+ return f"\x00{len(placeholders) - 1}\x00"
75
+
76
+ def repl_code(m: re.Match) -> str:
77
+ return stash(f"<code>{escape(m.group(1))}</code>")
78
+
79
+ out = _CODE_RE.sub(repl_code, text)
80
+ out = escape(out, quote=False)
81
+
82
+ def repl_img(m: re.Match) -> str:
83
+ alt = escape(m.group(1), quote=True)
84
+ src = escape(m.group(2), quote=True)
85
+ return stash(f'<img src="{src}" alt="{alt}">')
86
+
87
+ def repl_link(m: re.Match) -> str:
88
+ label = m.group(1)
89
+ href = escape(m.group(2), quote=True)
90
+ return stash(f'<a href="{href}">{label}</a>')
91
+
92
+ # The escape pass replaced angle brackets — restore the markers our
93
+ # regexes need by working on the escaped string for img/link too.
94
+ out = _IMG_RE.sub(repl_img, out)
95
+ out = _LINK_RE.sub(repl_link, out)
96
+ out = _BOLD_RE.sub(lambda m: f"<strong>{m.group(1)}</strong>", out)
97
+ out = _EM_RE.sub(lambda m: f"<em>{m.group(1)}</em>", out)
98
+ out = _EM_UNDER_RE.sub(lambda m: f"<em>{m.group(1)}</em>", out)
99
+
100
+ # Restore stashed HTML.
101
+ def unstash(m: re.Match) -> str:
102
+ return placeholders[int(m.group(1))]
103
+
104
+ out = re.sub(r"\x00(\d+)\x00", unstash, out)
105
+ return out
106
+
107
+
108
+ def query_xpath(html: str, xpath: str, as_text: bool = False) -> List[str]:
109
+ """Run ``xpath`` over ``html`` and return the matched fragments.
110
+
111
+ Requires the ``lxml`` extra (``pip install markdown-extractor[xpath]``).
112
+ By default each element match is returned as an HTML string;
113
+ string/attribute matches are returned as-is.
114
+
115
+ With ``as_text=True``, element matches are flattened to their text
116
+ content (recursively — so ``<li><strong>Bold</strong> rest</li>``
117
+ yields ``"Bold rest"``). Use this when you want the data inside the
118
+ element rather than the markup. Compare to writing ``/text()`` in
119
+ the XPath itself, which only collects *direct* text children and
120
+ skips text nested inside inline tags.
121
+ """
122
+ if not html:
123
+ return []
124
+ try:
125
+ from lxml import etree, html as lxml_html
126
+ except ImportError as e: # pragma: no cover - only hit without lxml
127
+ raise ModuleNotFoundError(
128
+ "XPath queries require the 'lxml' package. "
129
+ "Install with: pip install markdown-extractor[xpath]"
130
+ ) from e
131
+
132
+ fragment = lxml_html.fragment_fromstring(html, create_parent="div")
133
+ results = fragment.xpath(xpath)
134
+ out: List[str] = []
135
+ for r in results:
136
+ if isinstance(r, str):
137
+ out.append(r)
138
+ elif isinstance(r, etree._Element):
139
+ if as_text:
140
+ out.append(r.text_content())
141
+ else:
142
+ # ``with_tail=False`` excludes sibling text after the closing
143
+ # tag — callers asking for an element fragment want just the
144
+ # element, not its trailing context.
145
+ out.append(
146
+ lxml_html.tostring(r, encoding="unicode", with_tail=False).rstrip()
147
+ )
148
+ else:
149
+ out.append(str(r))
150
+ return out
@@ -0,0 +1,153 @@
1
+ """Header detection for Markdown documents.
2
+
3
+ The parser walks the document line by line while tracking which "block
4
+ context" each line belongs to. Headers found inside fenced code blocks,
5
+ math blocks, tables, or YAML front matter are intentionally ignored.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import List, NamedTuple, Tuple
12
+
13
+
14
+ class Header(NamedTuple):
15
+ """A single header occurrence detected in the source document."""
16
+
17
+ line: int
18
+ level: int
19
+ title: str
20
+
21
+
22
+ # ATX header: optional indentation, 1–6 #s, required space, title, optional
23
+ # trailing #s. We allow any amount of leading whitespace because the spec
24
+ # explicitly calls for "indentation support".
25
+ _ATX_RE = re.compile(r"^[ \t]*(#{1,6})[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$")
26
+
27
+ # Fenced code block opener: 3+ backticks or 3+ tildes with up to 3 leading
28
+ # spaces (CommonMark restricts the indent of a fence to <4 spaces).
29
+ _FENCE_RE = re.compile(r"^[ ]{0,3}(`{3,}|~{3,})")
30
+
31
+ # Setext underlines.
32
+ _SETEXT_H1_RE = re.compile(r"^[ ]{0,3}=+[ \t]*$")
33
+ _SETEXT_H2_RE = re.compile(r"^[ ]{0,3}-+[ \t]*$")
34
+
35
+ # Quick check for "looks like a list item" — used to disambiguate setext h2
36
+ # from a regular --- horizontal rule below a paragraph.
37
+ _LIST_RE = re.compile(r"^[ \t]*([-*+]|\d+[.)])[ \t]")
38
+
39
+ # Table separator row: |---|---| or :---:|---: etc.
40
+ _TABLE_SEP_RE = re.compile(r"^[ \t]*\|?[ \t]*:?-+:?([ \t]*\|[ \t]*:?-+:?)+[ \t]*\|?[ \t]*$")
41
+
42
+
43
+ def _closing_fence_re(fence_char: str, fence_len: int) -> re.Pattern[str]:
44
+ return re.compile(
45
+ r"^[ ]{0,3}" + re.escape(fence_char) + r"{" + str(fence_len) + r",}[ \t]*$"
46
+ )
47
+
48
+
49
+ def parse(content: str) -> Tuple[List[Header], List[str]]:
50
+ """Return ``(headers, lines)`` for ``content``.
51
+
52
+ ``lines`` is the document split on ``\n`` (newlines stripped) and is
53
+ shared with :class:`markdown_extractor.section.Section` so each section can
54
+ rebuild its own slice of the source on demand.
55
+ """
56
+ lines = content.split("\n")
57
+ headers: List[Header] = []
58
+
59
+ in_code = False
60
+ fence_char = ""
61
+ fence_len = 0
62
+ closing_re: re.Pattern[str] | None = None
63
+
64
+ in_math = False
65
+
66
+ in_yaml = False
67
+ yaml_checked = False
68
+
69
+ in_table = False
70
+
71
+ for i, line in enumerate(lines):
72
+ stripped = line.strip()
73
+
74
+ # ---------- YAML front matter (only at the very top of the file)
75
+ if not yaml_checked:
76
+ yaml_checked = True
77
+ if stripped == "---":
78
+ in_yaml = True
79
+ continue
80
+
81
+ if in_yaml:
82
+ if stripped == "---" or stripped == "...":
83
+ in_yaml = False
84
+ continue
85
+
86
+ # ---------- Fenced code blocks
87
+ if in_code:
88
+ assert closing_re is not None
89
+ if closing_re.match(line):
90
+ in_code = False
91
+ closing_re = None
92
+ continue
93
+
94
+ m_fence = _FENCE_RE.match(line)
95
+ if m_fence:
96
+ in_code = True
97
+ marker = m_fence.group(1)
98
+ fence_char = marker[0]
99
+ fence_len = len(marker)
100
+ closing_re = _closing_fence_re(fence_char, fence_len)
101
+ in_table = False
102
+ continue
103
+
104
+ # ---------- Math blocks ($$ ... $$)
105
+ if stripped == "$$":
106
+ in_math = not in_math
107
+ in_table = False
108
+ continue
109
+ if in_math:
110
+ continue
111
+
112
+ # ---------- Tables
113
+ # A table block runs from the first pipe-line through a blank line.
114
+ # Headers inside a table are extremely unusual but we still skip
115
+ # them to honour the spec.
116
+ if not stripped:
117
+ in_table = False
118
+ elif _TABLE_SEP_RE.match(line) or "|" in stripped:
119
+ # Stay in table mode while we see pipe-lines or separators.
120
+ if in_table or _TABLE_SEP_RE.match(line) or stripped.startswith("|"):
121
+ in_table = True
122
+ # Continue: a pipe-line itself can never be a header.
123
+ continue
124
+
125
+ if in_table:
126
+ continue
127
+
128
+ # ---------- ATX headers
129
+ m_atx = _ATX_RE.match(line)
130
+ if m_atx:
131
+ level = len(m_atx.group(1))
132
+ title = m_atx.group(2).strip().rstrip("#").rstrip()
133
+ if title:
134
+ headers.append(Header(line=i, level=level, title=title))
135
+ continue
136
+
137
+ # ---------- Setext headers (=== / ---)
138
+ # The underline lives on the *current* line; the title is the
139
+ # previous line. We only accept it when the previous line is
140
+ # genuine paragraph text (not a list item, not blank, and not
141
+ # already classified as something else).
142
+ if i > 0 and stripped:
143
+ prev = lines[i - 1]
144
+ prev_stripped = prev.strip()
145
+ if prev_stripped and not _LIST_RE.match(prev) and not _ATX_RE.match(prev):
146
+ if _SETEXT_H1_RE.match(line):
147
+ headers.append(Header(line=i - 1, level=1, title=prev_stripped))
148
+ continue
149
+ if _SETEXT_H2_RE.match(line) and prev_stripped != "---":
150
+ headers.append(Header(line=i - 1, level=2, title=prev_stripped))
151
+ continue
152
+
153
+ return headers, lines
File without changes
@@ -0,0 +1,320 @@
1
+ """The :class:`Section` node — one entry in the parsed header tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Dict, Iterator, List, Optional, Union
7
+
8
+ from markdown_extractor.blocks import Block, _null_block, flatten, parse_blocks
9
+ from markdown_extractor.html_renderer import query_xpath, render
10
+ from markdown_extractor.text_renderer import render_text
11
+
12
+
13
+ class Section:
14
+ """A single header (and its body) in a parsed Markdown document.
15
+
16
+ A ``Section`` lazily slices the original document on access — there is
17
+ no per-section copy of the source, so the tree is cheap to hold even
18
+ for very large documents.
19
+ """
20
+
21
+ __slots__ = (
22
+ "title",
23
+ "level",
24
+ "line_start",
25
+ "line_end",
26
+ "parent",
27
+ "children",
28
+ "_lines",
29
+ "_blocks_cache",
30
+ )
31
+
32
+ def __init__(
33
+ self,
34
+ title: str,
35
+ level: int,
36
+ line_start: int,
37
+ line_end: Optional[int] = None,
38
+ parent: Optional["Section"] = None,
39
+ lines: Optional[List[str]] = None,
40
+ ) -> None:
41
+ self.title = title
42
+ self.level = level
43
+ self.line_start = line_start
44
+ self.line_end = line_end
45
+ self.parent = parent
46
+ self.children: List["Section"] = []
47
+ self._lines = lines
48
+ self._blocks_cache: Optional[List[Block]] = None
49
+
50
+ # ------------------------------------------------------------------ slices
51
+
52
+ @property
53
+ def content(self) -> str:
54
+ """The header line plus everything beneath it, including subsections."""
55
+ if self._lines is None or self.line_end is None:
56
+ return ""
57
+ return "\n".join(self._lines[self.line_start : self.line_end])
58
+
59
+ @property
60
+ def body(self) -> str:
61
+ """The section content with the header line removed.
62
+
63
+ For the synthetic root (level 0) this is identical to :attr:`content`
64
+ because the root has no header line to strip.
65
+ """
66
+ if self._lines is None or self.line_end is None:
67
+ return ""
68
+ start = self.line_start if self.level == 0 else self.line_start + 1
69
+ return "\n".join(self._lines[start : self.line_end])
70
+
71
+ @property
72
+ def text(self) -> str:
73
+ """Just this section's own prose — the header is dropped and any
74
+ nested subsections are excluded."""
75
+ if self._lines is None or self.line_end is None:
76
+ return ""
77
+ start = self.line_start if self.level == 0 else self.line_start + 1
78
+ end = self.children[0].line_start if self.children else self.line_end
79
+ return "\n".join(self._lines[start:end])
80
+
81
+ # ------------------------------------------------------------------ navigation
82
+
83
+ @property
84
+ def path(self) -> List[str]:
85
+ """Titles from the topmost ancestor down to this section (root excluded)."""
86
+ result: List[str] = []
87
+ node: Optional[Section] = self
88
+ while node is not None and node.level > 0:
89
+ result.append(node.title)
90
+ node = node.parent
91
+ result.reverse()
92
+ return result
93
+
94
+ def list(self) -> List[str]:
95
+ """Titles of immediate child sections."""
96
+ return [c.title for c in self.children]
97
+
98
+ def get_section(self, *path: str) -> "Section":
99
+ """Walk a sequence of child titles, e.g. ``s.get_section("A", "B")``.
100
+
101
+ Strict — raises :class:`KeyError` if any title is missing. For
102
+ an error-free version that flows through to empty results, use
103
+ :meth:`get`.
104
+ """
105
+ node: Section = self
106
+ for title in path:
107
+ node = node[title]
108
+ return node
109
+
110
+ def get(self, *path: str) -> "Section":
111
+ """Soft path walk — returns a *null* section on miss instead of raising.
112
+
113
+ ``e.get("Foo", "Bar").to_list()`` returns ``[]`` (not a ``KeyError``)
114
+ if either ``"Foo"`` or ``"Bar"`` is missing. The returned null
115
+ section is falsy (``bool(s) is False``) and its ``to_list``,
116
+ ``to_dict``, ``to_json``, ``to_html``, and ``to_text`` methods
117
+ return empty values, so chains stay safe.
118
+
119
+ Use ``[]`` (or :meth:`get_section`) when you want missing keys
120
+ to fail loudly.
121
+ """
122
+ node: Section = self
123
+ for title in path:
124
+ if not node: # already null — keep flowing
125
+ return _null_section()
126
+ found: Optional[Section] = None
127
+ for child in node.children:
128
+ if child.title == title:
129
+ found = child
130
+ break
131
+ if found is None:
132
+ return _null_section()
133
+ node = found
134
+ return node
135
+
136
+ def find(self, title: str) -> List["Section"]:
137
+ """All descendants whose title equals ``title`` (depth-first order)."""
138
+ results: List[Section] = []
139
+ for child in self.children:
140
+ if child.title == title:
141
+ results.append(child)
142
+ results.extend(child.find(title))
143
+ return results
144
+
145
+ def walk(self) -> Iterator["Section"]:
146
+ """Yield this section and every descendant, depth-first."""
147
+ yield self
148
+ for child in self.children:
149
+ yield from child.walk()
150
+
151
+ # ------------------------------------------------------------------ body blocks
152
+
153
+ @property
154
+ def blocks(self) -> List[Block]:
155
+ """Lazy parse of this section's own prose into a block tree.
156
+
157
+ The block tree covers paragraphs, ordered/unordered lists with
158
+ nested items, code fences, and blockquotes. Header subsections
159
+ of this section are *not* included — those live in
160
+ :attr:`children`.
161
+ """
162
+ if self._blocks_cache is None:
163
+ self._blocks_cache = parse_blocks(self.text)
164
+ return self._blocks_cache
165
+
166
+ def to_list(self) -> List[str]:
167
+ """Flatten the body into a list of strings, one per top-level
168
+ block (or one per top-level list item if the body is a list).
169
+
170
+ Useful when you want the section's body as data — e.g. ``Overview``
171
+ bullets as a list of feature strings — rather than as a header
172
+ title roster (which is what :meth:`list` returns).
173
+ """
174
+ return flatten(self.blocks)
175
+
176
+ def block(self, *indices: int) -> Block:
177
+ """Soft index walk into this section's body block tree.
178
+
179
+ The first index addresses :attr:`blocks`; subsequent indices walk
180
+ into ``.children``. Returns a *null Block* (whose ``text_plain``
181
+ is ``""``) if any index is out of range, so chains like
182
+ ``section.block(99, 0).text_plain`` stay safe.
183
+
184
+ ``section.block(1, 1).text_plain`` is the soft equivalent of
185
+ ``section.blocks[1].children[1].text_plain``.
186
+ """
187
+ if not indices:
188
+ return _null_block()
189
+ blocks = self.blocks
190
+ head, *rest = indices
191
+ n = len(blocks)
192
+ if not n or head < -n or head >= n:
193
+ return _null_block()
194
+ return blocks[head].get(*rest)
195
+
196
+ def to_text(self) -> str:
197
+ """Render the body to plain text with Markdown markers stripped.
198
+
199
+ Bullets become ``- `` lines, ordered items become ``1. `` lines,
200
+ nested children indent by four spaces, code blocks are kept
201
+ verbatim, and inline ``**bold**`` / ``*em*`` / ``` `code` `` /
202
+ links / images are reduced to their visible text.
203
+ """
204
+ return render_text(self.blocks)
205
+
206
+ # ------------------------------------------------------------------ serialisation
207
+
208
+ def to_dict(self) -> Dict[str, Any]:
209
+ """Convert to a JSON-friendly nested dict.
210
+
211
+ Includes both header subsections (``children``) and the body
212
+ block tree (``blocks``). The ``blocks`` field captures bullet
213
+ lists, paragraphs, and indented continuations as nested nodes.
214
+ """
215
+ return {
216
+ "title": self.title,
217
+ "level": self.level,
218
+ "text": self.text,
219
+ "blocks": [b.to_dict() for b in self.blocks],
220
+ "children": [c.to_dict() for c in self.children],
221
+ }
222
+
223
+ def to_json(self, **kwargs: Any) -> str:
224
+ """Shorthand for ``json.dumps(self.to_dict(), **kwargs)``."""
225
+ return json.dumps(self.to_dict(), **kwargs)
226
+
227
+ def to_html(
228
+ self, xpath: Optional[str] = None, as_text: bool = False
229
+ ) -> Union[str, List[str]]:
230
+ """Render this section's body as an HTML fragment.
231
+
232
+ Without ``xpath``, returns the full HTML string. With ``xpath``,
233
+ returns a list of matched fragments — each is an HTML string for
234
+ element matches, or the raw value for string / attribute matches.
235
+
236
+ Pass ``as_text=True`` to flatten element matches to their text
237
+ content (recursively, so inline tags like ``<strong>`` are
238
+ unwrapped). Useful when you want the data inside the element
239
+ rather than the markup. Has no effect when ``xpath`` is ``None``.
240
+
241
+ XPath support requires the optional ``lxml`` extra::
242
+
243
+ pip install markdown-extractor[xpath]
244
+ """
245
+ html = render(self.blocks)
246
+ if xpath is None:
247
+ return html
248
+ return query_xpath(html, xpath, as_text=as_text)
249
+
250
+ def tree(self, _indent: int = 0) -> str:
251
+ """ASCII tree rendering of this section and its descendants."""
252
+ label = "<root>" if self.level == 0 else f"{'#' * self.level} {self.title}"
253
+ out = " " * _indent + label
254
+ for child in self.children:
255
+ out += "\n" + child.tree(_indent + 1)
256
+ return out
257
+
258
+ # ------------------------------------------------------------------ dunder
259
+
260
+ def __getitem__(self, key: Union[str, int]) -> "Section":
261
+ if isinstance(key, int):
262
+ return self.children[key]
263
+ for child in self.children:
264
+ if child.title == key:
265
+ return child
266
+ raise KeyError(
267
+ f"Section {key!r} not found. Available children: {self.list()!r}"
268
+ )
269
+
270
+ def __contains__(self, key: object) -> bool:
271
+ if isinstance(key, str):
272
+ return any(child.title == key for child in self.children)
273
+ return False
274
+
275
+ def __iter__(self) -> Iterator["Section"]:
276
+ return iter(self.children)
277
+
278
+ def __len__(self) -> int:
279
+ return len(self.children)
280
+
281
+ def __str__(self) -> str:
282
+ return self.content
283
+
284
+ def __bool__(self) -> bool:
285
+ """A non-null section is truthy. The sentinel returned by
286
+ :meth:`get` on a missing path is the only falsy ``Section``."""
287
+ return self._lines is not None
288
+
289
+ def __repr__(self) -> str:
290
+ if self._lines is None:
291
+ return "Section(<null>)"
292
+ return (
293
+ f"Section(title={self.title!r}, level={self.level}, "
294
+ f"children={len(self.children)})"
295
+ )
296
+
297
+
298
+ # ---------------------------------------------------------------- null sentinel
299
+
300
+ _NULL_SECTION: Optional[Section] = None
301
+
302
+
303
+ def _null_section() -> Section:
304
+ """Cached null-section sentinel returned by :meth:`Section.get` on miss.
305
+
306
+ Behaviour summary:
307
+ - ``bool(s)`` is ``False``
308
+ - ``to_list()`` → ``[]``
309
+ - ``to_dict()`` → ``{"title": "", "level": 0, "text": "", "blocks": [], "children": []}``
310
+ - ``to_json()`` → JSON of the above
311
+ - ``to_html()`` → ``""`` (with ``xpath=...`` → ``[]``)
312
+ - ``to_text()`` → ``""``
313
+ - ``get(*more)`` → keeps returning this sentinel
314
+ """
315
+ global _NULL_SECTION
316
+ if _NULL_SECTION is None:
317
+ _NULL_SECTION = Section(
318
+ title="", level=0, line_start=0, line_end=None, lines=None
319
+ )
320
+ return _NULL_SECTION