Pulp Engine Document Rendering
Get started
Release v0.61.0

Release v0.61.0

Date: 2026-04-10

Theme

v0.61.0 closes the Developer Blockers initiative — the four stages identified as the biggest friction points between “Pulp Engine API is running” and “external team is productive against it.” Shipping them together in one release is deliberate: each stage is usable on its own, but the value compounds when a developer can author a template with the CLI, validate it with a dryRun call, get precise expression error locations back, and see the full render trace in their tracing UI — all in the same session.

It also ships the first public Python SDK on PyPI, closing the “TypeScript-only client story” gap that’s been open since the SDK package was introduced, and the TypeScript SDK picks up matching enriched-error types and a dryRun() method so both clients expose the same surface.

Highlights

Developer Blockers Stage 1 — @pulp-engine/cli

New workspace CLI package with a pulp-engine binary:

  • pulp-engine push / pull — sync templates between a local directory and a Pulp Engine server.
  • pulp-engine validate — local schema validation with no server round-trip.
  • pulp-engine render — render a template against sample data and write the output to disk.
  • pulp-engine dev — watch-mode development loop.
  • pulp-engine codegen — generate typed TypeScript or Python bindings from a template’s inputSchema.

Depends on the new @pulp-engine/template-schema workspace package, which hoists TemplateDefinitionSchema, RenderConfigSchema, and formatValidationIssues out of apps/api so the CLI can validate templates locally without pulling in the server’s full dependency tree. This makes @pulp-engine/cli installable independently of the server.

Developer Blockers Stage 2 — Python SDK on PyPI

First-class Python client published to PyPI as pulp-engine. The package mirrors @pulp-engine/sdk 1:1 with snake_case method names and Pythonic conventions. Full resource surface:

  • client.templates — list / get / create / update / delete / schema / sample / validate / versions / get_version / restore
  • client.render — pdf / html / csv / xlsx / docx / pptx / dry_run / validate
  • client.batch, client.pdf_transform, client.assets
  • client.audit_events, client.admin, client.auth, client.health, client.schedules

Built on httpx>=0.27.0 and pydantic>=2.0.0. Supports Python 3.11, 3.12, and 3.13. Built with hatchling.

A new docs/sdk-generation-guide.md documents the full generation methodology for the TypeScript and Python SDKs, plus recipes for generating C#/.NET, Java, and Go clients from the same openapi.json spec using openapi-generator.

pip install pulp-engine
from pulp-engine import Pulp Engine

client = PulpEngine(base_url="https://your-pulp-engine.example.com", api_key="...")

# Fast pre-flight: no Chromium, no output bytes, just validation + expression eval.
result = client.render.dry_run(template="loan-approval-letter", data={"amount": 50000})
if not result.valid:
    for err in result.expressions.errors:
        print(f"{err.location.node_path}: {err.message}")
        if err.suggestion:
            print(f"  did you mean: {', '.join(err.suggestion.suggestions)}?")

Developer Blockers Stage 3 — dryRun on all render routes + enriched expression errors

