Skip to content

feat: declare derived columns on the column itself - #2517

Open
cosmicbboy wants to merge 1 commit into
feat/parser-source-targetfrom
feat/parsed-field-column
Open

cosmicbboy wants to merge 1 commit into
feat/parser-source-targetfrom
feat/parsed-field-column

Conversation

@cosmicbboy

Copy link
Copy Markdown
Collaborator

Stack: 3/6 — stacked on #2516. Completes layer 1 of specs/derived-columns-and-system-one.md (§3.3, §3.5, §3.7).

Review just the last commit. The base is #2516.

Puts the provenance of a derived column next to the column, instead of in a schema-level parsers list:

class Tickets(pa.DataFrameModel):
    body: str
    n_words: int = pa.ParsedField(
        source="body",
        parser=lambda s: s.str.split().str.len(),
        ge=1,
    )
schema = pa.DataFrameSchema({
    "body": pa.Column(str),
    "n_words": pa.ParsedColumn(
        int,
        source="body",
        parser=lambda s: s.str.split().str.len(),
        checks=pa.Check.ge(1),
    ),
})

ParsedColumn is a Column subclass and ParsedField a FieldInfo subclass, so everything they accept keeps working — checks, coercion, nullability, description. Each desugars into the Parser(source=, target=) primitive from #2516.

Config.parser_source (and DataFrameSchema(parser_source=...)) is the schema-wide default for columns that don't name their own source. Deriving from a column the schema doesn't declare is a SchemaInitError at build time, not a KeyError at parse time.

@pa.parser generalized, not duplicated

Given a source, the named fields become what it produces, and the parser attaches to the schema rather than the column — a column-level parser can't create its own column:

@pa.parser("n_words", source="body")
def count_words(cls, s):
    """Number of whitespace-separated tokens."""
    return s.str.split().str.len()

Without source it keeps its original meaning. One decorator, one concept.

The ColumnParser protocol

parser= also accepts an object implementing:

class ColumnParser(Protocol):
    def bind(self, ctx: ParseContext) -> Callable: ...
    def batch_key(self, ctx: ParseContext) -> Hashable: ...

bind is called once at schema-build time with a ParseContext describing the target column — name, declared dtype, description, nullability, checks, resolved sources, and the schema. Two things follow, and they're the reason the protocol exists at all:

  • A parser can read the column's declared type rather than being told what it's producing. This is what lets a later system_one.Choice() derive its options from the column's Enum without repeating them.
  • A parser can refuse early. SchemaInitError from bind surfaces before any data is touched.

batch_key groups columns whose parsers can share work. Equal non-None keys go to the class's batch classmethod and are filled by a single call; None opts out. This is what lets a schema declare one derivation per column without paying for one pass per column — the mechanism the System One layer needs so per-column declarations cost one request, not N.

Two details worth a look

  • Batched parsers always receive a DataFrame (Parser(frame_input=True)). Without it, a batch that happens to fill a single column would take the single-source/single-target Series -> Series shortcut and break. Found by a test, not by inspection.
  • ParsedColumn needs its own backend registration. The registry keys on the exact schema class rather than walking the MRO, so a Column subclass doesn't inherit Column's backend.
  • Worth knowing for reviewers: pandera deep-copies columns into the schema, so a ColumnParser instance handed to ParsedColumn is not the object that gets bound. The tests assert against schema.columns[...].parser, and stateful parsers can't rely on the caller's reference.

Testing

25 new tests in tests/pandas/test_parsed_columns.py: object and model APIs, schema-wide default and per-column override, chained derivation declared out of order, undeclared sources, the decorator in both modes, the protocol (bind receives the right context, errors surface at build time), and batching (merged, not merged, opted out, missing batch method).

Suites clean: pandas+io+base (2745 passed), polars+ibis (666 passed, plus the pre-existing test_ibis_backend_is_narwhals failure that also fails on main).

🤖 Generated with Claude Code

Adds `ParsedColumn` / `ParsedField`, so the provenance of a derived column
lives next to the column rather than in a schema-level parsers list:

    class Tickets(pa.DataFrameModel):
        body: str
        n_words: int = pa.ParsedField(
            source="body",
            parser=lambda s: s.str.split().str.len(),
            ge=1,
        )

`ParsedColumn` is a `Column` subclass and `ParsedField` a `FieldInfo`
subclass, so everything `Column`/`Field` accepts keeps working; each
desugars into the `Parser(source=, target=)` primitive from the previous
commit. `Config.parser_source` (and `DataFrameSchema(parser_source=...)`)
is the schema-wide default for columns that do not name their own source.
Deriving from a column the schema does not declare is a `SchemaInitError`.

`@pa.parser` is generalized rather than duplicated: given a `source`, the
named fields become what it *produces* and the parser is attached to the
schema instead of the column, since a column-level parser cannot create
its own column. Without `source` it keeps its original meaning.

`parser=` also accepts an object implementing the new `ColumnParser`
protocol -- `bind(ctx)` and `batch_key(ctx)`. `bind` is called once at
schema-build time with a `ParseContext` describing the target column, so
a parser can derive its behavior from the column's declared type rather
than being told what it is producing, and can refuse with
`SchemaInitError` before any data is touched. `batch_key` groups columns
whose parsers can share work: equal non-None keys are handed to the
class's `batch` classmethod and filled by a single call, which is what
lets a schema declare one derivation per column without paying for one
pass per column.

Two details worth knowing:

- Batched parsers always receive a DataFrame (`Parser(frame_input=True)`).
  Without this, a batch that happens to fill one column would take the
  single-source/single-target `Series -> Series` shortcut and break.
- `ParsedColumn` needs its own backend registration: the registry keys on
  the exact schema class, not the MRO, so a `Column` subclass does not
  inherit `Column`'s backend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
@cosmicbboy
cosmicbboy force-pushed the feat/parser-source-target branch from 4f8cca9 to 722e0ef Compare September 22, 2026 14:01
@cosmicbboy
cosmicbboy force-pushed the feat/parsed-field-column branch from 6b1b03d to 3acc06e Compare September 22, 2026 14:01

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant