From ccb586299d26ea22d8c064440888ede1065727f7 Mon Sep 17 00:00:00 2001 From: pennae Date: Wed, 25 Jan 2023 21:27:45 +0100 Subject: [PATCH] nixos-render-docs: move options conversion to options module --- .../src/nixos_render_docs/__init__.py | 174 ++-------------- .../src/nixos_render_docs/md.py | 32 ++- .../src/nixos_render_docs/options.py | 191 +++++++++++++++++- .../src/nixos_render_docs/types.py | 5 +- 4 files changed, 236 insertions(+), 166 deletions(-) diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/__init__.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/__init__.py index b8dc8e7f08eb..e28d516fed39 100644 --- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/__init__.py +++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/__init__.py @@ -16,129 +16,13 @@ from mdit_py_plugins.deflist import deflist_plugin from mdit_py_plugins.myst_role import myst_role_plugin from xml.sax.saxutils import escape, quoteattr -from .docbook import make_xml_id, DocBookRenderer -from .md import md_escape -from .options import option_is - -class Converter: - def __init__(self, manpage_urls: Dict[str, str]): - self._manpage_urls = frozendict(manpage_urls) - - self._md = markdown_it.MarkdownIt( - "commonmark", - { - 'maxNesting': 100, # default is 20 - 'html': False, # not useful since we target many formats - 'typographer': True, # required for smartquotes - }, - renderer_cls=lambda parser: DocBookRenderer(self._manpage_urls, parser) - ) - # TODO maybe fork the plugin and have only a single rule for all? - self._md.use(container_plugin, name="{.note}") - self._md.use(container_plugin, name="{.important}") - self._md.use(container_plugin, name="{.warning}") - self._md.use(deflist_plugin) - self._md.use(myst_role_plugin) - self._md.enable(["smartquotes", "replacements"]) - - def render(self, src: str) -> str: - return self._md.render(src) - -md = Converter(json.load(open(os.getenv('MANPAGE_URLS')))) - -# converts in-place! -def convertMD(options: Dict[str, Any]) -> str: - def convertCode(name: str, option: Dict[str, Any], key: str): - if option_is(option, key, 'literalMD'): - option[key] = md.render(f"*{key.capitalize()}:*\n{option[key]['text']}") - elif option_is(option, key, 'literalExpression'): - code = option[key]['text'] - # for multi-line code blocks we only have to count ` runs at the beginning - # of a line, but this is much easier. - multiline = '\n' in code - longest, current = (0, 0) - for c in code: - current = current + 1 if c == '`' else 0 - longest = max(current, longest) - # inline literals need a space to separate ticks from content, code blocks - # need newlines. inline literals need one extra tick, code blocks need three. - ticks, sep = ('`' * (longest + (3 if multiline else 1)), '\n' if multiline else ' ') - code = f"{ticks}{sep}{code}{sep}{ticks}" - option[key] = md.render(f"*{key.capitalize()}:*\n{code}") - elif option_is(option, key, 'literalDocBook'): - option[key] = f"{key.capitalize()}: {option[key]['text']}" - elif key in option: - raise Exception(f"{name} {key} has unrecognized type", option[key]) - - for (name, option) in options.items(): - try: - if option_is(option, 'description', 'mdDoc'): - option['description'] = md.render(option['description']['text']) - elif markdownByDefault: - option['description'] = md.render(option['description']) - else: - option['description'] = ("" + - option['description'] + - "") - - convertCode(name, option, 'example') - convertCode(name, option, 'default') - - if typ := option.get('type'): - ro = " *(read only)*" if option.get('readOnly', False) else "" - option['type'] = md.render(f'*Type:* {md_escape(typ)}{ro}') - - if 'relatedPackages' in option: - option['relatedPackages'] = md.render(option['relatedPackages']) - except Exception as e: - raise Exception(f"Failed to render option {name}") from e - - return options +from .options import DocBookConverter def need_env(n): if n not in os.environ: raise RuntimeError("required environment variable not set", n) return os.environ[n] -OTD_REVISION = need_env('OTD_REVISION') -OTD_DOCUMENT_TYPE = need_env('OTD_DOCUMENT_TYPE') -OTD_VARIABLE_LIST_ID = need_env('OTD_VARIABLE_LIST_ID') -OTD_OPTION_ID_PREFIX = need_env('OTD_OPTION_ID_PREFIX') - -def print_decl_def(header, locs): - print(f"""{header}:""") - print(f"""""") - for loc in locs: - # locations can be either plain strings (specific to nixpkgs), or attrsets - # { name = "foo/bar.nix"; url = "https://github.com/....."; } - if isinstance(loc, str): - # Hyperlink the filename either to the NixOS github - # repository (if it’s a module and we have a revision number), - # or to the local filesystem. - if not loc.startswith('/'): - if OTD_REVISION == 'local': - href = f"https://github.com/NixOS/nixpkgs/blob/master/{loc}" - else: - href = f"https://github.com/NixOS/nixpkgs/blob/{OTD_REVISION}/{loc}" - else: - href = f"file://{loc}" - # Print the filename and make it user-friendly by replacing the - # /nix/store/ prefix by the default location of nixos - # sources. - if not loc.startswith('/'): - name = f"" - elif loc.contains('nixops') and loc.contains('/nix/'): - name = f"" - else: - name = loc - print(f"""""") - print(escape(name)) - print(f"""""") - else: - href = f" xlink:href={quoteattr(loc['url'])}" if 'url' in loc else "" - print(f"""{escape(loc['name'])}""") - print(f"""""") - def main(): markdownByDefault = False optOffset = 0 @@ -147,49 +31,15 @@ def main(): optOffset += 1 markdownByDefault = True - options = convertMD(json.load(open(sys.argv[1 + optOffset], 'r'))) + md = DocBookConverter( + json.load(open(os.getenv('MANPAGE_URLS'))), + revision = need_env('OTD_REVISION'), + document_type = need_env('OTD_DOCUMENT_TYPE'), + varlist_id = need_env('OTD_VARIABLE_LIST_ID'), + id_prefix = need_env('OTD_OPTION_ID_PREFIX'), + markdown_by_default = markdownByDefault + ) - keys = list(options.keys()) - keys.sort(key=lambda opt: [ (0 if p.startswith("enable") else 1 if p.startswith("package") else 2, p) - for p in options[opt]['loc'] ]) - - print(f"""""") - if OTD_DOCUMENT_TYPE == 'appendix': - print("""""") - print(""" Configuration Options""") - print(f"""""") - - for name in keys: - opt = options[name] - id = OTD_OPTION_ID_PREFIX + make_xml_id(name) - print(f"""""") - # NOTE adding extra spaces here introduces spaces into xref link expansions - print(f"""""", end='') - print(f"""""", end='') - print(f"""""") - print(f"""""") - print(opt['description']) - if typ := opt.get('type'): - print(typ) - if default := opt.get('default'): - print(default) - if example := opt.get('example'): - print(example) - if related := opt.get('relatedPackages'): - print(f"""""") - print(f""" Related packages:""") - print(f"""""") - print(related) - if decl := opt.get('declarations'): - print_decl_def("Declared by", decl) - if defs := opt.get('definitions'): - print_decl_def("Defined by", defs) - print(f"""""") - print(f"""""") - - print("""""") - if OTD_DOCUMENT_TYPE == 'appendix': - print("""""") + options = json.load(open(sys.argv[1 + optOffset], 'r')) + md.add_options(options) + print(md.finalize()) diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py index 01e9659ce27e..50e09fc6db4b 100644 --- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py +++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/md.py @@ -1,9 +1,13 @@ from collections.abc import Mapping, MutableMapping, Sequence -from typing import Any, Optional +from frozendict import frozendict # type: ignore[attr-defined] +from typing import Any, Callable, Optional import markdown_it from markdown_it.token import Token from markdown_it.utils import OptionsDict +from mdit_py_plugins.container import container_plugin # type: ignore[attr-defined] +from mdit_py_plugins.deflist import deflist_plugin # type: ignore[attr-defined] +from mdit_py_plugins.myst_role import myst_role_plugin # type: ignore[attr-defined] _md_escape_table = { ord('*'): '\\*', @@ -175,3 +179,29 @@ class Renderer(markdown_it.renderer.RendererProtocol): def myst_role(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, env: MutableMapping[str, Any]) -> str: raise RuntimeError("md token not supported", token) + +class Converter: + __renderer__: Callable[[Mapping[str, str], markdown_it.MarkdownIt], Renderer] + + def __init__(self, manpage_urls: Mapping[str, str]): + self._manpage_urls = frozendict(manpage_urls) + + self._md = markdown_it.MarkdownIt( + "commonmark", + { + 'maxNesting': 100, # default is 20 + 'html': False, # not useful since we target many formats + 'typographer': True, # required for smartquotes + }, + renderer_cls=lambda parser: self.__renderer__(self._manpage_urls, parser) + ) + # TODO maybe fork the plugin and have only a single rule for all? + self._md.use(container_plugin, name="{.note}") + self._md.use(container_plugin, name="{.important}") + self._md.use(container_plugin, name="{.warning}") + self._md.use(deflist_plugin) + self._md.use(myst_role_plugin) + self._md.enable(["smartquotes", "replacements"]) + + def _render(self, src: str) -> str: + return self._md.render(src) # type: ignore[no-any-return] diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py index 0bba89a1e9a0..218683d1591c 100644 --- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py +++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/options.py @@ -1,6 +1,9 @@ -from typing import Optional +from typing import Any, Optional +from xml.sax.saxutils import escape, quoteattr -from .types import Option +from .docbook import DocBookRenderer, make_xml_id +from .md import Converter, md_escape +from .types import OptionLoc, Option, RenderedOption def option_is(option: Option, key: str, typ: str) -> Optional[dict[str, str]]: if key not in option: @@ -10,3 +13,187 @@ def option_is(option: Option, key: str, typ: str) -> Optional[dict[str, str]]: if option[key].get('_type') != typ: # type: ignore[union-attr] return None return option[key] # type: ignore[return-value] + +class DocBookConverter(Converter): + __renderer__ = DocBookRenderer + _options: dict[str, RenderedOption] + + def __init__(self, manpage_urls: dict[str, str], + revision: str, + document_type: str, + varlist_id: str, + id_prefix: str, + markdown_by_default: bool): + super().__init__(manpage_urls) + self._options = {} + self._revision = revision + self._document_type = document_type + self._varlist_id = varlist_id + self._id_prefix = id_prefix + self._markdown_by_default = markdown_by_default + + def _format_decl_def_loc(self, loc: OptionLoc) -> tuple[Optional[str], str]: + # locations can be either plain strings (specific to nixpkgs), or attrsets + # { name = "foo/bar.nix"; url = "https://github.com/....."; } + if isinstance(loc, str): + # Hyperlink the filename either to the NixOS github + # repository (if it’s a module and we have a revision number), + # or to the local filesystem. + if not loc.startswith('/'): + if self._revision == 'local': + href = f"https://github.com/NixOS/nixpkgs/blob/master/{loc}" + else: + href = f"https://github.com/NixOS/nixpkgs/blob/{self._revision}/{loc}" + else: + href = f"file://{loc}" + # Print the filename and make it user-friendly by replacing the + # /nix/store/ prefix by the default location of nixos + # sources. + if not loc.startswith('/'): + name = f"" + elif 'nixops' in loc and '/nix/' in loc: + name = f"" + else: + name = loc + return (href, name) + else: + return (loc['url'] if 'url' in loc else None, loc['name']) + + def _render_decl_def(self, header: str, locs: list[OptionLoc]) -> list[str]: + result = [] + result += self._decl_def_header(header) + for loc in locs: + href, name = self._format_decl_def_loc(loc) + result += self._decl_def_entry(href, name) + result += self._decl_def_footer() + return result + + def _render_code(self, option: Option, key: str) -> list[str]: + if lit := option_is(option, key, 'literalDocBook'): + return [ f"{key.capitalize()}: {lit['text']}" ] + elif lit := option_is(option, key, 'literalMD'): + return [ self._render(f"*{key.capitalize()}:*\n{lit['text']}") ] + elif lit := option_is(option, key, 'literalExpression'): + code = lit['text'] + # for multi-line code blocks we only have to count ` runs at the beginning + # of a line, but this is much easier. + multiline = '\n' in code + longest, current = (0, 0) + for c in code: + current = current + 1 if c == '`' else 0 + longest = max(current, longest) + # inline literals need a space to separate ticks from content, code blocks + # need newlines. inline literals need one extra tick, code blocks need three. + ticks, sep = ('`' * (longest + (3 if multiline else 1)), '\n' if multiline else ' ') + code = f"{ticks}{sep}{code}{sep}{ticks}" + return [ self._render(f"*{key.capitalize()}:*\n{code}") ] + elif key in option: + raise Exception(f"{key} has unrecognized type", option[key]) + else: + return [] + + def _render_description(self, desc: str | dict[str, str]) -> list[str]: + if isinstance(desc, str) and not self._markdown_by_default: + return [ f"{desc}" ] + elif isinstance(desc, str) and self._markdown_by_default: + return [ self._render(desc) ] + elif isinstance(desc, dict) and desc.get('_type') == 'mdDoc': + return [ self._render(desc['text']) ] + else: + raise Exception("description has unrecognized type", desc) + + def _convert_one(self, option: dict[str, Any]) -> list[str]: + result = [] + + if desc := option.get('description'): + result += self._render_description(desc) + if typ := option.get('type'): + ro = " *(read only)*" if option.get('readOnly', False) else "" + result.append(self._render(f"*Type:* {md_escape(typ)}{ro}")) + + result += self._render_code(option, 'default') + result += self._render_code(option, 'example') + + if related := option.get('relatedPackages'): + result += self._related_packages_header() + result.append(self._render(related)) + if decl := option.get('declarations'): + result += self._render_decl_def("Declared by", decl) + if defs := option.get('definitions'): + result += self._render_decl_def("Defined by", defs) + + return result + + def add_options(self, options: dict[str, Any]) -> None: + for (name, option) in options.items(): + try: + self._options[name] = RenderedOption(option['loc'], self._convert_one(option)) + except Exception as e: + raise Exception(f"Failed to render option {name}") from e + + def _related_packages_header(self) -> list[str]: + return [ + "", + " Related packages:", + "", + ] + + def _decl_def_header(self, header: str) -> list[str]: + return [ + f"{header}:", + "" + ] + + def _decl_def_entry(self, href: Optional[str], name: str) -> list[str]: + if href is not None: + href = " xlink:href=" + quoteattr(href) + return [ + f"", + escape(name), + "" + ] + + def _decl_def_footer(self) -> list[str]: + return [ "" ] + + def finalize(self) -> str: + keys = list(self._options.keys()) + keys.sort(key=lambda opt: [ (0 if p.startswith("enable") else 1 if p.startswith("package") else 2, p) + for p in self._options[opt].loc ]) + + result = [] + + result.append('') + if self._document_type == 'appendix': + result += [ + '', + ' Configuration Options', + ] + result += [ + f'', + ] + + for name in keys: + id = make_xml_id(self._id_prefix + name) + result += [ + "", + # NOTE adding extra spaces here introduces spaces into xref link expansions + (f"" + + f""), + "" + ] + result += self._options[name].lines + result += [ + "", + "" + ] + + result.append("") + if self._document_type == 'appendix': + result.append("") + + return "\n".join(result) diff --git a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/types.py b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/types.py index f6825ac936a9..9c1ec4f65bf5 100644 --- a/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/types.py +++ b/pkgs/tools/nix/nixos-render-docs/src/nixos_render_docs/types.py @@ -1,4 +1,7 @@ -from typing import Optional, Tuple +from typing import Optional, Tuple, NamedTuple OptionLoc = str | dict[str, str] Option = dict[str, str | dict[str, str] | list[OptionLoc]] + +RenderedOption = NamedTuple('RenderedOption', [('loc', list[str]), + ('lines', list[str])])