Skip to content

Split PyRanges construction from pandas' internal frame fallback - #171

Open
endrebak wants to merge 3 commits into
masterfrom
fix_new
Open

Split PyRanges construction from pandas' internal frame fallback#171
endrebak wants to merge 3 commits into
masterfrom
fix_new

Conversation

@endrebak

@endrebak endrebak commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

PyRanges.new was annotated as returning pr.PyRanges | pd.DataFrame, so every pr.PyRanges(df) call site was typed as a union and tripped type checkers on any downstream use expecting a strict PyRanges (e.g. passing the result to a PyRanges-typed parameter).

The DataFrame fallback is only meaningful for pandas' internal frame reconstruction (.drop(), groupby aggregations, .loc, etc.), never for direct construction. Move it there: __new__ now always returns a real PyRanges or raises ValueError with the missing columns, while _constructor gets a _pyranges_constructor_with_fallback classmethod that pandas calls internally and that still degrades to a DataFrame when a required column is missing — mirroring how geopandas splits GeoDataFrame.init from its _constructor_with_fallback.

RangeFrame.drop/drop_and_return/reindex switched from self.__class__(...) to self._constructor(...) so the degrade-to-DataFrame behavior (used by .drop() on PyRanges) still works now that new raises instead of silently downgrading. A few call sites that drop non-required columns got cast(...) since they can never hit the fallback.

@marco-mariotti Thanks for the Claude trial. It only needed a bit of nudging to find the optimal design and then it seems to have performed flawlessly. Will reread some other day when I am not so tired.

PyRanges.__new__ was annotated as returning `pr.PyRanges | pd.DataFrame`,
so every `pr.PyRanges(df)` call site was typed as a union and tripped
type checkers on any downstream use expecting a strict PyRanges (e.g.
passing the result to a `PyRanges`-typed parameter).

The DataFrame fallback is only meaningful for pandas' internal frame
reconstruction (`.drop()`, groupby aggregations, `.loc`, etc.), never
for direct construction. Move it there: `__new__` now always returns a
real PyRanges or raises ValueError with the missing columns, while
`_constructor` gets a `_pyranges_constructor_with_fallback` classmethod
that pandas calls internally and that still degrades to a DataFrame
when a required column is missing — mirroring how geopandas splits
GeoDataFrame.__init__ from its `_constructor_with_fallback`.

RangeFrame.drop/drop_and_return/reindex switched from `self.__class__(...)`
to `self._constructor(...)` so the degrade-to-DataFrame behavior (used by
.drop() on PyRanges) still works now that __new__ raises instead of
silently downgrading. A few call sites that drop non-required columns
got `cast(...)` since they can never hit the fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mFnfFDemzwoEZDLJshzhx
marco-mariotti and others added 2 commits August 14, 2026 19:19
pyright flagged the three self._constructor(...) calls in RangeFrame as
"Object of type Series[Any] is not callable": pandas-stubs does not declare
_constructor on DataFrame, so the attribute resolved through DataFrame's
__getattr__ (column access) instead.

It was not just a typing problem. pandas 3.0's DataFrame._constructor returns
DataFrame rather than type(self), and RangeFrame does not override it, so
RangeFrame.drop/drop_and_return/reindex (and combine_interval_columns through
it) had started returning plain DataFrames instead of RangeFrames.

Move the fallback to an explicit _constructor_with_fallback classmethod that
RangeFrame defines (always builds cls) and PyRanges overrides (degrades to a
DataFrame when a required column is missing, and is what PyRanges._constructor
hands to pandas). The three methods call that hook, so RangeFrame keeps its
type while PyRanges keeps the degrade-to-DataFrame behaviour.

drop_and_return loses its TypeVar, which the "| pd.DataFrame" in the return
type had already made unusable without a cast; every call site already casts.

Also gitignore the files the doctests write into the repo root, which until now
were left behind as untracked after every test run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@endrebak

Copy link
Copy Markdown
Collaborator Author

Did you fix it? I am a bit out of the loop here. Is it ready for submission?

@marco-mariotti

Copy link
Copy Markdown
Member

Not quite. I decided to benchmark before merging, and that opened a whole other can of worms. This PR takes away a little speed, but also, the way in which PyRanges are initiated (even pre-PR) is improvable. I'm running a few ideas. Only when I figured out that part, I will come back to this. Leave this here. If you want to roll back to your last commit, feel free.

@marco-mariotti

Copy link
Copy Markdown
Member

@endrebak for me, this is good to go now.
This PR adds some overhead in construction, which anyhow we never optimized.
This next PR body contains benchmarks in that regard:
#174
And addresses optimization, bringing significant speed up.
The final speed would be the same if PR174 were applied on top of current master, or after 171. Meaning, the overhead of 171 will be gone after 174 is merged.
Let's not release any version in between.

@endrebak let me know if you want to review them, in which case I'll leave everything open.

Note PR174 currently doesn't target master, must be changed to master after merging 171

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.

2 participants