Se a tua pipeline de build não pode ser executada sem abrires um terminal e escreveres make tangle, não tens um programa literário. Tens um diário com um compiler anexado.
Toda a ideia da programação literária é que a prosa e o código partilhem uma única fonte de verdade. O ficheiro Markdown é o artefacto. Tudo o resto — o source executável, a documentação renderizada, os ficheiros de teste — é derivado. Artefactos derivados pertencem ao CI, não à tua memória de trabalho.
A questão não é se o CI pode fazer weave e tangle. Claro que pode. Qualquer máquina que execute Python e pandoc pode fazê-lo. A questão é se a estrutura do teu repository permite ao CI ser dono dos ficheiros derivados, ou se ainda estás a fingir que os ficheiros .py são código source quando na realidade são output de build.
O que weave e tangle realmente fazem
Donald Knuth chamou às duas operações weave e tangle, e os nomes são confusos a ponto de as pessoas evitarem o tema por completo.
tangle pega no documento narrativo — o ficheiro .md ou .w — e extrai os blocos de código pela ordem que o compiler espera. Produz os ficheiros source executáveis. weave faz o oposto. Pega no mesmo documento e produz a documentação legível por humanos, com código pretty-printed, referências cruzadas e um index.
Ambas são transformações deterministas. Pegam num input e produzem outputs consistentes. Essa é a definição de algo que o CI deve lidar.
Por que a maioria das equipas constrói a pipeline ao contrário
O workflow típico de programação literária é assim. Escreves algorithm.md. Corres um script local para extrair algorithm.py. Corres testes contra algorithm.py. Fazes commit de ambos algorithm.md e algorithm.py no Git. Depois abres um pull request.
Isto já está partido. Tens duas cópias da mesma lógica no controlo de versões. Se um revisor sugerir uma mudança em algorithm.py, tens de te lembrar de a fazer backport para algorithm.md. Se editares algorithm.md e te esqueceres de re-fazer tangle, o ficheiro .py committed está obsoleto. A fonte única de verdade é uma ficção.
O workflow correto é mais simples. Fazes commit apenas do ficheiro .md. O CI executa tangle para produzir o ficheiro .py, e depois executa a test suite contra ele. Se os testes passarem, o CI executa opcionalmente weave para publicar documentação. O ficheiro .py nunca toca o controlo de versões. É output de build, como um ficheiro .o ou uma imagem Docker.
Uma pipeline de CI funcional para Markdown-para-Python
Aqui está um workflow completo de GitHub Actions que trata o ficheiro Markdown como a única fonte de verdade. Guarda-o como .github/workflows/literate.yml:
name: Tangle and Test
on:
push:
paths:
- "**/*.md"
pull_request:
paths:
- "**/*.md"
jobs:
tangle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Tangle code from Markdown
run: python scripts/tangle.py src/*.md --out build/
- name: Run tests on extracted code
run: python -m pytest build/ -v
- name: Upload generated source as artifact
uses: actions/upload-artifact@v4
with:
name: generated-source
path: build/
O filtro paths é importante. Este workflow só corre quando um ficheiro Markdown muda, porque Markdown é o único input que importa.
O script tangle.py é o mesmo extractor de trinta linhas que correrias localmente. Aqui está uma versão que lida com múltiplos ficheiros e um diretório de output:
import argparse
import re
import sys
from pathlib import Path
def tangle(md_path: Path, out_dir: Path) -> None:
content = md_path.read_text()
files: dict[str, list[str]] = {}
for block in re.findall(r"```python\n(.*?)```", content, re.DOTALL):
lines = block.strip().split("\n")
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)
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")
def main() -> None:
parser = argparse.ArgumentParser(description="Extract code from Markdown files.")
parser.add_argument("inputs", nargs="+", type=Path, help="Markdown files to process")
parser.add_argument("--out", type=Path, required=True, help="Output directory")
args = parser.parse_args()
for md in args.inputs:
tangle(md, args.out)
if __name__ == "__main__":
main()
Corre-o localmente para verificar:
$ python scripts/tangle.py src/calculator.md --out build/
$ python -m pytest build/ -v
O job de CI faz exactamente o mesmo. A única diferença é que o CI o executa num ambiente limpo, por isso um ficheiro .py obsoleto escondido no teu diretório de trabalho não pode enganar a test suite.
Tecer documentação na mesma pipeline
tangle produz código. weave produz documentação. Ambos podem correr no CI.
Para weave, precisas de uma ferramenta que converta Markdown num formato apresentável. Pandoc é a escolha aborrecida mas fiável. Se quiseres referências cruzadas, realce de sintaxe e um index, um pequeno modelo leva-te lá:
- name: Weave documentation
run: |
mkdir -p docs/output
pandoc src/*.md \
--from markdown \
--to html5 \
--standalone \
--toc \
--highlight-style=tango \
--output docs/output/index.html
- name: Publish to GitHub Pages
uses: actions/upload-pages-artifact@v3
with:
path: docs/output/
O HTML renderizado também é um artefacto derivado. Podes publicá-lo no GitHub Pages, Netlify, ou um bucket S3 a cada merge para main. O ficheiro Markdown permanece canónico. O HTML é o que os leitores veem.
O compromisso entre commit e gerar
Algumas equipas resistem a isto. Querem os ficheiros .py no Git porque facilita o git grep e porque a UI de code review do GitHub compreende melhor a sintaxe Python do que Markdown.
São problemas reais, mas são problemas de ferramentas, não de arquitetura. Fazer commit de código gerado porque o teu tool de code review é mau é como fazer commit de node_modules porque o teu gestor de packages é lento. Funciona, mas acumula dívida.
Se precisares absolutamente dos ficheiros .py no repository, gera-os no CI e faz commit deles com uma conta de bot. Aqui está o padrão:
- Um programador faz push de uma mudança para
algorithm.md. - O CI executa
tangle.pye produzalgorithm.py. - Se o código extraído difere do que está em
main, o CI abre um pull request com o ficheiro.pyactualizado. - Um humano revê o diff gerido e faz merge.
Isto mantém os ficheiros gerados no controlo de versões sem os deixar divergir. O bot torna-se a fonte única de verdade para o output gerado, e o bot só muda o que o ficheiro Markdown exige.
Quando a automação de CI vale a pena, e quando não
O weave e tangle automatizados brilham quando a prosa é longa, o código é complexo, e várias pessoas editam o documento. Código de investigação, documentação de bibliotecas, e implementações de algoritmos beneficiam todos de um ficheiro Markdown canónico que o CI valida em cada push.
Não vale a pena para scripts pequenos ou código de aplicação com muito boilerplate. Se o teu ficheiro Markdown é dois parágrafos e uma função de vinte linhas, o overhead do CI, os minutos de runner e o custo do modelo mental superam o problema de sincronização que estás a resolver. Escreve apenas uma boa docstring.
O outro caso em que isto falha é quando o teu tooling de linguagem assume que os ficheiros .py são primários. Debuggers, profilers, e ferramentas de cobertura geralmente querem apontar para o source gerado, não para o Markdown. Podes contornar isto gerando ficheiros localmente antes de debugar, mas isso é fricção. Se a tua equipa vive num debugger, a programação literária pode não ser a escolha certa independentemente de quão boa é a tua pipeline de CI.
FAQ
Devo fazer commit dos ficheiros .py geridos ou .gitignorená-los?
.gitignorenha-os. Se forem geridos deterministicamente a partir de Markdown, são artefactos de build. A única exceção é se um sistema externo, como um registo de packages, não conseguir executar o teu passo de tangle. Nesse caso, usa o padrão de commit por bot descrito acima.
E se o meu documento literário misturar várias linguagens?
O script tangle.py é agnóstico de linguagem. Usa # filename.rs para blocos Rust, # filename.go para blocos Go, e assim por diante. O CI executa o mesmo script para cada linguagem. Só precisas de um passo de teste por linguagem no workflow.
Isto funciona com chunks de código nomeados como o WEB de Knuth?
Ferramentas modernas como Entangled suportam chunks nomeados e tracking de dependências. Se precisares desse nível de expressividade, usa Entangled no CI em vez de um script personalizado. A estrutura da pipeline é a mesma. Só a ferramenta de extração muda.
Como lido com imports que abrangem múltiplos ficheiros Markdown?
Cada ficheiro Markdown é tanglado no seu próprio diretório de output. Se math.md define add e geometry.md o importa, ou tanglas ambos os ficheiros no mesmo diretório de build, ou estruturas o teu código como um package com imports relativos. O job de CI pode tanglar todos os ficheiros antes de correr os testes, por isso as dependências entre ficheiros resolvem-se naturalmente.
Faz do teu ficheiro Markdown o único que importa
Configura a pipeline uma vez. Faz commit do ficheiro .md. Deixa o CI lidar com o resto. Se um teste falhar, a falha aponta para a linha de Markdown que produziu o código, não para um ficheiro gerido que não deveria existir no controlo de versões de qualquer maneira.
O objetivo da programação literária é uma fonte única de verdade. Uma fonte de verdade que exige que um humano se lembre de um passo manual não é uma fonte de verdade. É uma sugestão.