Transform Tables⚓︎
Some teams write feature tables exactly in the shape their Python code wants. Other teams write tables in a shape that is easier for people to read, review, or maintain.
Table transformations are for the second case. They let a schema normalize the authored table before labels are matched, fields are parsed, and records are validated.
Feature: CMS table normalization
Scenario: Normalize authored content values before parsing
Given the content table:
| IDs | a-1 |
| Headline | market brief |
| Status | Ready For Review |
Then the parsed content record uses normalized values
In this table, the author has not done anything wrong. The ID is lowercase
because that is easy to type. The headline is sentence-like text. The status is
written as a human phrase. Your test code may still want A-1, Market Brief,
and ready-for-review.
Transform authored shape into logical shape
A table transformation should answer: "What table should the schema see?" It should not try to replace field parsers, record validators, or table validators.
Use transform_table for Schema-Specific Changes⚓︎
Override transform_table(...) when the transformation belongs to one schema.
The hook receives a TableData object and the current parse context.
from talika import ColumnTable, TableData, field, id_field
raw_content = [
["IDs", "a-1"],
["Headline", "market brief"],
["Status", "Ready For Review"],
]
class NormalizedContentTable(ColumnTable):
id = id_field("IDs")
headline = field("Headline")
status = field("Status")
@classmethod
def transform_table(cls, table, context):
rows = [list(row) for row in table.rows]
rows[0][1] = rows[0][1].with_value(rows[0][1].value.upper())
rows[1][1] = rows[1][1].with_value(rows[1][1].value.title())
rows[2][1] = rows[2][1].with_value(
rows[2][1].value.casefold().replace(" ", "-")
)
return TableData.from_cells(rows)
record = NormalizedContentTable.parse(raw_content)[0]
The transformed values are what the fields parse:
>> record
NormalizedContentTable(id='A-1', headline='Market Brief', status='ready-for-review')
>> (record.id, record.headline, record.status)
('A-1', 'Market Brief', 'ready-for-review')
The important detail is the return value. transform_table(...) must return
TableData, not plain rows. When you already have source-aware cells, build the
new table with TableData.from_cells(...).
Do not rebuild transformed tables from plain strings
table.to_rows() is useful for debugging, but it drops source metadata.
Inside a transformer, arrange existing cells and use cell.with_value(...)
when the logical value changes.
Preserve the Authored Cell⚓︎
with_value(...) changes the current value while keeping the original row,
column, and source text.
>> record.source_for("status")
TableCell(value='ready-for-review', source_row=3, source_column=2, source_value='Ready For Review')
>> (record.source_for("status").value, record.source_for("status").source_value)
('ready-for-review', 'Ready For Review')
That distinction matters later. A parser receives ready-for-review, but an
error can still point to row 3, column 2, where the author wrote
Ready For Review.
Current value and source value can differ
The current value is the value Talika should parse. The source value is the value the feature author wrote. Transformations often need both.
Compose Reusable Transformers⚓︎
Use reusable transformer objects when the same normalization should be shared
by several schemas. A transformer object only needs a transform(...) method:
from talika import ColumnTable, TableData, compose_transformers, field, id_field
class PrefixFromContext:
def transform(self, table, context, *, schema=None):
rows = [list(row) for row in table.rows]
prefix = context.user_data.get("id_prefix", "")
rows[0][1] = rows[0][1].with_value(prefix + rows[0][1].value)
return TableData.from_cells(rows)
class TitleHeadline:
def transform(self, table, context, *, schema=None):
rows = [list(row) for row in table.rows]
rows[1][1] = rows[1][1].with_value(rows[1][1].value.title())
return TableData.from_cells(rows)
class PipelineContentTable(ColumnTable):
table_transformer = compose_transformers(PrefixFromContext(), TitleHeadline())
id = id_field("IDs")
headline = field("Headline")
pipeline_raw = [
["IDs", "42"],
["Headline", "release notes"],
]
pipeline_record = PipelineContentTable.parse(
pipeline_raw,
context={"id_prefix": "DOC-"},
)[0]
This shape is the public TableTransformer protocol. You do not need to inherit
from a base class. Any object with a compatible transform(table, context, *,
schema=None) method can be used as a schema's table_transformer.
compose_transformers(...) runs each transformer from left to right. Each stage
receives the table returned by the previous stage.
>> pipeline_record
PipelineContentTable(id='DOC-42', headline='Release Notes')
>> pipeline_record.source_for("id")
TableCell(value='DOC-42', source_row=1, source_column=2, source_value='42')
The example also uses context.user_data. This is helpful when a test run wants
to pass a small amount of environment-specific information into parsing, such
as a prefix, locale, tenant, or mode.
compose_transformers(...) returns a TransformerPipeline. You normally do
not need to instantiate TransformerPipeline directly unless a project wants
to build the sequence dynamically.
Keep stages small
A readable transformer usually does one kind of work: normalize labels, expand compact authoring syntax, prefix IDs, or clean a family of values. Pipelines are easier to review when each stage has a clear job.
Return TableData⚓︎
Talika checks transformer results before continuing. If a hook returns plain rows, parsing stops with a table error:
from talika import RowTable, field
class InvalidTransformTable(RowTable):
value = field("value")
@classmethod
def transform_table(cls, table, context):
return table.to_rows()
InvalidTransformTable.parse([["value"], ["one"]])
Table transformation must return TableData (code=invalid_transform, schema=InvalidTransformTable)
Pipeline stages are checked in the same way. If a reusable stage returns the wrong kind of value, the error identifies the stage number and class name:
Table transformer stage 1 (BadStage) must return TableData (code=invalid_transform, schema=PipelineFailureTable)
Raise Intentional Table Errors⚓︎
If a transformer detects invalid authoring syntax, raise a TableError from
the specific cell. Talika preserves intentional table errors instead of wrapping
them as unexpected failures.
from talika import RowTable, TableError, field
class RangeTable(RowTable):
value = field("value")
@classmethod
def transform_table(cls, table, context):
cell = table.cell(2, 1)
raise TableError.from_cell("Invalid range", cell, schema=cls)
RangeTable.parse([["value"], ["3..1"]])
Invalid range (code=table_error, schema=RangeTable, row=2, column=1, value='3..1')
The error belongs to row 2, column 1, because that is where the problematic authoring text came from.
Do not hide authoring mistakes during transformation
If a compact table syntax is invalid, fail early and point to the exact cell. Silent cleanup makes feature files harder to trust because authors do not learn which table text was ambiguous or unsupported.
Choose Transformations Deliberately⚓︎
Use a table transformation when the authored table should become a different logical table before parsing. Good examples include:
- normalizing labels or IDs before field lookup
- expanding compact table syntax into ordinary rows or columns
- rewriting project-owned vocabulary into parser-friendly values
- applying test-run context before field parsing begins
Avoid transformations for ordinary type conversion. A field parser is usually
better for turning "34" into an integer, a configured token such as "yes"
into a Boolean, or a status label into a domain enum. Avoid transformations for
business rules too. Record validators and table validators give clearer intent
for rules such as duplicate emails, invalid ranges, or missing references.
A useful boundary
If the question is "What table should my schema see?", use a table transformation. If the question is "What does this cell mean?", use a parser. If the question is "Is this record or table valid?", use validation.