Skip to content

chore: Remove level from main and v2 classes init#3558

Draft
FBruzzesi wants to merge 17 commits into
mainfrom
chore/issue-2932-rm-level
Draft

chore: Remove level from main and v2 classes init#3558
FBruzzesi wants to merge 17 commits into
mainfrom
chore/issue-2932-rm-level

Conversation

@FBruzzesi

@FBruzzesi FBruzzesi commented Apr 19, 2026

Copy link
Copy Markdown
Member

Description

Not really user facing but I had it started some time ago, so why not finish it 😇

What type of PR is this? (check all applicable)

  • 💾 Refactor
  • ✨ Feature
  • 🐛 Bug Fix
  • 🔧 Optimization
  • 📝 Documentation
  • ✅ Test
  • 🐳 Other

Related issues

Checklist

  • Code follows style guide (ruff)
  • Tests added
  • Documented the changes

Comment thread src/narwhals/_utils.py Outdated
@@ -1922,7 +1922,7 @@ def convert_str_slice_to_int_slice(


def inherit_doc(
tp_parent: Callable[P, R1], /
tp_parent: Callable[..., R1], /

@FBruzzesi FBruzzesi Apr 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dangotbanned you might have a better way of doing this but here what happened - by changing the main namespace __init__ signatures, now the V1 is overriding them and the arguments are different, to so the child ones are not bind to the parent ones - hence removing the P from the parent here.

This meant that return DataFrame(self, level="interchange") was resulting in a typing issue

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I fixed that now 🎉

But it made me realize we don't need it for any Series, DataFrame, LazyFrame if we just remove the constructor override?

@inherit_doc(NwDataFrame)
def __init__(self, df: Any) -> None:
assert df._version is Version.V1 # noqa: S101
super().__init__(df)

That assertion is pointless - as it checks a ClassVar, and that's it AFAICT?

@dangotbanned dangotbanned self-assigned this Jun 22, 2026
dangotbanned

This comment was marked as outdated.

@dangotbanned dangotbanned removed their assignment Jun 22, 2026
@dangotbanned

Copy link
Copy Markdown
Member

I'm not sure how exactly, but I think the marimo failures mean that they're (probably indirectly) depending on ._level? 🤔

The error output isn't helping narrow it down though

FAILED tests/_data/test_preview_column.py::test_get_column_preview_for_duckdb - AssertionError: assert None is not None
 +  where None = DataColumnPreviewNotification(chart_spec=None, chart_code=None, error=None, missing_packages=None, stats=ColumnStats(t...=0.502518907629606, true=None, false=None, p5=0.0, p25=0.0, p75=1.0, p95=1.0), table_name='tbl', column_name='outcome').chart_spec
FAILED tests/_data/test_preview_column.py::test_get_column_preview_for_duckdb_categorical - AssertionError: assert None is not None
 +  where None = DataColumnPreviewNotification(chart_spec=None, chart_code=None, error=None, missing_packages=None, stats=ColumnStats(t...one, std=None, true=None, false=None, p5=None, p25=None, p75=None, p95=None), table_name='tbl', column_name='category').chart_spec
FAILED tests/_data/test_preview_column.py::test_get_column_preview_for_duckdb_date - AssertionError: assert None is not None
 +  where None = DataColumnPreviewNotification(chart_spec=None, chart_code=None, error=None, missing_packages=None, stats=ColumnStats(t...std=None, true=None, false=None, p5=None, p25=None, p75=None, p95=None), table_name='date_tbl', column_name='date_col').chart_spec
FAILED tests/_data/test_preview_column.py::test_get_column_preview_for_duckdb_datetime - AssertionError: assert None is not None
 +  where None = DataColumnPreviewNotification(chart_spec=None, chart_code=None, error=None, missing_packages=None, stats=ColumnStats(t..., true=None, false=None, p5=None, p25=None, p75=None, p95=None), table_name='datetime_tbl', column_name='datetime_col').chart_spec
FAILED tests/_data/test_preview_column.py::test_get_column_preview_for_duckdb_bool - AssertionError: assert None is not None
 +  where None = DataColumnPreviewNotification(chart_spec=None, chart_code=None, error=None, missing_packages=None, stats=ColumnStats(t...ne, std=None, true=50, false=50, p5=None, p25=None, p75=None, p95=None), table_name='bool_tbl', column_name='bool_col').chart_spec
= 5 failed, 2402 passed, 10 skipped, 26 xfailed, 1 xpassed, 49 warnings in 65.09s (0:01:05) =

@dangotbanned dangotbanned marked this pull request as draft June 22, 2026 14:55

return DataFrameV1(self, level="interchange") # type: ignore[no-any-return]
return self._version.lazyframe(self, level="lazy")
return DataFrameV1(self) # type: ignore[no-any-return]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right I think I've found the issue for (#3558 (comment))

The repro is this:

import narwhals as nw
import narwhals.stable.v1 as nw_v1  # noqa: F811

native_duck = nw.from_dict({"a": [1, 2, 3]}, backend="polars").lazy("duckdb").to_native()
v1_duck = nw_v1.from_native(native_duck)

nw_v1.get_level(v1_duck)
"full"

The issue is that I've misunderstood what these guys are for:

from narwhals._interchange.dataframe import InterchangeFrame
from narwhals._interchange.series import InterchangeSeries
ensure_type(obj, DataFrame, Series, LazyFrame)
if isinstance(obj, DataFrame):
return "interchange" if isinstance(obj._compliant, InterchangeFrame) else "full"
if isinstance(obj, Series):
return "interchange" if isinstance(obj._compliant, InterchangeSeries) else "full"

I thought that we'd have used InterchangeFrame, e.g. here:

# Interchange protocol
if version is Version.V1 and supports_dataframe_interchange(native_object):
from narwhals._interchange.dataframe import InterchangeFrame
if eager_only or series_only:
if not pass_through:
msg = (
"Cannot only use `series_only=True` or `eager_only=False` "
"with object which only implements __dataframe__"
)
raise TypeError(msg)
return native_object
return Version.V1.dataframe(InterchangeFrame(native_object))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still pretty confused why we aren't doing that, and were instead passing around a string 🤔

@dangotbanned dangotbanned Jun 22, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay more digging...

The tests added from #1266 appear to have been invalid since #1725.

On main (71a0204)

import duckdb

import narwhals as nw
import narwhals.stable.v1 as nw_v1  # noqa: F811


native_duck = nw.from_dict({"a": [1, 2, 3]}, backend="polars").lazy("duckdb").to_native()
v1_duck:nw_v1.DataFrame[duckdb.DuckDBPyRelation] = nw_v1.from_native(native_duck)

print(v1_duck._compliant.__class__.__name__, repr(nw_v1.get_level(v1_duck)))

v1_duck.select(nw_v1.col("a").max())
DuckDBLazyFrame 'interchange'
┌──────────────────┐
|Narwhals DataFrame|
|------------------|
|    ┌───────┐     |
|    │   a   │     |
|    │ int64 │     |
|    ├───────┤     |
|    │     3 │     |
|    └───────┘     |
└──────────────────┘

This looks like more than interchange-only support?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MarcoGorelli 👋

Just wanna be sure you see this, since it looks like a 🐛 that's been hiding in interchange only support for a long time

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this issue that it's allowing select(nw_v1.col("a").max()), whereas it should be erroring?

if so, that doesn't really look like an issue? i don't think interchange level was ever meant to mean "only interchange"

@dangotbanned dangotbanned Jun 25, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, it's still not clear to me what marimo is relying on that this PR is breaking leading to their CI to fail

The CI failure is because of how I tried to factor out level:

if isinstance(obj, DataFrame): 
     return "interchange" if isinstance(obj._compliant, InterchangeFrame) else "full" 
#                                                   	^^^^^^^^^^^^^^^^^ (me not realising that this was a `DuckDBPyRelation`)

That can be fixed by changing the condition to use both Version and Implementation.

But fixing that doesn't change (#3558 (comment)) - which I thought was a big issue

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That can be fixed

As promised (fix: try a different level refactor?) does fix (marimo)

@dangotbanned dangotbanned Jun 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As an experiment

I'm gonna branch off here (#3721) and see how bad downstream breaks if we enforce (#3558 (comment))

I think the answer will be a lot, but who knows 😅

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I am confident that a DuckDBPyRelation is not a DataFrame.

not sure what this means tbh, could you explain please?

@dangotbanned dangotbanned Jun 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Show thread

@MarcoGorelli
I don't think interchange level was ever meant to mean "only interchange"

@dangotbanned
Maybe I've misunderstood, and this never meant that:

link to docs

but I am confident that a DuckDBPyRelation is not a DataFrame.

@MarcoGorelli
not sure what this means tbh, could you explain please?

not sure what this means tbh, could you explain please?

Sure.

If you asked me a week ago what interchange level meant; I would've described a subset of this.
If I did my homework; I'd have taken a look at InterchangeFrame and describe that.

But both of those are wrong.

So now, if I want to describe what it means - what am I left with?

  • To a type checker it is v1.DataFrame[DuckDBPyRelation]
  • At runtime, it's anything that doesn't raise an error?

Summary

  • I don't understand what is being promised by interchange-level
  • That concerns me because we are supporting it indefinitely
  • Our test suite does not cover anything beyond IO, selecting columns by name, and dtypes

Updated

See (#3721), (#3721 (comment))

Comment thread src/narwhals/typing.py Outdated
dangotbanned added a commit that referenced this pull request Jun 27, 2026
Going scorched-earth to find out who's using it downstream

Part 1 of (#3558 (comment))
@dangotbanned dangotbanned dismissed their stale review June 29, 2026 11:38

I'm dismissing my review, because I left it before finding (#3558 (comment)), (#3558 (comment))

Just want to avoid this merging before finishing (#3721)

Really sorry, I shouldn't have approved while we still had downstream CI running

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Remove level, DataFrameLike from main, v2

3 participants