JINJA_REPORTS_PLAN

Jinja Templated Reports Plan

Goal: Replace hand-written Python format_*_markdown() functions with Jinja2 templates driven by the CLI --report-json output so the same templates can be reused by the TypeScript mirror via Nunjucks.

Does the idea make sense?

Yes. The current state makes the transition low-risk:

Current inventory

Six markdown formatters and their tree-level Pydantic inputs (lib/galaxy/tool_util/workflow_state/):

CLIFormatterTree report model
gxwf-state-validatevalidate.py:format_tree_markdownTreeValidationReport
gxwf-state-cleanclean.py:format_tree_clean_markdownTreeCleanReport
gxwf-roundtrip-validateroundtrip.py:format_roundtrip_markdownRoundTripTreeReport
gxwf-to-format2-statefulexport_format2.py:_format_tree_markdownExportTreeReport
gxwf-to-native-statefulto_native_stateful.py:_format_tree_markdownToNativeTreeReport
gxwf-lint-statefullint_stateful.py:_format_lint_tree_markdownLintTreeReport

Plus the embedded format_connection_markdown that validate.py splices in.

All six are wired through _report_output.emit_reports(... markdown_formatter=...) and _tree_orchestrator.run_tree(... format_markdown=...), so there is a single injection point per CLI.

Implementation plan

Step 1 — Make every report model fully serializable

Anything the markdown rendering needs must be in JSON. Audit each formatter and promote derived values to @computed_fields (not @property, which Pydantic does not emit in model_dump) on the report models so no template depends on a Python method or attribute that vanishes on serialization.

Before making any model change, land a JSON-contract-freeze snapshot test (see Step 5) over representative fixtures so the computed_field additions can be verified as additive-only. This protects any downstream consumer that pins the --report-json shape (notably the incoming TS mirror).

Concretely, add the following:

_report_models.py

roundtrip.py — biggest block of work, because the current formatter relies entirely on plain @property that don’t serialize:

export_format2.py / to_native_stateful.py

Out of scope for Step 1 (deferred to dedicated commits):

These improvements are still in scope for the overall effort but land as separate commits from the template port itself. This is the “separate model changes from template changes” discipline the review called out.

Red→green: add a unit test per model that freezes a fixture dict and asserts on the new computed fields. Then fill them in.

Step 2 — Land a template rendering module ✅ done

New file: lib/galaxy/tool_util/workflow_state/_report_templates.py

Responsibilities:

  1. Build a single jinja2.Environment (lazy singleton via _get_env()) with:
    • PackageLoader("galaxy.tool_util.workflow_state", "templates/reports")
    • trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=False
    • Autoescape disabled (markdown, not HTML)
    • No custom filters or globals — Nunjucks parity is the constraint.
  2. Provide render_report(template_name: str, report: BaseModel, meta=None) -> str that takes the report, model_dump(by_alias=True, mode="json")s it, passes the result under a single top-level var (report) plus a meta var for generated_at, tool_util_version (optional meta override for tests).
  3. Provide a make_markdown_renderer(template_name) helper returning a Callable[[BaseModel], str] — the exact signature emit_reports/run_tree already expect, so the swap at each CLI is one line.

Step 3 — Template scaffolding ✅ done

Scope of Step 3 is the shared scaffolding only — the six tree-level templates are created inside Step 4 alongside the per-CLI port so each diff stays reviewable. Delivered in this step:

lib/galaxy/tool_util/workflow_state/templates/reports/
  _macros.md.j2            # status_badge, kv_summary, workflow_state_cells, failure_bullet
  connection_section.md.j2 # ported from validate.format_connection_markdown; also include target for validate_tree

Added in Step 4 (one per CLI port):

  validate_tree.md.j2
  clean_tree.md.j2
  roundtrip_tree.md.j2
  export_tree.md.j2
  to_native_tree.md.j2
  lint_tree.md.j2

