"""Table-document IR: frozen pydantic models, ``ir_version`` 1.
The IR is the contract between the semantic engine and every renderer
(Python HTML, TikZ, text, and the JS walker). It carries *semantics only*:
no widths, no rule weights, no CSS, no fonts.
Two access paths with different strictness:
- **Write** (producer): construct models directly; unknown fields raise
(``extra='forbid'``), so producer typos surface immediately.
- **Read** (consumer): :meth:`TableDoc.read` strips unknown fields before
validation, so documents from a *newer* producer with additive advisory
fields remain readable ("must ignore" rule).
Canonical serialization lives in :func:`canonical_dict`: deterministic field
presence (defaults omitted), NFC-normalized strings, and the bare-string cell
shorthand. :mod:`greater_tables.engine.hashing` turns that dict into
canonical bytes and the content hash.
"""
from __future__ import annotations
import json
import unicodedata
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
__all__ = [
'IR_VERSION', 'Alignment', 'Cell', 'Column', 'FormatSpec', 'HeadCell',
'Row', 'TableDoc', 'canonical_dict',
]
IR_VERSION = 1
Alignment = Literal['l', 'r', 'c']
Role = Literal['stub', 'data']
Dtype = Literal['int', 'float', 'date', 'datetime', 'bool', 'string', 'object']
Tag = Literal['ratio', 'year', 'date', 'raw']
FormatKind = Literal['int', 'dec', 'pct', 'sci', 'gen', 'si', 'eng', 'year',
'date', 'str']
Negative = Literal['minus', 'paren']
RowFlag = Literal['total', 'subtotal', 'emphasis', 'muted']
CellFlag = Literal['neg', 'emphasis', 'muted']
_FROZEN = ConfigDict(frozen=True, extra='forbid')
[docs]
class Cell(BaseModel):
"""One body/foot cell.
``text`` is plain unicode — each renderer applies its own escaping.
Unless the document says ``math='off'``, ``text`` may contain inline
``$...$`` / ``$$...$$`` math spans (and ``\\$`` for a literal dollar);
a renderer is responsible for segmenting them — the shared rule is
:mod:`greater_tables.mathspans`. ``raw`` is only present when the owning
column advertises ``raw: true``; an explicit JSON ``null`` there means a
missing value, which is distinct from the field being absent (tracked
via pydantic's fields-set).
"""
model_config = _FROZEN
text: str
raw: float | int | str | bool | None = None
rowspan: int = Field(default=1, ge=1)
colspan: int = Field(default=1, ge=1)
flags: tuple[CellFlag, ...] = ()
math: bool = False
html: bool = False
[docs]
class HeadCell(BaseModel):
"""One header cell; level is implicit in its row position within ``head``."""
model_config = _FROZEN
text: str = ''
colspan: int = Field(default=1, ge=1)
align: Alignment | None = None
flags: tuple[CellFlag, ...] = ()
math: bool = False
html: bool = False
[docs]
class Row(BaseModel):
"""One body/foot row.
``cells`` holds only the *uncovered* columns — a stub cell with
``rowspan`` in an earlier row covers its column here, exactly as HTML
``<td rowspan>`` semantics. A cell that is plain text with no other
attributes may be a bare string (the shorthand; canonicalized on dump).
``break_depth`` is the shallowest stub level whose value changed versus
the previous row (0 = outermost). House style maps depth to rule weight;
the IR never names a weight.
"""
model_config = _FROZEN
cells: tuple[Cell | str, ...]
break_depth: int | None = Field(default=None, ge=0)
flags: tuple[RowFlag, ...] = ()
[docs]
class Column(BaseModel):
"""Descriptor for one visual column, stubs first."""
model_config = _FROZEN
key: str
name: tuple[str, ...]
dtype: Dtype
align: Alignment
role: Role = 'data'
level: int | None = Field(default=None, ge=0)
tag: Tag | None = None
wrap: bool = False
break_depth: int | None = Field(default=None, ge=0)
format: FormatSpec | None = None
raw: bool = False
[docs]
class TableDoc(BaseModel):
"""The table document — the versioned IR a semantic engine emits.
``hash`` is stamped by :func:`greater_tables.engine.hashing.stamp`
(sha256 over canonical bytes, ``hash`` and ``generator`` excluded).
``math`` is the document-wide switch for ``$``-delimiter interpretation
in every ``text`` field (cells, headers, caption, notes). The builder
always emits the default ``'auto'``; ``'off'`` is the escape hatch for
hand-authored and foreign documents whose dollars are all literal (a
ticker table). ``'off'`` stops renderers *detecting* delimiters — an
explicit ``Cell.math`` / ``HeadCell.math`` flag is honored regardless.
"""
model_config = _FROZEN
ir_version: int = IR_VERSION
generator: str | None = None
hash: str | None = Field(default=None, pattern=r'^[0-9a-f]{12}$')
caption: str | Cell | None = None
label: str | None = None
notes: tuple[str, ...] = ()
math: Literal['auto', 'off'] = 'auto'
n_stub_levels: int = Field(ge=0)
n_head_levels: int = Field(ge=0)
columns: tuple[Column, ...]
head: tuple[tuple[HeadCell, ...], ...]
body: tuple[Row, ...]
foot: tuple[Row, ...] = ()
@model_validator(mode='after')
def _check_structure(self) -> TableDoc:
if self.ir_version != IR_VERSION:
raise ValueError(
f'unsupported ir_version {self.ir_version}; this build reads {IR_VERSION}')
if self.n_head_levels != len(self.head):
raise ValueError(
f'n_head_levels={self.n_head_levels} != len(head)={len(self.head)}')
stubs = [c for c in self.columns if c.role == 'stub']
if len(stubs) != self.n_stub_levels:
raise ValueError(
f'n_stub_levels={self.n_stub_levels} != {len(stubs)} stub columns')
for i, col in enumerate(self.columns):
if col.role == 'stub':
if i >= self.n_stub_levels:
raise ValueError(f'stub column {col.key} after data columns')
if col.level != i:
raise ValueError(
f'stub column {col.key} has level={col.level}, expected {i}')
return self
[docs]
@classmethod
def read(cls, source: str | bytes | dict[str, Any]) -> TableDoc:
"""Validate a foreign document, ignoring unknown fields.
Parameters
----------
source
JSON text/bytes or an already-parsed dict.
Returns
-------
TableDoc
The validated document.
Raises
------
ValueError
If ``ir_version`` is unsupported or the document is malformed.
"""
data = json.loads(source) if isinstance(source, (str, bytes)) else source
if not isinstance(data, dict):
raise ValueError('table document must be a JSON object')
version = data.get('ir_version', IR_VERSION)
if version != IR_VERSION:
raise ValueError(
f'unsupported ir_version {version}; this build reads {IR_VERSION}')
return cls.model_validate(_strip_unknown(data))
# ------------------------------------------------------------- read cleaning
def _keep(data: dict[str, Any], model: type[BaseModel]) -> dict[str, Any]:
return {k: v for k, v in data.items() if k in model.model_fields}
def _strip_unknown(data: dict[str, Any]) -> dict[str, Any]:
"""Recursively drop fields no model knows — the must-ignore read rule."""
def cell(c: Any) -> Any:
return _keep(c, Cell) if isinstance(c, dict) else c
def head_cell(c: Any) -> Any:
return _keep(c, HeadCell) if isinstance(c, dict) else c
def row(r: Any) -> Any:
if not isinstance(r, dict):
return r
r = _keep(r, Row)
if isinstance(r.get('cells'), list):
r['cells'] = [cell(c) for c in r['cells']]
return r
def column(c: Any) -> Any:
if not isinstance(c, dict):
return c
c = _keep(c, Column)
if isinstance(c.get('format'), dict):
c['format'] = _keep(c['format'], FormatSpec)
return c
out = _keep(data, TableDoc)
if isinstance(out.get('caption'), dict):
out['caption'] = cell(out['caption'])
if isinstance(out.get('columns'), list):
out['columns'] = [column(c) for c in out['columns']]
if isinstance(out.get('head'), list):
out['head'] = [[head_cell(c) for c in level] if isinstance(level, list)
else level for level in out['head']]
for part in ('body', 'foot'):
if isinstance(out.get(part), list):
out[part] = [row(r) for r in out[part]]
return out
# ------------------------------------------------------- canonical emission
def _nfc(value: Any) -> Any:
if isinstance(value, str):
return unicodedata.normalize('NFC', value)
if isinstance(value, tuple):
return [_nfc(v) for v in value]
return value
def _canonical_model(obj: BaseModel, always: tuple[str, ...]) -> dict[str, Any]:
"""Dump a model keeping ``always`` fields plus non-default ones.
Canonical form ignores whether a default was passed explicitly — the one
exception is ``Cell.raw``, where *presence* is meaningful (an explicit
JSON null means a missing value) and is tracked via the fields-set.
"""
out: dict[str, Any] = {}
for name, field in type(obj).model_fields.items():
value = getattr(obj, name)
if name not in always:
if isinstance(obj, Cell) and name == 'raw':
if 'raw' not in obj.model_fields_set:
continue
elif value == field.get_default():
continue
out[name] = _nfc(value)
return out
def _canonical_cell(cell: Cell | str) -> dict[str, Any] | str:
if isinstance(cell, str):
return _nfc(cell)
d = _canonical_model(cell, always=('text',))
if list(d) == ['text']:
return d['text'] # bare-string shorthand
return d
def _canonical_row(row: Row) -> dict[str, Any]:
d = _canonical_model(row, always=('cells',))
d['cells'] = [_canonical_cell(c) for c in row.cells]
return d
[docs]
def canonical_dict(doc: TableDoc, *, include_hash: bool = True) -> dict[str, Any]:
"""Return the canonical plain-dict form of a document.
Deterministic field presence: structural fields always appear; optional
fields appear only when they differ from their defaults; trivial cells
collapse to bare strings; all strings are NFC-normalized. Key ordering is
left to the JSON writer (sorted there).
Parameters
----------
doc
The document to canonicalize.
include_hash
When False, ``hash`` and ``generator`` are omitted — the form that
content hashing operates on.
"""
out: dict[str, Any] = {
'ir_version': doc.ir_version,
'n_stub_levels': doc.n_stub_levels,
'n_head_levels': doc.n_head_levels,
}
if include_hash:
if doc.generator is not None:
out['generator'] = _nfc(doc.generator)
if doc.hash is not None:
out['hash'] = doc.hash
if doc.caption is not None:
out['caption'] = (_canonical_cell(doc.caption)
if isinstance(doc.caption, Cell) else _nfc(doc.caption))
if doc.label is not None:
out['label'] = _nfc(doc.label)
if doc.notes:
out['notes'] = [_nfc(n) for n in doc.notes]
if doc.math != 'auto':
out['math'] = doc.math
out['columns'] = [_canonical_column(c) for c in doc.columns]
out['head'] = [[_canonical_model(c, always=('text',)) for c in level]
for level in doc.head]
out['body'] = [_canonical_row(r) for r in doc.body]
if doc.foot:
out['foot'] = [_canonical_row(r) for r in doc.foot]
return out
def _canonical_column(col: Column) -> dict[str, Any]:
d = _canonical_model(col, always=('key', 'name', 'dtype', 'align'))
if col.format is not None:
d['format'] = _canonical_model(col.format, always=('kind',))
return d