Skip to content

Parser Factories⚓︎

Parser factories create the callables you pass to field(parser=...).

A table cell starts as text. A parser decides what that text means for one field: maybe it becomes an integer, a boolean, a decimal, a normalized string, a domain value, a list, or None.

The factories in Talika are deliberately small. Each one does one clear job, and the composition helpers let you build larger parsing rules without hiding the table vocabulary inside a long custom function.

A table with scalar values
Given the users exist
  | username | age | mask | rating | balance |
  | Alice    | 34  | ff   | 4.5    | 12.30   |

Read parsers as table vocabulary

A parser is not just a Python conversion. It is part of the contract for what authors are allowed to write in the table. If a cell says ff, the parser is the rule that explains why that means 255.

A parser factory returns a parser callable. Talika calls that parser with the cell value and source-aware context while parsing the field. Most built-in parsers do not need the context, but they accept it so they can be composed with custom parsers that do.

Start with Scalar Parsers⚓︎

Use scalar parsers when one cell should become one ordinary Python value.

Scalar parser fields
from decimal import Decimal

from talika import RowTable, decimal, field, floating, integer, string


class ScalarParsers(RowTable):
    username = field("username", parser=string(strip=True, lower=True))
    age = field("age", parser=integer())
    mask = field("mask", parser=integer(base=16))
    rating = field("rating", parser=floating())
    balance = field("balance", parser=decimal())

This schema uses:

  • string(strip=True, lower=True) to normalize authored text.
  • integer() to parse base-10 whole numbers.
  • integer(base=16) to parse hexadecimal text.
  • floating() to parse Python floats.
  • decimal() to parse exact decimal values.
Parsing scalar values
user = ScalarParsers.parse(
    [
        ["username", "age", "mask", "rating", "balance"],
        [" Alice ", "34", "ff", "4.5", "12.30"],
    ]
)[0]

assert user.username == "alice"
assert user.age == 34
assert user.mask == 255
assert user.rating == 4.5
assert user.balance == Decimal("12.30")
Scalar parser result
>> user
ScalarParsers(username='alice', age=34, mask=255, rating=4.5, balance=Decimal('12.30'))

>> user.as_dict()
{'username': 'alice', 'age': 34, 'mask': 255, 'rating': 4.5, 'balance': Decimal('12.30')}

Use decimal() for values such as money, balances, and exact quantities where binary floating-point behavior would make assertions harder to read.

String parsing is still parsing

string(...) is useful even though the input is already text. It gives your schema a declared normalization rule, so every parsed record sees the same stripped or cased value.

Parse Boolean Vocabulary⚓︎

Boolean values are common in feature tables, but the words are rarely universal. One team may write yes and no; another may write enabled and disabled; another may require exact uppercase tokens from an external system.

Different boolean vocabularies
Given the account states exist
  | default active | lifecycle active | strict active |
  | true           | enabled          | YES           |
  | false          | inactive         | NO            |
Boolean parser fields
from talika import boolean, compose


class BooleanParsers(RowTable):
    default_active = field("default active", parser=boolean())
    lifecycle_active = field(
        "lifecycle active",
        parser=boolean(
            true_values=("enabled", "active", "y"),
            false_values=("disabled", "inactive", "n"),
        ),
    )
    strict_active = field(
        "strict active",
        parser=boolean(
            true_values=("YES",),
            false_values=("NO",),
            case_sensitive=True,
        ),
    )

The default boolean() parser accepts only true and false. Matching is case-insensitive unless you set case_sensitive=True, so TRUE, False, and other case variations still have the same meaning.

Values such as yes, no, 1, 0, on, and off are not universal booleans. If they belong to your project language, declare them through true_values and false_values, as the lifecycle_active field does above. Pass those options as collections of strings: ("yes",) is a one-token tuple, while the bare string "yes" is rejected as an invalid declaration.

Boolean vocabulary belongs to the schema

A reader should be able to discover every accepted spelling from the field declaration. Talika does not hide a broad list of convenience tokens behind boolean().

