Design space: generating the Python wrapper from a single source of truth
Today the Python wrapper is written by hand (in practice: by AI, feature by feature), following the six-step round-trip checklist in CLAUDE.md. That checklist exists because one construct is currently described in at least six hand-maintained places: the JS factory, the deserializer registry, the parity harness switch, three IR-schema encodings (schema.ts, validate.ts, jsonSchema.ts), and the Python factory. This doc surveys how much of that could be generated mechanically, from what source, and what the options are.
What the audit found
The Python wrapper (packages/gofish-python/gofish/, ~3,460 LOC):
- Of 45 public names, ~29 (~64%) are purely mechanical: kwargs collected into a
{"type": tag, **opts}dict, sometimes with a wire-key rename (from_→"from",intersect→"inside"). These total well under 500 LOC and are exactly what a generator would emit. - The majority of LOC is infrastructure a generator would keep hand-written:
Mark/ChartBuilder/LayerBuildercopy-on-write plumbing,_RefProxyattribute magic,DatumValuearithmetic (datum(v) + 6), the derive/lambda RPC bridge, the widget/traitlets layer, DataFrame⇄Arrow conversion, and the 179-line d3binport (needed only becausederive()runs in Python and must byte-match JS bin edges). - Confirmed avoidable elaboration (the wrapper does more than a shallow port needs):
_wire_layer_tierre-derives the.layer()auto-naming +selectAllrewiring in Python, even though thebuilder: trueIR tag exists precisely so JS'sLayerBuildercan own that logic.ConstrainableMark.constrainre-implements the JScollectConstraintRefstree-walk (documented as a mirror ofast/constraints/index.ts) so callbacks get refs synchronously.- The empty-placeholder Arrow table construction is copy-pasted ~4×.
- Four wire-name tables (Porter-Duff-style compositing renames, constraint type strings, mark type strings, operator type strings) are hand-copied from
schema.ts.
The IR schema (packages/gofish-ir/src/frontend/):
- The three encodings are genuinely hand-duplicated, not derived, and are already drifting:
OPERATOR_TYPESincludes"treemap"but theOperatorIRunion and the JSON Schema enum omit it. (Resolved during implementation — see § What shipped below. The fix went the opposite direction from what this drift suggests:treemapisn't a strayOPERATOR_TYPESentry to delete, it's confirmed as a genuine dual-form operator (.flow(treemap(...)), grounded in a real Python story that sizes tiles withh: "fare"), so the fix added aTreemapOperatormember to theOperatorIRunion and the JSON Schema enum instead of removing it fromOPERATOR_TYPES.) - Richness is asymmetric. Operators are fully typed, doc-commented discriminated interfaces in
schema.ts— an excellent codegen source. Leaf marks are deliberately open ([key: string]: unknown): per-shape channel lists (rect'sw,h,fill,rx,…,theta,r) exist only in the JS factory option types — which are inlineFancyDims<T>unions, not clean exported interfaces — and are hand-mirrored in Python. Coord transforms aren't in the IR at all (they ride the untyped chartoptionsbag), even though their TS option types (PolarOptions) are ironically the cleanest extraction targets in the codebase (exported alias, JSDoc per field, visible defaults). - The emitted JSON Schema (
dist/frontend/v0.json) is the least rich encoding — a flat option bag per operator, no per-mark fields — so "generate from the JSON Schema" à la Altair does not work on today's artifact without enriching it first.
Prior art
- Altair is the canonical precedent: its entire low-level API (
schema/core.py,channels.py) is generated from the Vega-Lite JSON Schema by a ~custom script (tools/generate_schema_wrapper.py+schemapi), with docstrings taken from schemadescriptionfields; the ergonomicalt.Chartlayer (api.py) is hand-written on top. - Plotly.py does the same from plotly.js's
plot-schema.json(codegen/emitsgraph_objs/+validators/;basedatatypes.pyis the hand-written residue). - Both commit the generated Python to the repo — pip-installability without a JS toolchain, IDE navigation, and reviewable diffs on schema bumps. Regeneration is a maintainer script + a "never hand-edit generated files" rule. This is the community norm; nobody generates at build time.
- No off-the-shelf tool fits.
datamodel-code-generator/quicktypeemit validation-model classes, not kwargs factory functions; TypeSpec targets RPC clients and would be a second source-of-truth DSL; nobody ships a TS-types→Python-kwargs generator. Every real precedent is a small custom generator (a few hundred lines) walking a schema-like artifact — which is the part AI-generated well once, and then never needs AI again. - Negative result worth noting: pyecharts and the anywidget ecosystem are fully hand-written. Codegen pays off when the schema churns and is centrally owned — which
git logsays is exactly our situation (#617, #637, #642, #654 all reshaped the IR recently).
The design space
Option A — descriptor table as the single source (recommended)
Add one declarative table to gofish-ir, e.g. descriptors.ts: one entry per construct (operator, mark, coord transform, constraint) listing { type, kind, fields: [{ name, wireName?, pyName?, type, required?, default?, doc }] }, where type is a small type DSL ("string", "number", enum(...), channel, axes, …).
Generate from it:
- Python factories (
gofish/_generated.py, checked in) — kwargs signatures, wire-key renames (from_→"from"comes frompyName/wireName, killing the hand-copied rename tables), docstrings fromdoc. jsonSchema.ts— per-construct$defsinstead of today's flat bag.validate.ts's per-type field checks — either generated, or better, replaced by a ~100-line generic interpreter that walks the descriptor (types like"x"|"y"enums andnumber | [number, number]are exactly what the current imperative checks re-state).schema.tstypes — generated so TS consumers keep real types; or keepschema.tsauthored and check it against the table in CI.- Optionally the deserializer registry maps and the parity harness switch — or make both generic (construct-by-type keyed off the table), deleting two more checklist steps.
The CLAUDE.md checklist steps 2a, 3, and 4 collapse to "add one descriptor entry"; the AI/manual work per feature drops to the genuinely novel parts. This is the Altair architecture with the source of truth moved from a JSON Schema to a richer native table (we need defaults, dual naming, and doc text — JSON Schema can carry those but a TS table is easier to author and typecheck).
Cost: designing the small type DSL; a one-time enumeration decision for marks (below); ~a few hundred lines of generator (plain script, run via pnpm --filter gofish-ir gen, guarded by a CI check-gen exactly like the existing check-ir-schema / check-backlinks pattern).
Option B — extract from schema.ts via the TS compiler API
Skip the new table; point ts-morph at the existing interfaces and emit Python. Works today for operators and constraints; marks fall back to **kwargs; coord transforms could be extracted from their (clean) option types in gofish-graphics.
Cheaper to start, but: TS types can't carry defaults or Python-specific kwarg names without inventing JSDoc tag conventions (a worse table); it leaves the validate.ts/jsonSchema.ts triplication untouched; and it inherits schema.ts's gaps. Fine as a proof of concept, but it converges on Option A the moment you want docstrings and renames.
Option C — Altair-literal: generate from the emitted JSON Schema
Only viable after enriching jsonSchema.ts to per-construct $defs — at which point the enrichment work is Option A's table, just written in a clumsier language. Mentioned for completeness; not recommended as the driver (though A naturally produces a rich JSON Schema as one of its outputs, which external tooling gets for free).
Option D — don't generate; make the wrapper too thin to need it
Push the shallow-port ideal to its limit: a generic def _op(type, **kwargs) / _mark(type, **kwargs) plus a ~50-line name→wire-tag table, and move the remaining Python-side elaboration (layer wiring, constraint walk) to JS. Nearly zero drift surface, but users lose signatures, autocomplete, and docstrings — the things that make a Python API feel native — and the IR triplication remains. Not recommended alone, but its refactor half is the right first step for every option.
The mark-channel decision — resolved: enumerated (closed)
Leaf marks are open-world in the IR today, but they aren't really open on the JS side either: a mark's channels are exactly the destructured options of its factory plus the FancyDims box channels plus the coord aliases in KNOWN_ALIAS_KEYS. Everything else is silently ignored. Case in point, found while grounding this doc: the current Python rect() exposes rs= and ts= kwargs that exist nowhere in JS (the real alias names are rSize/thetaSize) — they serialize, pass the open-world validator, and are dropped on the floor at render. A closed list turns that class of bug into an autocomplete error.
Decision: enumerate channels per mark in the descriptor, built up incrementally. Two things keep the maintenance cost low:
- Shared field groups. Most channels aren't per-mark.
boxDims(the 14FancyDims/alias channels:x, cx, x2, w, emX, y, cy, y2, h, emY, theta, thetaSize, r, rSize) andpaint(fill, stroke, strokeWidth, opacity, filter) are declared once and included by reference; a mark entry then lists only its genuinely own fields (rect:rx, ry, aspectRatio). - An explicit escape hatch instead of open kwargs. If raw SVG passthrough is ever needed, it gets one named kwarg (e.g.
svg={...}/ astyledict), not**kwargs— autocomplete and closed-world checking survive.
Strictness rolls out gradually: the generated Python signatures are closed immediately (that's where autocomplete lives); validate.ts can start warning rather than rejecting unknown leaf-mark fields until the enumerated lists have been proven against the story corpus, then flip to strict.
What a descriptor entry looks like
Grounded in the real factories (graphicalOperators/spread.tsx, shapes/rect.tsx, dims.ts). The table is plain TS data in packages/gofish-ir/src/frontend/descriptors.ts, typechecked against a small field-type DSL (t.*):
// Shared field groups — declared once, included by reference.
const boxDims = group({
x: ch.num("Left edge position."), cx: ch.num("Center x."),
x2: ch.num("Right edge position."), w: ch.num("Width."),
emX: { type: t.boolean, doc: "Embed x in the parent's x space." },
y: ch.num(), cy: ch.num(), y2: ch.num(), h: ch.num(),
emY: { type: t.boolean },
// Coord aliases (KNOWN_ALIAS_KEYS) — resolved to x/y/w/h by resolveAliases.
theta: ch.num(), thetaSize: ch.num(), r: ch.num(), rSize: ch.num(),
});
const paint = group({
fill: ch.color("Fill color, or a field name for a color scale."),
stroke: ch.color("Stroke color. Defaults to `fill`."),
strokeWidth: { type: t.number, default: 0 },
opacity: { type: t.number, default: 1 },
filter: { type: t.string, doc: "Raw SVG filter attribute." },
});
// --- an operator entry ---------------------------------------------------
operator("spread", {
doc: "Arrange children along `dir` with spacing, aligning them on the cross axis.",
fields: {
by: { type: t.string, doc: "Field to partition rows by." },
dir: { type: t.enum("x", "y"), required: true },
spacing: { type: t.number, default: 8, doc: "Gap between children, px." },
alignment: { type: t.alignment, default: "baseline" },
sharedScale: { type: t.boolean, default: false },
mode: { type: t.enum("edge", "center"), default: "edge" },
reverse: { type: t.boolean, default: false },
glue: { type: t.boolean, default: false,
doc: "Stack semantics: children glued, sizes sum; spacing forced to 0." },
axes: { type: t.ref("AxesOptions") },
},
});
// --- a mark entry ---------------------------------------------------------
mark("rect", {
doc: "A rectangle. Box geometry via the shared dims channels.",
include: [boxDims, paint],
fields: {
rx: { type: t.number, default: 0, doc: "Corner radius, x." },
ry: { type: t.number, default: 0 },
aspectRatio: { type: t.number,
doc: "w/h ratio to enforce; the constraining axis wins when both are data-driven." },
},
// base fields (key, name, label, zOrder, translate, constraints) are implicit
});
// --- a rename entry (kills the hand-copied tables) -------------------------
mark("inside", {
pyName: "intersect", // Python spells it intersect(); wire type stays "inside"
kind: "combinator",
doc: "Clip the lower layers to the top layer's silhouette.",
fields: { ... },
});ch.num(doc?) / ch.color(doc?) are shorthand for { type: t.channel(number|color) } — a ChannelValue slot accepting a literal, a field name, or a datum() wrapper. Python-keyword collisions declare pyName per field the same way ({ pyName: "from_", wire: "from" } on line's from field).
From the rect entry the generator emits, mechanically:
def rect(*, x=None, cx=None, x2=None, w=None, emX=None,
y=None, cy=None, y2=None, h=None, emY=None,
theta=None, thetaSize=None, r=None, rSize=None,
fill=None, stroke=None, strokeWidth=None, opacity=None, filter=None,
rx=None, ry=None, aspectRatio=None,
key=None, label=None) -> Mark:
"""A rectangle. Box geometry via the shared dims channels.
Args:
rx: Corner radius, x. Default 0.
aspectRatio: w/h ratio to enforce; ...
...
"""
return _leaf("rect", locals())plus a Rect $def in the JSON Schema (closed property list), the knownFields row + typed checks in the validator, and the LeafMarkIR member type in schema.ts — one authored entry, four generated artifacts. Note the emitted signature is exactly today's hand-written rect() minus its two phantom kwargs (rs, ts) and plus the four coord aliases it was missing — i.e., the generator's first diff already fixes real drift.
Recommended staging
- Shallow-port refactor (no codegen yet). Move
_wire_layer_tier's auto-naming/selectAllwiring JS-side behind the existingbuilder: truetag; evaluate doing the same for the constrain ref-walk; dedupe the placeholder-Arrow helper. Shrinks and regularizes the residue so the generated/hand-written boundary is clean. - Descriptor table + Python generator for operators, constraints, coord transforms, and permissive marks. Generated file checked in;
pnpm gen+ CI check. Delete the four hand-copied rename/type tables. - Retarget
jsonSchema.ts+validate.ts(generate or interpret from the table); fix thetreemapdrift in passing. Update the/internals/frontend/serializationessay (it currently documents the hand-written-triplication state). - Optional, incremental: enumerate mark channels; generify the deserializer registry and parity-harness switch off the same table.
Steps 1–2 alone get the stated goal ("~80% generated, Python as a dumb pass-through"); 3–4 are where the checklist itself starts shrinking.
Hand-written residue (permanent, by design)
Callback/lambda bridge (derive, mark-fn, accessor sentinels), widget + RPC + save/display, DataFrame⇄Arrow (incl. the Int64 downcast), DatumValue arithmetic, _RefProxy, the builder chain itself (.flow/.mark/.layer/… — thin methods over generated factories), and the d3 bin port (until/unless binning becomes a declarative operator resolved JS-side, which would delete it).
What shipped
Stages 1–3 of the recommended staging above landed (three commits: a1d83ddc, 7f7ce088, f2b3f3b0):
- The descriptor table (
packages/gofish-ir/src/frontend/descriptors.ts) — one authored entry per operator, leaf mark, combinator mark, and coord transform, in thet.*/ch.*field-type DSL sketched above.validate.tswas retargeted to interpret it generically (operators keep their original exact accept/reject behavior; leaf marks only warn, never reject, for a gradual rollout — see the caveat in the Frontend IR essay) andjsonSchema.tsnow builds its per-operator and per-leaf-mark$defsfrom the same table. Thetreemapdrift this doc flagged was fixed the direction described in § What the audit found above:TreemapOperatorjoined theOperatorIRunion rather than"treemap"being stripped out ofOPERATOR_TYPES. - The generated Python factory layer (
packages/gofish-python/gofish/_generated.py, emitted bypackages/gofish-python/scripts/generate.ts,pnpm --filter gofish-python gen, CI-checked for freshness). Net about -450 lines in the Python package; the four hand-copied wire-name tables (compositing renames, constraint/mark/operator type strings) are gone, replaced by descriptor lookups. Two real drift bugs were fixed as a byproduct: the hand-writtenrect()had phantomrs=/ts=kwargs that don't exist in JS (the real names arerSize/thetaSize) and silently dropped on the floor at render;text()had a phantomfontWeightand a phantomlabelkwarg and was missing its box-dims channels. IR for all 161 Python stories was verified byte-identical across the change. .layer()wiring moved JS-side via a new{type: "previous-tier"}DataIRvariant (an emptychart()scope inside a.layer(...)chain now serializes as that sentinel instead of Python pre-minting a tier name and rewriting toselectAll); JS's ownLayerBuilder.wireTiers()— the same path a native JS chain runs — derives the auto-naming and wiring on both reconstruction sites (thefromJSONregistry and the parity harness). 160/160 Python stories stayed DOM-identical to their JS counterparts. A latent pyarrow-22 crash on the empty-placeholder Arrow path was fixed in passing (deduped into oneempty_placeholder_arrow_bytes()helper usingschema.empty_table()).
Deliberately deferred, not follow-up bugs:
- The constrain ref-walk (
ConstrainableMark.constrain's Python-side mirror ofcollectConstraintRefs) — evaluated and kept hand-written. Dropping it needs a loud unknown/duplicate-ref guard added JS-side first; without one, authoring errors that are cheap to catch in Python today would surface as cryptic solver failures instead. - Generifying the deserializer registry and the parity-harness switch off the descriptor table (option 5 in § Option A above) — both remain hand-maintained; see the CLAUDE.md checklist's step 2 note.
- Closing the
spread/stack/scatteroperator-vs-combinatorw/hschema drift — the low-level combinator forms accept explicitw/hpassthrough that the v3-operator IR doesn't expose;descriptors.tsdocuments this as IR truth rather than resolving it (see theNOTEcomments onOPERATORS.spread/OPERATORS.stack). - Flipping leaf-mark validation from warn to strict — waiting on the enumerated channel lists being checked against the full story corpus, per the gradual-rollout stance in § The mark-channel decision above.
