Ваша документация, тесты и код — это три файла, которые плохо рассказывают одну и ту же историю.
Вы обновляете сигнатуру функции в исходнике. Забываете про пример из README. Неделю спустя новичок копирует устаревший сниппет в продакшн. Ваш тестовый файл по-прежнему кодирует старое поведение как ожидаемый результат. Теперь у вас два бага и тикет документации.
Это проблема синхронизации. Она есть в любой кодовой базе. Большинство команд решают её дисциплиной, которая проваливается, как только кто-то торопится.
Literate programming решает её, делая файл с прозой единственным источником правды. Вы пишете Markdown-документ, который читают люди. Небольшой инструмент извлекает блоки кода в исполняемые Python-файлы. Тесты, реализация и пояснения живут в одном месте. Меняете прозу — меняете код.
Что такое literate programming?
Donald Knuth ввёл этот термин в 1984 году. Идея была проста: пишите программу так, как пишете эссе. Объясняйте логику на естественном языке, чередуйте с кодом, и пусть инструмент tangle извлекает исполняемый исходник, а weave создаёт отформатированную документацию.
Классические инструменты, такие как система WEB Knuth, были тесно связаны с Pascal и TeX. Они так и не стали популярными в мейнстримной разработке. Рабочий процесс казался чуждым, инструменты были тяжеловесными, и большинство программистов предпочитало читать код в IDE, а не в PDF.
Но ключевая идея всё ещё актуальна. Проза и код должны иметь единый источник правды. Современные языки и Markdown делают это проще, чем Knuth мог себе представить. Нужен не специальный компилятор, а около тридцати строк Python и Markdown-парсер.
Как один Markdown-файл становится тестовым набором
Механизм прост. Вы пишете .md-файл с fenced code blocks. Заголовочный комментарий внутри каждого блока сообщает экстрактору, к какому файлу он относится. Экстрактор объединяет блоки с одинаковым заголовком в один Python-модуль. Затем вы запускаете pytest для результата.
Вот полный пример. Сохраните как calculator.md:
# Calculator: addition and its properties
The `add` function is trivial. That is the point. Even trivial code deserves context about why it exists and what invariants it preserves.
```python
# calc.py
def add(a: int, b: int) -> int:
"""Return the sum of two integers."""
return a + b
Addition commutes. We verify this explicitly because it is a property future maintainers might break by accident if they switch to a different implementation.
# test_calc.py
from calc import add
def test_addition_commutes():
assert add(2, 3) == add(3, 2)
def test_identity():
assert add(7, 0) == 7
Комментарии `# calc.py` и `# test_calc.py` — не магический синтаксис. Это соглашения, которые понимает наш скрипт.
## Скрипт извлечения
Это весь инструмент. Сохраните как `tangle.py`:
```python
import re
import tempfile
import subprocess
from pathlib import Path
def tangle(md_path: Path, out_dir: Path) -> Path:
"""Extract code blocks from Markdown into runnable Python files."""
content = md_path.read_text()
files: dict[str, list[str]] = {}
# Extract all python code blocks
for block in re.findall(r"```python\n(.*?)```", content, re.DOTALL):
lines = block.strip().split("\n")
# First line like '# filename.py' sets the target file
if lines and lines[0].startswith("# "):
filename = lines[0][2:].strip()
code = lines[1:]
else:
filename = "module.py"
code = lines
files.setdefault(filename, []).extend(code)
# Write extracted files
out_dir.mkdir(parents=True, exist_ok=True)
for filename, code_lines in files.items():
(out_dir / filename).write_text("\n".join(code_lines) + "\n")
return out_dir
if __name__ == "__main__":
out = tangle(Path("calculator.md"), Path("build"))
result = subprocess.run(
["python", "-m", "pytest", str(out), "-v"],
capture_output=False,
)
raise SystemExit(result.returncode)
Запустите:
$ python tangle.py
============================= test session starts ==============================
build/test_calc.py::test_addition_commutes PASSED
build/test_calc.py::test_identity PASSED
============================== 2 passed in 0.01s
Теперь в каталоге build/ находятся calc.py и test_calc.py. Вы можете импортировать их, проверять типы или распространять как пакет. Markdown-файл — канонический источник. Всё остальное генерируется.
Когда один файл помогает, а когда мешает
Этот подход блестяще работает для библиотек, алгоритмов и всего, где почему важно не меньше, чем что. Документация API, исследовательский код и пайплайны конфигурации выигрывают от прозы, привязанной к реализации.
Он не подходит для шаблонного кода приложений. Представление Django с пятнадцатью импортами декораторов не нуждается в эссе. Если ваш модуль — в основном plumbing фреймворка, накладные расходы literate-структуры добавляют трения без ясности.
Другое ограничение — поддержка инструментов. IDE ожидают найти ваш код в .py-файлах. Переход к определению, встроенный линтинг и автодополнение требуют, чтобы сгенерированные файлы существовали заранее. Это можно решить, запуская tangle.py как pre-commit hook или как часть сборки. Но это лишний шаг. Если ваша команда уже испытывает трудности со сложностью сборки, добавление кастомного конвейера извлечения может не окупиться.
Реальные инструменты, которые это делают
Тридцатистрочный скрипт выше достаточен для начала. Если нужно что-то production-ready, есть зрелые варианты.
Entangled — современный инструмент literate programming, работающий с любым языком. Использует несколько другой синтаксис, но идея та же. Отслеживает зависимости между блоками кода и поддерживает несколько выходных файлов.
Jupyter notebooks решают схожую задачу для data science. Они смешивают прозу, код и вывод в одном файле. Минус в том, что ноутбуки ужасны для контроля версий. Диффы нечитаемы, а конфликты слияния — обычное дело.
Org-mode с Babel — самая мощная реализация. Если вы уже живёте в Emacs, он непревзойдён. Если нет — кривая обучения крутая.
Для большинства команд простой Markdown-экстрактор — прагматичный компромисс. Он использует инструменты, которые все уже знают.
FAQ
Работает ли это с type checker?
Да. Сначала сгенерируйте .py-файлы, затем запустите mypy или pyright для каталога сборки.
А как насчёт языков, отличных от Python?
Скрипт tangle.py не зависит от языка. Измените регулярное выражение с python на rust или go, и всё будет работать так же. Можно даже смешивать языки в одном документе.
Как обрабатывать импорты между блоками?
Экстрактор объединяет все блоки с одинаковым именем файла в порядке их появления в Markdown. Храните импорты в первом блоке для этого файла или повторяйте их. Python корректно обрабатывает дублирующиеся импорты.
Начните с одного модуля
Вам не нужно переписывать всю кодовую базу. Выберите один небольшой модуль, где документация всегда отстаёт от кода. Преобразуйте его в Markdown-файл, добавьте скрипт извлечения в CI-пайплайн и запускайте тесты для сгенерированного вывода.
Если документация начнёт оставаться актуальной без напоминаний, вы поймёте, что подход работает. Если это кажется излишним — откажитесь от него. Literate programming — это инструмент, а не религия. Цель не в том, чтобы впечатлить Knuth. Цель — перестать обманывать себя в трёх разных файлах.