Skip to content

CellDSL Composition⚓︎

Use CellDSL composition when table vocabulary is split across layers.

A project may have shared values such as none and today. A CMS area may add content-specific values such as fake:hero. A payments area may add a different set of generated values. Composition lets each vocabulary stay in its own CellDSL, while one field parser can consult them together.

A table using shared and content-specific syntax
Feature: CMS shared and content-specific cell vocabulary

  Scenario: Compose shared tokens with content generated values
    Given the content items:
      | IDs      | A-1  | A-2       | A-3     | A-4   |
      | Headline | none | fake:hero | Literal | today |
    Then the first matching DSL owns each value

Composition does not copy all rules into one new registry. It asks each DSL in order. The first DSL that matches the cell owns the result.

Think chain of responsibility

A composed parser asks the first DSL, then the second DSL, and so on. Once a DSL matches, later DSLs are not consulted for that cell.

Define Shared Vocabulary⚓︎

Shared vocabulary belongs in its own DSL.

Shared cell vocabulary
from talika import (
    CellDSL,
    ColumnTable,
    RowTable,
    compose_cell_dsls,
    field,
    id_field,
)


shared_cells = CellDSL()


@shared_cells.token("none")
def none_value(context):
    return None


@shared_cells.token("today")
def today_value(context):
    return context.user_data["today"]

These tokens are not tied to one table shape. They can be reused across schemas that want the same project meaning for none and today.

Shared does not mean global state

A shared DSL is just a Python object containing parser rules. It does not create a global registry. A schema only uses it when you attach it as a field parser directly or through composition.

Define Feature-Specific Vocabulary⚓︎

Feature-specific vocabulary can live in another DSL.

Content-specific generated values
content_cells = CellDSL()


@content_cells.pattern(r"fake:(.+)")
def fake_value(match, context):
    return f"Generated {match[1]} for {context.item_id}"

This rule is only meaningful for content setup. Keeping it separate from shared tokens avoids turning one DSL into a large mixed dictionary of unrelated project syntax.

Group rules by ownership

Put organization-wide tokens in a shared DSL. Put feature-area syntax in a feature-area DSL. Then compose the pieces needed by each schema field.

Compose the Parser⚓︎

Use compose_cell_dsls(...) to create a parser that consults several DSLs.

Composed parser
headline_parser = compose_cell_dsls(shared_cells, content_cells)

Attach the composed parser like any other field parser.

A schema using the composed parser
class ContentTable(ColumnTable):
    id = id_field("IDs")
    headline = field("Headline", parser=headline_parser)
Parsing composed vocabulary
records = ContentTable.parse(
    [
        ["IDs", "A-1", "A-2", "A-3", "A-4"],
        ["Headline", "none", "fake:hero", "Literal", "today"],
    ],
    context={"today": "2026-07-04"},
)
Composed parser result
>> [record.headline for record in records]
[None, 'Generated hero for A-2', 'Literal', '2026-07-04']

The values resolve as follows:

  • none matches shared_cells
  • fake:hero does not match shared_cells, then matches content_cells
  • Literal matches neither DSL and passes through unchanged
  • today matches shared_cells

Unmatched values still pass through

If no DSL in the chain matches the cell, the original value is returned. Composition does not make unmatched values invalid by itself.

Order Decides Conflicts⚓︎

If two DSLs can handle the same value, the earlier DSL wins.

Two DSLs with the same token
shared_conflict = CellDSL()
project_conflict = CellDSL()


@shared_conflict.token("draft")
def shared_draft(context):
    return "DRAFT"


@project_conflict.token("draft")
def project_draft(context):
    return "project-draft"
Same DSLs in different orders
class SharedFirstTable(RowTable):
    value = field(
        "value",
        parser=compose_cell_dsls(shared_conflict, project_conflict),
    )


class ProjectFirstTable(RowTable):
    value = field(
        "value",
        parser=compose_cell_dsls(project_conflict, shared_conflict),
    )
Composition order result
>> SharedFirstTable.parse([["value"], ["draft"]])[0].value
'DRAFT'

>> ProjectFirstTable.parse([["value"], ["draft"]])[0].value
'project-draft'

This is useful, but it should be deliberate. If shared vocabulary and feature-specific vocabulary use the same token, put the more specific DSL first only when the feature really should override the shared meaning.

Composition order is part of the contract

Do not treat DSL order as cosmetic. It decides which parser owns conflicts. Keep the order visible near the schema field that uses it.

Use the Method Form When It Reads Better⚓︎

Each CellDSL also has .compose(...).

Composing from the first DSL
headline_parser = content_cells.compose(shared_cells)


class MethodTable(ColumnTable):
    id = id_field("IDs")
    headline = field("Headline", parser=headline_parser)
Method composition result
>> records = MethodTable.parse(
...     [["IDs", "A-1", "A-2"], ["Headline", "fake:hero", "none"]]
... )
>> [record.headline for record in records]
['Generated hero for A-1', None]

This is equivalent to:

Equivalent function form
compose_cell_dsls(content_cells, shared_cells)

Use whichever form makes ownership clearest in the surrounding code. The order is the same: the receiver DSL is consulted first.

The receiver goes first

content_cells.compose(shared_cells) means content rules are tried before shared rules.

Place Fallbacks Carefully⚓︎

A DSL with a fallback always matches any value that reaches it. That affects composition.

A fallback DSL and a later token DSL
fallback_first = CellDSL()
later_cells = CellDSL()


@fallback_first.fallback
def fallback(value, context):
    return f"fallback:{value}"


@later_cells.token("none")
def later_none(context):
    return None

The same two DSLs produce different results depending on order.

Fallback first vs fallback last
class FallbackFirstTable(RowTable):
    value = field(
        "value",
        parser=compose_cell_dsls(fallback_first, later_cells),
    )


class FallbackLastTable(RowTable):
    value = field(
        "value",
        parser=compose_cell_dsls(later_cells, fallback_first),
    )
Fallback order result
>> FallbackFirstTable.parse([["value"], ["none"], ["literal"]])
[FallbackFirstTable(value='fallback:none'), FallbackFirstTable(value='fallback:literal')]

>> FallbackLastTable.parse([["value"], ["none"], ["literal"]])
[FallbackLastTable(value=None), FallbackLastTable(value='fallback:literal')]

When fallback is first, it handles none before the later token DSL can see it. When fallback is last, none resolves to None, and only unmatched values reach fallback.

Fallbacks should usually be last

Put a fallback early only when it is meant to own every unmatched value before later DSLs. In most parser chains, fallback belongs at the end.

Invalid Chains Fail Early⚓︎

A composed chain must contain at least one DSL.

Empty composition
compose_cell_dsls()
Empty composition error
CellDSLChain requires at least one DSL

Every item must be a CellDSL instance.

Invalid composition item
compose_cell_dsls(CellDSL(), object())
Invalid composition item error
CellDSLChain accepts only CellDSL instances

These checks happen when the composed parser is created, so schema code fails early if the parser chain is not valid.

Composition returns a parser

compose_cell_dsls(...) returns a callable parser object. Pass that object to field(..., parser=...) the same way you would pass a single CellDSL.

Choose a Composition Shape⚓︎

Use one DSL when the field has a small local vocabulary.

Use composition when the field should combine:

  • shared project tokens
  • feature-area generated values
  • domain-specific normalizers
  • temporary migration vocabulary
  • a final fallback parser

Keep composition shallow. If a field needs many DSLs in a precise order, that is a sign the table language may need to be simplified or split by ownership.

Keep parser ownership visible

A reader should be able to look at the schema field and understand which vocabularies are being applied, and in what order.