Caxton Architecture¶
Caxton is a declarative document-generation library. The user describes the content and meaning of a document, and the compiler and the selected renderer transform this specification into the target artifact. XLSX is the first primary format, but its engines and constraints do not define the Core.
Authority and delivered scope¶
This file is the normative source for the current architecture and supported
boundaries. AGENTS.md translates those boundaries into repository workflow;
the README and examples demonstrate selected public flows. Design notes and use
case documents can explain motivation or proposed APIs, but they do not expand
the compatibility contract described here.
The delivered document family is SpreadsheetDocument and the delivered
artifact profile is XLSX. Flow, tabular, and fixed-layout families below define
extension boundaries only: their names in examples do not imply public
constructors, compilers, IRs, or renderers.
Core invariants¶
- The public generative API creates immutable semantic nodes directly; it does not expose a separate public builder graph. Fluent operations return a new node.
- The Semantic Model stores intent, but it does not store coordinates, layout decisions, execution state, caches, or backend-native objects.
- Nested collections are available only as immutable/read-only values.
- An entity
idis separate from its value source (source) and label (title). - Validation and compilation do not modify the Semantic Model.
- A document family owns its model, validation, compiler, IR, and testing view; no universal super-model or super-IR exists.
- I/O belongs to operations and sinks, not to the document model.
Structural immutability does not imply universal hashability or repeatable
execution: a semantic node can reference a stateful or one-shot DataSource.
Public boundaries and dependencies¶
caxton short public facade
├── api factories and render/write/validate
├── core
│ ├── models immutable semantic nodes
│ ├── types semantic value types
│ ├── formatting backend-neutral presentation vocabulary
│ ├── protocols DataSource, Renderer, and other contracts
│ ├── ir versioned read-only family IR
│ └── errors
├── testing public inspection and comparison API
└── _internal orchestration, compilers, resolver, and backends
The allowed dependency direction is:
api ───────→ core testing ───→ core, _internal
api ───────→ _internal _internal ─→ core
backends ──→ public core contracts
core does not import api, _internal, testing, or backend engines.
_internal does not import api. The public API does not return OpenPyXL,
XlsxWriter, XML, or PDF canvas objects. Mutable compiler passes, the resolver,
caches, and execution plans are not compatibility contracts.
Public stability is divided deliberately:
caxtonis the recommended short facade andcaxton.apiis the extended generative API;caxton.corecontains semantic models, value types, errors, protocols, renderer signature types, and versioned read-only IR contracts used by custom renderers;caxton.testingis the stable inspection and comparison surface;caxton._internal, including bundled renderer implementations, mutable IR builders, parsers, planners, and package post-processors, is not public API.
Semantic Model and families¶
The common Document contract contains only shared semantics (kind, metadata).
Families do not inherit capabilities from one another:
| Family | Semantics | Typical targets |
|---|---|---|
| Spreadsheet | worksheets, grid, formulas, ranges | XLSX, ODS |
| Flow | sections, paragraphs, pagination | DOCX, HTML, PDF |
| Tabular | schema and ordered records | CSV, TSV, simple XLSX |
| Fixed layout | pages and positioned blocks | PDF, SVG, images |
Shared tabular data uses TableData (schema + DataSource), but the visual
SpreadsheetTable and FlowTable remain family-specific nodes. Business
concepts (Receipt, Invoice, or a specific Report) are composed in the
application layer from neutral nodes and do not become Core families.
Backend-independent semantic types include Text, Integer, Decimal,
Boolean, Date, Time, DateTime, Duration, Money, Percentage, and
Link. Formatting (alignment, border, color, font, display format, and width
hints) is stored separately from value semantics. The renderer controls the
physical representation and emits a capability diagnostic when it cannot
preserve the semantics.
Pipeline¶
Public API + raw inputs
↓ DataSource coercion without reading rows
Immutable Semantic Model
↓ structural validation
Requirement analysis → RequiredCapabilities + WorkbookOperation
↓ resolver checks the renderer descriptor and IR compatibility
Family compiler × selected renderer capabilities
↓
Versioned read-only Family IR
↓
Renderer → OutputSink → RenderResult
Requirement analysis does not depend on the renderer. The compiler performs lowering: it resolves references, coordinates, layout, and other physical decisions without writing them back to the model. The renderer serializes the agreed IR version and owns the backend-specific execution plan.
render() uses a memory sink and returns RenderResult; write() normalizes a
path, buffer, or another target into an OutputSink. The system rejects an
incompatible or ambiguous renderer before writing starts.
Built-in resolution considers, in order, an explicitly supplied renderer, an explicit backend, an explicit format, target extension or MIME hints, document kind, required capabilities, workbook operation, and compatible IR versions. The default is selected only when exactly one bundled route remains. Requirement analysis is renderer-independent and never reads rows merely to make this selection.
For a path target, the built-in FileSink owns a sibling staging file: the
renderer writes to the destination provided by the sink, and the sink atomically
replaces the target only after the backend completes successfully. For a binary
target, the adapter retries short writes until it delivers the complete output
or raises a stable render error.
Data, computation, and streaming¶
table(data, ...) coerces the input to a public DataSource once and stores the
source instead of the original framework object or a materialized list. Built-in
ingestion supports mappings, NamedTuple, dataclass and attribute objects, lazy
iterables, and direct custom DataSource/RowAccessor implementations without
importing Pydantic, ORM, or other frameworks into the Core. DataFrame/Arrow-like
inputs require a separate batch contract.
Coercion recognizes an existing structural DataSource first, rejects
DataFrame/Arrow-like and scalar/text/callable inputs with a focused error, wraps
single supported row objects, and otherwise adapts a lazy iterable. It does not
use asdict, model_dump, vars, dir, schema inference, or a global adapter
registry. ORM/session lifecycle, eager loading, projection, and prefetch remain
the caller's responsibility.
Mapping access uses row[field]; object access uses the exact attribute.
Explicit path(...) defines nested traversal; a single semantic row evaluator
evaluates callables and expressions. An error from an existing property or
descriptor does not appear as a missing-field error.
Python row expressions and spreadsheet formulas form separate semantic
hierarchies. field()/path() read raw data-source values and ref() reads
the evaluated value of another semantic column; all three are evaluated by the
library before rendering, while col(), table_ref(), and sheet_ref()
remain formula intent. field() never resolves a column id and ref() never
reads a row field, so the two namespaces cannot be confused. The
compiler resolves their semantic IDs into cell/range nodes in the Spreadsheet
IR; the XLSX renderer materializes A1 or structured references. A Python
expression cannot depend on a formula-backed column because its value appears
only in the artifact. A range reference requires a known row_count: the
compiler does not perform a hidden pass over a one-shot or UNKNOWN source to
calculate the end of the range.
Aggregation is expression intent represented by backend-independent
AggregateExpr(function, expressions, where, default). The initial execution adapter
passes one Python value sequence per expression to an arbitrary Python callable;
it does not normalize inputs or remove None. An ungrouped aggregate uses the
whole source as one scope, while grouped tables use one leaf group per scope.
When filtering leaves a scope empty, an explicitly declared default is returned
without invoking the callable; without a default, the callable retains authority
over the empty input and any failure is reported as AggregateEvaluationError.
Grouping belongs to SpreadsheetTable columns rather than a separate public
GroupedTable; order defaults to first seen, and the compiler derives exact row
and merge ranges from resolved hierarchical scopes. Group hierarchy follows the
physical declaration order of grouped columns. Dimension identity is strict:
Python value types are distinct, and Decimal scale is preserved, so True,
1, Decimal("1"), and Decimal("1.0") form separate groups. Sorted groups
place None last for both ascending and descending order. Float identity folds
-0.0 into 0.0 and treats all NaN values as one canonical group.
Matrix is a spreadsheet-family block with typed row-dimension,
column-dimension, and value columns. Expressions accepted by the convenience
factory are normalized into columns; explicit columns carry semantic type,
format, style, width, and stable ids. Its compiler path discovers dynamic keys
in first-seen order unless a dimension declares grouping order, uses the same
strict identity as grouping, and requires an AggregateExpr when more than one
source value resolves to a cell. Row-dimension merge intent produces vertical
merges; flattened column headers reject merge intent. Generated
value columns expose their dimension key through the public testing layout, so
tests do not depend on compiler-generated ids. Grouped tables and matrices
buffer semantic input rows internally and consume ONE_SHOT sources exactly
once. Aggregate inputs are evaluated during that pass, after their filters, so
the original source row object can be released before grouping. Matrix output
rows are emitted lazily after the dynamic axes are known, so sparse inputs do
not also retain the dense Cartesian output. They are not append-only streaming
plans. Prepared placements are checked against the XLSX sheet bounds (1,048,576
rows by 16,384 columns) before rendering. DataSource remains responsible only
for row ingestion and never gains groupby, pivot, or aggregation operations.
Spreadsheet block layout¶
A worksheet holds a closed set of spreadsheet blocks: SpreadsheetTable,
Matrix, Title, Spacer, Image, Chart, and the Stack flow container.
Blocks carry intent only; the compiler owns placement. A dedicated layout pass
walks the declared blocks in order, measures each one, and assigns a physical
anchor and occupied range before any IR node is built. Ordinary-table
measurements are structural: one header row plus non-consuming row_count plus
an optional footer row. Grouped tables and matrices are measured from their
single-pass prepared result because their output shape differs from the input.
Titles occupy one row; images and charts convert their declared pixel size into
whole cells.
Explicit anchor remains the escape hatch. An anchored block keeps its declared
position and still advances the flow cursor, so a following implicit block never
lands inside it. Overlaps between statically measurable blocks are reported as
block_overlap validation issues before any source is consumed, even when the
worksheet also contains a shape-dependent grouped table or matrix. Prepared
shapes receive a second placement check before rendering. When a table height is
unknown the flow cursor becomes invalid instead of guessing; the next implicit
block raises an UnsupportedFeatureError and the document keeps working if
every following block is anchored explicitly.
Because structural validation does not consume rows, a clean validate()
result cannot guarantee a clean render for shape-dependent blocks. Grouped-table
or matrix preparation can consume a ONE_SHOT source before the second placement
check discovers an overlap; after that failure, the source remains consumed.
Charts bind to data through table_ref(...) plus semantic column ids. The
compiler resolves them into physical ranges of the placed table, so a chart, like
a range reference, requires a known row_count.
A source declares its repeatability as REITERABLE, ONE_SHOT, or UNKNOWN.
Coercion and structural validation do not read rows. A second pass over a
one-shot source raises DataSourceConsumedError; an unknown source does not
permit a hidden repeated pass. A multi-pass feature is rejected before writing
or uses an explicit documented buffering policy. Data validation and semantic
inspection never read rows implicitly. Ordinary layout inspection reads rows
only with an explicit sample/full scope; grouped/matrix layout compilation
still performs its documented single-pass buffering because shape resolution
requires the complete source.
An error while retrieving the next row from an iterator is a
DataSourceIterationError, not a backend failure, and it preserves the index of
the next row and the original cause.
Renderer and XLSX¶
The public Renderer accepts a versioned family IR, an OutputSink, and a
RenderContext, while its descriptor declares family/IR versions, formats,
MIME types/extensions, workbook operations, capabilities, and execution modes.
A custom renderer can be passed directly without importing _internal; no
global provider registry or entry-point discovery exists at this stage.
For XLSX, the workbook operation is determined before adapter selection:
| Operation | Adapter |
|---|---|
CREATE_NEW_WORKBOOK |
default XlsxWriterRenderer |
| explicit legacy create-new | OpenpyxlRenderer |
USE_EXISTING_TEMPLATE |
dedicated OpenpyxlTemplateRenderer after inspection |
The template pipeline first builds a read-only TemplateContext and then passes
it to the family compiler. A template operation does not silently fall back to
create-new. Constant-memory/write-only is a renderer execution plan with
verifiable capabilities, not a separate document type. Backend hooks and
post-processing are explicit, namespaced, and absent from the Core model.
The XLSX adapter resolves generic ref(...) template targets through workbook-
or worksheet-scoped defined names. A normal target is a data-only region; a
repeat(ref(...)) target copies the named block once per semantic row, including
styles, translated relative formulas, and contained merges. The renderer works
on a private workbook copy and writes to the sink only after rendering, hooks,
and ordered XLSX package post-processing succeed. Pivot package paths and
relationships remain backend-local descriptor data.
Caxton ships as a single distribution. XlsxWriter and OpenPyXL are part of the
base runtime dependencies, but they do not form a shared rendering pipeline:
the built-in XLSX artifact inspector also uses OpenPyXL. Official backends ship
with caxton; they do not use separate packages.
Delivered spreadsheet/XLSX profile¶
The following behavior is implemented and forms the current feature boundary:
| Area | Delivered behavior |
|---|---|
| Tables and values | typed columns, mappings and Python row objects, Python expressions, semantic value normalization, explicit and automatic widths |
| Spreadsheet expressions | semantic cell/range and cross-sheet formulas, conditional formatting, totals, and named table references |
| Data shaping | hierarchical grouping, arbitrary Python aggregates with filters/defaults, and dynamic matrix axes with duplicate-cell conflict detection |
| Presentation | reusable styles/themes, multiple worksheets, filters, freeze panes, titles, spacers, stacks, images, and charts bound to named tables |
| Layout | flow placement, explicit A1 anchors, overlap detection, merge ranges, sheet-bound checks, and post-preparation placement checks for shape-dependent blocks |
| Execution | standard and constant-memory create-new plans, one-shot protection, atomic file output, binary targets, and stable capability diagnostics |
| Templates and XLSX extensions | named-range targets, repeated template blocks, pivot cache refresh, namespaced OpenPyXL hooks, and ordered backend-local package post-processing |
| Testing | semantic inspection/diff, canonical snapshots, explicit-scope layout inspection, XLSX artifact inspection, and optional Hypothesis strategies |
Charts currently bind to an existing named table; an independent inline chart
data= source is not part of the public API. A reusable Python factory can
construct a fresh immutable specification, but bind-time source_ref() and
bind() placeholders are not implemented.
Validation, diagnostics, and testing¶
Validation has three levels: construction-time local invariants, structural
cross-node rules without data reads, and explicitly requested data validation.
All library errors inherit from CaxtonError; the public categories distinguish
validation, data source/evaluation, invalid operation, unsupported feature, and
render/backend failures. Errors contain a semantic path and structured context,
and exception chaining preserves the original cause. Multiple validation issues
are aggregated; non-fatal issues use warnings categories. Type and value
errors in the public construction API use the Python-compatible TypeError and
ValueError subclasses CaxtonTypeError and CaxtonValueError, so callers
can also catch them through CaxtonError.
caxton.testing is a stable, pytest-independent API with three levels:
- semantic inspection by IDs, domain-aware comparison, and canonical snapshots;
- read-only family layout inspection with an explicit row scope;
- backend-neutral inspection of a completed artifact.
The XLSX inspector uses OpenPyXL only inside the implementation and returns immutable public values. The public API does not expose internal IR storage, parsers, or diff algorithms.
Extension¶
- A new operation is added as a function, not as a method on every semantic node.
- Direct custom objects use small structural protocols without a registry.
- A new backend implements the public renderer contract and accepts the existing IR.
- A new family adds its own model/compiler/IR without extending the other families.
- Conversion between families uses an explicit
DocumentConverterand returns a loss report; changing the backend within a family remains a render operation. - Registries, discovery, pass managers, and universal hook systems appear only with a validated use case.
Deliberate deferrals¶
The following are architectural extension points or proposals, not current public promises:
- Flow, tabular, and fixed-layout models, their IRs and renderers, including CSV, DOCX, HTML, PDF, SVG, and image outputs;
- cross-family
DocumentConverterand loss reports; - schema inference,
BatchDataSource, async sources, framework-specific source adapters, and public source/type-adapter registries; - bind-time data placeholders, inline chart data, and a universal testing contract spanning semantic, layout, and artifact views;
- entry-point discovery, provider priority/conflict resolution, compiler pass managers, global hook/event registries, and universal extension managers.
A deferred item becomes part of the contract only when a concrete public use case, implementation, focused tests, examples, and this file agrees. Names or sketches in design documentation do not reserve a public API.