opensheaf-py the tools of OpenSheaf, a literate program chunks · source

Tangling: from the documents to the program

The documents in literate/ are the source of opensheaf-py. The Python files of the package, its templates and the tools that build them are tangled out of the documents by a small program, tools/tangle.py, which is itself tangled out of this chapter. The files it writes are kept in the repository, so that the package installs and runs as any other Python package; the documents are what one edits, and the continuous integration checks that the files are exactly what the documents say.

The idea, and the syntax, come from Kragen Sitaker's handaxeweb, itself inspired by Phil Bewig's "The Essence of Literate Programming" (1996): the essence of literate programming is rearranging chunks of code, and a few dozen lines suffice for that. The documents are plain Markdown, readable as they stand on GitHub and woven into a website by tools/weave.py (next chapter).

The syntax

handaxeweb also has versions of chunks (in NAME v2:), to keep several versions of a program in one document. We leave them out: git keeps our versions.

Here is a complete small example, with the program indented and the reference on a line of its own:

    # in hello.py:
    import sys

    <<say hello>>

Saying hello needs only a print.

    # in say hello:
    print("hello", " ".join(sys.argv[1:]))

The program

tools/tangle.py reads the documents, collects the fragments of every chunk, and writes every file chunk, expanding the references. It can also check that the files are up to date, list the chunks, and tell from which line of which document a line of a program comes, which is what one needs when Python reports an error at a line of a tangled file.

#!/usr/bin/env python3
"""Tangle the literate sources of opensheaf-py: write the program files.

    python3 tools/tangle.py              write every file defined in literate/
    python3 tools/tangle.py --check      exit 1 if a file differs from its document
    python3 tools/tangle.py --out DIR    write the files under DIR instead
    python3 tools/tangle.py --list       list the files and the chunks
    python3 tools/tangle.py --where src/opensheaf/core.py:120
                                         the document line a program line comes from
"""
import argparse
import re
import sys
from dataclasses import dataclass
from pathlib import Path

⟨the syntax as patterns⟩

⟨reading the documents⟩

⟨expanding the chunks⟩

⟨the files⟩

⟨the command line of the tangler⟩

if __name__ == "__main__":
    main()

The syntax as patterns

A header is a line of the form in NAME:, with any punctuation (a comment sign) before it and after the colon; a reference is a whole line <<NAME>>; fences are lines starting, after at most three spaces, with three backquotes or tildes; a file name is a name without spaces, with a slash or a dot.

HEADER = re.compile(r"^[^\w\s]*\s*in (.+?):\s*[^\w\s]*$")
REFERENCE = re.compile(r"^(\s*)<<(.+?)>>\s*$")
FENCE = re.compile(r"^ {0,3}(```|~~~)")
FILE = re.compile(r"^[\w.-]*[/.][\w./-]*$")
continued further on; used in ⟨tools/tangle.py⟩.

Every tangled file in which a comment is possible starts with a notice saying where it comes from, after the #! line if there is one. Files where a comment would be seen by others (the HTML templates, which are copied into archives) or impossible (JSON) get no notice.

COMMENTS = {".py": "# {}", "": "# {}", ".sh": "# {}", ".toml": "# {}",
            ".yml": "# {}", ".yaml": "# {}"}
NOTICE = "Generated from {doc} by tools/tangle.py: edit the document, not this file."

Reading the documents

A code block, for us as for Markdown, is a run of lines indented by four spaces, after a blank line (or at the start of the document) and outside a fenced block; blank lines inside the run belong to it, blank lines at its end do not. We record for each block the document, the lines it occupies (the weaver replaces them by its rendering), the chunk it belongs to, whether it began with a header, and its lines without the four spaces.

@dataclass
class Block:
    doc: Path        # the document
    start: int       # its first line in the document, counting from 0
    end: int         # the line after its last
    name: str        # the chunk it belongs to
    header: bool     # whether its first line is a header
    lines: list      # its program lines, without the four spaces

    @property
    def first(self):
        """The line of the document, counting from 1, of its first program line."""
        return self.start + 1 + self.header


