Example: structured data#

Technique: new handlers and a new complete(). Not every renderer produces text. This one produces a list of dictionaries, one per heading, paragraph, calculation or block, so a web front-end (or a template engine, or a database) can lay the calculation out itself. It keeps the Unicode symbol rules of ASCIIRenderer for the strings inside each entry.

The code#

"""
A renderer whose output is data, not text: a list of dicts that a web front-end
(or a template) can lay out however it likes. It keeps the Unicode symbol rules
of ASCIIRenderer and replaces the line handlers and complete().
"""
from handcalcs.renderers import ASCIIRenderer
from handcalcs.renderers.base import RenderContext


class DataRenderer(ASCIIRenderer):
    name = "data"

    def complete(self, tree, base_context):
        """Flatten the render tree into a list of dicts."""
        return list(self._entries(tree))

    def _entries(self, items):
        for item in items:
            if not item:
                continue  # an ignored line, a command, a spacer
            if isinstance(item, dict):
                yield item
            elif isinstance(item, str):
                yield {"kind": "text", "text": item}
            elif all(isinstance(part, str) for part in item):  # a line's parts
                yield {"kind": "text", "text": " ".join(item)}
            elif isinstance(item[-1], list):  # a block: [header, body]
                header, body = item[0], item[-1]
                body = list(self._entries(body))
                if header.strip():
                    yield {"kind": "block", "header": header, "body": body}
                else:
                    yield from body  # a headerless group of lines


def columns(renderer, nodes, mode, base_context):
    base_context.line_context.current_mode = mode
    return ", ".join(renderer.render(node, base_context) for node in nodes)


@DataRenderer.register("calc_line")
def calc_line(renderer, node, base_context):
    # Render the comment first: an inline ``# hc:`` command sets the line context.
    comment = renderer.render(node.comment, base_context) if node.comment else ""
    context = base_context.current
    if getattr(context, "ignore", False):
        base_context.line_context = RenderContext.sparse()
        return None
    entry = {
        "kind": "param" if getattr(context, "param_line", False) else "calc",
        "target": columns(renderer, node.assigns, "sym", base_context),
        "result": columns(renderer, node.assigns, "num", base_context),
        "comment": comment,
    }
    if entry["kind"] == "calc":
        base_context.line_context.current_mode = "sym"
        entry["symbolic"] = "".join(renderer.render(n, base_context) for n in node.expression_tree)
        base_context.line_context.current_mode = "num"
        entry["numeric"] = "".join(renderer.render(n, base_context) for n in node.expression_tree)
    base_context.line_context = RenderContext.sparse()  # end of the line's overrides
    return entry


@DataRenderer.register("params_block")
def params_block(renderer, node, base_context):
    cells = []
    for line in node.lines:
        base_context.line_context.param_line = True
        cells.append(renderer.render(line, base_context))
    return {"kind": "params", "cells": [cell for cell in cells if cell]}


@DataRenderer.register("heading")
def heading(renderer, node, base_context):
    return {"kind": "heading", "level": node.heading_level, "text": node.content}


@DataRenderer.register("inline_comment")
def inline_comment(renderer, node, base_context):
    return node.content

The result#

## Web check
# The web must be at least class 2.
h_w = 310; t_w = 6
x_1 = h_w / t_w  # slenderness
if x_1 <= 60:
    web_class = 1
Output (DataRenderer)
[{'kind': 'heading', 'level': 1, 'text': 'Web check'},
 {'kind': 'text', 'text': 'The web must be at least class 2.'},
 {'cells': [{'comment': '', 'kind': 'param', 'result': '310', 'target': 'h_w'},
            {'comment': '', 'kind': 'param', 'result': '6', 'target': 't_w'}],
  'kind': 'params'},
 {'comment': 'slenderness',
  'kind': 'calc',
  'numeric': '310 / 6',
  'result': '51.667',
  'symbolic': 'h_w / t_w',
  'target': 'x_1'},
 {'body': [{'comment': '', 'kind': 'param', 'result': '1', 'target': 'web_class'}],
  'header': 'Since (x_1<=60) -> (51.667<=60) is True:',
  'kind': 'block'}]

json.dumps(result) turns it into JSON.

How it works#

calc_line replaces the built-in handler, which returns a list of parts, with one that returns a dict. It follows the same steps as the built-in render_calcline:

  1. Render the comment first. An inline # hc: command sets the line context, and that has to happen before the rest of the line is rendered.

  2. Honour ignore (# hc: -i) and param_line (set by the inherited calc_line:pre rule for parameters).

  3. Render each step with current_mode set: targets and the symbolic step in "sym", the numeric step and the result in "num".

  4. Reset the line context to empty, so this line’s overrides don’t leak to the next.

params_block renders each line as a parameter by setting param_line on the line context, as the built-in handler does, and returns the cells directly instead of a grid.

heading and inline_comment return the raw content. (The inherited pre rule on comments still runs first, so {{ }} values are filled in.)

complete walks the render tree. Every built-in block handler still returns [header, body], and the comment-line handler still returns a string, so complete handles the three tree shapes as well as the dicts.

Variations#

  • Return the numbers as well as the strings: read node.assigns[0].value in the calc_line handler.

  • Produce XML by building xml.etree.ElementTree elements in the handlers and serialising them in complete.

  • Write straight to a file or a service in complete, as the Excel renderer does with its filepath option.

  • Keep markup strings in the handlers and parse them only in complete, as the Word renderer does. Its handlers return fragments of Word’s math markup (OMML) the way the LaTeX handlers return LaTeX, so the base line and block handlers concatenate them unchanged. Brackets are the two halves of one element (the lpar/rpar context fields), always used as a pair. A few handlers return small objects (a heading, a params table) that its complete walks into a python-docx document (WordRenderer.write_item).

Next: Testing your renderer.