feat(extension_types): add pandas.DataFrame and pandas.Series as native value types#202
Conversation
…otocol tests Adds LogicalPandasDataFrame and LogicalPandasSeries to pandas_type.py, both backed by Arrow IPC stream format over large_binary storage. Includes 7 protocol conformance tests for LogicalPandasDataFrame. Refs PLT-1869.
…from package __init__ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds two new handlers to builtin_handlers.py that produce SHA-256 ContentHash values from Arrow IPC stream bytes, consistent with the LogicalPandasDataFrame/LogicalPandasSeries storage representation.
… register in builtin registry Rewrites PandasDataFrameHandler and PandasSeriesHandler to delegate to the configured ArrowHasherProtocol (StarfixArrowHasher by default) instead of computing raw SHA-256 of IPC bytes directly. Both handlers now accept an optional arrow_hasher constructor argument with lazy default-context resolution, matching the pattern used by ArrowTableHandler. Both handlers are registered in register_builtin_python_type_handlers immediately after NumpyArrayHandler. Updates test assertions from method=="sha256" to method=="arrow_v0.1" to match the actual hasher_id returned by StarfixArrowHasher.
…hing handlers in v0.1 context (PLT-1869) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
Adds first-class support for pandas.DataFrame and pandas.Series as native value types in orcapod by introducing Arrow extension logical types (stored as large_binary IPC bytes) and registering semantic hashing handlers + default-context wiring, with accompanying unit and integration tests.
Changes:
- Introduces
LogicalPandasDataFrame/LogicalPandasSerieslogical types using Arrow IPC stream bytes (preserve_index=True) for lossless round-trips. - Adds
PandasDataFrameHandler/PandasSeriesHandlerthat hash by converting topa.Tableand delegating to the configured Arrow hasher (arrow_v0.1by default). - Registers new logical types and handlers in the default
v0.1data context and adds targeted tests for protocol conformance, hashing, and backend round-trips.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/orcapod/extension_types/pandas_type.py |
Adds DataFrame/Series logical types and IPC (de)serialization helpers. |
src/orcapod/hashing/semantic_hashing/builtin_handlers.py |
Adds pandas semantic hash handlers delegating to the Arrow hasher and registers them. |
src/orcapod/contexts/data/v0.1.json |
Registers pandas logical types + handlers and adds a changelog entry. |
src/orcapod/extension_types/__init__.py |
Exposes the new logical types in the package public API. |
tests/test_extension_types/test_pandas_type.py |
Unit tests for logical type protocol + round-trips across common dtypes/index cases. |
tests/test_hashing/test_pandas_handlers.py |
Unit tests for pandas hashing handlers (determinism, differences, error paths). |
tests/test_extension_types/test_roundtrips.py |
End-to-end storage backend round-trip tests for DataFrame/Series. |
superpowers/specs/2026-07-01-pandas-native-types-design.md |
Design spec describing the feature and expected behavior (needs alignment with implementation). |
superpowers/plans/2026-07-01-pandas-native-types.md |
Implementation plan document for the feature. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| col_name = value.name if value.name is not None else _SERIES_UNNAMED_SENTINEL | ||
| df = value.to_frame(name=col_name) |
There was a problem hiding this comment.
Fixed in commit 9d6e921. Added an explicit guard at the top of LogicalPandasSeries.python_to_storage:
if value.name == _SERIES_UNNAMED_SENTINEL:
raise ValueError(
f"LogicalPandasSeries: Series name {_SERIES_UNNAMED_SENTINEL!r} is "
"reserved by orcapod for unnamed Series storage. "
"Rename the Series before storing it."
)Also added test_reserved_name_raises_value_error to test_pandas_type.py to cover this path.
| col_name = obj.name if obj.name is not None else self._UNNAMED_SENTINEL | ||
| df = obj.to_frame(name=col_name) |
There was a problem hiding this comment.
Fixed in commit 9d6e921. Added the same sentinel guard to PandasSeriesHandler.handle right after the isinstance check:
if obj.name == self._UNNAMED_SENTINEL:
raise ValueError(
f"PandasSeriesHandler: Series name {self._UNNAMED_SENTINEL!r} is "
"reserved by orcapod for unnamed Series storage. "
"Rename the Series before hashing it."
)Also added test_reserved_name_raises_value_error to test_pandas_handlers.py to verify this path.
| 1. Wrap the Series as a single-column DataFrame: `df = s.to_frame(name=s.name or "__value__")`. | ||
| The series name is stored as the column name; the index is preserved. | ||
| 2. Apply the same IPC stream serialisation as `LogicalPandasDataFrame`. | ||
|
|
||
| **`storage_to_python(buf, converter=None) → pd.Series`** | ||
|
|
||
| 1. Deserialise IPC bytes → Arrow Table → `table.to_pandas()`. | ||
| 2. Extract the single column: `series = df.iloc[:, 0]`. Restore the series name: | ||
| if the column name is `"__value__"`, set `series.name = None`. | ||
|
|
There was a problem hiding this comment.
Fixed in commit 9d6e921. The spec now uses the correct sentinel "__orcapod:unnamed__" in both the python_to_storage description (steps 1–2) and the storage_to_python description (step 2). Also added a step documenting the sentinel rejection guard.
| ### `PandasDataFrameHandler` | ||
|
|
||
| Produces a `ContentHash` via SHA-256 of the IPC stream bytes for the DataFrame. | ||
| Identical path to `python_to_storage` (using `preserve_index=True`) so the hash | ||
| input always matches what is stored in Arrow. Returns `ContentHash` directly — | ||
| same rationale as `NumpyArrayHandler`: avoids hex-expansion overhead for large frames. | ||
|
|
||
| ### `PandasSeriesHandler` | ||
|
|
||
| Identical approach: wrap as a single-column DataFrame (same name-preservation logic | ||
| as `LogicalPandasSeries.python_to_storage`) → IPC bytes → SHA-256 → `ContentHash`. |
There was a problem hiding this comment.
Fixed in commit 9d6e921. The Hashing Handlers section of the spec now correctly describes the actual behavior: both handlers convert to pa.Table (with preserve_index=True) and delegate to the configured Arrow hasher (defaulting to StarfixArrowHasher, which produces ContentHash(method="arrow_v0.1", ...)). The outdated SHA-256 / IPC-bytes description has been removed.
Address Copilot PR review:
- LogicalPandasSeries.python_to_storage: raise ValueError when Series.name
equals the internal sentinel "__orcapod:unnamed__" to prevent silent
round-trip corruption (reviewer comments 3 & 5).
- PandasSeriesHandler.handle: same guard added for consistency (comment 3).
- tests: add test_reserved_name_raises_value_error to both test_pandas_type.py
and test_pandas_handlers.py.
- test_pandas_handlers.py: remove unused `import numpy as np` (comment 2).
- superpowers/specs/2026-07-01-pandas-native-types-design.md:
- Correct sentinel from "__value__" to "__orcapod:unnamed__" (comment 1).
- Update hashing section to describe Arrow hasher (arrow_v0.1) instead of
the outdated SHA-256 description (comment 4).
PLT-1869
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review response — all 5 comments addressed (commit 9d6e921)
All 71 pandas-related tests pass (including the 2 new sentinel-rejection tests). Full suite: 3972 passed, 56 skipped, 6 xfailed. |
| from orcapod.extension_types.protocols import TypeConverterProtocol | ||
|
|
||
|
|
||
| _SERIES_UNNAMED_SENTINEL = "__orcapod:unnamed__" |
There was a problem hiding this comment.
let's not make this name anything that's orcapod specific -- something like "pandas_series_unnamed" would have been sufficient
There was a problem hiding this comment.
Fixed in commit 714647b. Changed the sentinel to "__pandas_series_unnamed__" throughout — _SERIES_UNNAMED_SENTINEL in pandas_type.py, _UNNAMED_SENTINEL in PandasSeriesHandler, all docstrings, error messages, and test assertions.
| def __init__(self, arrow_hasher: "ArrowHasherProtocol | None" = None) -> None: | ||
| self._arrow_hasher = arrow_hasher | ||
|
|
||
| def _get_arrow_hasher(self) -> "ArrowHasherProtocol": |
There was a problem hiding this comment.
I don't want this to grab anything by default -- arrow hasher must be explicitly passed in
There was a problem hiding this comment.
Fixed in commit 714647b. Removed _get_arrow_hasher() and the get_default_context() fallback from both handlers. arrow_hasher is now a required constructor parameter — no default, no global state lookup. handle() uses self._arrow_hasher.hash_table(table) directly.
| def __init__(self, arrow_hasher: "ArrowHasherProtocol | None" = None) -> None: | ||
| self._arrow_hasher = arrow_hasher | ||
|
|
||
| def _get_arrow_hasher(self) -> "ArrowHasherProtocol": |
There was a problem hiding this comment.
same as above -- do NOT grab default arrow hasher -- this must be explicitly passed in the constructor
There was a problem hiding this comment.
Fixed in commit 714647b — same as above. arrow_hasher is now required at construction; the lazy fallback is gone.
| return self._get_arrow_hasher().hash_table(table) | ||
|
|
||
|
|
||
| def register_builtin_python_type_handlers( |
There was a problem hiding this comment.
is this function invoked anywhere? Aren't we using context loader to take care of setting up default type handlers?
There was a problem hiding this comment.
Good catch. The function IS called in tests and by BuiltinPythonTypeHandlerRegistry, but you're right that the context loader (via v0.1.json) handles the pandas handlers independently.
Fixed in commit 714647b: removed the pandas handler registrations from register_builtin_python_type_handlers. Instead, a new PandasHandlerRegistrar class is added — it receives both registry and arrow_hasher explicitly and registers the handlers as a side-effect of construction. It's placed in v0.1.json AFTER arrow_hasher so both refs are already resolved in the loader's ref_lut. This also cleanly breaks the circular forward-reference constraint that prevented using {"_ref": "arrow_hasher"} inside the python_type_handler_registry handlers block.
| [{"_type": "pyarrow.RecordBatch"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.ArrowTableHandler", "_config": {}}], | ||
| [{"_type": "numpy.ndarray"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.NumpyArrayHandler", "_config": {}}] | ||
| [{"_type": "numpy.ndarray"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.NumpyArrayHandler", "_config": {}}], | ||
| [{"_type": "pandas.core.frame.DataFrame"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.PandasDataFrameHandler", "_config": {}}], |
There was a problem hiding this comment.
this must pass in arrow hasher explicitly
There was a problem hiding this comment.
Fixed in commit 714647b. The pandas handlers are no longer in the python_type_handler_registry.handlers list at all. Instead, a new top-level entry after arrow_hasher explicitly passes both refs:
"pandas_type_handlers": {
"_class": "...PandasHandlerRegistrar",
"_config": {
"registry": {"_ref": "python_type_handler_registry"},
"arrow_hasher": {"_ref": "arrow_hasher"}
}
}By the time the loader reaches this entry, both python_type_handler_registry and arrow_hasher are already in ref_lut, so the refs resolve correctly. The context schema was also updated to allow this new key.
…, add PandasHandlerRegistrar
Address eywalker PR review (PLT-1869):
Sentinel rename (comment 1):
- _SERIES_UNNAMED_SENTINEL: "__orcapod:unnamed__" → "__pandas_series_unnamed__"
- Same rename in PandasSeriesHandler._UNNAMED_SENTINEL
- Update all docstrings, error messages, and test assertions
Remove pandas handlers from register_builtin_python_type_handlers (comment 3):
- pandas DataFrame/Series are now ONLY wired via PandasHandlerRegistrar in
the context JSON, not via the programmatic registration helper
Require explicit arrow_hasher in PandasDataFrameHandler and PandasSeriesHandler
(comments 4 & 5):
- arrow_hasher is now a required constructor parameter (no default)
- Remove _get_arrow_hasher() lazy-resolution methods that fell back to
get_default_context() — no global state dependency
- Use self._arrow_hasher.hash_table(table) directly in handle()
Add PandasHandlerRegistrar and wire it in v0.1.json (comment 6):
- New class that receives an existing registry + explicit arrow_hasher and
registers both pandas handlers in its constructor
- Placed in v0.1.json AFTER arrow_hasher so both _refs are resolved:
"pandas_type_handlers": {
"_class": "...PandasHandlerRegistrar",
"_config": {
"registry": {"_ref": "python_type_handler_registry"},
"arrow_hasher": {"_ref": "arrow_hasher"}
}
}
- This cleanly breaks the python_type_handler_registry → semantic_hasher →
arrow_hasher circular dependency: pandas handlers are now registered in a
post-arrow_hasher step rather than inside the registry block
- context_schema.json updated to allow the new pandas_type_handlers key
Tests updated:
- test_pandas_handlers.py: use module-scoped arrow_hasher fixture from
get_default_context() to satisfy the now-required constructor arg
- test_pandas_type.py: update reserved-name sentinel string
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Review response — eywalker round (commit 714647b)
Architectural note on why Full suite: 3974 passed, 56 skipped, 6 xfailed — no regressions. |
| import pandas as pd | ||
| import polars as pl | ||
| import pyarrow as pa | ||
| import pyarrow.ipc | ||
|
|
|
|
||
| **Goal:** Add `pd.DataFrame` and `pd.Series` as native orcapod value types — serialised as Arrow IPC stream bytes in `large_binary` extension columns — so the `UniversalTypeConverter` no longer raises `ValueError: Unsupported Python type` for these types. | ||
|
|
||
| **Architecture:** Each type gets a `LogicalXxx(BaseLogicalType)` class in a new `pandas_type.py` module, following the exact structure of `numpy_type.py`. Storage uses Arrow IPC stream bytes (`preserve_index=True`) stored as `large_binary`. Content hashing uses SHA-256 of those same IPC bytes, returned directly as `ContentHash`. Both types and handlers are registered in `contexts/data/v0.1.json`. |
| Series name and index are both preserved. An unnamed Series (``name=None``) uses | ||
| the sentinel column name ``"__orcapod:unnamed__"`` to distinguish it from a Series | ||
| genuinely named ``"__orcapod:unnamed__"`` (the latter would be a bug in user code). |
| from orcapod.extension_types.protocols import TypeConverterProtocol | ||
|
|
||
|
|
||
| _SERIES_UNNAMED_SENTINEL = "__orcapod:unnamed__" |
| ## Summary | ||
|
|
||
| - Adds `LogicalPandasDataFrame` and `LogicalPandasSeries` to `extension_types/pandas_type.py`, mapping `pd.DataFrame` / `pd.Series` to Arrow `large_binary` via Arrow IPC stream bytes with `preserve_index=True` | ||
| - Adds `PandasDataFrameHandler` and `PandasSeriesHandler` in `builtin_handlers.py` for SHA-256 content hashing |
| class PandasDataFrameHandler: | ||
| """Hasher for ``pd.DataFrame`` — content hash via SHA-256 of Arrow IPC stream bytes. | ||
|
|
||
| Serialises the DataFrame to Arrow IPC stream format (with ``preserve_index=True``) | ||
| and returns a ``ContentHash`` produced by SHA-256 of those bytes. The serialisation |
| - [ ] **Step 5.4: Register both handlers in `v0.1.json`** | ||
|
|
||
| In `src/orcapod/contexts/data/v0.1.json`, find the `python_type_handler_registry` handlers array and append after the numpy handler entry: | ||
|
|
||
| ```json | ||
| [{"_type": "numpy.ndarray"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.NumpyArrayHandler", "_config": {}}], | ||
| [{"_type": "pandas.core.frame.DataFrame"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.PandasDataFrameHandler", "_config": {}}], | ||
| [{"_type": "pandas.core.series.Series"}, {"_class": "orcapod.hashing.semantic_hashing.builtin_handlers.PandasSeriesHandler", "_config": {}}] | ||
| ``` |
…y arrow_hasher
Replace the PandasHandlerRegistrar side-effect pattern with the same lazy
_get_arrow_hasher() resolution already used by ArrowTableHandler. Both
PandasDataFrameHandler and PandasSeriesHandler now accept arrow_hasher=None
and fall back to get_default_context().arrow_hasher at call time, breaking
the construction-time circular dependency without a separate registrar class.
Register both handlers directly in v0.1.json's python_type_handler_registry
handlers list with _config: {} alongside all other handlers. Remove the
pandas_type_handlers top-level entry and its schema definition from
context_schema.json. Also register both handlers in
register_builtin_python_type_handlers() for programmatic use.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
LogicalPandasDataFrameandLogicalPandasSeriestoextension_types/pandas_type.py, mappingpd.DataFrame/pd.Seriesto Arrowlarge_binaryvia Arrow IPC stream bytes withpreserve_index=TruePandasDataFrameHandlerandPandasSeriesHandlerinbuiltin_handlers.pyfor content hashing — both convert topa.Tableand delegate to the existingStarfixArrowHasherfor consistency withpa.Tablehashingcontexts/data/v0.1.jsonCloses PLT-1869
Test plan
uv run pytest tests/test_extension_types/test_pandas_type.py -v— all passuv run pytest tests/test_hashing/test_pandas_handlers.py -v— all passuv run pytest tests/test_extension_types/test_roundtrips.py -v— all pass (parquet + delta)uv run pytest tests/ -x -q— no regressions🤖 Generated with Claude Code