Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ tracking and IDE autocomplete; they're a progressive enhancement, not a prerequi
- ✅ **Rust-fast** - Milliseconds, not seconds, even on hundreds of files; fast enough for pre-commit hooks and CI (see [benchmarks](#static-analysis-performance))
- ✅ **Cross-file awareness** - Add `BaseSchema` and typed return annotations to follow schemas across module boundaries
- ✅ **Refactor-safe access** - `df[Schema.column_group.s].mean()` (pandas) or `df.select(Schema.col.col)` (polars) instead of scattered string literals
- ✅ **Works with pandas AND polars** - Same schema API, native backend types
- ✅ **Works with pandas AND polars** - Same schema API, native backend types; `dask.dataframe` is supported experimentally ([details](#use-with-dask-experimental))
- ✅ **Dynamic column matching** - Regex-based ColumnSets for time-series data
- ✅ **Zero runtime overhead** - No validation, no slowdown
- ✅ **Type-safe backends** - Type checker knows pandas vs polars methods
Expand Down Expand Up @@ -191,6 +191,40 @@ filtered = df.filter(SalesData.revenue.col > 1000)
grouped = df.group_by("customer_id").agg(SalesData.revenue.col.sum())
```

### Use With Dask (Experimental)

`dask.dataframe` is recognized as a third backend. Because it is a deliberate near
drop-in for pandas, the same inference and the same tracked operations apply —
`usecols=`/`columns=` seed a column set, `drop`/`rename`/`assign`/`pop`/`del`/subscript
assignment update it, and the lazy-to-eager `.compute()` step carries it through rather
than losing it.

```python
from typing import Annotated
import dask.dataframe as dd

# Inference — no schema class needed, columns come from usecols=
orders = dd.read_csv("orders.csv", usecols=["order_id", "customer_id", "amount"])

# Tracking survives the lazy → eager boundary
totals = orders.compute()
print(totals["amount"])
print(totals["revenue"]) # ✗ unknown-column: 'revenue' not in {order_id, customer_id, amount}

# dd.from_pandas carries an in-memory frame's columns across
ddf = dd.from_pandas(pd.read_csv("orders.csv", usecols=["order_id", "amount"]), npartitions=2)
print(ddf["customer_id"]) # ✗ unknown-column: the pandas source never had it

# Or annotate explicitly, same as pandas/polars
df: Annotated[dd.DataFrame, SalesData] = dd.read_parquet("sales.parquet", columns=["revenue"])
print(df["profit"]) # ✗ unknown-column: Column 'profit' not in SalesData
```

Dask needs to be installed only to *run* this code — the checker never imports it. The
operations dask does not implement (`filter`, `sort`, `insert`, `select`) are simply
never encountered; the ones it does implement behave exactly as the
[Method Matrix](https://typedframes.readthedocs.io/en/latest/method-matrix/) describes.

---

## Column Inference
Expand Down Expand Up @@ -446,8 +480,13 @@ mypy src/
The checker tracks schema changes through `rename`, `drop`, `assign`, `select`, `pop`,
`insert`, `del`, subscript assignment, `merge`, and `concat`. Row-passthrough operations
like `filter`, `query`, `head`, `sort_values`, and `dropna` are validated without schema
changes. Operations with runtime-dependent output (`join`, `pivot`, `melt`, `groupby`,
`apply`, etc.) are left untracked to avoid false positives.
changes, as are the lazy-to-eager finalizers (`collect` for polars, `compute` /
`persist` / `repartition` for dask). Operations with runtime-dependent output (`join`,
`pivot`, `melt`, `groupby`, `apply`, etc.) are left untracked to avoid false positives.

Operations are matched by method name against a receiver the checker already tracks, so
they apply to whichever backend implements them — that is what lets `dask.dataframe`,
a near drop-in for pandas, reuse the whole table.

See the full [Method Matrix](https://typedframes.readthedocs.io/en/latest/method-matrix/)
for the complete list of tracked, passthrough, and untracked operations, plus the error
Expand Down Expand Up @@ -793,6 +832,7 @@ Comprehensive comparison of pandas/DataFrame typing and validation tools. **type
| **Backend Support** |
| Pandas | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ❌ Own | ✅ Yes | ❌ No | ⚠️ Limited |
| Polars | ✅ Yes | ✅ Yes | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No | ❌ Own | ✅ Yes | ✅ Yes (only) | ✅ Yes |
| Dask | ⚠️ Experimental | ✅ Yes | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No | ❌ Own | ✅ Lazy | ❌ No | ❌ No |
| DuckDB, cuDF, etc. | ❌ No | ❌ No | ✅ Spark, SQL | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No | ✅ Yes | ❌ No | ❌ No |
| **Project Status (Aug 2026)** |
| Active development | ✅ Yes | ✅ Yes | ✅ Yes | ⚠️ Low | ✅ Yes | ❌ Inactive | ⚠️ Low | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
Expand Down Expand Up @@ -934,6 +974,9 @@ Runnable versions of everything shown in [Quick Start](#quick-start) and
`Annotated[pd.DataFrame, Schema]` with string and `.s` descriptor column access
- [`annotated_polars_example.py`](examples/features/annotated_polars_example.py) —
the same, with `pl.col()` and `.col` descriptor expressions
- [`annotated_dask_example.py`](examples/features/annotated_dask_example.py) —
experimental `dask.dataframe` support: inference from `usecols=`, tracking through
`.compute()`, and `Annotated[dd.DataFrame, Schema]`
- [`typedframes_example.py`](examples/features/typedframes_example.py) — pandas and
polars side by side against one shared schema
- [`schema_algebra_example.py`](examples/features/schema_algebra_example.py) —
Expand Down
21 changes: 19 additions & 2 deletions docs/method-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ Operations fall into three categories: **schema-modifying** (the checker updates
internal column model), **row-passthrough** (the checker assumes the schema is unchanged),
and **untracked** (the variable is dropped from tracking to avoid false positives).

Operations are matched by method name against a receiver the checker already tracks, so
the tables below apply to whichever backend implements that method. `dask.dataframe`
(experimental) reuses the pandas spellings throughout — the dask-only entries are called
out where they appear.

---

## Schema-Modifying Operations
Expand All @@ -25,8 +30,10 @@ are validated against the new schema.
| `df.pop("col")` | Removes `"col"` from the schema | `df.pop("score")` |
| `df.insert(pos, "col", val)` | Adds `"col"` to the schema | `df.insert(0, "rank", …)` |
| `df[["c1", "c2"]]` | Narrows schema to selected columns | `subset = df[["id", "name"]]` |
| `pd.merge(left, right, …)` | Merges both schemas | `merged = pd.merge(a, b, on="id")` |
| `left.merge(right, …)` | Unions both schemas | `merged = a.merge(b, on="id")` |
| `pd.concat([df1, df2], …)` | Unions both schemas | `combined = pd.concat([a, b])` |
| `dd.from_pandas(pdf, …)` | Carries the source frame's schema over | `ddf = dd.from_pandas(pdf, npartitions=2)` |
| `pl.from_pandas(pdf)` | Same | `df = pl.from_pandas(pdf)` |

---

Expand All @@ -50,6 +57,14 @@ the same column model as the input.
| `df.fillna(…)` | Fill NaN values; columns unchanged |
| `df.dropna(…)` | Drop NaN rows; columns unchanged |
| `df.ffill()` / `df.bfill()` | Forward/back fill; columns unchanged |
| `lf.collect()` | polars lazy → eager; columns unchanged |
| `ddf.compute()` | dask lazy → eager (a real pandas DataFrame); columns unchanged |
| `ddf.persist()` | dask; materializes into memory, columns unchanged |
| `ddf.repartition(…)` | dask; changes partition layout only, columns unchanged |

A chain is followed through named intermediates (`step = df.query(…)`, then
`out = step.sort_values(…)`), not through several calls stacked into a single
expression (`df.query(…).sort_values(…)`) — for every backend.

---

Expand All @@ -66,7 +81,9 @@ checker.
| Operation | Why untracked |
|-----------|--------------|
| `df.join(other, …)` | Output schema depends on join keys and `how=` parameter |
| `df.merge(other, …)` | (pandas instance method) Same as join |
| `pd.merge(left, right, …)` | Module-level form; only the `left.merge(right, …)` instance form is tracked |
| `dd.from_delayed(…)` / `dd.from_array(…)` | dask; columns exist only once the graph runs |
| `dd.from_dict({…})` | dask; a literal rather than a tracked frame, like `pd.DataFrame({…})` |
| `df.pivot(…)` | Output columns are derived from cell values at runtime |
| `df.pivot_table(…)` | Same as pivot |
| `df.melt(…)` | Converts columns to rows; output schema varies by `id_vars` |
Expand Down
33 changes: 33 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,39 @@ df.filter(EventSchema.user_id.col > 100)
df.select(EventSchema.event_id.col, EventSchema.user_id.col)
```

## Step 4a — Use with dask (experimental)

`dask.dataframe` is recognized as a third backend. It is a deliberate near drop-in for
pandas, so the same inference and the same tracked operations apply, plus the
lazy-to-eager step:

```python
from typing import Annotated
import dask.dataframe as dd
from typedframes import BaseSchema, Column


class EventSchema(BaseSchema):
event_id = Column(type=int)
user_id = Column(type=int)


# Inference from usecols=, no schema class required
events = dd.read_csv("events.csv", usecols=["event_id", "user_id"])

# `.compute()` materializes to a real pandas DataFrame; the column set carries over
frame = events.compute()
print(frame["event_id"]) # ✓ OK
print(frame["typo"]) # ✗ unknown-column

# Or annotate explicitly
df: Annotated[dd.DataFrame, EventSchema] = dd.read_parquet("events.parquet", columns=["event_id"])
print(df["timestamp"]) # ✗ unknown-column
```

The checker never imports dask — it is a pure AST pass, so dask only needs to be
installed to run the code, not to check it.

## Step 5 — Schema composition

Build merged schemas for joins using inheritance or the `+` operator:
Expand Down
5 changes: 3 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ Three groups, each with its own README/SPEC:
## [`features/`](features/)

Core typedframes usage: inference from `usecols=`/`columns=`, multi-file schema
propagation, `Annotated` schemas for pandas/polars, schema algebra, Jupyter notebook
(`.ipynb`) checking, and the Pandera bridge. Start here if you're new to typedframes. See
propagation, `Annotated` schemas for pandas/polars (and, experimentally,
`dask.dataframe`), schema algebra, Jupyter notebook (`.ipynb`) checking, and the Pandera
bridge. Start here if you're new to typedframes. See
[`features/README.md`](features/README.md).

## [`sql_connectors/`](sql_connectors/)
Expand Down
20 changes: 20 additions & 0 deletions examples/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ uv run ty check annotated_polars_example.py
uv run typedframes check annotated_polars_example.py
```

## annotated_dask_example.py

**Experimental.** `dask.dataframe` as a third backend: inference from
`dd.read_csv(usecols=...)`, structural operations (drop / assign) on a dask frame,
column tracking surviving the lazy-to-eager `.compute()` step,
`dd.from_pandas(...)` carrying an in-memory frame's columns across, and
`Annotated[dd.DataFrame, Schema]` for the annotated form. Four intentional
unknown-column errors and one `untracked-dataframe` warning.

Run without `--strict`, unlike the pandas/polars examples above: dask ships a
`py.typed` marker but its annotations are incomplete (`dd.read_csv` returns `Any`,
`dd.from_pandas` is untyped), so `--strict` reports `no-any-return` /
`no-untyped-call` against dask's own API rather than anything in this file.

```shell
uv run mypy --config-file mypy_empty.ini annotated_dask_example.py
uv run ty check annotated_dask_example.py
uv run typedframes check annotated_dask_example.py
```

## schema_algebra_example.py

Schema composition via inheritance and the `+` operator. Shows how to build
Expand Down
81 changes: 81 additions & 0 deletions examples/features/annotated_dask_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Dask example (experimental): column inference and Annotated[dd.DataFrame, Schema].

dask.dataframe is a near-drop-in for pandas, so the checker treats it as a third
backend: `dd.read_csv(...)`/`dd.read_parquet(...)` seed a column set from
`usecols=`/`columns=` exactly as their pandas counterparts do, the structural
operations (drop / rename / assign / subscript slice / del / pop) update it, and the
lazy-to-eager step -- `.compute()`, plus `.persist()`/`.repartition()` -- carries it
through instead of losing it.

Nothing here needs a dask cluster, or even dask itself, to be *checked*: the checker
is a pure AST pass. dask is only imported so this file is a real, runnable program.
"""

from typing import Annotated

import dask.dataframe as dd
import pandas as pd

from typedframes import BaseSchema, Column


class OrderSchema(BaseSchema):
"""Schema for the order records this pipeline reads."""

order_id = Column(type=int)
customer_id = Column(type=int)
amount = Column(type=float)


def load_orders() -> Annotated[dd.DataFrame, OrderSchema]:
"""Load orders lazily, asserting OrderSchema for every caller."""
return dd.read_csv("orders.csv", usecols=["order_id", "customer_id", "amount"])


def inferred_pipeline() -> None:
"""No schema class at all -- the column set comes from `usecols=` alone."""
orders = dd.read_csv("orders.csv", usecols=["order_id", "customer_id", "amount"])

# Row-passthrough and materialization keep the inferred set intact, so the
# checker is still validating column names after `.compute()`. Each step is
# bound to its own variable: the checker follows a chain through named
# intermediates, not through several calls stacked in one expression (that
# holds for every backend, not just dask).
recent = orders.query("amount > 0")
ordered = recent.sort_values("amount")
totals = ordered.compute()

print(totals["amount"])
print(totals["revenue"]) # ✗ unknown-column: 'revenue' not in {order_id, customer_id, amount}

# Structural operations update the inferred set rather than dropping tracking.
trimmed = orders.drop(columns=["customer_id"])
print(trimmed["customer_id"]) # ✗ unknown-column: dropped on the line above

enriched = orders.assign(tax=1.0)
print(enriched["tax"]) # ✓ added by assign()


def annotated_pipeline(orders: Annotated[dd.DataFrame, OrderSchema]) -> None:
"""Same validation, driven by an explicit schema instead of inference."""
print(orders["amount"])
print(orders["amoutn"]) # ✗ unknown-column: did you mean 'amount'?

# `.s` gives a refactor-safe string name from the descriptor.
print(orders[OrderSchema.amount.s])


def from_pandas_pipeline() -> None:
"""`dd.from_pandas` re-wraps an in-memory frame, columns unchanged."""
pdf = pd.read_csv("orders.csv", usecols=["order_id", "amount"])
ddf = dd.from_pandas(pdf, npartitions=2)

print(ddf["amount"])
print(ddf["customer_id"]) # ✗ unknown-column: the pandas source never had it


def untracked_pipeline() -> None:
"""No `usecols=` and no annotation -- the checker says so instead of guessing."""
# ⚠ untracked-dataframe: columns unknown at lint time
anything = dd.read_csv("orders.csv")
print(anything["whatever"])
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ dev = [
"fastexcel>=0.20.2",
"pandas-stubs>=3.0.5.260730",
"mypy>=2.3.1",
"dask[dataframe]>=2024.1.0",
]

[tool.bandit]
Expand Down
72 changes: 72 additions & 0 deletions rust/src/ast_extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,30 @@ pub(crate) fn is_relationship_annotation(annotation: &Expr) -> bool {
}
}

// Flatten a call receiver that is a plain dotted module reference into a single string,
// so it can be matched against `LOAD_MODULES` regardless of how the module was imported:
// `import pandas as pd` gives a bare `Expr::Name` ("pd"), while `import dask.dataframe`
// gives a nested `Expr::Attribute` ("dask.dataframe"). A bare name flattens to itself,
// so callers that previously destructured `Expr::Name` keep the exact same string.
//
// Returns `None` as soon as any segment is not a name or attribute -- a call,
// subscript, or literal -- so `loader.frames().read_csv(...)` never collapses into
// something that could be mistaken for a module path. Note that `self.loader` DOES
// flatten (to "self.loader"), which is harmless: it matches no entry in any of the
// module tables it is compared against.
pub(crate) fn dotted_module_path(expr: &Expr) -> Option<String> {
match expr {
Expr::Name(name) => Some(name.id.to_string()),
Expr::Attribute(attr) => {
let mut path = dotted_module_path(&attr.value)?;
path.push('.');
path.push_str(attr.attr.as_str());
Some(path)
}
_ => None,
}
}

// Check if a type name is a DataFrame/Frame type
pub(crate) fn is_frame_type(name: &str) -> bool {
name == "DataFrame"
Expand Down Expand Up @@ -699,6 +723,54 @@ mod tests {
)));
}

fn call_receiver_of(source: &str) -> ruff_python_ast::Expr {
let parsed = parse_module(source).unwrap();
let Stmt::Expr(expr_stmt) = &parsed.into_syntax().body[0] else {
panic!("Expected an expression statement");
};
let Expr::Call(call) = expr_stmt.value.as_ref() else {
panic!("Expected a call");
};
let Expr::Attribute(attr) = call.func.as_ref() else {
panic!("Expected an attribute call");
};
(*attr.value).clone()
}

#[test]
fn test_should_flatten_a_bare_and_a_dotted_module_receiver() {
assert_eq!(
dotted_module_path(&call_receiver_of("pd.read_csv('a.csv')")).as_deref(),
Some("pd")
);
assert_eq!(
dotted_module_path(&call_receiver_of("dask.dataframe.read_csv('a.csv')")).as_deref(),
Some("dask.dataframe")
);
// Arbitrary depth, and no special-casing of `self`: it flattens like any
// other name, and simply matches nothing in the module tables it is
// compared against.
assert_eq!(
dotted_module_path(&call_receiver_of("self.loader.frames.read_csv('a.csv')"))
.as_deref(),
Some("self.loader.frames")
);
}

#[test]
fn test_should_refuse_to_flatten_a_receiver_containing_a_call_or_subscript() {
// A call or subscript anywhere in the chain means this is an expression, not
// a module path -- collapsing it to a string could let it be mistaken for one.
assert_eq!(
dotted_module_path(&call_receiver_of("loader.frames().read_csv('a.csv')")),
None
);
assert_eq!(
dotted_module_path(&call_receiver_of("registry['frames'].read_csv('a.csv')")),
None
);
}

#[test]
fn test_should_not_detect_an_unrelated_return_type_as_a_bare_dataframe() {
assert!(!extract_bare_dataframe_type(&annotation_of(
Expand Down
Loading
Loading