Custom applications#
YMPrint can be the engine for your own document tool: a calc-sheet generator, an
inspection-report app, a letter writer. You define the tool as an instance of the
Ymprint class. The instance holds:
blocks: YMPrint’s built-in blocks plus your own;
render hooks: your code that runs before, during and after every render;
a house style: a config file bundled with your application;
a CLI: its own
convertandlivecommands.
ym itself is just the default Ymprint instance.
A minimal application#
# calcsheet/__init__.py
from pathlib import Path
from reportlab.platypus import Paragraph
from ymprint import RenderEvent, Ymprint
HERE = Path(__file__).parent
app = Ymprint(name="calcsheet", config_path=HERE / "config")
@app.block("_beam")
def beam_block(block_key, block_value, context):
"""Renders `_beam: {span: 6.0, load: 12.5}` as a one-line summary."""
style = context["styles"]["rl"]["_style"]["body"]
span, load = block_value["span"], block_value["load"]
moment = load * span**2 / 8
return [Paragraph(f"Span {span} m, w = {load} kN/m: M = {moment:.1f} kNm", style)]
@app.before_render
def load_project(event: RenderEvent) -> None:
event.vars["project"] = {"name": "Riverside Footbridge", "number": "P-1042"}
@app.after_render
def announce(event: RenderEvent) -> None:
print(f"Wrote {event.destination}")
cli = app.cli()
Expose the CLI as a console script in your package’s pyproject.toml:
[project.scripts]
calcsheet = "calcsheet:cli"
Your users then get the same commands as ym, with your blocks, hooks and house style:
calcsheet convert beam-B1.yml
calcsheet live beam-B1.yml
# beam-B1.yml
"{{project.name}} ({{project.number}})":
- Beam B1:
_beam:
span: 6.0
load: 12.5
Blocks#
Register a block with the @app.block(code) decorator or with
app.register_block(code, convert). A block code is an underscore followed by
letters and digits, such as _beam. A second underscore can’t be part of the code,
because in documents it separates the code from an optional id: _beam_b1 and
_beam_b2 are both _beam blocks (see Block syntax).
The converter is called as convert(block_key, block_value, context) and returns a
list of ReportLab flowables:
Argument |
Contents |
|---|---|
|
The key as written, e.g. |
|
The data under the key, with any |
|
The render state: |
Each application starts from its own copy of the built-in blocks, so blocks you
register never leak into ym or into another application. To replace a built-in
block, pass override=True. Registering a code that already exists otherwise raises
BlockExistsError. To start with no blocks at all, use Ymprint(builtin_blocks=False).
Render hooks#
Hooks run on every render: convert, live (each refresh) and app.render().
Register them with decorators. Several hooks at the same stage run in registration
order.
Hook |
Signature |
Runs |
If it raises |
|---|---|---|---|
|
|
Before the document is read |
The render is skipped |
|
|
After config is loaded and the context built, before content is rendered. Style changes made here take effect |
The render is skipped |
|
|
After the PDF is written |
— (the PDF already exists) |
after_render only runs when the PDF was written. If the document has an error, it
does not run.
The RenderEvent#
Attribute |
Meaning |
|---|---|
|
The |
|
The document file, or |
|
The document’s target PDF. |
|
Where the PDF is actually written when not |
|
Where relative paths in the document resolve (images, |
|
The config layers in effect, lowest priority first. |
|
In live mode, the watched file whose change caused this render; otherwise |
|
|
|
|
|
Variables merged over the document’s |
Feeding data into a document#
event.vars is how an application supplies data without writing files. Everything in it
is available to {{var}} templating, $var block arguments and _py blocks, and it takes
precedence over the document’s own _vars:
@app.before_render
def load_design_data(event):
event.vars["loads"] = read_load_table(event.base_dir / "loads.xlsx")
A before_render hook can also regenerate the document or its inputs on disk. In live
mode, changes that hooks make to watched files don’t trigger another refresh.
Working with the context#
on_context receives the full render context. You can change it in place:
context["vars"]: the merged variables (the document’s_varsplusevent.vars)._pyand_loadjsonblocks add to them as the content renders;context["content"]: the document content that is about to be rendered;context["frames"]: the content boxes that blocks size themselves against;context["styles"]["ymprint"]andcontext["tablestyles"]["ymprint"]: the text and table style models, after all config layers have been merged.
@app.on_context
def stamp_revision(event, context):
context["vars"]["revision"] = git_describe(event.base_dir)
Changing styles#
Change the style models, either by setting attributes or by replacing a model.
YMPrint rebuilds the ReportLab styles from them after your on_context hooks run, so the
changes apply to the whole document. Headings scale with the body size, as they do in
config:
@app.on_context
def draft_styles(event, context):
styles = context["styles"]["ymprint"]
styles.body.size = 11
styles.headings.color = "#888888"
styles.styles["note"] = {"body": {"size": 8}} # named style, sparse like in config
context["tablestyles"]["ymprint"].headers.text.color = "#ff0000"
To replace a model, validate a new one:
context["styles"]["ymprint"] = ReportStyles.model_validate(data), where ReportStyles
is imported from ymprint.config. Attribute assignments are not validated, so a wrong
type only fails later, when the styles are rebuilt.
context["styles"]["yaml"] and context["tablestyles"]["yaml"] hold the config as it was
loaded. They are not read back, so editing them has no effect.
For a fixed house style, prefer the application config.
on_context is for styling that depends on the render, such as a “DRAFT” look from a
flag in event.vars.
Errors in hooks#
If a hook raises, the error is wrapped in RenderCallbackError (with .stage, .callback
and .original). convert shows it in an error panel and exits with code 1. live shows
it in the live panel and keeps watching. If your hook detects a problem in the user’s
input, raise ymprint.errors.YmprintAuthoringError instead: it passes through unwrapped
and is reported like any other error in the document.
Hooks are called with the event (and context) only. To pass extra arguments, bind them
with functools.partial or a closure. For state that persists between live refreshes,
register an instance of a class with __call__.
House style: the application config#
Ymprint(config_path=...) takes a config file or a directory containing one. It is layered
between YMPrint’s defaults and the user’s config, and each layer only overrides the keys
it sets:
ymprint defaults ─► app config ─► *.ymprint.yml near the document ─► --config-dir ─► document front matter
(lowest) (highest)
A background with relative-to: config resolves against the directory of the config
file that defines it. So letterhead PDFs shipped next to your application’s config keep
working when a user adds their own *.ymprint.yml.
Rendering from Python#
app.render("beam-B1.yml") # -> beam-B1.pdf
app.render("beam-B1.yml", "out/B1.pdf", "site-config/")
# A document you build in code:
doc = {"Beam B1": [{"_beam": {"span": 6.0, "load": 12.5}}]}
app.render(doc, "B1.pdf", base_dir=job_dir)
app.live("beam-B1.yml", watch=["loads.xlsx"]) # blocks until Ctrl+C
render() returns the RenderEvent. When the source is a dict, destination is
required, and relative paths resolve against base_dir (by default, the current
directory). The dict you pass is not modified. Live mode needs a file to watch.
Ymprint(starter_document=...) sets the document that live offers to create when the
source file doesn’t exist. {title} in it is replaced with the file name.
Adding commands#
app.cli() returns a regular Typer app, so you can add your
own commands next to convert and live:
cli = app.cli()
@cli.command()
def batch(folder: str):
"""Render every calc sheet in FOLDER."""
for path in sorted(Path(folder).glob("*.yml")):
app.render(path, mode="convert")