lol

nixos-render-docs: don't use markdown-it RendererProtocol

our renderers carry significantly more state than markdown-it wants to
easily cater for, and the html renderer will need even more state still.
relying on the markdown-it-provided rendering functions has already
proven to be a nuisance, and since parsing and rendering are split well
enough we can just replace the rendering part with our own stuff outright.

this also frees us from the tyranny of having to set instance variables
before calling super().__init__ just to make sure that the renderer
creation callback has access to everything it needs.

pennae 0236dcb5 3794c04d

+101 -94
+2 -3
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/asciidoc.py
··· 5 5 6 6 from .md import Renderer 7 7 8 - import markdown_it 9 8 from markdown_it.token import Token 10 9 from markdown_it.utils import OptionsDict 11 10 ··· 59 58 _list_stack: list[List] 60 59 _attrspans: list[str] 61 60 62 - def __init__(self, manpage_urls: Mapping[str, str], parser: Optional[markdown_it.MarkdownIt] = None): 63 - super().__init__(manpage_urls, parser) 61 + def __init__(self, manpage_urls: Mapping[str, str]): 62 + super().__init__(manpage_urls) 64 63 self._parstack = [ Par("\n\n", "====") ] 65 64 self._list_stack = [] 66 65 self._attrspans = []
+2 -3
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/commonmark.py
··· 4 4 5 5 from .md import md_escape, md_make_code, Renderer 6 6 7 - import markdown_it 8 7 from markdown_it.token import Token 9 8 from markdown_it.utils import OptionsDict 10 9 ··· 26 25 _link_stack: list[str] 27 26 _list_stack: list[List] 28 27 29 - def __init__(self, manpage_urls: Mapping[str, str], parser: Optional[markdown_it.MarkdownIt] = None): 30 - super().__init__(manpage_urls, parser) 28 + def __init__(self, manpage_urls: Mapping[str, str]): 29 + super().__init__(manpage_urls) 31 30 self._parstack = [ Par("") ] 32 31 self._link_stack = [] 33 32 self._list_stack = []
+2 -3
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/docbook.py
··· 32 32 partintro_closed: bool = False 33 33 34 34 class DocBookRenderer(Renderer): 35 - __output__ = "docbook" 36 35 _link_tags: list[str] 37 36 _deflists: list[Deflist] 38 37 _headings: list[Heading] 39 38 _attrspans: list[str] 40 39 41 - def __init__(self, manpage_urls: Mapping[str, str], parser: Optional[markdown_it.MarkdownIt] = None): 42 - super().__init__(manpage_urls, parser) 40 + def __init__(self, manpage_urls: Mapping[str, str]): 41 + super().__init__(manpage_urls) 43 42 self._link_tags = [] 44 43 self._deflists = [] 45 44 self._headings = []
+2 -5
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manpage.py
··· 75 75 # horizontal motion in a line) we do attempt to copy the style of mdoc(7) semantic requests 76 76 # as appropriate for each markup element. 77 77 class ManpageRenderer(Renderer): 78 - __output__ = "man" 79 - 80 78 # whether to emit mdoc .Ql equivalents for inline code or just the contents. this is 81 79 # mainly used by the options manpage converter to not emit extra quotes in defaults 82 80 # and examples where it's already clear from context that the following text is code. ··· 90 88 _list_stack: list[List] 91 89 _font_stack: list[str] 92 90 93 - def __init__(self, manpage_urls: Mapping[str, str], href_targets: dict[str, str], 94 - parser: Optional[markdown_it.MarkdownIt] = None): 95 - super().__init__(manpage_urls, parser) 91 + def __init__(self, manpage_urls: Mapping[str, str], href_targets: dict[str, str]): 92 + super().__init__(manpage_urls) 96 93 self._href_targets = href_targets 97 94 self._link_stack = [] 98 95 self._do_parbreak_stack = []
+7 -11
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/manual.py
··· 18 18 class ManualDocBookRenderer(DocBookRenderer): 19 19 _toplevel_tag: str 20 20 21 - def __init__(self, toplevel_tag: str, manpage_urls: Mapping[str, str], 22 - parser: Optional[markdown_it.MarkdownIt] = None): 23 - super().__init__(manpage_urls, parser) 21 + def __init__(self, toplevel_tag: str, manpage_urls: Mapping[str, str]): 22 + super().__init__(manpage_urls) 24 23 self._toplevel_tag = toplevel_tag 25 24 self.rules |= { 26 25 'included_sections': lambda *args: self._included_thing("section", *args), ··· 92 91 self._headings[-1] = self._headings[-1]._replace(partintro_closed=True) 93 92 # must nest properly for structural includes. this requires saving at least 94 93 # the headings stack, but creating new renderers is cheap and much easier. 95 - r = ManualDocBookRenderer(tag, self._manpage_urls, None) 94 + r = ManualDocBookRenderer(tag, self._manpage_urls) 96 95 for (included, path) in token.meta['included']: 97 96 try: 98 97 result.append(r.render(included, options, env)) ··· 118 117 info = f" language={quoteattr(token.info)}" if token.info != "" else "" 119 118 return f"<programlisting{info}>\n{escape(token.content)}</programlisting>" 120 119 121 - class DocBookConverter(Converter): 122 - def __renderer__(self, manpage_urls: Mapping[str, str], 123 - parser: Optional[markdown_it.MarkdownIt]) -> ManualDocBookRenderer: 124 - return ManualDocBookRenderer('book', manpage_urls, parser) 125 - 120 + class DocBookConverter(Converter[ManualDocBookRenderer]): 126 121 _base_paths: list[Path] 127 122 _revision: str 128 123 129 124 def __init__(self, manpage_urls: Mapping[str, str], revision: str): 130 - super().__init__(manpage_urls) 125 + super().__init__() 126 + self._renderer = ManualDocBookRenderer('book', manpage_urls) 131 127 self._revision = revision 132 128 133 129 def convert(self, file: Path) -> str: ··· 195 191 196 192 try: 197 193 conv = options.DocBookConverter( 198 - self._manpage_urls, self._revision, False, 'fragment', varlist_id, id_prefix) 194 + self._renderer._manpage_urls, self._revision, False, 'fragment', varlist_id, id_prefix) 199 195 with open(self._base_paths[-1].parent / source, 'r') as f: 200 196 conv.add_options(json.load(f)) 201 197 token.meta['rendered-options'] = conv.finalize(fragment=True)
+23 -9
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py
··· 1 1 from abc import ABC 2 2 from collections.abc import Mapping, MutableMapping, Sequence 3 - from typing import Any, Callable, cast, get_args, Iterable, Literal, NoReturn, Optional 3 + from typing import Any, Callable, cast, Generic, get_args, Iterable, Literal, NoReturn, Optional, TypeVar 4 4 5 5 import dataclasses 6 6 import re ··· 44 44 45 45 AdmonitionKind = Literal["note", "caution", "tip", "important", "warning"] 46 46 47 - class Renderer(markdown_it.renderer.RendererProtocol): 47 + class Renderer: 48 48 _admonitions: dict[AdmonitionKind, tuple[RenderFn, RenderFn]] 49 49 _admonition_stack: list[AdmonitionKind] 50 50 51 - def __init__(self, manpage_urls: Mapping[str, str], parser: Optional[markdown_it.MarkdownIt] = None): 51 + def __init__(self, manpage_urls: Mapping[str, str]): 52 52 self._manpage_urls = manpage_urls 53 53 self.rules = { 54 54 'text': self.text, ··· 466 466 467 467 md.core.ruler.push("block_attr", block_attr) 468 468 469 - class Converter(ABC): 470 - __renderer__: Callable[[Mapping[str, str], markdown_it.MarkdownIt], Renderer] 469 + TR = TypeVar('TR', bound='Renderer') 471 470 472 - def __init__(self, manpage_urls: Mapping[str, str]): 473 - self._manpage_urls = manpage_urls 471 + class Converter(ABC, Generic[TR]): 472 + # we explicitly disable markdown-it rendering support and use our own entirely. 473 + # rendering is well separated from parsing and our renderers carry much more state than 474 + # markdown-it easily acknowledges as 'good' (unless we used the untyped env args to 475 + # shuttle that state around, which is very fragile) 476 + class ForbiddenRenderer(markdown_it.renderer.RendererProtocol): 477 + __output__ = "none" 474 478 479 + def __init__(self, parser: Optional[markdown_it.MarkdownIt]): 480 + pass 481 + 482 + def render(self, tokens: Sequence[Token], options: OptionsDict, 483 + env: MutableMapping[str, Any]) -> str: 484 + raise NotImplementedError("do not use Converter._md.renderer. 'tis a silly place") 485 + 486 + _renderer: TR 487 + 488 + def __init__(self) -> None: 475 489 self._md = markdown_it.MarkdownIt( 476 490 "commonmark", 477 491 { ··· 479 493 'html': False, # not useful since we target many formats 480 494 'typographer': True, # required for smartquotes 481 495 }, 482 - renderer_cls=lambda parser: self.__renderer__(self._manpage_urls, parser) 496 + renderer_cls=self.ForbiddenRenderer 483 497 ) 484 498 self._md.use( 485 499 container_plugin, ··· 502 516 def _render(self, src: str, env: Optional[MutableMapping[str, Any]] = None) -> str: 503 517 env = {} if env is None else env 504 518 tokens = self._parse(src, env) 505 - return self._md.renderer.render(tokens, self._md.options, env) # type: ignore[no-any-return] 519 + return self._renderer.render(tokens, self._md.options, env)
+30 -30
pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py
··· 7 7 from collections.abc import Mapping, MutableMapping, Sequence 8 8 from markdown_it.utils import OptionsDict 9 9 from markdown_it.token import Token 10 - from typing import Any, Optional 10 + from typing import Any, Generic, Optional 11 11 from urllib.parse import quote 12 12 from xml.sax.saxutils import escape, quoteattr 13 13 14 14 import markdown_it 15 15 16 + from . import md 16 17 from . import parallel 17 18 from .asciidoc import AsciiDocRenderer, asciidoc_escape 18 19 from .commonmark import CommonMarkRenderer ··· 30 31 return None 31 32 return option[key] # type: ignore[return-value] 32 33 33 - class BaseConverter(Converter): 34 + class BaseConverter(Converter[md.TR], Generic[md.TR]): 34 35 __option_block_separator__: str 35 36 36 37 _options: dict[str, RenderedOption] 37 38 38 - def __init__(self, manpage_urls: Mapping[str, str], 39 - revision: str, 40 - markdown_by_default: bool): 41 - super().__init__(manpage_urls) 39 + def __init__(self, revision: str, markdown_by_default: bool): 40 + super().__init__() 42 41 self._options = {} 43 42 self._revision = revision 44 43 self._markdown_by_default = markdown_by_default ··· 153 152 # since it's good enough so far. 154 153 @classmethod 155 154 @abstractmethod 156 - def _parallel_render_init_worker(cls, a: Any) -> BaseConverter: raise NotImplementedError() 155 + def _parallel_render_init_worker(cls, a: Any) -> BaseConverter[md.TR]: raise NotImplementedError() 157 156 158 157 def _render_option(self, name: str, option: dict[str, Any]) -> RenderedOption: 159 158 try: ··· 162 161 raise Exception(f"Failed to render option {name}") from e 163 162 164 163 @classmethod 165 - def _parallel_render_step(cls, s: BaseConverter, a: Any) -> RenderedOption: 164 + def _parallel_render_step(cls, s: BaseConverter[md.TR], a: Any) -> RenderedOption: 166 165 return s._render_option(*a) 167 166 168 167 def add_options(self, options: dict[str, Any]) -> None: ··· 199 198 token.meta['compact'] = False 200 199 return super().bullet_list_open(token, tokens, i, options, env) 201 200 202 - class DocBookConverter(BaseConverter): 203 - __renderer__ = OptionsDocBookRenderer 201 + class DocBookConverter(BaseConverter[OptionsDocBookRenderer]): 204 202 __option_block_separator__ = "" 205 203 206 204 def __init__(self, manpage_urls: Mapping[str, str], ··· 209 207 document_type: str, 210 208 varlist_id: str, 211 209 id_prefix: str): 212 - super().__init__(manpage_urls, revision, markdown_by_default) 210 + super().__init__(revision, markdown_by_default) 211 + self._renderer = OptionsDocBookRenderer(manpage_urls) 213 212 self._document_type = document_type 214 213 self._varlist_id = varlist_id 215 214 self._id_prefix = id_prefix 216 215 217 216 def _parallel_render_prepare(self) -> Any: 218 - return (self._manpage_urls, self._revision, self._markdown_by_default, self._document_type, 217 + return (self._renderer._manpage_urls, self._revision, self._markdown_by_default, self._document_type, 219 218 self._varlist_id, self._id_prefix) 220 219 @classmethod 221 220 def _parallel_render_init_worker(cls, a: Any) -> DocBookConverter: ··· 300 299 class OptionsManpageRenderer(OptionDocsRestrictions, ManpageRenderer): 301 300 pass 302 301 303 - class ManpageConverter(BaseConverter): 304 - def __renderer__(self, manpage_urls: Mapping[str, str], 305 - parser: Optional[markdown_it.MarkdownIt] = None) -> OptionsManpageRenderer: 306 - return OptionsManpageRenderer(manpage_urls, self._options_by_id, parser) 307 - 302 + class ManpageConverter(BaseConverter[OptionsManpageRenderer]): 308 303 __option_block_separator__ = ".sp" 309 304 310 305 _options_by_id: dict[str, str] ··· 314 309 *, 315 310 # only for parallel rendering 316 311 _options_by_id: Optional[dict[str, str]] = None): 312 + super().__init__(revision, markdown_by_default) 317 313 self._options_by_id = _options_by_id or {} 318 - super().__init__({}, revision, markdown_by_default) 314 + self._renderer = OptionsManpageRenderer({}, self._options_by_id) 319 315 320 316 def _parallel_render_prepare(self) -> Any: 321 317 return ((self._revision, self._markdown_by_default), { '_options_by_id': self._options_by_id }) ··· 324 320 return cls(*a[0], **a[1]) 325 321 326 322 def _render_option(self, name: str, option: dict[str, Any]) -> RenderedOption: 327 - assert isinstance(self._md.renderer, OptionsManpageRenderer) 328 - links = self._md.renderer.link_footnotes = [] 323 + links = self._renderer.link_footnotes = [] 329 324 result = super()._render_option(name, option) 330 - self._md.renderer.link_footnotes = None 325 + self._renderer.link_footnotes = None 331 326 return result._replace(links=links) 332 327 333 328 def add_options(self, options: dict[str, Any]) -> None: ··· 339 334 if lit := option_is(option, key, 'literalDocBook'): 340 335 raise RuntimeError("can't render manpages in the presence of docbook") 341 336 else: 342 - assert isinstance(self._md.renderer, OptionsManpageRenderer) 343 337 try: 344 - self._md.renderer.inline_code_is_quoted = False 338 + self._renderer.inline_code_is_quoted = False 345 339 return super()._render_code(option, key) 346 340 finally: 347 - self._md.renderer.inline_code_is_quoted = True 341 + self._renderer.inline_code_is_quoted = True 348 342 349 343 def _render_description(self, desc: str | dict[str, Any]) -> list[str]: 350 344 if isinstance(desc, str) and not self._markdown_by_default: ··· 428 422 class OptionsCommonMarkRenderer(OptionDocsRestrictions, CommonMarkRenderer): 429 423 pass 430 424 431 - class CommonMarkConverter(BaseConverter): 432 - __renderer__ = OptionsCommonMarkRenderer 425 + class CommonMarkConverter(BaseConverter[OptionsCommonMarkRenderer]): 433 426 __option_block_separator__ = "" 434 427 428 + def __init__(self, manpage_urls: Mapping[str, str], revision: str, markdown_by_default: bool): 429 + super().__init__(revision, markdown_by_default) 430 + self._renderer = OptionsCommonMarkRenderer(manpage_urls) 431 + 435 432 def _parallel_render_prepare(self) -> Any: 436 - return (self._manpage_urls, self._revision, self._markdown_by_default) 433 + return (self._renderer._manpage_urls, self._revision, self._markdown_by_default) 437 434 @classmethod 438 435 def _parallel_render_init_worker(cls, a: Any) -> CommonMarkConverter: 439 436 return cls(*a) ··· 481 478 class OptionsAsciiDocRenderer(OptionDocsRestrictions, AsciiDocRenderer): 482 479 pass 483 480 484 - class AsciiDocConverter(BaseConverter): 485 - __renderer__ = AsciiDocRenderer 481 + class AsciiDocConverter(BaseConverter[OptionsAsciiDocRenderer]): 486 482 __option_block_separator__ = "" 487 483 484 + def __init__(self, manpage_urls: Mapping[str, str], revision: str, markdown_by_default: bool): 485 + super().__init__(revision, markdown_by_default) 486 + self._renderer = OptionsAsciiDocRenderer(manpage_urls) 487 + 488 488 def _parallel_render_prepare(self) -> Any: 489 - return (self._manpage_urls, self._revision, self._markdown_by_default) 489 + return (self._renderer._manpage_urls, self._revision, self._markdown_by_default) 490 490 @classmethod 491 491 def _parallel_render_init_worker(cls, a: Any) -> AsciiDocConverter: 492 492 return cls(*a)
+5 -3
pkgs/tools/nix/nixos-render-docs/src/tests/test_asciidoc.py
··· 1 - import nixos_render_docs 1 + import nixos_render_docs as nrd 2 2 3 3 from sample_md import sample1 4 4 5 - class Converter(nixos_render_docs.md.Converter): 6 - __renderer__ = nixos_render_docs.asciidoc.AsciiDocRenderer 5 + class Converter(nrd.md.Converter[nrd.asciidoc.AsciiDocRenderer]): 6 + def __init__(self, manpage_urls: dict[str, str]): 7 + super().__init__() 8 + self._renderer = nrd.asciidoc.AsciiDocRenderer(manpage_urls) 7 9 8 10 def test_lists() -> None: 9 11 c = Converter({})
+5 -3
pkgs/tools/nix/nixos-render-docs/src/tests/test_commonmark.py
··· 1 - import nixos_render_docs 1 + import nixos_render_docs as nrd 2 2 3 3 from sample_md import sample1 4 4 ··· 6 6 7 7 import markdown_it 8 8 9 - class Converter(nixos_render_docs.md.Converter): 10 - __renderer__ = nixos_render_docs.commonmark.CommonMarkRenderer 9 + class Converter(nrd.md.Converter[nrd.commonmark.CommonMarkRenderer]): 10 + def __init__(self, manpage_urls: Mapping[str, str]): 11 + super().__init__() 12 + self._renderer = nrd.commonmark.CommonMarkRenderer(manpage_urls) 11 13 12 14 # NOTE: in these tests we represent trailing spaces by ` ` and replace them with real space later, 13 15 # since a number of editors will strip trailing whitespace on save and that would break the tests.
+5 -3
pkgs/tools/nix/nixos-render-docs/src/tests/test_headings.py
··· 1 - import nixos_render_docs 1 + import nixos_render_docs as nrd 2 2 3 3 from markdown_it.token import Token 4 4 5 - class Converter(nixos_render_docs.md.Converter): 5 + class Converter(nrd.md.Converter[nrd.docbook.DocBookRenderer]): 6 6 # actual renderer doesn't matter, we're just parsing. 7 - __renderer__ = nixos_render_docs.docbook.DocBookRenderer 7 + def __init__(self, manpage_urls: dict[str, str]) -> None: 8 + super().__init__() 9 + self._renderer = nrd.docbook.DocBookRenderer(manpage_urls) 8 10 9 11 def test_heading_id_absent() -> None: 10 12 c = Converter({})
+5 -3
pkgs/tools/nix/nixos-render-docs/src/tests/test_lists.py
··· 1 - import nixos_render_docs 1 + import nixos_render_docs as nrd 2 2 import pytest 3 3 4 4 from markdown_it.token import Token 5 5 6 - class Converter(nixos_render_docs.md.Converter): 6 + class Converter(nrd.md.Converter[nrd.docbook.DocBookRenderer]): 7 7 # actual renderer doesn't matter, we're just parsing. 8 - __renderer__ = nixos_render_docs.docbook.DocBookRenderer 8 + def __init__(self, manpage_urls: dict[str, str]) -> None: 9 + super().__init__() 10 + self._renderer = nrd.docbook.DocBookRenderer(manpage_urls) 9 11 10 12 @pytest.mark.parametrize("ordered", [True, False]) 11 13 def test_list_wide(ordered: bool) -> None:
+8 -15
pkgs/tools/nix/nixos-render-docs/src/tests/test_manpage.py
··· 1 - import nixos_render_docs 1 + import nixos_render_docs as nrd 2 2 3 3 from sample_md import sample1 4 4 ··· 6 6 7 7 import markdown_it 8 8 9 - class Converter(nixos_render_docs.md.Converter): 10 - def __renderer__(self, manpage_urls: Mapping[str, str], 11 - parser: Optional[markdown_it.MarkdownIt] = None 12 - ) -> nixos_render_docs.manpage.ManpageRenderer: 13 - return nixos_render_docs.manpage.ManpageRenderer(manpage_urls, self.options_by_id, parser) 14 - 9 + class Converter(nrd.md.Converter[nrd.manpage.ManpageRenderer]): 15 10 def __init__(self, manpage_urls: Mapping[str, str], options_by_id: dict[str, str] = {}): 16 - self.options_by_id = options_by_id 17 - super().__init__(manpage_urls) 11 + super().__init__() 12 + self._renderer = nrd.manpage.ManpageRenderer(manpage_urls, options_by_id) 18 13 19 14 def test_inline_code() -> None: 20 15 c = Converter({}) ··· 32 27 33 28 def test_collect_links() -> None: 34 29 c = Converter({}, { '#foo': "bar" }) 35 - assert isinstance(c._md.renderer, nixos_render_docs.manpage.ManpageRenderer) 36 - c._md.renderer.link_footnotes = [] 30 + c._renderer.link_footnotes = [] 37 31 assert c._render("[a](link1) [b](link2)") == "\\fBa\\fR[1]\\fR \\fBb\\fR[2]\\fR" 38 - assert c._md.renderer.link_footnotes == ['link1', 'link2'] 32 + assert c._renderer.link_footnotes == ['link1', 'link2'] 39 33 40 34 def test_dedup_links() -> None: 41 35 c = Converter({}, { '#foo': "bar" }) 42 - assert isinstance(c._md.renderer, nixos_render_docs.manpage.ManpageRenderer) 43 - c._md.renderer.link_footnotes = [] 36 + c._renderer.link_footnotes = [] 44 37 assert c._render("[a](link) [b](link)") == "\\fBa\\fR[1]\\fR \\fBb\\fR[1]\\fR" 45 - assert c._md.renderer.link_footnotes == ['link'] 38 + assert c._renderer.link_footnotes == ['link'] 46 39 47 40 def test_full() -> None: 48 41 c = Converter({ 'man(1)': 'http://example.org' })
+5 -3
pkgs/tools/nix/nixos-render-docs/src/tests/test_plugins.py
··· 1 - import nixos_render_docs 1 + import nixos_render_docs as nrd 2 2 3 3 from markdown_it.token import Token 4 4 5 - class Converter(nixos_render_docs.md.Converter): 5 + class Converter(nrd.md.Converter[nrd.docbook.DocBookRenderer]): 6 6 # actual renderer doesn't matter, we're just parsing. 7 - __renderer__ = nixos_render_docs.docbook.DocBookRenderer 7 + def __init__(self, manpage_urls: dict[str, str]) -> None: 8 + super().__init__() 9 + self._renderer = nrd.docbook.DocBookRenderer(manpage_urls) 8 10 9 11 def test_attr_span_parsing() -> None: 10 12 c = Converter({})