def is_blank(line):
    return not line.strip()


def is_indented(line):
    return line.startswith("    ")
continued further on; used in ⟨tools/tangle.py⟩.

The blocks of a document are found in one pass. Inside a fence nothing is code; otherwise an indented line after a blank line starts a block, which extends over the indented and blank lines that follow.

def blocks(doc, text=None):
    """The code blocks of a document, in order."""
    if text is None:
        text = doc.read_text(encoding="utf-8")
    lines = text.split("\n")
    found, name, fenced, i = [], None, False, 0
    while i < len(lines):
        line = lines[i]
        if FENCE.match(line):
            fenced = not fenced
        elif (not fenced and is_indented(line) and not is_blank(line)
              and (i == 0 or is_blank(lines[i - 1]))):
            ⟨read the block that starts at line i⟩
            continue
        i += 1
    return found

The block ends before its trailing blank lines. If its first line is a header, it names the chunk; otherwise the block continues the chunk of the block before it, and a document cannot start with such a block.

start = i
while i < len(lines) and (is_indented(lines[i]) or is_blank(lines[i])):
    i += 1
end = i
while is_blank(lines[end - 1]):
    end -= 1
code = ["" if is_blank(line) else line[4:] for line in lines[start:end]]
header = HEADER.match(code[0])
if header:
    name, code = header.group(1), code[1:]
elif name is None:
    raise SystemExit(f"{doc}:{start + 1}: a code block before the first chunk name")
found.append(Block(doc, start, end, name, bool(header), code))
⟨warn about a header inside the block⟩
i = end

Two code blocks separated only by a blank line are, for Markdown, one block; a header in the second is then program text. That is almost always a mistake: a sentence of prose between the blocks separates them.

for n in range(1, len(code)):
    if not code[n - 1] and HEADER.match(code[n]) and not code[n].startswith("#!"):
        print(f"{doc}:{start + n + 1 + bool(header)}: a header inside a code block "
              "(put some prose before it)", file=sys.stderr)

The chunks are the fragments of all documents, grouped by name, in the order of the documents: index.md first, then the others in the order of their names (which start with numbers).

def documents(folder="literate"):
    """The literate documents, in reading order."""
    return sorted(Path(folder).glob("*.md"), key=lambda d: (d.name != "index.md", d.name))


def read_chunks(docs):
    """The fragments of every chunk, by name."""
    chunks = {}
    for doc in docs:
        for block in blocks(doc):
            chunks.setdefault(block.name, []).append(block)
    return chunks

Expanding the chunks

Expanding a chunk appends its lines to a list, each with the document and line it comes from (blank lines between fragments come from nowhere). A reference is expanded in place, with the indentation of the reference added to every line; a chunk that includes itself, directly or not, is an error, as is a reference to a chunk that is not defined.

def expand(chunks, name, indent, out, python, stack=()):
    """Append the lines of chunk `name` to `out` as (text, document, line)."""
    if name not in chunks:
        raise SystemExit(f"<<{name}>> is used in <<{stack[-1]}>> but never defined")
    if name in stack:
        raise SystemExit("<<{}>> includes itself: {}".format(name, " > ".join(stack + (name,))))
    gap = 2 if python and indent == "" else 1
    for k, block in enumerate(chunks[name]):
        if k:
            out.extend([("", None, None)] * gap)
        for n, line in enumerate(block.lines):
            ref = REFERENCE.match(line)
            if ref:
                expand(chunks, ref.group(2), indent + ref.group(1), out, python, stack + (name,))
            else:
                out.append((indent + line if line else "", block.doc, block.first + n))

The files

A file is the expansion of its chunk, with the notice, ending with a newline. With the text we return the origin of every line, for --where.

def file_names(chunks):
    return [name for name in chunks if FILE.match(name)]