Every per-format render route now accepts dryRun: true in the request body:

  • POST /render (PDF), POST /render/html, POST /render/csv, POST /render/xlsx, POST /render/docx, POST /render/pptx
  • Plus the six /render/preview/* variants — 12 routes total

When set, the pipeline runs data adaptation, schema validation, templateRef resolution, data-source resolution, and a full HTML generation pass (exercising every Handlebars expression), then discards the output and returns a structured DryRunResult:

{
  "valid": true,
  "validation": { "valid": true, "errors": [] },
  "expressions": { "valid": true, "errors": [] },
  "fieldMappingsApplied": 3,
  "dataSourcesResolved": 1
}

Typically 5-50 ms — no Chromium launch, no DOCX/PPTX assembly. ~80× speedup vs. producing and discarding a real render. Designed for CI/CD pre-flight gates and integration tests that want to validate the full data-to-template binding without paying for a real render.

TemplateExpressionError now carries optional location and suggestion fields:

{
  "error": "Template expression error",
  "message": "The partial customre could not be found",
  "code": "template_expression_error",
  "location": {
    "nodeId": "txt-042",
    "nodeType": "text",
    "nodePath": "Page 1 > Section \"Header\" > Text block 3",
    "line": 12,
    "column": 18
  },
  "suggestion": {
    "variable": "customre",
    "suggestions": ["customer", "customerId", "customerName"]
  }
}

nodePath is computed by a new AST walker in packages/template-renderer/src/node-path.ts (breadcrumbs stop at templateRef boundaries so sub-template paths don’t leak into parent errors). suggestion.suggestions is computed via Levenshtein distance in packages/template-renderer/src/suggestions.ts (max 3 candidates, distance scaled to variable length). Both surface on the 422 error body and inside DryRunResult.expressions.errors[].

Developer Blockers Stage 4 — render accounting, phase timings, metrics, OpenTelemetry

Phase 1 — in-process instrumentation.

A new PhaseTracker in packages/template-renderer/src/telemetry/phase-tracker.ts records per-phase wall-clock for each render with a bounded label set:

data_adaptation | validation | ref_resolution | data_source_resolution
| html_generation | asset_inlining | dispatcher_render
| docx_generation | pptx_generation | csv_generation | xlsx_generation

Zero overhead when no tracker is passed — the tracker is optional on every render request. The API route handlers use it to populate three new Prometheus histograms in apps/api/src/lib/metrics.ts:

  • render_phase_duration_seconds — labels: phase, format. One observation per populated phase per render.
  • render_output_size_bytes — label: format. Buckets 1 KB – 50 MB.
  • render_page_count — PDF only. Buckets 1 – 500 pages.

Every render response now carries:

  • X-Render-Duration-Ms
  • X-Render-Size-Bytes
  • X-Render-Pages (PDF)
  • X-Render-Memory-Delta-Bytes-Approx (child-process mode only; documented as GC-noisy and directional)
  • X-Render-Trace-Id (when OTel is active)

A structured render_accounting log event is emitted once per render with requestId, templateKey, format, totalDurationMs, and the full phase breakdown — queryable via log aggregation for chargeback, cost analysis, and regression hunting.

OpenTelemetry SDK bootstrap in apps/api/src/lib/otel-bootstrap.ts activates automatically when OTEL_EXPORTER_OTLP_ENDPOINT is set. Kill-switch via OTEL_SDK_DISABLED=true. Service name via OTEL_SERVICE_NAME (default pulp-engine-api). Fastify auto-instrumentation emits one span per HTTP request, HTTP outbound instrumentation covers downstream calls, and W3C Trace Context propagation is automatic. Zero cold-start cost when disabled — the SDK packages are dynamically imported only when the env var is present.

Phase 2 — cross-process render spans.

With OTel active, operators now see a distinct pdf.render child span nested under the Fastify request span for every PDF render:

POST /render                              ← Fastify auto-instrumentation
  └── pdf.render                          ← Phase 2
        pulp-engine.render.dispatcher = "child-process" | "container" | "socket"
        pulp-engine.render.format = "pdf"
        pulp-engine.render.html_size_bytes = <utf8 bytes, not utf16 codepoints>
        pulp-engine.render.output_size_bytes = <pdf bytes>
        pulp-engine.render.memory_delta_bytes_approx = <heap delta>  (child-process only)
        pulp-engine.render.error.code = "render_timeout"             (failure only)
        status: OK | ERROR

Implemented as a span-synthesizing decorator at apps/api/src/lib/otel-render-span.ts (wrapDispatcherWithSpan(dispatcher, mode)) wrapping each concrete dispatcher at construction time in apps/api/src/routes/render/render.ts. Identity function when OTel is disabled — zero overhead. Parent linkage happens implicitly via AsyncLocalStorage; no trace context is plumbed through the renderer pipeline.

Crucially, the worker process still runs with no OTel SDK. This approach preserves the --network none isolation guarantee in container and socket modes and avoids forwarding the secret-bearing OTEL_EXPORTER_OTLP_HEADERS across the env: {} boundary in child-process mode. Every OTel call inside the wrapper is routed through safeStartSpan/safeSetAttr/safeEndOk/safeEndError helpers so a tracing failure can never break a render — same posture as the bootstrap file itself.

Deliberate trade-off. The worker is a child span node, not a distinct service in the topology view. Real cross-process propagation is fundamentally incompatible with --network none and was rejected during planning. The synthesized span’s wall-clock includes IPC overhead (process spawn for container mode, IPC send/receive for child-process mode), not just Puppeteer time — for “where did my render time go” this is the right granularity.

TypeScript SDK — matching enriched errors and dryRun()

@pulp-engine/sdk now exposes the Stage 3 enriched error shape:

  • PulpEngineError carries readonly location and suggestion fields parsed from the 422 response body via new parseLocation() / parseSuggestion() helpers in packages/sdk-typescript/src/error.ts.
  • New render.dryRun(params, format?) method (default format='pdf', accepts all six formats) returns a typed DryRunResult.
  • New exported types at the package entry point: DryRunFormat, DryRunResult, DryRunExpressionError, DryRunExpressionLocation, DryRunExpressionSuggestion.
import { PulpEngineClient, PulpEngineError } from '@pulp-engine/sdk'

const client = new PulpEngineClient({ baseUrl, apiKey })

try {
  const result = await client.render.dryRun({ template: 'invoice', data })
  if (!result.valid) {
    for (const err of result.expressions.errors) {
      console.error(`${err.location?.nodePath}: ${err.message}`)
    }
  }
} catch (err) {
  if (err instanceof PulpEngineError && err.location) {
    console.error(`at ${err.location.nodePath}:${err.location.line}:${err.location.column}`)
    if (err.suggestion) {
      console.error(`did you mean: ${err.suggestion.suggestions.join(', ')}?`)
    }
  }
}

PyPI publishing workflow

New GitHub Actions workflow at .github/workflows/publish-sdk-python.yml publishes the pulp-engine Python package to PyPI via Trusted Publishing — OIDC-based, no long-lived API tokens stored in the repo.

  • Automatic trigger: push of a v*.*.* tag publishes to production PyPI.
  • Manual trigger: workflow_dispatch with target=pypi|testpypi for TestPyPI dry runs.
  • Build: hatchling via the python -m build flow; twine check validates metadata.
  • Publish: pypa/gh-action-pypi-publish@release/v1 with skip-existing: true so Docker-only patch releases (where the Python SDK is unchanged) are idempotent no-ops.
  • Environments: two GitHub environments (pypi, testpypi) carry the OIDC trust configuration.
  • CI gate: verifies ci.yml completed successfully on the exact release commit (branch=main, event=push) before publishing — a failed CI on the tagged commit blocks the publish.

Changed

  • check-version.mjs now enforces docs/release-vX.Y.Z.md at tag time in addition to the existing package-version lockstep and CHANGELOG invariants. Off-tag development still only requires [Unreleased] at the top of CHANGELOG.md; at tag time the script additionally verifies the release notes file exists, the CHANGELOG has a dated [X.Y.Z] section, and a link reference points to the matching GitHub release. The lockstep check has also been extended to include packages/sdk-python/pyproject.toml via a targeted PEP 621 regex reader (no TOML parser dependency added).

Operator guidance

  • Opt in to tracing by setting OTEL_EXPORTER_OTLP_ENDPOINT (and optionally OTEL_SERVICE_NAME) in the API process environment. No other configuration needed — the SDK wires up Fastify and HTTP instrumentations automatically, and Phase 2 cross-process render spans appear immediately.
  • No config change needed for accounting headers, Prometheus histograms, or the render_accounting log event — these ship on by default.
  • PyPI publication: the workflow is in place but has not yet been exercised against production PyPI. First publish should be a TestPyPI dry run via workflow_dispatch before the tag push.
  • CLI distribution: @pulp-engine/cli is published to the npm workspace but not yet to the public npm registry — that gate is deferred to a follow-up release.

Residual risk

  • Known pre-existing test flake under load in the apps/api suite (render-dry-run, render-preview, render-batch, render-pdf-transform, render-xlsx, render.test.ts, and the render-accounting > X-Render-Trace-Id when OTel disabled assertion). All pass reliably in isolation and in small subsets; the failure set varies between full-suite runs. Consistent with the codebase’s documented pre-existing timeout flakiness under heavy parallel load. Not a v0.61.0 regression.
  • Environmental test failures when ANTHROPIC_API_KEY is set in local .env: the templates-generate.route.test.ts “feature off” cases and the pptx-route.test.ts > formats.pptx = true capabilities assertion both assume the key is unset. Developers running the local suite should unset it before running pnpm --filter @pulp-engine/api test to avoid spurious failures. Follow-up worth scheduling: wire vi.stubEnv('ANTHROPIC_API_KEY', '') into the affected beforeEach blocks.