Skip to content

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.

An authored table with friendly values
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.

Normalizing values before the schema parses them
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:

Parsed normalized content
>> 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.

Transformed value with original source
>> 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:

Reusable transformers in a left-to-right pipeline
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 result
>> 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:

Returning plain rows by mistake
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"]])
Invalid transformer return
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:

Invalid pipeline stage
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.

Pointing a transformer error at the authored cell
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"]])
Source-aware transformer error
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.