Skip to content

Custom Parsers⚓︎

Use a custom parser when the built-in parser factories cannot fully describe your table language.

The parser contract is simple: a parser is a callable that receives the current cell value and a CellContext, then returns the Python value for that field.

The custom parser shape
def parse_value(value, context):
    ...

The value is usually a string from the table, but it may already have been changed by a table transformer. The context tells the parser which schema, field, source cell, item ID, and parse-time project data are involved.

A table that needs parser context
Given imported users exist
  | user id | username |
  | U-1     | Alice    |

Write custom parsers for project vocabulary

A custom parser is best when the table uses language that belongs to your project: imported IDs, role aliases, external status codes, compact domain syntax, or validation that depends on parse-time configuration.

Write the Parser Signature⚓︎

A custom parser must accept two arguments:

  • value: the current cell value being parsed.
  • context: a CellContext for the active field.
A parser that uses context
from talika import RowTable, field, id_field


seen_context = []


def parse_username(value, context):
    seen_context.append(
        {
            "schema": context.schema.__name__,
            "field_name": context.field_name,
            "field_label": context.field_label,
            "row": context.row,
            "column": context.column,
            "item_id": context.item_id,
            "source_value": context.source_value,
        }
    )
    prefix = context.user_data["prefix"]
    return f"{prefix}-{context.item_id}-{value.strip().lower()}"


class ImportUsers(RowTable):
    user_id = id_field("user id")
    username = field("username", parser=parse_username)

This parser uses three pieces of information:

  • value supplies the authored username cell.
  • context.item_id supplies the parsed row ID from id_field(...).
  • context.user_data["prefix"] supplies project data passed to parse(...).
Parsing with project context
users = ImportUsers.parse(
    [
        ["user id", "username"],
        ["U-1", " Alice "],
    ],
    context={"prefix": "import"},
)

assert users[0].username == "import-U-1-alice"
assert seen_context[0]["item_id"] == "U-1"
assert seen_context[0]["source_value"] == " Alice "
Custom parser result
>> users[0]
ImportUsers(user_id='U-1', username='import-U-1-alice')

>> seen_context[0]
{'schema': 'ImportUsers', 'field_name': 'username', 'field_label': 'username', 'row': 2, 'column': 2, 'item_id': 'U-1', 'source_value': ' Alice '}

The parser returns the value stored on the record. Talika does not apply a second conversion after the custom parser returns.

The signature must accept context

A parser that only accepts value will fail because Talika always calls parsers with both value and context.

Read CellContext⚓︎

CellContext is the parser's view of the parsing operation. It gives the parser source-aware information without forcing the parser to know how row and column tables are implemented.

Attribute Meaning
schema The schema class currently parsing the field.
field_name The Python attribute that will receive the parsed value.
field_label The canonical table label declared by field(...).
row One-based source row, when available.
column One-based source column, when available.
item_id Parsed item ID, when the table has one.
source_value The original authored cell text.
user_data Read-only project data supplied to parse(..., context=...).

Use field_name when your parser cares about the Python schema attribute. Use field_label when the error or normalization belongs to the authored table label. Use item_id when parsing depends on the current record identity.

Value and source_value are not always the same

value is the current value being parsed. context.source_value is the original authored cell text. They are usually the same in basic tables, but table transformation can change value while preserving the original source for diagnostics.

Pass Project Data into Parsers⚓︎

Do not hide project state in global variables when the value can change per test run. Pass it to parse(..., context={...}) and read it from context.user_data.

A parser driven by project data
def parse_role(value, context):
    aliases = context.user_data.get("role_aliases", {})
    allowed_roles = context.user_data["allowed_roles"]
    normalized = str(value).strip().lower()
    role = aliases.get(normalized, normalized)
    if role not in allowed_roles:
        raise ValueError(f"{role!r} is not allowed for {context.field_label}")
    return role


class RoleImport(RowTable):
    email = field("email", required=True)
    role = field("role", parser=parse_role, required=True)

This parser normalizes authored role names, applies aliases from the parse context, and checks the result against a configured set of allowed roles.

Parsing role aliases
records = RoleImport.parse(
    [
        ["email", "role"],
        ["a@example.com", "Administrator"],
    ],
    context={
        "allowed_roles": {"admin", "editor", "viewer"},
        "role_aliases": {"administrator": "admin"},
    },
)

assert records[0].role == "admin"
Role parser result
>> records[0].as_dict()
{'email': 'a@example.com', 'role': 'admin'}

The table author can write Administrator, while the test receives the project role value admin.

If the value is not accepted, raise a normal exception with a clear message:

Rejected project value
RoleImport.parse(
    [
        ["email", "role"],
        ["a@example.com", "owner"],
    ],
    context={
        "allowed_roles": {"admin", "editor", "viewer"},
        "role_aliases": {"administrator": "admin"},
    },
)
Custom parser failure
Field parser failed: 'owner' is not allowed for role 
(code=parser_failed, schema=RoleImport, field='role', 
row=2, column=2, value='owner'). 
Hint: Check the cell value or adjust the field parser for this syntax.

