From b1494d78c31e85b2b966fd52ff4efad528e03e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=B6rnquist?= <hugo@lysator.liu.se> Date: Mon, 3 Jul 2023 22:58:08 +0200 Subject: [PATCH] Harden typesystem + misc. Misc includes: - at least one more category - basic sidebars --- muppet/format.py | 154 ++--- muppet/gather.py | 11 +- muppet/markdown.py | 4 +- muppet/output.py | 557 ++++++++++++------ muppet/puppet/__init__.py | 9 + muppet/puppet/parser.py | 9 +- muppet/puppet/strings.py | 34 -- muppet/puppet/strings/__init__.py | 402 +++++++++++++ muppet/puppet/strings/__main__.py | 29 + muppet/puppet/strings/internal.py | 260 ++++++++ muppet/tabs.py | 2 +- muppet/templates.py | 81 +++ mypy.ini | 1 + static-src/_sidebar.scss | 17 + static-src/style.scss | 25 + templates/base.html | 21 +- templates/code_page.html | 6 + templates/index.html | 12 +- templates/module_index.html | 26 +- .../snippets/ResourceType-index-entry.html | 15 + .../snippets/ResourceType-list-entry.html | 10 + templates/{ => snippets}/tabset.html | 0 22 files changed, 1369 insertions(+), 316 deletions(-) create mode 100644 muppet/puppet/__init__.py delete mode 100644 muppet/puppet/strings.py create mode 100644 muppet/puppet/strings/__init__.py create mode 100644 muppet/puppet/strings/__main__.py create mode 100644 muppet/puppet/strings/internal.py create mode 100644 muppet/templates.py create mode 100644 static-src/_sidebar.scss create mode 100644 templates/snippets/ResourceType-index-entry.html create mode 100644 templates/snippets/ResourceType-list-entry.html rename templates/{ => snippets}/tabset.html (100%) diff --git a/muppet/format.py b/muppet/format.py index bf107ad..48d692c 100644 --- a/muppet/format.py +++ b/muppet/format.py @@ -33,10 +33,22 @@ from .data import ( declaration, render, ) + from .data.html import ( HTMLRenderer, ) +from .puppet.strings import ( + DataTypeAlias, + DefinedType, + DocString, + Function, + PuppetClass, + ResourceType, + DocStringParamTag, + DocStringExampleTag, +) + parse_puppet = puppet_parser HashEntry: TypeAlias = Union[Tuple[Literal['=>'], str, Any], @@ -159,7 +171,11 @@ def handle_case_body(forms: list[dict[str, Any]], # - qr # - var (except when it's the var declaration) -def parse(form: Any, indent: int, context: list[str]) -> Tag: +LineFragment: TypeAlias = str | Tag +Line: TypeAlias = list[LineFragment] + + +def parse(form: Any, indent: int, context: list[str]) -> Markup: """ Print everything from a puppet parse tree. @@ -426,9 +442,6 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag: case ['heredoc', {'text': ['concat', *parts]}]: items = ['@("EOF")'] - LineFragment: TypeAlias = str | Tag - Line: TypeAlias = list[LineFragment] - lines: list[Line] = [[]] for part in parts: @@ -1022,7 +1035,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag: return tag(f'[|[{form}]|]', 'parse-error') -def format_docstring(name: str, docstring: dict[str, Any]) -> Tuple[str, str]: +def format_docstring(name: str, docstring: DocString) -> Tuple[str, str]: """ Format docstrings as they appear in some puppet types. @@ -1036,30 +1049,26 @@ def format_docstring(name: str, docstring: dict[str, Any]) -> Tuple[str, str]: out = '' - if 'tags' in docstring: - param_doc = {tag['name']: tag.get('text') or '' - for tag in docstring['tags'] - if tag['tag_name'] == 'param'} - tags = docstring['tags'] - else: - param_doc = {} - tags = [] + param_doc = {tag.name: tag.text or '' + for tag in docstring.tags + if isinstance(tag, DocStringParamTag)} + tags = docstring.tags # print(param_doc, file=sys.stderr) # param_defaults = d_type['defaults'] for t in tags: - text = html.escape(t.get('text') or '') - if t['tag_name'] == 'summary': + text = html.escape(t.text) + if t.tag_name == 'summary': out += '<em class="summary">' out += text out += '</em>' for t in tags: - text = html.escape(t.get('text') or '') - if t['tag_name'] == 'example': - if name := t.get('name'): + text = html.escape(t.text) + if isinstance(t, DocStringExampleTag): + if name := t.name: out += f'<h3>{name}</h3>\n' # TODO highlight? out += f'<pre class="example"><code class="puppet">{text}</code></pre>\n' @@ -1075,16 +1084,15 @@ def format_docstring(name: str, docstring: dict[str, Any]) -> Tuple[str, str]: # out += f"<dd>{text}</dd>" # out += '</dl>' - if 'text' in docstring: - out += '<div>' - out += markdown(docstring['text']) - out += '</div>' + out += '<div>' + out += markdown(docstring.text) + out += '</div>' return (name, out) # TODO @option tags -def build_param_dict(docstring: dict[str, Any]) -> dict[str, str]: +def build_param_dict(docstring: DocString) -> dict[str, str]: """ Extract all parameter documentation from a docstring dict. @@ -1099,28 +1107,24 @@ def build_param_dict(docstring: dict[str, Any]) -> dict[str, str]: for that key. Undocumented keys (even those with the tag, but no text) are ommitted from the resulting dictionary. """ - if tags := docstring.get('tags'): - obj = {} - for t in tags: - if t['tag_name'] == 'param': - if text := t.get('text'): - obj[t['name']] = re.sub(r'(NOTE|TODO)', - r'<mark>\1</mark>', - markdown(text)) - return obj - else: - return {} + obj = {} + for t in docstring.tags: + if isinstance(t, DocStringParamTag): + obj[t.name] = re.sub(r'(NOTE|TODO)', + r'<mark>\1</mark>', + markdown(t.text)) + return obj -def format_class(d_type: dict[str, Any]) -> Tuple[str, str]: +def format_class(d_type: DefinedType | PuppetClass) -> Tuple[str, str]: """Format Puppet class.""" - t = parse_puppet(d_type['source']) + t = parse_puppet(d_type.source) data = parse(t, 0, ['root']) - renderer = HTMLRenderer(build_param_dict(d_type['docstring'])) + renderer = HTMLRenderer(build_param_dict(d_type.docstring)) out = '' - name = d_type['name'] + name = d_type.name # print(name, file=sys.stderr) - name, body = format_docstring(name, d_type['docstring']) + name, body = format_docstring(name, d_type.docstring) out += body out += '<pre class="highlight-muppet"><code class="puppet">' @@ -1134,96 +1138,104 @@ def format_type() -> str: return 'TODO format_type not implemented' -def format_type_alias(d_type: dict[str, Any]) -> Tuple[str, str]: +def format_type_alias(d_type: DataTypeAlias) -> Tuple[str, str]: """Format Puppet type alias.""" renderer = HTMLRenderer() out = '' - name = d_type['name'] + name = d_type.name # print(name, file=sys.stderr) - title, body = format_docstring(name, d_type['docstring']) + title, body = format_docstring(name, d_type.docstring) out += body out += '\n' out += '<pre class="highlight-muppet"><code class="puppet">' - t = parse_puppet(d_type['alias_of']) + t = parse_puppet(d_type.alias_of) data = parse(t, 0, ['root']) out += render(renderer, data) out += '</code></pre>\n' return title, out -def format_defined_type(d_type: dict[str, Any]) -> Tuple[str, str]: +def format_defined_type(d_type: DefinedType) -> Tuple[str, str]: """Format Puppet defined type.""" - renderer = HTMLRenderer(build_param_dict(d_type['docstring'])) + renderer = HTMLRenderer(build_param_dict(d_type.docstring)) out = '' - name = d_type['name'] + name = d_type.name # print(name, file=sys.stderr) - title, body = format_docstring(name, d_type['docstring']) + title, body = format_docstring(name, d_type.docstring) out += body out += '<pre class="highlight-muppet"><code class="puppet">' - t = parse_puppet(d_type['source']) + t = parse_puppet(d_type.source) out += render(renderer, parse(t, 0, ['root'])) out += '</code></pre>\n' return title, out -def format_resource_type(r_type: dict[str, Any]) -> str: +def format_resource_type(r_type: ResourceType) -> str: """Format Puppet resource type.""" - name = r_type['name'] + name = r_type.name out = '' out += f'<h2>{name}</h2>\n' - out += str(r_type['docstring']) - if 'properties' in r_type: - out += '<h3>Properties</h3>\n' + out += str(r_type.docstring) + + out += '<h3>Properties</h3>\n' + if props := r_type.properties: out += '<ul>\n' - for property in r_type['properties']: - out += f'<li>{property["name"]}</li>\n' + for property in props: + out += f'<li>{property.name}</li>\n' # description, values, default out += '</ul>\n' + else: + out += '<em>No providers</em>' out += '<h3>Parameters</h3>\n' out += '<ul>\n' - for parameter in r_type['parameters']: - out += f'<li>{parameter["name"]}</li>\n' + for parameter in r_type.parameters: + out += f'<li>{parameter.name}</li>\n' # description # Optional[isnamevar] out += '</ul>\n' - if 'providers' in r_type: - out += '<h3>Providers</h3>\n' - for provider in r_type['providers']: - out += f'<h4>{provider["name"]}</h4>\n' + out += '<h3>Providers</h3>\n' + if providers := r_type.providers: + for provider in providers: + out += f'<h4>{provider.name}</h4>\n' # TODO + else: + print('<em>No providers</em>') return out -def format_puppet_function(function: dict[str, Any]) -> str: +def format_puppet_function(function: Function) -> str: """Format Puppet function.""" out = '' - name = function['name'] + name = function.name out += f'<h2>{name}</h2>\n' - t = function['type'] - # docstring = function['docstring'] - for signature in function['signatures']: - signature['signature'] - signature['docstring'] + t = function.type + # docstring = function.docstring + for signature in function.signatures: + signature.signature + signature.docstring if t in ['ruby3x', 'ruby4x']: # TODO syntax highlighting s = '<pre class="highlight-muppet"><code class="ruby">' - s += function["source"] + s += function.source s += '</code></pre>\n' out += s elif t == 'puppet': out += '<pre class="highlight-muppet"><code class="puppet">' try: - t = parse_puppet(function['source']) - out += str(parse(t, 0, ['root'])) + source = parse_puppet(function.source) + out += str(parse(source, 0, ['root'])) except CalledProcessError as e: print(e, file=sys.stderr) print(f"Failed on function: {name}", file=sys.stderr) out += '</code></pre>\n' + else: + # TODO do something + pass return out diff --git a/muppet/gather.py b/muppet/gather.py index 4795553..eb9c6f2 100644 --- a/muppet/gather.py +++ b/muppet/gather.py @@ -9,12 +9,13 @@ present in our environment, their metadata, and their output of from dataclasses import dataclass from typing import ( Any, + Optional, ) import json import os.path import hashlib from glob import glob -from .puppet.strings import puppet_strings +from .puppet.strings import puppet_strings, PuppetStrings from .cache import Cache @@ -38,7 +39,7 @@ class ModuleEntry: name: str path: str - strings_output: bytes + strings_output: Optional[PuppetStrings] metadata: dict[str, Any] doc_files: list[str] @@ -47,7 +48,7 @@ class ModuleEntry: return os.path.join(self.path, path) -def get_puppet_strings(cache: Cache, path: str) -> bytes: +def get_puppet_strings(cache: Cache, path: str) -> Optional[PuppetStrings]: """ Run puppet string, but check cache first. @@ -67,13 +68,13 @@ def get_puppet_strings(cache: Cache, path: str) -> bytes: else: result = puppet_strings(path) cache.put(key, result) - return result + return PuppetStrings.from_json(json.loads(result)) except FileNotFoundError: # TODO actually run puppet strings again. # This is just since without a metadata.json we always get a # cache miss, which is slow. # return puppet_strings(path) - return b'' + return None # try: # with open(module.file('.git/FETCH_HEAD')) as f: diff --git a/muppet/markdown.py b/muppet/markdown.py index dd7e1ea..f13ea10 100644 --- a/muppet/markdown.py +++ b/muppet/markdown.py @@ -7,7 +7,9 @@ and to allow "easy" switching of the markdown engine. """ from markdown_it import MarkdownIt -from mdit_py_plugins.anchors import anchors_plugin +# Mypy believes that mdit_py_plugins.anchors doesn't explicitly export +# "anchors_plugin" ([attr-defined]), but it does. +from mdit_py_plugins.anchors import anchors_plugin # type: ignore def markdown(text: str) -> str: diff --git a/muppet/output.py b/muppet/output.py index e4069ab..016de72 100644 --- a/muppet/output.py +++ b/muppet/output.py @@ -7,31 +7,37 @@ Both generates output strings, and writes them to disk. import os import os.path import pathlib -import json +# import json import html from .gather import ModuleEntry from jinja2 import ( Environment, FileSystemLoader, ) -from .lookup import lookup, Ref +# from .lookup import lookup, Ref from .markdown import markdown from .format import ( format_class, format_type_alias, ) from typing import ( - Any, - TypedDict, - NotRequired, -) -from collections.abc import ( - Iterable, + Optional, + Protocol, ) from .util import group_by -from .puppet.strings import isprivate +from .puppet.strings import ( + isprivate, + PuppetStrings, + ResourceType, + DefinedType, + DataTypeAlias, + PuppetClass, +) + from .breadcrumbs import breadcrumbs from .syntax_highlight import highlight +from dataclasses import dataclass, field +from . import templates # TODO replace 'output' with base, or put this somewhere else @@ -42,6 +48,53 @@ jinja = Environment( ) +class HtmlSerializable(Protocol): + """Classes which can be serialized as HTML.""" + + def to_html(self) -> str: + """Return HTML string.""" + ... + + def to_html_list(self) -> str: + """Return HTML suitable for a list.""" + ... + + +@dataclass +class ResourceTypeOutput: + """Basic HTML implementation.""" + + title: str + children: list['HtmlSerializable'] = field(default_factory=list) + link: Optional[str] = None + summary: Optional[str] = None + + def base(self) -> str: + """ + Return base text of the node. + + If the node has a link, create a hyperlink, otherwise return + it's title directly. + """ + if self.link: + return f'<a href="{ self.link }">{ self.title }</a>' + else: + return self.title + + def to_html(self) -> str: + """Return HTML string.""" + # self.__class__.__name__ + return jinja \ + .get_template('snippets/ResourceType-index-entry.html') \ + .render(item=self) + + def to_html_list(self) -> str: + """Return HTML suitable for a list.""" + return jinja \ + .get_template('snippets/ResourceType-list-entry.html') \ + .render(item=self) + + def setup_index(base: str, modules: list[ModuleEntry], *, path_base: str) -> None: """ Create the main index.html file. @@ -53,35 +106,127 @@ def setup_index(base: str, modules: list[ModuleEntry], *, path_base: str) -> Non :param path_base: Web path where this module will be deployed """ - template = jinja.get_template('index.html') with open(os.path.join(base, 'index.html'), 'w') as f: - f.write(template.render(modules=modules, + f.write(templates.index(modules=modules, path_base=path_base)) -class IndexItem(TypedDict): - """A single list entry in a module index page.""" +@dataclass +class IndexItem: + """ + A concrete type, on an index page. + + This will be something like a class or resource type. + + :param name: + Name of the resource or similar + :param file: + Relative path to the resource. + :param summary: + One line summary of the resource, will be displayed in the UI. + """ name: str file: str - summary: NotRequired[str] + summary: Optional[str] = None + + def base(self) -> str: + """Return link to self.""" + return f'<a href="{self.file}">{ self.name }</a>' + + def to_html(self) -> str: + """Convert item to an HTML string.""" + out: str = '' + out += f'<dt>{self.base()}</dt>' + if self.summary: + out += f"<dd>{self.summary}</dd>" -class IndexSubcategory(TypedDict): - """A subheading on an index page.""" + return out + + def to_html_list(self) -> str: + """Convert itom to an HTML string sutibale for a list.""" + out: str = '' + out += f'<li>{self.base()}</li>' + return out + + +@dataclass +class IndexSubcategory: + """ + Subheading on index page. + + Will most likely be 'Public' or 'Private' objects for the given + top heading. + """ title: str - list: Iterable[IndexItem] + list: list[IndexItem] + def to_html(self) -> str: + """Convert subcategory to an HTML string.""" + out: str = '' + out += f'<h3>{html.escape(self.title)}</h3><dl class="overview-list">' -class IndexCategory(TypedDict): - """A top heading on an index page.""" + for item in self.list: + out += item.to_html() + + out += '</dl>' + + return out + + def to_html_list(self) -> str: + """Convert itom to an html string suitable for a list.""" + pass + out: str = '' + out += f'<li>{html.escape(self.title)}<ul>' + for item in self.list: + out += item.to_html_list() + out += '</ul></li>' + + return out + + +@dataclass +class IndexCategory: + """ + Top level heading in index. + + This should be something like 'Classes', 'Types', ... + Each entry contains a set of subentries, which can either be + distinct entries, or sometimes subcategories (such as 'Public' and + 'Private'). + """ title: str - list: Iterable[IndexSubcategory] + list: list[IndexSubcategory] + + def base(self) -> str: + """Return HTML escaped title.""" + return html.escape(self.title) + + def to_html(self) -> str: + """Return class as an HTML string.""" + out: str = '' + out += f'<h2>{self.base()}</h2>' + + for item in self.list: + out += item.to_html() + + return out + def to_html_list(self) -> str: + """Return class as an HTML string suitable for a list.""" + out: str = '' + out += f'<li>{self.base()}<ul>' + for item in self.list: + out += item.to_html_list() + out += '</ul></li>' -def index_item(obj: dict) -> IndexItem: + return out + + +def index_item(obj: PuppetClass | DefinedType) -> IndexItem: """ Format a puppet type declaration into an index entry. @@ -91,50 +236,46 @@ def index_item(obj: dict) -> IndexItem: then a summary tag is searched for, and added to the resulting object. """ - name = obj['name'] - summary = lookup(obj) \ - .ref('docstring') \ - .ref('tags') \ - .find(Ref('tag_name') == 'summary') \ - .ref('text') \ - .value() - - out: IndexItem = { - 'file': os.path.splitext(obj['file'])[0], - 'name': name, - } - - if summary: - out['summary'] = markdown(summary) + name = obj.name + + out: IndexItem = IndexItem( + file=os.path.splitext(obj.file)[0], + name=name, + ) + + for tag in obj.docstring.tags: + if tag.tag_name == 'summary': + out.summary = markdown(tag.text) + break return out -def class_index(class_list: list) -> IndexCategory: +def class_index(class_list: list[PuppetClass]) -> IndexCategory: """Prepage class index list.""" groups = group_by(isprivate, class_list) lst: list[IndexSubcategory] = [] if publics := groups.get(False): - lst.append({ - 'title': 'Public Classes', - 'list': (index_item(i) for i in publics), - }) + lst.append(IndexSubcategory( + title='Public Classes', + list=[index_item(i) for i in publics], + )) if privates := groups.get(True): - lst.append({ - 'title': 'Private Classes', - 'list': (index_item(i) for i in privates), - }) + lst.append(IndexSubcategory( + title='Private Classes', + list=[index_item(i) for i in privates], + )) - return { - 'title': 'Classes', - 'list': lst - } + return IndexCategory( + title='Classes', + list=lst + ) -def defined_types_index(defined_list: list) -> IndexCategory: +def defined_types_index(defined_list: list[DefinedType]) -> IndexCategory: """ Prepare defined types index list. @@ -146,47 +287,99 @@ def defined_types_index(defined_list: list) -> IndexCategory: lst: list[IndexSubcategory] = [] if publics := groups.get(False): - lst.append({ - 'title': 'Public Defined Types', - 'list': (index_item(i) for i in publics), - }) + lst.append(IndexSubcategory( + title='Public Defined Types', + list=[index_item(i) for i in publics], + )) if privates := groups.get(True): - lst.append({ - 'title': 'Private Defined Types', - 'list': (index_item(i) for i in privates), - }) + lst.append(IndexSubcategory( + title='Private Defined Types', + list=[index_item(i) for i in privates], + )) - return { - 'title': 'Defined Types', - 'list': lst - } + return IndexCategory( + title='Defined Types', + list=lst + ) -def type_aliases_index(alias_list: list) -> IndexCategory: +def type_aliases_index(alias_list: list[DataTypeAlias]) -> IndexCategory: """Prepare type alias index list.""" groups = group_by(isprivate, alias_list) lst: list[IndexSubcategory] = [] if publics := groups.get(False): - lst.append({ - 'title': 'Public Type Aliases', - 'list': ({'name': i['name'], - 'file': os.path.splitext(i['file'])[0]} - for i in publics), - }) + lst.append(IndexSubcategory( + title='Public Type Aliases', + list=[IndexItem(name=i.name, + file=os.path.splitext(i.file)[0]) + for i in publics], + )) if privates := groups.get(True): - lst.append({ - 'title': 'Private Type Aliases', - 'list': ({'name': i['name'], - 'file': os.path.splitext(i['file'])[0]} - for i in privates), - }) + lst.append(IndexSubcategory( + title='Private Type Aliases', + list=[IndexItem(name=i.name, + file=os.path.splitext(i.file)[0]) + for i in privates], + )) - return { - 'title': 'Type Aliases', - 'list': lst, - } + return IndexCategory( + title='Type Aliases', + list=lst, + ) + + +@dataclass +class ResourceIndex: + """Placeholder.""" + + title: str + children: list[HtmlSerializable] + + def to_html(self) -> str: + """Return something.""" + out: str = '' + out += f'<h2>{self.title}</h2>' + out += '<dl>' + for child in self.children: + out += child.to_html() + out += '</dl>' + return out + + def to_html_list(self) -> str: + """Return something.""" + out: str = '' + out += f'<li>{self.title}<ul>' + for child in self.children: + out += child.to_html_list() + out += '</ul></li>' + return out + + +def resource_type_index(resource_types: list[ResourceType]) -> list[HtmlSerializable]: + """Generate index for all known resource types.""" + lst: list[HtmlSerializable] = [] + + for resource_type in resource_types: + # resource_type['file'] + # resource_type['docstring'] + + items: list[HtmlSerializable] = [] + if providers := resource_type.providers: + for provider in providers: + # TODO summary tag? + # TODO, instead, render markdown and take first paragraph + documentation = provider.docstring.text.split('\n')[0] + items.append(ResourceTypeOutput( + title=provider.name, + link=provider.file, + summary=documentation)) + + lst.append(ResourceTypeOutput(title=resource_type.name, + children=items)) + + return lst # def resource_types_index(resource_list: list) -> IndexCategory: @@ -202,52 +395,62 @@ def type_aliases_index(alias_list: list) -> IndexCategory: def setup_module_index(*, base: str, module: ModuleEntry, - data: dict[str, Any], + data: PuppetStrings, path_base: str, doc_files: dict[str, str], ) -> None: """Create the index file for a specific module.""" - template = jinja.get_template('module_index.html') - - content = [] - - content.append(class_index(data['puppet_classes'])) - - data['data_types'] - content.append({ - 'title': 'Data types not yet implmented', - 'list': [], - }) - - content.append(type_aliases_index(data['data_type_aliases'])) - - content.append(defined_types_index(data['defined_types'])) - - data['resource_types'] - content.append({ - 'title': 'Resource types not yet implmented', - 'list': [], - }) - data['providers'] - content.append({ - 'title': 'Providers not yet implmented', - 'list': [], - }) - data['puppet_functions'] - content.append({ - 'title': 'Puppet Functions not yet implmented', - 'list': [], - }) - data['puppet_tasks'] - content.append({ - 'title': 'Puppet Tasks not yet implmented', - 'list': [], - }) - data['puppet_plans'] - content.append({ - 'title': 'Puppet Plans not yet implmented', - 'list': [], - }) + content: list[ResourceIndex | IndexCategory] = [] + + content.append(class_index(data.puppet_classes)) + + # data['data_types'] + content.append(IndexCategory( + title='Data types not yet implmented', + list=[], + )) + + content.append(type_aliases_index(data.data_type_aliases)) + + content.append(defined_types_index(data.defined_types)) + + content.append(ResourceIndex( + title='Resource Types', + children=resource_type_index(data.resource_types))) + + # data['providers'] + content.append(IndexCategory( + title='Providers not yet implmented', + list=[], + )) + + # data['puppet_functions'] + content.append(IndexCategory( + title='Puppet Functions not yet implmented', + list=[], + )) + + # templates/ + # files/ + # examples or tests/ + # (spec)/ + # lib/puppet_x/ + # lib/facter/ + # facts.d/ + # data/ + # hiera.yaml + + # data['puppet_tasks'] + content.append(IndexCategory( + title='Puppet Tasks not yet implmented', + list=[], + )) + + # data['puppet_plans'] + content.append(IndexCategory( + title='Puppet Plans not yet implmented', + list=[], + )) crumbs = breadcrumbs( ('Environment', ''), @@ -255,12 +458,13 @@ def setup_module_index(*, ) with open(os.path.join(base, 'index.html'), 'w') as f: - f.write(template.render(module_name=module.name, - module_author=module.metadata['author'], - breadcrumbs=crumbs, - content=content, - path_base=path_base, - doc_files=doc_files.items())) + f.write(templates.module_index( + module_name=module.name, + module_author=module.metadata['author'], + breadcrumbs=crumbs, + content=content, + path_base=path_base, + doc_files=list(doc_files.items()))) GENERATED_MESSAGE = '<!-- DO NOT EDIT: This document was generated by Puppet Strings -->\n' @@ -288,11 +492,12 @@ def setup_module(base: str, module: ModuleEntry, *, path_base: str) -> None: pathlib.Path(path).mkdir(exist_ok=True) if not module.strings_output: return - data = json.loads(module.strings_output) - for puppet_class in data['puppet_classes'] + data['defined_types']: + data = module.strings_output + + for puppet_class in data.puppet_classes + data.defined_types: # localpath = puppet_class['name'].split('::') - localpath, _ = os.path.splitext(puppet_class['file']) + localpath, _ = os.path.splitext(puppet_class.file) dir = os.path.join(path, localpath) pathlib.Path(dir).mkdir(parents=True, exist_ok=True) # puppet_class['docstring'] @@ -301,65 +506,69 @@ def setup_module(base: str, module: ModuleEntry, *, path_base: str) -> None: # TODO option to add .txt extension (for web serverse which # treat .pp as application/binary) with open(os.path.join(dir, 'source.pp.txt'), 'wb') as f: - with open(module.file(puppet_class['file']), 'rb') as g: + with open(module.file(puppet_class.file), 'rb') as g: f.write(g.read()) + crumbs = breadcrumbs( + ('Environment', ''), + module.name, + (puppet_class.name, + 'manifests/' + '/'.join(puppet_class.name.split('::')[1:])), + 'This', + ) + with open(os.path.join(dir, 'source.pp.html'), 'w') as f: - template = jinja.get_template('code_page.html') - crumbs = breadcrumbs( - ('Environment', ''), - module.name, - (puppet_class['name'], - 'manifests/' + '/'.join(puppet_class['name'].split('::')[1:])), - 'This', - ) - - with open(module.file(puppet_class['file']), 'r') as g: - f.write(template.render(title='', - content=highlight(g.read(), 'puppet'), - path_base=path_base, - breadcrumbs=crumbs)) - - with open(os.path.join(dir, 'source.json'), 'w') as f: - json.dump(puppet_class, f, indent=2) + + with open(module.file(puppet_class.file), 'r') as g: + f.write(templates.code_page( + title='', + content=highlight(g.read(), 'puppet'), + path_base=path_base, + breadcrumbs=crumbs)) + + # TODO reimplement this? + # with open(os.path.join(dir, 'source.json'), 'w') as f: + # json.dump(puppet_class, f, indent=2) # with open(os.path.join(dir, 'source.pp.html'), 'w') as f: # f.write(format_class(puppet_class)) + crumbs = breadcrumbs( + ('Environment', ''), + module.name, + (puppet_class.name, + 'manifests/' + '/'.join(puppet_class.name.split('::')[1:])), + ) + + title, body = format_class(puppet_class) with open(os.path.join(dir, 'index.html'), 'w') as f: - template = jinja.get_template('code_page.html') - crumbs = breadcrumbs( - ('Environment', ''), - module.name, - (puppet_class['name'], - 'manifests/' + '/'.join(puppet_class['name'].split('::')[1:])), - ) - title, body = format_class(puppet_class) - f.write(template.render(title=title, - content=body, - path_base=path_base, - breadcrumbs=crumbs)) + f.write(templates.code_page( + title=title, + content=body, + path_base=path_base, + breadcrumbs=crumbs)) # puppet_class['file'] # puppet_class['line'] - for type_alias in data['data_type_aliases']: - localpath, _ = os.path.splitext(type_alias['file']) + for type_alias in data.data_type_aliases: + localpath, _ = os.path.splitext(type_alias.file) dir = os.path.join(path, localpath) pathlib.Path(dir).mkdir(parents=True, exist_ok=True) with open(os.path.join(dir, 'source.pp.txt'), 'w') as f: - f.write(type_alias['alias_of']) + f.write(type_alias.alias_of) - with open(os.path.join(dir, 'source.json'), 'w') as f: - json.dump(type_alias, f, indent=2) + # TODO reimplement this? + # with open(os.path.join(dir, 'source.json'), 'w') as f: + # json.dump(type_alias, f, indent=2) - template = jinja.get_template('code_page.html') + title, body = format_type_alias(type_alias) with open(os.path.join(dir, 'index.html'), 'w') as f: - title, body = format_type_alias(type_alias) - f.write(template.render(title=title, - content=body, - path_base=path_base)) + f.write(templates.code_page( + title=title, + content=body, + path_base=path_base)) # data['data_type_aliases'] # data['defined_types'] @@ -391,15 +600,15 @@ def setup_module(base: str, module: ModuleEntry, *, path_base: str) -> None: else: content = '<pre>' + html.escape(raw_content) + '</pre>' - template = jinja.get_template('content.html') crumbs = breadcrumbs(('Environment', ''), module.name, name) with open(out_path, 'w') as f: - f.write(template.render(content=content, - path_base=path_base, - breadcrumbs=crumbs)) + f.write(templates.content( + content=content, + path_base=path_base, + breadcrumbs=crumbs)) doc_files[name] = os.path.join(module.name, name, 'index.html') diff --git a/muppet/puppet/__init__.py b/muppet/puppet/__init__.py new file mode 100644 index 0000000..d7629f4 --- /dev/null +++ b/muppet/puppet/__init__.py @@ -0,0 +1,9 @@ +""" +Various wrappers around different puppet functionality. + +strings + wraps ``puppet strings``, and maps it to python types. + +parser + Wraps ``puppet parser``. +""" diff --git a/muppet/puppet/parser.py b/muppet/puppet/parser.py index d1f95c6..846ce11 100644 --- a/muppet/puppet/parser.py +++ b/muppet/puppet/parser.py @@ -24,9 +24,10 @@ def tagged_list_to_dict(lst: list[Any]) -> dict[Any, Any]: A tagged list in this context is a list where every even value (zero-indexed) is a key, and every odd value is a value. - :example: - >>> tagged_list_to_dict(['a', 1, 'b', 2]) - {'a': 1, 'b': 2} + .. sourcecode:: python + + >>> tagged_list_to_dict(['a', 1, 'b', 2]) + {'a': 1, 'b': 2} """ return {lst[i]: lst[i+1] for i in range(0, len(lst), 2)} @@ -113,6 +114,8 @@ def __main() -> None: inp = sys.stdin case [_, file]: inp = open(file) + case _: + raise Exception("This is impossible to rearch") json.dump(puppet_parser(inp.read()), sys.stdout, diff --git a/muppet/puppet/strings.py b/muppet/puppet/strings.py deleted file mode 100644 index 0f4930d..0000000 --- a/muppet/puppet/strings.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Python wrapper around puppet strings.""" - -import subprocess -from typing import Any - - -def puppet_strings(path: str) -> bytes: - """Run `puppet strings` on puppet module at path.""" - # TODO adding an --out flag (to not stdout) causes warnings to be - # printed to stdout. Warnings - - cmd = subprocess.run('puppet strings generate --format json'.split(' '), - cwd=path, - check=True, - stdout=subprocess.PIPE) - return cmd.stdout - - -def isprivate(entry: dict[str, Any]) -> bool: - """ - Is the given puppet declaration marked private. - - Assumes input is a dictionary as returned by puppet strings, one - of the entries in (for example) 'puppet_classes'. - - Currently only checks for an "@api private" tag. - """ - if ds := entry.get('docstring'): - if tags := ds.get('tags'): - for tag in tags: - if tag.get('tag_name') == 'api' and \ - tag.get('text') == 'private': - return True - return False diff --git a/muppet/puppet/strings/__init__.py b/muppet/puppet/strings/__init__.py new file mode 100644 index 0000000..25c0650 --- /dev/null +++ b/muppet/puppet/strings/__init__.py @@ -0,0 +1,402 @@ +""" +Python wrapper around puppet strings. + +This maps +`Puppet Strings <https://www.puppet.com/docs/puppet/7/puppet_strings.html>`_ +JSON output onto python objects, using type-level reflection (see +:class:`Deserializable` for details). + +Fields declared without defaults are required, those with defaults are +optional. Extra fields may be given, but will result in a warning +being logged (TODO make this configurable). + +Puppet Strings output should follow +`this schema <https://github.com/puppetlabs/puppet-strings/blob/main/JSON.md>`_, +unfortunately, that document is out of date [#f1]_, so the following +is mostly based on observation. + +Some of the fields defined here have an Optional type, all these can +be changed to simply having a default value, if that better reflects +the possibilities of the field. The same is true with the above types +swaped. + +.. rubric:: Footnotes + +.. [#f1] It's out of date as of writing this (2023-07-03) +""" + +import subprocess +from typing import ( + Any, + Optional, + Protocol, +) +from dataclasses import dataclass, field +import logging +from .internal import Deserializable + + +logger = logging.getLogger(__name__) + + +@dataclass(kw_only=True) +class DocStringTag(Deserializable): + """ + A generic DocString tag. + + note, api, summary + + :param tag_name: + Type of the tag, like 'param', 'api', ... + + :param text: + Freeform text content of the tag. + """ + + tag_name: str + text: str = '' + + types: Optional[list[str]] = None + + @staticmethod + def _key() -> str: + raise NotImplementedError() + + +@dataclass(kw_only=True) +class DocStringParamTag(DocStringTag): + """Tag with tag_name set to 'param'.""" + + types: list[str] + """ + If tag_name is 'param', then this will be a singleton list of the + type (as a string). Untyped fields will be typed as 'Any'. + """ + + name: str + """Parameter name.""" + + @staticmethod + def _key() -> str: + return 'param' + + +@dataclass(kw_only=True) +class DocStringExampleTag(DocStringTag): + """Tag with tag_name set to 'example'.""" + + name: str + """ + Most likely name of language of ``text``. + + Will be the empty string if not set. + """ + + @staticmethod + def _key() -> str: + return 'example' + + +@dataclass(kw_only=True) +class DocStringOverloadTag(DocStringTag): + """ + Tag with tag_name set to 'overload'. + + These three will be set for puppet 4.x functions with overloads + when tag_name is 'overload'. + """ + + name: str + signature: str + docstring: Optional['DocString'] = None + defaults: dict[Any, Any] = field(default_factory=dict) + + @staticmethod + def _key() -> str: + return 'overload' + + +@dataclass(kw_only=True) +class DocStringOptionTag(DocStringTag): + """Tag with tag_name set to 'option'.""" + + opt_name: str + opt_text: str + opt_types: str + parent: str + name: str + + @staticmethod + def _key() -> str: + return 'option' + + +@dataclass(kw_only=True) +class DocStringSeeTag(DocStringTag): + """Tag with tag_name set to 'see'.""" + + name: str + + @staticmethod + def _key() -> str: + return 'see' + + +@dataclass(kw_only=True) +class DocString(Deserializable): + """Documentation entry for something.""" + + text: str + tags: list[DocStringTag] = field(default_factory=list) + + @staticmethod + def handle_tags(items: list[dict[str, Any]]) -> list[DocStringTag]: + """ + Parse list of tag dictionaries. + + The type of a tag dictionary depends on the value of + ``tag_name``. + """ + result: list[DocStringTag] = [] + for object in items: + # cls: type[DocStringTag] + cls: type + for c in DocStringTag.__subclasses__(): + if object['tag_name'] == c._key(): + cls = c + break + else: + cls = DocStringTag + try: + result.append(cls(**object)) + except TypeError as e: + logger.error("Bad tag set for tag object (class=%s) %s", + cls.__name__, object, + exc_info=e) + raise e + return result + + +@dataclass(kw_only=True) +class PuppetClass(Deserializable): + """Documentation for a puppet class.""" + + name: str + file: str + line: int + inherits: Optional[str] = None + docstring: DocString + defaults: dict[str, str] = field(default_factory=dict) + source: str + + +@dataclass(kw_only=True) +class DataType(Deserializable): + """Documentation for a data type.""" + + name: str + file: str + line: int + docstring: DocString + defaults: dict[str, str] + # source: str + + +@dataclass(kw_only=True) +class DataTypeAlias(Deserializable): + """Documentation for a type alias.""" + + name: str + file: str + line: int + docstring: DocString + alias_of: str + # source: str + + +@dataclass(kw_only=True) +class DefinedType(Deserializable): + """Documentation for a defined type.""" + + name: str + file: str + line: int + docstring: DocString + defaults: dict[str, str] = field(default_factory=dict) + source: str + + +@dataclass(kw_only=True) +class Provider(Deserializable): + """Documentation for a resource type provider.""" + + name: str + type_name: str + file: str + line: int + docstring: DocString + confines: dict[str, str] = field(default_factory=dict) + features: list[str] = field(default_factory=list) + defaults: list[Any] = field(default_factory=list) + commands: dict[str, str] = field(default_factory=dict) + + +@dataclass(kw_only=True) +class ResourceTypeProperty(Deserializable): + """Documentation for a property of a resource type.""" + + name: str + description: str + values: Optional[list[str]] = None + aliases: dict[str, str] = field(default_factory=dict) + isnamevar: bool = False + default: Optional[str] = None + + required_features: Optional[str] = None + data_type: Optional[str] = None + + +@dataclass(kw_only=True) +class ResourceTypeParameter(Deserializable): + """Documentation for a parameter of a resource type.""" + + name: str + description: Optional[str] = None + values: Optional[list[str]] = None + aliases: Any = field(default_factory=list) + isnamevar: bool = False + default: Optional[str] = None + + required_features: Optional[str] = None + data_type: Optional[str] = None + + +@dataclass(kw_only=True) +class ResourceTypeFeature(Deserializable): + """Documentation for a published feature of a resource type.""" + + name: str + description: str + + +@dataclass(kw_only=True) +class ResourceType(Deserializable): + """Documentation for a resource type.""" + + name: str + file: str + line: int + docstring: DocString + properties: list[ResourceTypeProperty] = field(default_factory=list) + parameters: list[ResourceTypeParameter] + features: Optional[list[ResourceTypeFeature]] = None + providers: Optional[list[Provider]] = None + + +@dataclass(kw_only=True) +class Signature(Deserializable): + """Documentation for a function signature.""" + + signature: str + docstring: DocString + + +@dataclass(kw_only=True) +class Function(Deserializable): + """Documentation for a function.""" + + name: str + file: str + line: int + type: str # Probably one of 'ruby3x', 'ruby4x', 'puppet' + signatures: list[Signature] + docstring: DocString + defaults: dict[str, str] = field(default_factory=dict) + source: str + + +@dataclass(kw_only=True) +class Task(Deserializable): + """Documentation for a task.""" + + name: str + file: str + line: int + docstring: Optional[DocString] = None + source: str + supports_noop: bool + input_method: Any + + +@dataclass(kw_only=True) +class Plan(Deserializable): + """Documentation for a plan.""" + + name: str + file: str + line: int + docstring: DocString + defaults: dict[str, Any] + source: str + + +@dataclass(kw_only=True) +class PuppetStrings(Deserializable): + """Complete documentation for a Puppet module.""" + + puppet_classes: list[PuppetClass] + data_types: list[DataType] + data_type_aliases: list[DataTypeAlias] + defined_types: list[DefinedType] + resource_types: list[ResourceType] + providers: list[Provider] + puppet_functions: list[Function] + puppet_tasks: list[Task] + puppet_plans: list[Plan] + + +# -------------------------------------------------- + + +def puppet_strings(path: str) -> bytes: + """ + Run ``puppet strings`` on puppet module at path. + + Returns a bytes object rather than a :class:`PuppetStrings` + object, to efficeiently writing the output to a cache. + + .. code-block:: python + :caption: Example Invocation + + >>> PuppetStrings.from_json(puppet_strings("/etc/puppetlabs/code/modules/stdlib")) + """ + # TODO adding an --out flag (to not stdout) causes warnings to be + # printed to stdout. Warnings + + cmd = subprocess.run('puppet strings generate --format json'.split(' '), + cwd=path, + check=True, + stdout=subprocess.PIPE) + return cmd.stdout + + +class HasDocstring(Protocol): + """Something which has a docstring attribute.""" + + docstring: DocString + + +def isprivate(entry: HasDocstring) -> bool: + """ + Is the given puppet declaration marked private. + + Assumes input is a dictionary as returned by puppet strings, one + of the entries in (for example) 'puppet_classes'. + + Currently only checks for an "@api private" tag. + """ + for tag in entry.docstring.tags: + if tag.tag_name == 'api' and \ + tag.text == 'private': + return True + return False diff --git a/muppet/puppet/strings/__main__.py b/muppet/puppet/strings/__main__.py new file mode 100644 index 0000000..b826955 --- /dev/null +++ b/muppet/puppet/strings/__main__.py @@ -0,0 +1,29 @@ +""" +Loads the output of puppet strings into puppet items. + +This is mostly to aid in debugging. +""" + +from . import PuppetStrings + + +def __main() -> None: + import json + import argparse + from pprint import PrettyPrinter + pp = PrettyPrinter(compact=True) + + parser = argparse.ArgumentParser() + parser.add_argument('--pretty', action='store_true') + parser.add_argument('source', type=argparse.FileType('r')) + args = parser.parse_args() + + data = PuppetStrings.from_json(json.load(args.source)) + if args.pretty: + pp.pprint(data) + else: + print(data) + + +if __name__ == '__main__': + __main() diff --git a/muppet/puppet/strings/internal.py b/muppet/puppet/strings/internal.py new file mode 100644 index 0000000..e88e4ba --- /dev/null +++ b/muppet/puppet/strings/internal.py @@ -0,0 +1,260 @@ +""" +Setup for automatic deserialization into dataclasses. + +This automatically deserializes dictionaries like those returned from +json objects, into actual python classes, using the dataclass +typelevel introspection. + +Each key in the dataclass should either be a primitive type +:py:data:`primitive_types`, a class extending from +:py:class:`Deserializable`, or an optional, list, or dictionary with +one of the above as it's type. + +See :py:meth:`Deserializable.handle` for details about customizing deserialization. + +.. code-block:: python + :caption: Sample usage. + + @dataclass + class OtherItem(Deserializable): + x: str + + @dataclass + class MyItem(Deserializable): + x: str + y: Optional[int] = None + xs: OtherItem = None + + MyItem.from_json({ + 'x': 'Hello', + 'xs': { + 'x': 'World', + } + }) + +.. code-block:: + :caption: result of the above: + + MyItem(x='Hello', + y=None, + xs=OtherItem(x='World')) + + +.. todo:: + + Forwards declarations in type fields sometimes work, and sometimes + not. I don't know what causes it. Use with caution. +""" + +import typing +from typing import ( + Any, + Optional, + TypeVar, + final, +) +from types import GenericAlias, NoneType +import dataclasses +from dataclasses import dataclass +import logging + +logger = logging.getLogger(__name__) + + +T = TypeVar('T', bound='Deserializable') + + +primitive_types = {str, int, bool} +"""Types which are directly allowed as children.""" + + +def check_optional(field: dataclasses.Field[Any]) -> Optional[Any]: + """ + Check if field represents an optional field. + + :returns: + ``None`` if the object isn't an Optional, and the contained + type if it is an Optional. + """ + # Documentation I have seem indicates that the correct way to + # check for unions would be + # ``isinstance(field.type, types.UnionType)``, + # but that always returns False for me. + if typing.get_origin(field.type) == typing.Union \ + and len(typing.get_args(field.type)) == 2: + args = typing.get_args(field.type) + if args[0] == NoneType: + return args[1] + if args[1] == NoneType: + return args[0] + else: + return None + else: + return None + + +@dataclass +class Deserializable: + """ + Something which can be deserialized from a JSON object. + + This class shouldn't be instansiated directly, but instead + subclassed to allow deserialization. + """ + + @final + @classmethod + def fields_dict(cls) -> dict[str, dataclasses.Field[Any]]: + """Return "this" dataclass fields as a dictionary from name to field.""" + return {f.name: f for f in dataclasses.fields(cls)} + + @final + @classmethod + def from_json(cls: type[T], d: dict[str, Any]) -> T: + """ + Load instance of "this" class from given dictionary. + + The name ``from_json`` is thereby slightly missleading. + + :param cls: + + :param d: + Dictionary which should be deseriablized. + + If the entry is another Deserializable (or lists of + Deserializable, or dictionaries with Deserialiazable + values) then ``from_json`` is called recursively. + + Other objects are returned verbatim. + """ + # if (extra := d.keys() - set(x.name for x in dataclasses.fields(cls))) != set(): + + params = {} + fields = cls.fields_dict() # allowed keys + + # For each present key + for k, v in d.items(): + if k in fields: + try: + params[k] = cls.handle(fields[k], k, v) + except TypeError as e: + logger.error('An error occurred while handling class [%s]', + cls.__name__, + exc_info=e) + raise e + else: + msg = "Attempting to set non-existant field [%(field)s] on class [%(cls)s] to value %(value)a" # noqa: E501 + logger.warning(msg, { + 'field': k, + 'cls': cls.__name__, + 'value': v + }) + + try: + return cls(**params) + except TypeError as e: + msg = 'Failed constructing object in from_json. class=%(cls)s, params=%(params)s' + logger.error(msg, { + 'cls': cls.__name__, + 'params': params, + }, exc_info=e) + + raise e + + @final + @classmethod + def handle(cls, field: dataclasses.Field[Any], key: str, value: Any) -> Any: + """ + Deserialize given field. + + The given value is deserialized according to the type of ``field``. + First, any ``Optional`` wrapping is removed (e.g. ``Optional[T] → T``). + + Then, one of the following steps are taken: + + if the class has a method called ``handle_${key}``: + Call that method with the value, propagating the result. + + if the type indicates another subclass of ``Deserializable``: + Recurse, returing that deserialized value. + + if the type indicates a list: + If it's either a primitive type, or another subclass of + ``Deserializable``, then each element in deserialized, and + collected into a list which is then returned. + + if the type indicates a dictionary: + The key type is ignored, and propagated directly, while + the value type follows the same rules as for lists (see + above). + + if the type is a primitive type (:py:data:`primitive_types`): + Return the value directoly + + otherwise: + Also return the value directly, but log a warning about + unhandled complex data. + + :param field: + Specifies the expected type of ``value``, and is used in + determining how to proceed. + :param key: + Key of the value we are deserializing. Is only used for + the manual method dispatch, and error messages. + :param value: + The value to deserialize. + :raises TypeError: + If there's a mismatch between the type and value a + TypeError will most likely be raised. These should + preferably be handled somehow. + """ + # Unwrap optional types. This is always ok, since handle is + # only called when we have a value, so the None case is + # already discarded. + if unwrapped := check_optional(field): + ttt = unwrapped + else: + ttt = field.type + + # Check for explicit handle_{key} method in current class + if callable(method := getattr(cls, f'handle_{key}', None)): + return method(value) + + # If the target class is Deserializable + elif isinstance(ttt, type) and issubclass(ttt, Deserializable): + return ttt.from_json(value) + + # If the target is a list of Deserializable + elif isinstance(ttt, GenericAlias) \ + and typing.get_origin(ttt) == list: + if issubclass(typing.get_args(ttt)[0], Deserializable): + typ = typing.get_args(ttt)[0] + return [typ.from_json(x) for x in value] + elif typing.get_args(ttt)[0] in primitive_types: + return value + + # If the target is a dictionary with Deserializable keys + elif isinstance(ttt, GenericAlias) \ + and typing.get_origin(ttt) == dict: + if issubclass(typing.get_args(ttt)[1], Deserializable): + typ = typing.get_args(ttt)[1] + return {k: typ.from_json(v) for (k, v) in value.items()} + elif typing.get_args(ttt)[1] in primitive_types: + return value + + # If the target is a primitive type + elif type(value) in primitive_types: + return value + + # if the target is something else weird + else: + msg = "setting complex variable: %(cls)s[%(field)a] = %(value)a as %(type)s" + args = { + 'cls': cls.__name__, + 'field': key, + 'value': value, + 'type': type(value).__name__, + } + logger.warning(msg, args) + + return value diff --git a/muppet/tabs.py b/muppet/tabs.py index 702a491..d00d17c 100644 --- a/muppet/tabs.py +++ b/muppet/tabs.py @@ -45,7 +45,7 @@ def tab_widget(tabgroup: TabGroup) -> str: The argument is the list of tabs, nothing is returned, but instead written to stdout. """ - template = env.get_template('tabset.html') + template = env.get_template('snippets/tabset.html') return template.render(tabset=tabgroup) diff --git a/muppet/templates.py b/muppet/templates.py new file mode 100644 index 0000000..492c501 --- /dev/null +++ b/muppet/templates.py @@ -0,0 +1,81 @@ +""" +Function wrappers around jinja templates. + +This allows for type checking. +""" + +from typing import ( + Any, + Optional, +) +from jinja2 import ( + Environment, + FileSystemLoader, +) +from .breadcrumbs import Breadcrumbs +from .gather import ModuleEntry + +jinja = Environment( + loader=FileSystemLoader('templates'), + autoescape=False, +) + + +def code_page(*, + title: str, + content: str, + path_base: str, + breadcrumbs: Optional[Breadcrumbs] = None) -> str: + """Template for a page containing puppet code.""" + template = jinja.get_template('code_page.html') + return template.render( + title=title, + content=content, + path_base=path_base, + breadcrumbs=breadcrumbs) + + +def content(*, + content: str, + path_base: str, + breadcrumbs: Optional[Breadcrumbs] = None) -> str: + """Template for a page with arbitrary content.""" + template = jinja.get_template('content.html') + return template.render( + content=content, + path_base=path_base, + breadcrumbs=breadcrumbs) + + +def index(*, + modules: list[ModuleEntry], + path_base: str, + breadcrumbs: Optional[Breadcrumbs] = None + ) -> str: + """Root index file.""" + template = jinja.get_template('index.html') + return template.render( + path_base=path_base, + modules=modules, + breadcrumbs=breadcrumbs) + + +def module_index( + *, + # content: list[], # something with to_html_list and to_html + content: list[Any], # TODO something with to_html_list and to_html + module_author: str, + module_name: str, + doc_files: list[tuple[str, str]], + path_base: str, + breadcrumbs: Optional[Breadcrumbs] = None, + ) -> str: + """Index for a single module.""" + template = jinja.get_template('module_index.html') + return template.render( + content=content, + module_author=module_author, + module_name=module_name, + doc_files=doc_files, + path_base=path_base, + breadcrumbs=breadcrumbs) diff --git a/mypy.ini b/mypy.ini index a47647c..7c7a251 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,4 +7,5 @@ disallow_untyped_defs = True disallow_incomplete_defs = True check_untyped_defs = True warn_return_any = True +strict = True # warn_unrearchable = True diff --git a/static-src/_sidebar.scss b/static-src/_sidebar.scss new file mode 100644 index 0000000..c1ca137 --- /dev/null +++ b/static-src/_sidebar.scss @@ -0,0 +1,17 @@ +#page-root { + display: grid; + grid-template-columns: 10ch auto 30ch; + + .sidebar { + font-size: 70%; + font-family: sans; + } + + #left-sidebar { + background: lightblue; + } + + #right-sidebar { + background: pink; + } +} diff --git a/static-src/style.scss b/static-src/style.scss index a4846ba..57c4387 100644 --- a/static-src/style.scss +++ b/static-src/style.scss @@ -159,6 +159,30 @@ code.json { /* -------------------------------------------------- */ +dl.module-index { + display: grid; + grid-template-columns: 1fr auto; + grid-row-gap: 4pt; + + dt { + text-align: right; + color: grey; + } +} + +/* -------------------------------------------------- */ + +ul.toc { + list-style-type: none; + padding-left: 1em; +} + +.toc ul { + padding-left: 2em; +} + +/* -------------------------------------------------- */ + @import "colorscheme_default"; .highlight-pygments { @@ -176,3 +200,4 @@ code.json { @import "breadcrumb"; @import "tabset"; @import "color-headers"; +@import "sidebar"; diff --git a/templates/base.html b/templates/base.html index c9cbb88..9c8fa46 100644 --- a/templates/base.html +++ b/templates/base.html @@ -34,16 +34,21 @@ Parameters: <body> <header> {% if breadcrumbs %} - <ul class="breadcrumb"> - {%- for item in breadcrumbs.crumbs -%} - <li><a href="{{ path_base }}{{ item.ref }}">{{ item.text }}</a></li> - {%- endfor -%} - <li>{{ breadcrumbs.final }}</li> - </ul> + <nav> + <ul class="breadcrumb"> + {%- for item in breadcrumbs.crumbs -%} + <li><a href="{{ path_base }}{{ item.ref }}">{{ item.text }}</a></li> + {%- endfor -%} + <li>{{ breadcrumbs.final }}</li> + </ul> + </nav> {% endif %} </header> - {% block content %} - {% endblock %} + <div id="page-root"> + <aside id="left-sidebar" class="sidebar">{% block left_sidebar %}{% endblock %}</aside> + <main>{% block content %}{% endblock %}</main> + <aside id="right-sidebar" class="sidebar">{% block right_sidebar %}{% endblock %}</aside> + </div> </body> </html> {# ft: jinja #} diff --git a/templates/code_page.html b/templates/code_page.html index 989fc5a..2e7f99c 100644 --- a/templates/code_page.html +++ b/templates/code_page.html @@ -7,6 +7,12 @@ Parameters: Content of page #} {% extends "base.html" %} +{% block left_sidebar %} + {# Class list, basically the right sidebar from module index #} +{% endblock %} +{% block right_sidebar %} + {# All defined variables #} +{% endblock %} {% block content %} <h1><code>{{ title }}</code></h1> <ul class="alternatives"> diff --git a/templates/index.html b/templates/index.html index 5cd4683..b3f4b6e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -10,20 +10,22 @@ Parameters: {% extends "base.html" %} {% block content %} <h1>Muppet Strings</h1> - <ul> + <dl class="module-index"> {% for module in modules %} - <li> + <dt> {%- if module.metadata.get('name') -%} {{ module.metadata['name'].split('-')[0] }}/ {%- endif -%} <a href="{{ module.name }}" class="{{ 'error' if module.strings_output == None }}" >{{ module.name }}</a> + </dt> + <dd> {%- if module.metadata.get('summary') %} - — {{ module.metadata['summary'] }} + {{ module.metadata['summary'] }} {%- endif -%} - </li> + </dd> {% endfor %} - </ul> + </dl> {% endblock %} {# ft: jinja #} diff --git a/templates/module_index.html b/templates/module_index.html index e8a74db..3448000 100644 --- a/templates/module_index.html +++ b/templates/module_index.html @@ -7,6 +7,17 @@ Parameters: content: #} {% extends "base.html" %} +{% block left_sidebar %} + {# environment list #} +{% endblock %} +{% block right_sidebar %} + {# Table of contents, including all types #} + <ul class="toc"> + {% for entry in content %} + {{ entry.to_html_list() }} + {% endfor %} + </ul> +{% endblock %} {% block content %} <h1>{{ module_author }} / {{ module_name.title() }}</h1> @@ -17,20 +28,7 @@ Parameters: </ul> {% for entry in content %} - <h2>{{ entry['title'] }}</h2> - {% for subentry in entry['list'] %} - <h3>{{ subentry['title'] }}</h3> - <ul class="overview-list"> - {% for item in subentry['list'] %} - <li> - <a href="{{ item['file'] }}">{{ item['name'] }}</a> - {%- if 'summary' in item %} - — {{ item['summary'] }} - {%- endif -%} - </li> - {% endfor %} - </ul> - {% endfor %} + {{ entry.to_html() }} {% endfor %} {% endblock %} diff --git a/templates/snippets/ResourceType-index-entry.html b/templates/snippets/ResourceType-index-entry.html new file mode 100644 index 0000000..b08d658 --- /dev/null +++ b/templates/snippets/ResourceType-index-entry.html @@ -0,0 +1,15 @@ +{# +#} +<dt><a href="#">{{ item.base() }}</a></dt> +<dd> + {% if item.summary %} + <!-- TODO docstring.text --> + {{ item.summary }} + {% endif %} + <dl> + {% for provider in item.children %} + {{ provider.to_html() }} + {% endfor %} + </dl> +</dd> +{# ft:jinja2 #} diff --git a/templates/snippets/ResourceType-list-entry.html b/templates/snippets/ResourceType-list-entry.html new file mode 100644 index 0000000..dedb1a0 --- /dev/null +++ b/templates/snippets/ResourceType-list-entry.html @@ -0,0 +1,10 @@ +{# +#} +<li><a href="#">{{ item.base() }}</a> + <ul> + {% for provider in item.children %} + {{ provider.to_html_list() }} + {% endfor %} + </ul> +</li> +{# ft:jinja2 #} diff --git a/templates/tabset.html b/templates/snippets/tabset.html similarity index 100% rename from templates/tabset.html rename to templates/snippets/tabset.html -- GitLab