def tangle(chunks, name):
    """The text of file `name`, and where each of its lines comes from."""
    out = []
    expand(chunks, name, "", out, name.endswith(".py"))
    comment = COMMENTS.get(Path(name).suffix)
    if comment:
        notice = comment.format(NOTICE.format(doc=chunks[name][0].doc.as_posix()))
        at = 1 if out and out[0][0].startswith("#!") else 0
        out.insert(at, (notice, None, None))
    return "\n".join(text for text, _, _ in out) + "\n", out
continued further on; used in ⟨tools/tangle.py⟩.

A file is written only when its text changes, so that the dates of the others stay, and with Unix line ends whatever the system. A file that starts with #! is a script and is made executable.

def write(path, text):
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(text.encode("utf-8"))
    if text.startswith("#!"):
        path.chmod(path.stat().st_mode | 0o111)

A chunk that is neither a file nor used anywhere is probably a mistake (a misspelt name); we say so, without stopping.

def unused(chunks):
    used = {ref.group(2) for fragments in chunks.values() for block in fragments
            for line in block.lines for ref in [REFERENCE.match(line)] if ref}
    return [name for name in chunks if name not in used and not FILE.match(name)]

The command line

def main(argv=None):
    p = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    p.add_argument("documents", nargs="*", type=Path,
                   help="the literate documents (default: literate/*.md)")
    p.add_argument("--out", type=Path, default=Path("."), help="where to write the files")
    p.add_argument("--check", action="store_true",
                   help="write nothing; exit 1 if a file differs from its document")
    p.add_argument("--list", action="store_true", help="list the files and the chunks")
    p.add_argument("--where", metavar="FILE:LINE",
                   help="the line of the document a line of a file comes from")
    args = p.parse_args(argv)
    chunks = read_chunks(args.documents or documents())
    for name in unused(chunks):
        block = chunks[name][0]
        print(f"{block.doc}:{block.start + 1}: <<{name}>> is never used", file=sys.stderr)
    ⟨list, locate, or write the files⟩

Listing shows each file with the document that starts it, then each chunk with the places of its fragments.

if args.list:
    for name in file_names(chunks):
        print(f"{name}  ({chunks[name][0].doc})")
    print()
    for name, fragments in chunks.items():
        if not FILE.match(name):
            print(f"<<{name}>>  " + ", ".join(f"{b.doc.name}:{b.start + 1}" for b in fragments))
    return
if args.where:
    name, line = args.where.rsplit(":", 1)
    _, lines = tangle(chunks, name)
    _, doc, n = lines[int(line) - 1]
    print(f"{doc}:{n}" if doc else "a line added by the tangler")
    return

Otherwise every file is tangled and compared with what is on disk; with --check the differences are only reported.

stale = []
for name in file_names(chunks):
    text, _ = tangle(chunks, name)
    path = args.out / name
    if path.exists() and path.read_bytes() == text.encode("utf-8"):
        continue
    stale.append(name)
    if not args.check:
        write(path, text)
if args.check and stale:
    sys.exit("not as the documents say (run tools/build): " + ", ".join(stale))
for name in stale:
    print("wrote", name)

Building

tools/build tangles the program, following build_handaxeweb: since the tangler is tangled from its own document, a change to this chapter is only accepted if the new tangler, run on the documents, reproduces itself. The tangler of the repository tangles everything into a temporary folder; the new tangler found there does the same again; the two must agree, and then the new tangler writes the files. Finally the website is woven, if the packages it needs are installed.

#!/bin/sh
# Tangle the program out of literate/, checking that the tangler
# reproduces itself, then weave the website into site/.
set -e
cd "$(dirname "$0")/.."
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
python3 tools/tangle.py --out "$tmp/a"
python3 "$tmp/a/tools/tangle.py" --out "$tmp/b"
diff -r "$tmp/a" "$tmp/b"
python3 "$tmp/b/tools/tangle.py"
if python3 -c "import markdown, pygments" 2>/dev/null; then
    python3 tools/weave.py --out site
else
    echo "not weaving: pip install markdown pygments" >&2
fi