Talika wraps the exception with schema, field, row, column, and authored value. That means the parser message can focus on the domain problem.

Keep parser messages domain-specific

A good parser error says what was wrong with the table value, not where the value came from. Talika adds the source location around it.

Parse Compact Domain Syntax⚓︎

Custom parsers are also useful when a table cell has compact syntax that is specific to your test domain.

A percent parser
def parse_percent(value, context):
    text = str(value).strip()
    if not text.endswith("%"):
        raise ValueError("expected a percent value such as '95%'")
    number = int(text[:-1])
    if not 0 <= number <= 100:
        raise ValueError("percent must be between 0 and 100")
    return number / 100


class Metrics(RowTable):
    success_rate = field("success rate", parser=parse_percent)

This parser accepts values such as 95%, rejects values without the percent sign, rejects percentages outside the range 0..100, and returns a decimal ratio that test code can compare directly.

Parsing a percent value
metric = Metrics.parse(
    [
        ["success rate"],
        ["95%"],
    ]
)[0]

assert metric.success_rate == 0.95
Percent parser result
>> metric.as_dict()
{'success_rate': 0.95}

When the value is outside the project rule, the parser raises ValueError:

A percent outside the allowed range
Metrics.parse(
    [
        ["success rate"],
        ["110%"],
    ]
)
Percent parser failure
Field parser failed: percent must be between 0 and 100 
(code=parser_failed, schema=Metrics, field='success rate', 
row=2, column=1, value='110%'). 
Hint: Check the cell value or adjust the field parser for this syntax.

This style keeps the parser small and readable. It does not try to validate the whole record. It only answers one field-level question: how should this cell be converted?

Keep record rules out of field parsers

A field parser should parse one cell. If a rule needs multiple fields from the same record, it belongs in record validation rather than in a field parser.

Empty Cells and Custom Parsers⚓︎

By default, an explicit empty optional cell is not sent to the parser. Talika returns "" for that field.

If your parser needs to handle empty text itself, set empty="parse" on the field.

A parser that handles blank text
def parse_blank(value, context):
    return "<blank>" if value == "" else value


class EmptyAware(RowTable):
    normal = field("normal", parser=parse_blank)
    parsed_empty = field("parsed empty", parser=parse_blank, empty="parse")
Parsing empty cells
record = EmptyAware.parse(
    [
        ["normal", "parsed empty"],
        ["", ""],
    ]
)[0]

assert record.normal == ""
assert record.parsed_empty == "<blank>"
Empty-cell parser result
>> record.as_dict()
{'normal': '', 'parsed_empty': '<blank>'}

The normal field keeps the empty string because the parser is skipped for blank optional cells. The parsed_empty field sends the blank string to the parser because the field explicitly opts into empty="parse".

Do not assume your parser sees blanks

If an optional field has an empty cell and the field does not use empty="parse", your custom parser will not run for that cell.

Use Source Value Carefully⚓︎

Most custom parsers should use value. That is the current value in the table lifecycle. In ordinary tables, it is the text written in the feature file.

Use context.source_value when the original authored text matters for a diagnostic or for project syntax that should survive transformation.

A parser that mentions the original cell
def require_poll(value, context):
    if value != "Poll":
        raise ValueError(
            f"expected Poll after transformation; original cell was {context.source_value!r}"
        )
    return value

The parser above checks the transformed value, but its error message can still mention the original cell text. This matters when a transformer expands or normalizes compact authored syntax before field parsing.

Prefer value for conversion

Treat value as the thing you are converting. Treat source_value as source context that helps explain where the value came from.

Understand Parser Failures and Exception Wrapping⚓︎

When a custom parser raises an ordinary exception during parsing, Talika wraps it as TableError(code="parser_failed") and retains the original exception as the diagnostic cause.

Because of this auto-wrapping behavior:

  • Raise Plain Exceptions when Talika should classify the failure as parser_failed and populate its normal schema, field, location, and values.
  • Raise TableError deliberately when the project owns a more specific code, hint, or location. Talika passes that exact exception through unchanged.
  • Raise TableErrors or SchemaDefinitionError deliberately when the extension owns an aggregate or schema-level failure; these also pass through.

The same pass-through rule applies to default factories, transformers, reference-key parsers, validators, and output builders.

A parser with the wrong signature
def bad_parser(value):
    return value


class BadSignature(RowTable):
    value = field("value", parser=bad_parser)


BadSignature.parse(
    [
        ["value"],
        ["x"],
    ]
)
Bad parser signature diagnostic
Field parser failed: bad_parser() takes 1 positional argument but 2 were given 
(code=parser_failed, schema=BadSignature, field='value', 
row=2, column=1, value='x'). 
Hint: Check the cell value or adjust the field parser for this syntax.

Test custom parsers through a schema

A parser function can be unit-tested directly for small conversions, but also test it through RowTable.parse(...) or ColumnTable.parse(...). That verifies the error message, source location, context data, and empty-cell behavior together.