Parsing boolean values
enabled, disabled = BooleanParsers.parse(
    [
        ["default active", "lifecycle active", "strict active"],
        ["true", "enabled", "YES"],
        ["false", "inactive", "NO"],
    ]
)

assert enabled.default_active is True
assert enabled.lifecycle_active is True
assert enabled.strict_active is True

assert disabled.default_active is False
assert disabled.lifecycle_active is False
assert disabled.strict_active is False
Boolean parser result
>> enabled.as_dict()
{'default_active': True, 'lifecycle_active': True, 'strict_active': True}

>> disabled.as_dict()
{'default_active': False, 'lifecycle_active': False, 'strict_active': False}

Boolean parsing is strict. Unknown values fail instead of falling back to Python truthiness:

Unknown boolean token
BooleanParsers.parse(
    [
        ["default active"],
        ["maybe"],
    ]
)
Boolean parser failure
Field parser failed: Expected one of ['false', 'true']
(code=parser_failed, schema=BooleanParsers, field='default active', 
row=2, column=1, value='maybe'). 
Hint: Check the cell value or adjust the field parser for this syntax.

Do not rely on Python truthiness

In plain Python, many non-empty strings are truthy. In a feature table, "false" should not accidentally become true. Talika's boolean parser only accepts the configured tokens.

The parser also does not remove whitespace. This keeps normalization visible instead of silently changing authored cells. If the table format permits padding, compose that rule explicitly:

Allowing padded boolean text
class PaddedBoolean(RowTable):
    active = field(
        "active",
        parser=compose(string(strip=True), boolean()),
    )


record = PaddedBoolean.parse([["active"], [" true "]])[0]

assert record.active is True

Whitespace is part of the token

The default parser rejects " true ". Use compose(string(strip=True), boolean()) only when the table contract says surrounding whitespace is harmless.

describe() reports the effective Boolean vocabulary and case policy. This is useful for CLI output, generated tooling, and reviews of a large schema:

Inspecting Boolean parser configuration
default_contract = BooleanParsers.describe().fields[0].parser
lifecycle_contract = BooleanParsers.describe().fields[1].parser

assert default_contract == (
    "boolean(true_values=('true',), false_values=('false',), "
    "case_sensitive=False)"
)
assert lifecycle_contract == (
    "boolean(true_values=('active', 'enabled', 'y'), "
    "false_values=('disabled', 'inactive', 'n'), case_sensitive=False)"
)

Inspect the contract during a migration

When replacing an older broad Boolean vocabulary, use describe() to confirm which fields intentionally retain domain tokens such as yes/no.

Choose Between Choice and Mapping⚓︎

Use choice(...) when the table should contain one of a known set of strings. Use map_value(...) when the table text should become a different Python value.

Vocabulary parser fields
from talika import choice, map_value


class VocabularyParsers(RowTable):
    role = field("role", parser=choice("admin", "editor", case_sensitive=False))
    status = field("status", parser=choice("Draft", "Published", case_sensitive=False))
    priority = field("priority", parser=map_value({"low": 1, "medium": 3, "high": 5}))

choice("Draft", "Published", case_sensitive=False) accepts case-insensitive input but returns the canonical configured spelling. map_value(...) returns whatever Python value is stored in the mapping.

Parsing vocabulary values
user = VocabularyParsers.parse(
    [
        ["role", "status", "priority"],
        ["Admin", "published", "high"],
    ]
)[0]

assert user.role == "admin"
assert user.status == "Published"
assert user.priority == 5
Vocabulary parser result
>> user.as_dict()
{'role': 'admin', 'status': 'Published', 'priority': 5}

These two parsers solve different problems. choice() is about validation and canonical spelling. map_value() is about translating table vocabulary into a domain value, such as "high" becoming 5.

