lucio
Render Markdown templates by executing embedded shell or code blocks.
Overview
lucio reads a GitHub-flavored Markdown template,
executes the fenced shell or code blocks that opted in,
and writes a rendered Markdown file:
lucio README.template.md
It is strictly template-to-output, never in-place,
and everything that did not opt in passes through byte-for-byte:
tabs, trailing spaces, indented fences, tilde fences, HTML comments,
and code blocks that merely talk about lucio are all left alone.
The typical use case is documentation that must not go stale: the template holds the commands, the rendered file holds the commands and the output they produced when the file was generated.
lucio has been developed to render the Markdown files
forming the CLI usage guide for
volumito;
the design strive to achieve flexibility
while keeping the tool extremely light
(just a PyPI package with minimal dependencies)
and the annotations reasonably short and simple
for the human user to add.
Features
- Simple trigger syntax based on fenced block annotations
- Byte-for-byte passthrough of everything that is not a trigger block
- Source block, captured stdout, and captured stderr, each shown or hidden per block
- Output merged into the source fence, as a terminal transcript, or kept in a fence of its own
- Hidden setup blocks, for the commands that prepare the stage but must not appear
- Raw Markdown file inclusion, by path, relative to the template
- Check on exit codes, allowing for documenting failing behavior while still catching unexpected errors
- Syntax errors surface before any block is executed
- AI-generated, Human-reviewed code
- Type-safe implementation with type hints
- Comprehensive unit test coverage (100%)
Requirements
- Python 3.13 or later
- A package/virtual environment manager tool (e.g.,
micromamba,conda,uv, etc.) bash, available onPATH
Installation
IMPORTANT: the examples in the documentation use micromamba
to manage virtual environments; feel free to replace it
with your favorite tool (conda, uv, etc.).
From PyPI (Recommended)
lucio is published on PyPI as the same-name package
lucio
, and this is the recommended way of installing it for most users.
Only the first time: create a virtual environment,
activate it, and install the latest release of lucio
available on PyPI with pip:
$ micromamba create -n lucio_env python=3.13
$ micromamba activate lucio_env
(lucio_env) $ pip install lucio
From Source
Clone this repository and install from source in a virtual environment:
$ git clone https://github.com/pettarin/lucio
$ cd lucio
$ micromamba create -n lucio_env python=3.13
$ micromamba activate lucio_env
(lucio_env) $ pip install -e .
(lucio_env) $ # or
(lucio_env) $ make install-e-this
You should be able to run:
lucio --version
lucio, version 0.0.5
(dropping the (lucio_env) $ prefix in the examples from now on).
Usage
lucio --help
Usage: lucio [OPTIONS] INPUT [OUTPUT]
Render the Markdown template INPUT into OUTPUT, executing its lucio blocks.
Without OUTPUT, an INPUT named NAME.template.md or NAME.tmd is rendered into
NAME.md, and any other INPUT is printed on stdout. An OUTPUT of "-" always
means stdout, and the diagnostics of the tool always go to stderr, so the
two never mix.
The rendered document opens with a comment naming the template it came from,
unless -E is given.
The template is rendered in memory and written out only once everything
succeeded, so a failing block leaves OUTPUT untouched.
Options:
-b, --block-timeout FLOAT Timeout for one block, in seconds; -1 for no
timeout. [default: 60.0]
-D, --do-not-color Do not color the messages of the tool.
-E, --omit-do-not-edit-comment Do not open the rendered document with the
do-not-edit comment.
-O, --overwrite-files Overwrite OUTPUT if it already exists.
-P, --pager Print the data output through a pager (when
on a terminal).
-R, --remove-do-not-edit-comment-on-include
Strip the do-not-edit comment from an
included file.
-t, --total-timeout FLOAT Timeout for the whole run, in seconds; -1
for no timeout. [default: 300.0]
-v, --verbose Log each executed block and its exit code to
stderr.
-V, --version Show the version and exit.
-h, --help Show this message and exit.
INPUT is required; OUTPUT is optional, and the two must resolve to different files. Where the rendered document ends up depends on the two arguments:
| INPUT | OUTPUT | Destination |
|---|---|---|
NAME.template.md |
(omitted) | NAME.md, next to INPUT |
NAME.tmd |
(omitted) | NAME.md, next to INPUT |
| anything else | (omitted) | stdout |
| anything | - |
stdout |
| anything | a path | that path |
Examples:
lucio README.template.md # writes README.md
lucio README.tmd # writes README.md
lucio README.mark /tmp/OUT.mark # writes /tmp/OUT.mark
lucio README.tmd - # writes to stdout
An OUTPUT of - prints the document instead of writing it,
which keeps lucio usable in a pipeline even for a template-named INPUT:
lucio README.template.md - | less
-P / --pager does the same without the pipe, sending the document to your
PAGER when stdout is a terminal:
lucio README.template.md - -P
Off a terminal the option does nothing at all, so a redirected document is byte-identical whether or not it was asked for.
Everything lucio has to say goes to stderr, the rendered document being the
only thing ever written to stdout.
An existing OUTPUT is never clobbered by accident, derived names included: lucio
refuses to run unless -O / --overwrite-files is given. The check happens before
the template is parsed, so a refusal costs nothing and executes no block.
By default, the rendered document opens with a comment naming the template it came from, and a blank line, so that whoever finds the generated file knows what to edit instead:
<!-- This file README.md has been rendered by CLI tool 'lucio'. Do not edit this file, but rather its template README.template.md . -->
This behavior can be prevented by issuing option -E / --omit-do-not-edit-comment.
Two timeouts bound a run: each block is given 60 seconds, and the run as a whole is
given 300 seconds. Pass -b / --block-timeout and -t / --total-timeout to raise or
lower either, or -1 to disable it.
Logging
The messages of the tool go through the standard logging machinery, under the
lucio logger, and come out on stderr stamped with the UTC time and their level:
[2026-08-11T10:14:52.310Z] [DEBU] Input file: "/home/user/lucio/README.template.md"
[2026-08-11T10:14:52.310Z] [DEBU] Output file: "/home/user/lucio/README.md"
[2026-08-11T10:14:52.310Z] [DEBU] Omit do-not-edit comment: False
[2026-08-11T10:14:52.311Z] [DEBU] Overwrite files: True
[2026-08-11T10:14:52.311Z] [DEBU] Pager: False
[2026-08-11T10:14:52.311Z] [DEBU] Block timeout: 60.0 seconds
[2026-08-11T10:14:52.311Z] [DEBU] Total timeout: 300.0 seconds
[2026-08-11T10:14:52.312Z] [INFO] Rendering "README.template.md" into "README.md"...
[2026-08-11T10:14:52.318Z] [DEBU] README.template.md:12: executing bash block
[2026-08-11T10:14:52.402Z] [DEBU] README.template.md:12: exit code 0
[2026-08-11T10:14:52.404Z] [DEBU] README.template.md:24: including "PART.md"
[2026-08-11T10:14:52.406Z] [INFO] Rendering "README.template.md" into "README.md"... done
INFO and above are shown by default, which is the pair of lines above: one when the
work starts, naming what is being written, and one when it succeeded. A run that fails
shows the first without the second; failures themselves are reported as ERRO. -v lowers the bar to DEBUG, which opens
the log with the settings of the run --- the resolved paths and every option that
shapes what happens --- and then reports every block as it is performed: the command
run and the code it exited with, or the file included. The levels are colored when
stderr is a terminal, and -D / --do-not-color turns that off everywhere.
Exit Codes
| Code | Meaning |
|---|---|
0 |
success: OUTPUT was written, or the document was printed on stdout |
1 |
OUTPUT could not be written, or it exists and --overwrite-files was not given |
2 |
usage error (missing or bad arguments, INPUT and OUTPUT are the same file) |
3 |
template error: syntax error, or INPUT cannot be read or decoded |
4 |
execution error: unexpected exit code, timed-out block, bash not runnable |
On any error other than 0, OUTPUT is never opened and stdout stays empty:
a pre-existing OUTPUT is left exactly as it was.
Newlines And Whitespace
- CRLF line endings in the template are normalized to LF on read; a lone CR is left alone.
- A captured stream is normalized to end with exactly one newline; a stream holding nothing but newlines counts as empty.
- The output file ends with exactly one newline.
- The do-not-edit comment and the blank line below it are the only bytes
lucioadds of its own; issuing-E/--omit-do-not-edit-commentremoves them. - Everything else, whitespace included, is copied byte-for-byte.
- A fence is emitted with as many backticks as needed to wrap the captured output, even if it contains fences of its own. A merged fence grows only when the output demands it; as long as it does not, the closing line of the template is reused byte-for-byte.
Template Syntax
Only fences annotated with the trigger word lucio are processed:
```bash lucio [key=value ...]
```
Anything else is ordinary Markdown:
```bash, ```bash lucioX, ```bash run lucio,
```lucio, ```bash include, and <!-- include FILE.md -->
all pass through untouched, as does any trigger fence written inside a longer
fence (that is how the examples in this file survive).
What the block does is chosen by its command attribute: execute, the
default, runs the body through bash, and include pastes a file named by
path.
bash lucio
The body of the block is executed by bash, and the block is replaced by its source fence and/or what the body printed:
```bash lucio
echo "hello"
```
renders as:
```bash
echo "hello"
hello
```
Note that the info string is reduced to bash in the output,
while the body and the closing fence are copied byte-for-byte.
By default the captured output is merged into the source fence, right after
the commands that produced it, the way a terminal transcript reads.
With merge=false it goes into a separate unlabeled fence instead,
one blank line below the source:
```bash
echo "hello"
```
```
hello
```
Either way, stdout comes first and stderr after it, and if the selected streams are empty no output is emitted at all.
Attributes
| Key | Values | Default | Meaning |
|---|---|---|---|
command |
execute, include |
execute |
what the block does |
exit |
any, or an integer between 0 and 255 |
0 |
the exit code the block must exit with |
merge |
true, false |
true |
put the captured output inside the source fence, rather than in a fence of its own |
path |
a file name | (none) | the file command=include reads |
show_source |
true, false |
true |
emit the source block, as a plain ```bash fence |
stderr |
true, false |
true |
include the captured stderr in the output |
stdout |
true, false |
true |
include the captured stdout in the output |
Attributes are unquoted key=value tokens, separated by whitespace.
Booleans are written true and false, lowercase like every other value,
and nothing else will do: True, TRUE, 1, and "true" are all errors,
not silent falsehoods.
An unknown key, a repeated key, a malformed token, or an unknown value
is an error too.
Each command takes only the attributes it uses: path belongs to
command=include and is required by it, while exit, merge, show_source,
stderr and stdout belong to command=execute. Writing one where it has no
meaning is an error rather than a silent no-op. A file name with a space in it
cannot be written, since attributes are separated by whitespace and there is no
quoting.
merge has nothing to do when there is no source fence to merge into
(show_source=false) or no output to merge (empty streams, or both
stdout=false and stderr=false): in those cases it changes nothing.
A block with show_source=false stdout=false stderr=false renders to nothing:
it is a hidden setup block. Blank lines around it are collapsed, so it leaves
no trace in the output. Every block runs in the working directory lucio
was invoked from, so a hidden block can prepare files for the blocks below it:
```bash lucio show_source=false stdout=false stderr=false
rm -f ./configuration.yaml
```
command=execute
Each block is executed in its own bash subprocess, with the body passed
verbatim and nothing injected into it, so shell state (variables, cd,
functions) does not carry over from one block to the next; the filesystem,
of course, does.
If a block exits with a code other than the expected one, the whole run is
aborted and OUTPUT is not written. Expected failures must declared with
exit to prevent that:
```bash lucio exit=1
cat missing_file.txt
```
exit=any accepts whatever return code the block returns.
A block that exits non-zero and is allowed to says so in the verbose log, naming the attribute that let it through, so a tolerated failure is never mistaken for an unnoticed one:
[2026-08-11T10:14:52.402Z] [DEBU] README.template.md:12: exit code 1 (permitted by exit=1)
[2026-08-11T10:14:53.118Z] [DEBU] README.template.md:24: exit code 3 (permitted by exit=any)
command=include
The file named by path is pasted raw, as Markdown, in place of the block:
no source fence, no output fence, and no bash involved. The block takes no
body, and neither timeout applies, there being no process to bound.
```bash lucio command=include path=CONFIGURATION_FILE.md
```
A relative path is resolved against the directory of the template, so a
template and the files it includes travel together and can be rendered from
anywhere. Note that this differs from the bash blocks, which run in the working
directory lucio was invoked from: in a template stored in docs/,
path=PART.md means docs/PART.md, while a block's cat PART.md does not.
The file is pasted as it is: a trigger fence inside it is text, not something
lucio renders in turn. A file that cannot be read aborts the run, like any
other failing block.
Including a file that lucio generated pastes its do-not-edit comment along
with it, in the middle of the document and naming the wrong template.
Option -R / --remove-do-not-edit-comment-on-include drops that opening comment
and the blank line below it, from every file included in the run:
```bash lucio command=include path=PART.md
```
PART.md, itself rendered by lucio: included with -R:
<!-- This file PART.md has ... --> ## Part
## Part Text.
Text.
Only the first line is considered, and only when it is a comment lucio itself
would have written: a licence header, a linter directive or another generator's
banner is left where it is. Note that -R is about the file being read, while
-E is about the file being written; a run can use either, both or neither.
Development
See the DEVELOPMENT document.
License
This project is licensed under the GNU General Public License v3.0 or later (GPLv3+).
See the LICENSE file for details.
Authors
- Alberto Pettarin (Web)
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
File details
Details for the file lucio-0.0.5.tar.gz.
File metadata
- Download URL: lucio-0.0.5.tar.gz
- Upload date:
- Size: 44.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.15
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0ab4d41ec511efcb833197e389ae38179e94b75475fa6aed399a58ab7f286a55
|
|
| MD5 |
2ab63da0c95972a149fefc3514befed7
|
|
| BLAKE2b-256 |
bc05e209e58d39d8cb7de1a7da4c06a9c29d5c35614747d3e78c14de95c106b7
|