One macro file avoids cross-template duplication while staying inside the Jinja2/Nunjucks shared subset ({% import "_macros.md.j2" as m %} works in both).

Templates are packaged as data files. packages/tool_util/setup.cfg has include_package_data = True, but sdists built from packages/tool_util/ still need an explicit MANIFEST.in include for the new .j2 files — added as include galaxy/tool_util/workflow_state/templates/reports/*.j2.

Step 4 — Port formatters one at a time ✅ done

For each CLI, in this order (smallest → largest), to keep diffs reviewable:

  1. export_format2 — simplest (bullet list). Lowest risk smoke test. ✅
  2. to_native_stateful — near-identical to #1. ✅
  3. lint_stateful — single table. ✅
  4. clean — affected table + per-workflow details. ✅
  5. validate — categories + failure details + spliced connection section. ✅
  6. roundtrip — most structure (diff classification, conversion failures). ✅

Per CLI, the change is:

Conventions established while porting CLIs #1–#4:

Step 5 — Testing strategy (red→green)

Three layers:

  1. JSON contract freeze (lands first, before any Step 1 model changes): test/unit/tool_util/workflow_state/test_report_json_contract.py. For each of the six tree reports, build a representative fixture and snapshot model_dump(by_alias=True, mode="json"). After Step 1 lands, update the goldens and review the diff — it must be additive-only. Catches any accidental breakage of the TS mirror’s or IWC CI’s JSON expectations.
  2. Snapshot tests (new, per CLI): test/unit/tool_util/workflow_state/test_report_templates.py. Feed a hand-built fixture TreeValidationReport (etc.) into the renderer, compare output to a checked-in .md golden. Run pytest --snapshot-update only intentionally. This gives us the red-before-green for Step 4.
  3. Regression parity (one-shot): before deleting each format_*_markdown, capture its output against the shared fixtures as a checked-in golden. The Jinja template must match the golden exactly (pure port) or the golden must be updated deliberately in the same commit (intentional improvement). No dual-rendering shim phase — goldens are the bridge.

Also: extend test_iwc_sweep.py (gated on GALAXY_TEST_IWC_DIRECTORY) with one run per CLI that writes --report-markdown to a tempfile and asserts non-empty

Step 6 — Nunjucks compatibility gate (removed)

Decided against a dedicated CI gate. The templates stay within the Jinja2/Nunjucks shared subset by convention (documented in _macros.md.j2 header); the npm package itself will validate compatibility when consumed.

Step 7 — TypeScript consumer wiring (removed)

Out of scope for this effort. TS consumption will be handled separately when the TS mirror project is ready.

Markdown report utility review + improvement suggestions

Reviewing each current formatter against its JSON counterpart:

validate_tree (strong baseline)

clean_tree (good)

roundtrip_tree (this one most needs improvement)

export_tree / to_native_tree (sparse)

lint_tree (adequate)

Cross-cutting improvements to bake into the shared macros

None of the above require template features outside the Jinja2/Nunjucks shared subset.

Deliverable summary

  1. Computed-field additions on report models (Step 1). ✅
  2. _report_templates.py rendering module (Step 2). ✅
  3. templates/reports/ scaffolding — _macros.md.j2 + connection_section.md.j2 (Step 3). ✅
  4. Per-CLI *_tree.md.j2 templates + swap of format_*_markdown → Jinja renderer (Step 4).
    • export_format2 ✅ · to_native_stateful ✅ · lint_stateful ✅ · clean ✅ · validate ✅ · roundtrip ✅
  5. Snapshot + parity tests (Step 5) — JSON contract freeze ✅, per-CLI goldens landing alongside each Step 4 commit (6 of 6 done).
  6. Nunjucks compat CI gate (removed — convention-enforced, not CI-gated).
  7. TypeScript consumer wiring (removed — out of scope).
  8. Updated report content per review above — un-bundled from Step 4 in practice: pure ports first, content improvements as follow-up commits after all six ports land.

Unresolved questions

Resolved (no longer unresolved):