Skip to content

CLI and Input Modes

Frederik Beimgraben edited this page Jun 4, 2026 · 2 revisions

CLI and input modes

The pytex command takes one input file and dispatches on its extension. There's no mode flag — the suffix decides how the file is read.

Extension What happens
.py Imported as a module; its module-level __pytex__ node is rendered. Name it <doc>.tex.py by convention.
.tex Wrapped in IncludeTeX; inline \iffalse{pytex(...)}\fi markers are evaluated, then the whole thing is rendered. Name it <doc>.py.tex.
.md / .markdown Converted to nodes and wrapped in a document per --variant. Without --variant, the style is auto-detected. See Markdown to PDF.

.tex.py — a Python document

The file is ordinary Python. Build a tree and assign it to __pytex__:

from pytex.commands.builtin import Section, Title, MakeTitle
from pytex.model.concat import Concat
from pytex.model.document import Document

__pytex__ = Document(
    preamble=Title("Report"),
    body=Concat(MakeTitle(), Section("Intro"), "Body text."),
)

Because it's just Python, you can compute the document: read a CSV with pandas, loop to emit rows, pull values from the environment. Whatever __pytex__ ends up holding is what gets rendered.

.py.tex — inline replacements in real LaTeX

Start from a .tex file that already compiles on its own, and splice PyTeX into it through \iffalse{pytex(...)}\fi markers. The \iffalse … \fi pair is a LaTeX no-op, so the source still builds with a plain LaTeX toolchain — PyTeX just fills in the markers when it renders.

Today is \iffalse{pytex(Today())}\fi.
A fraction: $\iffalse{pytex(Frac("1", "2"))}\fi$.
Plain Python works too: $3^2 = \iffalse{pytex(3 ** 2)}\fi$.

Any registered factory is in scope inside a marker — the same names you'd import in a .tex.py file. The expression's result is rendered in place.

tex(t"...") — template strings (Python 3.14+)

On 3.14, pytex.tex takes a PEP 750 template string and builds a tree from it. Static parts are literal LaTeX; interpolations are escaped when they're plain values and spliced as-is when they're TeX nodes (nested t-strings and lists work too):

from pytex import tex
from pytex.commands.builtin import Bold

name = "Q&A: 50%"
body = tex(t"{Bold('Heading')} — {name}")
# -> \textbf{Heading} — Q\&A: 50\%

tex is only exported on 3.14+; everything else runs on 3.13.

Building

pytex example.tex.py          # render only -> build/example.out.tex
pytex example.tex.py --build  # render + compile -> build/example.out.pdf

--build runs tectonic, then makeindex if the document uses glossaries/acronyms, then reruns tectonic when an index changed. Shell-escape is on by default because inline images decode their base64 payloads at compile time; turn it off with --no-shell-escape if you don't need that.

Output is minimal and color-tagged (==>, note:, warning:, error:) in tectonic's style; on failure it points at the likely cause and the log file. Set NO_COLOR to disable color.

Flags

Flag Default Meaning
-o, --output <build-dir>/<input>.out.tex rendered LaTeX output path
-b, --build off compile the rendered .tex to PDF with tectonic
--build-dir DIR build directory for artifacts and tectonic output
--no-shell-escape shell-escape on disable shell-escape
-t, --tree off print the input's TeX-node tree before rendering
-f, --force off skip the optimize + analysis pass and build anyway
--variant STYLE auto Markdown output style (see Markdown to PDF)
--config JSON none JSON object of document-class params, merged over the frontmatter
--untrusted off render foreign input through the trust policy (see Trust)
--trust-level LEVEL trusted trusted, sandboxed, or untrusted

Trust and untrusted input

The CLI is a trusted tool. Everything above — importing .py files, evaluating pytex(...) markers, running eval comments in Markdown, leaving shell-escape on — is code execution, and that's the whole point: a PyTeX document is a program. So treat the default CLI like you'd treat python somefile.py: run it on your own documents, never on a file someone handed you.

When you do need to render input from an untrusted source, don't use the trusted path. Pass --untrusted (shorthand for --trust-level untrusted), which sends the build through the same trust policy the Blob API uses. On that path the dangerous surfaces are closed:

  • .py / .tex.py inputs are refused outright — no module import, no exec.
  • .tex pytex(...) markers and Markdown eval comments stay inert; they render as literal text instead of running.
  • Shell-escape is forced off, and the document may only pull packages from the allowlist — code-/file-surface packages like minted or pythontex are rejected.
  • CPU, memory, and output-size limits apply to the compile.

--trust-level sandboxed is the middle ground: same locked-down code surface, a wider package allowlist, and — for PDF builds — it runs tectonic inside a rootless Podman sandbox (and refuses rather than downgrade if Podman isn't set up). trusted is the default, so nothing changes for documents you already build.

If you're wiring PyTeX into a service that accepts uploads, prefer the Blob API directly — it's bytes-in / bytes-out with the same trust levels and never touches your filesystem.

Pre-flight checks

Before rendering, the builder runs two render-equivalent passes. Optimize tidies the tree (flattens nested Concats, drops empty nodes, turns whole-Raw LaTeX — including \[...\], \(...\), $...$ and comments — into native nodes, and expands inline pytex(...) markers). Then pytex_analyze flags problems LaTeX would otherwise surface late or silently:

  • references (\ref, \cref, \autoref, …) to a label that's never defined,
  • labels defined more than once,
  • \includegraphics paths that don't exist on disk.

Missing images are errors and abort the build; the rest are warnings. -f skips both passes.

--tree is the debugging view: it prints the parsed node tree (then renders as usual), with package-requiring nodes tagged [+package]:

$ pytex example.tex.py --tree
Document (article)
├── ControlSequence \title
│   └── Parameter { }
│       └── Raw "PyTeX Example"
└── Concat
    ├── ControlSequence \maketitle
    ├── ControlSequence \cref [+cleveref]
    └── ...

Converting existing LaTeX

pytex-tex2py turns a .tex file into an equivalent .tex.py. It reads the file, runs Optimize over it, and serialises the result back to Python that rebuilds the same tree:

pytex-tex2py paper.tex            # -> paper.tex.py
pytex-tex2py paper.tex -o out.py

Rendering the generated .tex.py reproduces the original byte-for-byte — anything the serialiser doesn't special-case falls back to a literal Raw, so the round-trip always holds.