aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorHugo Hörnquist <hugo@lysator.liu.se>2023-07-03 22:58:08 +0200
committerHugo Hörnquist <hugo@lysator.liu.se>2023-07-03 22:58:08 +0200
commitb1494d78c31e85b2b966fd52ff4efad528e03e03 (patch)
tree3e3a0b2e0ae40c790b3a695a52d5ac3f6f950cf3
parentMakefile: Also add pyright check. (diff)
downloadmuppet-strings-b1494d78c31e85b2b966fd52ff4efad528e03e03.tar.gz
muppet-strings-b1494d78c31e85b2b966fd52ff4efad528e03e03.tar.xz
Harden typesystem + misc.
Misc includes: - at least one more category - basic sidebars
-rw-r--r--muppet/format.py154
-rw-r--r--muppet/gather.py11
-rw-r--r--muppet/markdown.py4
-rw-r--r--muppet/output.py557
-rw-r--r--muppet/puppet/__init__.py9
-rw-r--r--muppet/puppet/parser.py9
-rw-r--r--muppet/puppet/strings.py34
-rw-r--r--muppet/puppet/strings/__init__.py402
-rw-r--r--muppet/puppet/strings/__main__.py29
-rw-r--r--muppet/puppet/strings/internal.py260
-rw-r--r--muppet/tabs.py2
-rw-r--r--muppet/templates.py81
-rw-r--r--mypy.ini1
-rw-r--r--static-src/_sidebar.scss17
-rw-r--r--static-src/style.scss25
-rw-r--r--templates/base.html21
-rw-r--r--templates/code_page.html6
-rw-r--r--templates/index.html12
-rw-r--r--templates/module_index.html26
-rw-r--r--templates/snippets/ResourceType-index-entry.html15
-rw-r--r--templates/snippets/ResourceType-list-entry.html10
-rw-r--r--templates/snippets/tabset.html (renamed from templates/tabset.html)0
22 files changed, 1369 insertions, 316 deletions
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') %}
- &mdash; {{ 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 %}
- &mdash; {{ 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
index 559af0a..559af0a 100644
--- a/templates/tabset.html
+++ b/templates/snippets/tabset.html