Unknown choice value
VocabularyParsers.parse(
    [
        ["status"],
        ["Archived"],
    ]
)
Choice parser failure
Field parser failed: Expected one of ['Draft', 'Published'] 
(code=parser_failed, schema=VocabularyParsers, field='status', 
row=2, column=1, value='Archived'). 
Hint: Check the cell value or adjust the field parser for this syntax.
Unknown mapped value
VocabularyParsers.parse(
    [
        ["priority"],
        ["urgent"],
    ]
)
Mapping parser failure
Field parser failed: No mapped value for 'urgent' 
(code=parser_failed, schema=VocabularyParsers, field='priority', 
row=2, column=1, value='urgent'). 
Hint: Check the cell value or adjust the field parser for this syntax.

Use mapping when the test needs another type

If the test should receive "Published", use choice(). If the test should receive an enum member, integer weight, sentinel object, or domain value, use map_value() or a custom parser.

Build Lists with Split, Compose, and Each⚓︎

Tables often store compact lists in one cell. Talika does not guess a list syntax from a type annotation, so the parser should describe how the cell is written.

A table with compact list cells
Given the user metadata exists
  | tags              | scores | reviewer |
  | qa, docs          | 1;2;3  | none     |
  | smoke, regression | 4;5    | 42       |
List parser fields
from talika import compose, each, optional, split


class ListParsers(RowTable):
    tags = field("tags", parser=split(","))
    scores = field("scores", parser=compose(split(";"), each(integer())))
    reviewer = field(
        "reviewer",
        parser=optional(integer(), none_values=("none", "n/a", "null")),
        empty="parse",
    )

This schema uses three parser helpers together:

  • split(",") turns one text cell into a list of strings.
  • compose(a, b) runs parser a, then sends its result to parser b.
  • each(integer()) applies integer() to every item in a non-string iterable.
Parsing list values
reviewed, assigned = ListParsers.parse(
    [
        ["tags", "scores", "reviewer"],
        ["qa, docs", "1;2;3", "none"],
        ["smoke, regression", "4;5", "42"],
    ]
)

assert reviewed.tags == ["qa", "docs"]
assert reviewed.scores == [1, 2, 3]
assert reviewed.reviewer is None

assert assigned.tags == ["smoke", "regression"]
assert assigned.scores == [4, 5]
assert assigned.reviewer == 42
List parser result
>> reviewed.as_dict()
{'tags': ['qa', 'docs'], 'scores': [1, 2, 3], 'reviewer': None}

>> assigned.as_dict()
{'tags': ['smoke', 'regression'], 'scores': [4, 5], 'reviewer': 42}

Parser order matters. each(integer()) expects an iterable that is already a list-like value. It should usually come after split(...), not before it.

A list item that cannot be parsed
ListParsers.parse(
    [
        ["scores"],
        ["1;two;3"],
    ]
)
List parser failure
Field parser failed: invalid literal for int() with base 10: 'two' 
(code=parser_failed, schema=ListParsers, field='scores', 
row=2, column=1, value='1;two;3'). 
Hint: Check the cell value or adjust the field parser for this syntax.

In this diagnostic, the bad item is two, but the source value is still the original cell 1;two;3. That is useful because the feature author fixes the whole authored cell, not an intermediate parser value.

Using each before split
class WrongOrder(RowTable):
    scores = field("scores", parser=each(integer()))


WrongOrder.parse(
    [
        ["scores"],
        ["1;2;3"],
    ]
)
Parser order failure
Field parser failed: each parser expects a non-string iterable 
(code=parser_failed, schema=WrongOrder, field='scores', 
row=2, column=1, value='1;2;3'). 
Hint: Check the cell value or adjust the field parser for this syntax.

Composition is left to right

Read compose(split(";"), each(integer())) as: first split the cell on semicolons, then parse each split item as an integer. If you reverse that order, each() receives the original string and rejects it.

Composing Custom Pipelines⚓︎

Because built-in parser factories are small, you can compose them using compose(...) to create a parser pipeline for a project-specific cell shape.

This is useful when the authored cell has several steps of meaning. A category cell might first need to be split on semicolons, then each item needs whitespace handling, then each item must belong to a vocabulary. None of those steps is large enough to deserve a custom parser by itself, but together they describe a real table rule.

