#!/usr/bin/env python3 """ This script renders Jinja comments found at the beginning of `.sls` files. Since this was written for the salt tool suite, it should be placed in a sibling directory of a directory named tool_ containing the actual states to be able autodiscover them. Of course, it can be used by other formulae by manually providing the root path and I might publish this at some point as a python module or at least in a separate repo to be used by pre-commit. It would need to be configurable without editing the source code for that. @TODO """ from pathlib import Path from jinja2 import Environment from jinja2.exceptions import TemplateSyntaxError # import sphinx.util.docstrings as d base_dir = Path(__file__).parent.parent.absolute() class State: """ Object representing a documented state. """ def __init__(self, path, comment): self.path = path self.comment = comment def tostateid(self, include_init=True, relative_to=""): ret = self.path.with_suffix("") if not include_init and "init" == ret.parts[-1]: ret = ret.parent if relative_to: relative_to = Path(relative_to.replace(".", "/")) ret = ret.relative_to(relative_to) return str(ret).replace("/", ".") def isinit(self): return "init.sls" == self.path.parts[-1] def isclean(self): return "clean.sls" == self.path.parts[-1] class StateRegistry: """ Registers State objects. It generates several indices: 1. nested dictionary containing the states 2. flattened list of states 3. flattened list of prefixes ~ directories containing .sls files All of them are guaranteed to be sorted, which is required by some of the downstream classes. """ def __init__(self, reg=None, states=None, prefixes=None): if reg is None: reg = {} if states is None: states = [] if prefixes is None: prefixes = [] self.reg = reg self.states = states self.prefixes = prefixes def lookup(self, state, tree=False): """ Looks up State objects by ID ('some.example' or ['some', 'example']). If tree, also returns pointers to dicts inside the nested registry. """ ptr = self.reg if isinstance(state, str): state = state.split(".") try: while state: cur = state.pop(0) ptr = ptr[cur] except KeyError: return False if isinstance(ptr, State) or tree: return ptr # map init.sls same way as salt does elif "init" in ptr: return ptr["init"] raise ValueError("State could not be found.") def register(self, state): state_id = state.tostateid() self.states.append(state_id) parts = state_id.split(".") ptr = self.reg prefix = "" while len(parts) > 1: cur = parts.pop(0) prefix = (prefix + "." + cur) if prefix else cur # make sure subdicts are initialized if cur not in ptr: ptr[cur] = {} self.prefixes.append(prefix) ptr = ptr[cur] ptr[parts.pop(0)] = state self.prefixes.sort() self.states.sort() def unregister(self, state): state_id = state.tostateid() self.states.remove(state_id) if state_id.find("."): path, key = state_id.rsplit(".", maxsplit=1) parent = self.lookup(path, tree=True) else: parent = self.reg key = state_id del parent[key] def depth(self, prefix=""): """ Returns depth of nested dict. Optionally from a specified prefix. """ d = self.lookup(prefix, tree=True) if prefix else self.reg return self._get_depth(d) def _get_depth(self, d): """ Recursively look up depth. """ if isinstance(d, dict) and set(d.keys()) - {"init", "clean"}: return 1 + (max(map(self._get_depth, d.values())) if d else 0) return 0 def count(self): return len(self.states) def dump(self, prefix=""): """ Returns all states sorted alphabetically, optionally from a prefix onwards. """ for s in self.states: if not prefix or s.startswith(prefix): yield self.lookup(s) class Renderer: """ Base class for Renderer objects. """ opts = {} def __init__(self, target="docs", opts=None): if opts is not None: self.opts = self.opts | opts if isinstance(target, str): target = Path(target) self.target = base_dir / target self._ensurepath(self.target) def render(self, states): raise NotImplementedError() def _write(self, text, ncount=1): """ Convenience method to mimic writeln This is bad form though, implicitly requires self.f to be set before being called """ if not hasattr(self, "f"): raise ValueError( "Sorry, you need to set self.f to a file pointer before calling self._write methods." ) self.f.write(text + "\n" * ncount) def _write_heading(self, text, uline="-"): """ Convenience method to write a subheading """ self._write(text) self._write(uline * len(text)) def _write_main_heading(self, text, uline="="): """ Convenience method to write the main heading """ self.f.write(text + "\n") self.f.write(uline * len(text) + "\n\n") def _write_main_description(self, text): """ Convenience method to write the main description """ self.f.write(text + "\n\n") def _get_main_heading(self, states): """ Returns a string to be used as the main heading of an rst file. Looks up in the following order: 1. If there is a documented root init.sls, return its first line. 2. If a header was specified in opts, return that. 3. If this renderer was instantiated with a prefix, return the capitalized last part of it. 4. "Available States" """ if main := states.lookup(self.prefix + ".init"): lines = main.comment.splitlines() header = lines.pop(0) # make sure the header does not get printed twice main.comment = "\n".join(lines).strip("\n") return header if main := self.opts.get("header"): return main if self.prefix: return self.prefix.split(".")[-1].title() return "Available States" def _get_main_description(self, states): """ Returns a string to be used as the main description (after the main header). Looks up in the following order: 1. If there is a documented root init.sls, return everything but its first line. If it was found, unregister it to not render it twice (bad form again, maybe consider was_rendered on State) 2. If a description was specified in opts, return that. 3. general description """ if main := states.lookup(self.prefix + ".init"): comment = main.comment # make sure the comment does not get printed twice states.unregister(main) return comment if main := self.opts.get("description"): return main return "The following states are found in {}:".format( self.prefix if self.prefix else "this formula" ) def _write_header(self, states): """ Convenience method to write the main heading and description. """ self._write_main_heading(self._get_main_heading(states)) self._write_main_description(self._get_main_description(states)) self.f.write("\n") def _ensurepath(self, path): """ Convenience method to mimic mkdir -p on the parent dir of a path """ if not path.parent.is_dir(): path.parent.mkdir(parents=True) class SingleRenderer(Renderer): """ Renders documented Salt states in a single file. When a prefix is specified, it only considers states nested inside that prefix. If there is a documented top-layer init.sls, the first line of its comment will become the main heading of the file, the stripped rest will be rendered directly below before any nested states. """ opts = { # include init.sls files "init": True, # include .init suffix "init_suffix": False, # include clean.sls files "clean": False, # when rendering with prefix `settings.something`, # write `else` instead of `settings.something.else` "cut_prefix": False, } def __init__(self, target="docs/states.rst", prefix="", opts=None): self.prefix = prefix if isinstance(target, str): target = Path(target) target = target.with_suffix(".rst") super().__init__(target, self.opts | opts or {}) def render(self, states): relative_to = self.prefix if self.opts["cut_prefix"] else "" with open(self.target, "w") as f: self.f = f self._write_header(states) for state in states.dump(self.prefix): if not self.opts["clean"] and state.isclean(): continue if not self.opts["init"] and state.isinit(): continue self._write_heading( state.tostateid(self.opts["init_suffix"], relative_to) ) self._write(state.comment, 3) class DepthRenderer(Renderer): """ Renders documented Salt states in separate files. They are combined according to the maximum nesting depth specified during instantiation. Consider an example with max_depth=1. This directory structure: settings ├── animations │ ├── cursor_blinking │ └── window_resize_time └── apps └── messages └── read_receipts will result in these files: * docs/settings/index.rst - animations - apps/index * docs/settings/animations.rst - cursor_blinking doc - window_resize_time doc * docs/settings/apps/index.rst - messages * docs/settings/apps/messages.rst - read_receipts doc """ opts = { # maxdepth for generated index.rst files "index_maxdepth": 4, # also any options are passed through to the # delegate renderer, by default SingleRenderer } def __init__(self, max_depth=1, target="docs/states", delegate_cls=None, opts=None): if delegate_cls is None: delegate_cls = SingleRenderer self.max_depth = max_depth self.delegate = delegate_cls super().__init__(target, self.opts | opts or {}) def render(self, states): found = [] indices = {} cur_top = "" # find at which prefix length the maxdepth is fulfilled for prefix in states.prefixes: # filtering out more specific = shallower dicts # this requires the list to be sorted! if cur_top: if prefix.startswith(cur_top): continue else: cur_top = "" if states.depth(prefix) <= self.max_depth: found.append(prefix) cur_top = prefix # fill indices with relative links to # a) other indices if there are nested prefixes # b) the rendered files at that depth # example: settings.one settings.two.[subone, subtwo] => # { settings: {'_index': ['one', 'two/index'], # 'two': {'_index': [subone, subtwo]}}, # '_index': 'settings/index'} for prefix in found: ptr = indices prefix = prefix.split(".") while prefix: cur = prefix.pop(0) if prefix and cur not in ptr: ptr[cur] = {} if "_index" not in ptr: ptr["_index"] = [] if prefix and cur + "/index" not in ptr["_index"]: ptr["_index"].append(cur + "/index") elif not prefix and cur not in ptr["_index"]: ptr["_index"].append(cur) if prefix: ptr = ptr[cur] if "_index" in indices: self._render_index(states, indices) for prefix in found: self._render_single(states, prefix) def _render_single(self, states, prefix): target = self._get_target(prefix) renderer = self.delegate(target=target, prefix=prefix, opts=self.opts) renderer.render(states) def _render_index(self, states, indices, prefix=""): target = self._get_target(prefix) / Path("index.rst") self._ensurepath(target) with open(target, "w") as f: self.f = f self.prefix = prefix self._write_header(states) self.prefix = "" f.write( """ .. toctree:: :maxdepth: {} :caption: Contents: """.format( self.opts.get("index_maxdepth", 4) ) ) for s in indices["_index"]: f.write(" " + s + "\n") for sub in indices: if "_index" == sub: continue if "_index" in indices[sub]: new_prefix = prefix + "." + sub if prefix else sub self._render_index(states, indices[sub], new_prefix) def _get_target(self, prefix): target_prefix = Path(prefix.replace(".", "/")) target = self.target / target_prefix self._ensurepath(target) return target class SaltAutodoc: """ Looks for salt state files (*.sls) relative to base, reads them and looks for the first token being a Jinja comment. If the comment does not start with the string '@internal', it will be saved in the registry. The registry is then passed to the renderer. """ SKIP_FILE = "@internal" def __init__(self, base=None, renderer=None, registry=None, env=None): """ base: base path containing Salt states to autodocument renderer: renderer to use for rendering """ if base is None: base = list(base_dir.glob("tool_*"))[0] self.base = base if renderer is None: renderer = SingleRenderer() self.renderer = renderer if env is None: env = Environment() self.env = env if registry is None: registry = StateRegistry() self.registry = registry def run(self, src=""): """ 1. look for .sls files 2. lex them with Jinja and see if the first token is a comment 3. in that case, save it to the registry 4. pass the registry to the renderer """ states = {} for file in self.glob(src): with open(file, "r") as f: text = "".join(f.readlines()) try: comment = self.find_comment(text) except TemplateSyntaxError as e: raise RuntimeError( "There was an error parsing the Jinja syntax in file '{}':\n\n{}".format( file, str(e) ) ) if comment and not comment.startswith(self.SKIP_FILE): path = self.path2saltpath(file) self.registry.register(State(path, comment)) self.renderer.render(self.registry) def path2saltpath(self, path): """ Returns a Path object relative to the base directory. """ return path.relative_to(self.base) def glob(self, src): """ Returns Path objects pointing to Salt states provided by this formula. """ for path in (self.base / Path(src)).rglob("*.sls"): yield path def find_comment(self, text): """ Lexes files with Jinja. If the first token is a comment, return it. """ lexed = list(self.env.lex(text)) try: i, t, content = lexed[1] except IndexError: i = t = content = "" if "comment" == t: return self.parse(content) return "" def parse(self, comment): """ Strips indentation from [multiline] strings. Could have used docutils.prepare_docstring. """ # return d.prepare_docstring(comment) ws = min([len(x) - len(x.lstrip()) for x in comment.splitlines() if len(x) > 0]) return "\n".join([x[ws:] for x in comment.splitlines()]).strip("\n") if __name__ == "__main__": options = { "init": True, "init_suffix": False, "clean": False, "cut_prefix": False, "index_maxdepth": 4, } # by default, # - render all documented states to a single file # - skip clean.sls descriptions # - do not include .init suffix on state ID r = SingleRenderer(target="docs/states.rst", opts=options) a = SaltAutodoc(base=base_dir / "gitea", renderer=r) a.run()