Skip to main content

dplyr for Python: tidy piped verbs over polars and duckdb, with real autocompletion and dplyr-verified semantics.

Project description

dpyr

dplyr for Python. The tidyverse's verbs — filter, mutate, group_by, summarize, joins, across, tidyselect — as Python method chains, executing on polars or duckdb, with real IDE autocompletion and semantics verified against dplyr itself.

pip install dpyr        # or: uv add dpyr
from dpyr import read, col, n, desc

starwars = read("starwars.parquet")   # read() takes anything tabular:
                                      # .parquet/.csv/.arrow/.db paths, dicts,
                                      # polars/pandas frames, arrow tables,
                                      # Hugging Face datasets, numpy/torch/jax

(
    starwars
    .filter(col.height > 180, col.mass < 100)
    .mutate(bmi = col.mass / (col.height / 100) ** 2)
    .group_by(col.species)
    .summarize(
        n = n(),
        mean_bmi = col.bmi.mean(),
    )
    .arrange(desc(col.mean_bmi))
)

Evaluate that in a notebook and you see rows immediately. Typo a column name and you get the error on that line, with a did-you-mean suggestion. Wrap the same code in a pipeline and only .collect() at the end, and the whole chain runs as one fused query with predicate pushdown. That combination — schema-eager, data-lazy, display-eager — is the core design.

Two backends, one semantics

import duckdb
from dpyr import read

df  = read({"x": [1, 2, 3], "g": ["a", "a", "b"]})   # polars engine
con = duckdb.connect("warehouse.db")
tbl = read(con, "events")                            # SQL pushdown

Identical chains produce identical results on both engines — enforced by a Hypothesis fuzzer that runs random verb chains on both and compares bit-for-bit, and by differential tests against real dplyr: every spec in tests/specs/ is executed by dplyr (via oracle/run_specs.R) to produce a committed golden parquet, then replayed through dpyr on both backends. Where R and the engines genuinely disagree, the decision is documented in docs/SEMANTICS.md, not left to chance.

The dplyr you know

dplyr dpyr
filter(df, height > 180) df.filter(col.height > 180)
mutate(df, bmi = mass / h^2) df.mutate(bmi = col.mass / col.h ** 2)
summarise(df, n = n(), m = mean(x, na.rm = TRUE)) df.summarize(n = n(), m = col.x.mean())
arrange(df, desc(mass)) df.arrange(desc(col.mass))
select(df, name, starts_with("h")) df.select(col.name, starts_with("h"))
select(df, -mass) df.select(-col.mass)
across(where(is.numeric), mean) across(where(is_numeric), "mean")
left_join(a, b, by = "k") a.left_join(b, on = col.k)
pivot_longer(df, x:y) df.pivot_longer([col.x, col.y])
if_else(), case_when(), n_distinct() if_else(), case_when(), .n_unique()
lag(), lead(), row_number(), min_rank() lag(), lead(), row_number(), min_rank()
cumsum(), dense_rank(), percent_rank() cum_sum(), dense_rank(), percent_rank()
slice_min(x, n), slice_max(x, n) (ties kept) slice_min(col.x, n), slice_max(col.x, n)
separate(), unite(), relocate() separate(), unite(), relocate()
coalesce(), replace_na() coalesce(), replace_na()

Grouped mutate/filter are windowed per group, summarize peels one grouping level, joins use .x/.y suffixes and match NAs by default — the dplyr behaviors, deliberately.

Autocompletion that actually works

  • df.c.height — frame-bound proxy: column names complete from the live schema, and the returned expression is typed (.mean() on numerics, .str_detect() on strings; calling .mean() on a string column raises immediately, at build time).
  • df.filter(lambda c: c.height > 180) — lambda style for the same effect.
  • dpyr stubgen data/*.parquet -o schemas.py — generates typed schema modules so completion and type-checking work statically in any IDE.

The database is a destination, not just a source

db = read("warehouse.db")                 # catalog object: db.tables, db.orders
gold = db.orders.group_by(col.region).summarize(rev = col.amount.sum())
gold.to_table("gold_revenue")             # CREATE TABLE AS <sql>, fully in-engine
gold.to_view("gold_live")                 # the lazy plan as a named view
gold.write("gold.parquet")                # in-engine COPY (extension dispatch)
mem = read({"region": ["east"], "target": [1000.0]})
gold.inner_join(mem, on = col.region)     # in-memory frames bridge into duckdb
                                          # automatically (arrow, zero-copy)

Interactive by default, lazy when you need it

df.persist()           # checkpoint: materialize now (duckdb: temp table)
df.lazy()              # this frame never executes implicitly
dpyr.options.interactive = False   # global opt-out for production pipelines

Results are cached by plan hash, so re-displaying a frame in a notebook never recomputes it.

Documentation

Full guides at maximerivest.github.io/dpyr — get started, grouped data, joins, window functions, column-wise operations, reshaping, expressions & autocompletion, and the backends guide (connecting and operating polars and duckdb).

Project documents

Doc What it pins down
docs/DESIGN.md API design, the materialization model, autocompletion strategy, architecture
docs/SEMANTICS.md Every deliberate decision where R, polars and duckdb disagree
docs/TESTING.md dplyr-as-oracle goldens, backend-agreement fuzzing, Hypothesis properties
docs/ROADMAP.md What shipped in 1.0 and what's next

License

MIT © Maxime Rivest

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

dpyr-1.7.1.tar.gz (140.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

dpyr-1.7.1-py3-none-any.whl (54.7 kB view details)

Uploaded Python 3

File details

Details for the file dpyr-1.7.1.tar.gz.

File metadata

  • Download URL: dpyr-1.7.1.tar.gz
  • Upload date:
  • Size: 140.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for dpyr-1.7.1.tar.gz
Algorithm Hash digest
SHA256 bbe455689e85ef5c67b2bd9cb540c834bd6cea568c362297aa0801596edd22e0
MD5 43c20d9051ca48a5bb2782f269e30d9c
BLAKE2b-256 cd030f86c532defcca8037bd64370fde041d14416d6a7e64cc8efa0301ac50c9

See more details on using hashes here.

File details

Details for the file dpyr-1.7.1-py3-none-any.whl.

File metadata

  • Download URL: dpyr-1.7.1-py3-none-any.whl
  • Upload date:
  • Size: 54.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.9.26 {"installer":{"name":"uv","version":"0.9.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"22.04","id":"jammy","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for dpyr-1.7.1-py3-none-any.whl
Algorithm Hash digest
SHA256 25661ec75f7ea83f78949eef7b1cda6979f792ff665c9db50e4361e70134c2ee
MD5 4edbf5119174c2e2b86c522252c0c255
BLAKE2b-256 2629f507bcff9601c367d2f6eb55d2ebcb080eb0dfc0f7c0f7e8d7f9444680ef

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page