Composition pipeline
class PipelineSchema(RowTable):
    categories = field(
        "categories",
        parser=compose(
            split(";"),
            each(choice("A", "B", "C", case_sensitive=False))
        )
    )
Parsing through a composed pipeline
records = PipelineSchema.parse(
    [
        ["categories"],
        ["a;B"],
    ]
)
assert records[0].categories == ["A", "B"]

This pipeline approach keeps the intent visible in the field declaration. The reader can see that the cell is split first, then every item is checked. A custom parser can still be the right choice for domain-heavy logic, but parser factories are easier to reuse when the behavior is mostly mechanical.

Use composition for boring rules

If the logic is split, trim, convert, choose, or validate each item, compose factories. If the logic needs domain decisions, service data, or several named branches, write a custom parser.

Handle Empty and Null-Like Values⚓︎

Use optional(parser) when an empty cell or a null-like token should become None, while non-null values should still be parsed.

Optional parser field
class OptionalParsers(RowTable):
    reviewer = field(
        "reviewer",
        parser=optional(integer(), none_values=("none", "n/a", "null")),
    )
Parsing optional values
records = OptionalParsers.parse(
    [
        ["reviewer"],
        [""],
        ["NULL"],
        ["none"],
        ["n/a"],
        ["7"],
    ]
)

assert [record.reviewer for record in records] == [None, None, None, None, 7]
Optional parser result
>> [record.reviewer for record in records]
[None, None, None, None, 7]

optional(...) understands blank cells, none, and null. A blank reaches the parser only when the field declares empty="parse"; without that policy, the field handles the blank first. When you pass none_values=..., you are replacing the configured null-like tokens. Include every token your table should accept.

Replacing null-like tokens
class ReplacedNullTokens(RowTable):
    reviewer = field(
        "reviewer",
        parser=optional(integer(), none_values=("n/a",)),
        empty="parse",
    )


ReplacedNullTokens.parse(
    [
        ["reviewer"],
        ["none"],
    ]
)
Optional parser failure
Field parser failed: invalid literal for int() with base 10: 'none' 
(code=parser_failed, schema=ReplacedNullTokens, field='reviewer', 
row=2, column=1, value='none'). 
Hint: Check the cell value or adjust the field parser for this syntax.

Here none_values=("n/a",) means none is no longer a null-like token. The parser tries to parse none as an integer and fails.

Optional parser and optional field are different

An optional field may be absent from the table. optional(parser) handles values that are present but intentionally blank or null-like. Both ideas are useful, but they solve different cases.

Understand Parser Failures⚓︎

Parser failures are wrapped in TableError during table parsing. The wrapper keeps the original source location, schema name, field label, and authored value.

That wrapping is why failures from boolean(), choice(), integer(), or a composed parser still point to the table cell instead of only showing a Python stack trace. The underlying exception remains useful, but the table diagnostic tells the feature author where to look.

Configuration errors are different. If a parser factory is called with an impossible configuration, it raises immediately while the schema is being defined. Each line below is a separate invalid parser declaration:

Invalid parser configurations
string(lower=True, upper=True)
boolean(true_values=("yes",), false_values=("YES",))
boolean(true_values="yes")
choice()
split("")
compose()
Configuration errors
ValueError: string parser cannot enable both lower and upper
ValueError: Boolean true and false values overlap: ['yes']
TypeError: true_values must be a non-string iterable of strings
ValueError: choice parser requires at least one allowed value
ValueError: split separator cannot be empty
ValueError: compose requires at least one parser

These errors are not table data errors. They mean the parser declaration itself is contradictory, incomplete, or uses an ambiguous argument shape such as a bare string where a collection of Boolean tokens is required.

Keep parser declarations close to the field

When a parser is short, place it directly in field(parser=...). When the parser starts to express project vocabulary, assign it to a named variable or move it into a small helper so the table contract remains readable.