Skip to content
Draft
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9ad45ba
feat: Use `_repr_html_` when native supports it
dangotbanned Jul 3, 2025
04e1f51
Merge branch 'main' into df-repr-html
dangotbanned Jul 3, 2025
bf7e87a
Merge branch 'main' into df-repr-html
dangotbanned Jul 3, 2025
edb7ae4
Merge remote-tracking branch 'upstream/main' into df-repr-html
dangotbanned Jul 9, 2025
3769326
feat: Support `pl.LazyFrame._repr_html_`
dangotbanned Jul 9, 2025
3559cf8
Merge remote-tracking branch 'upstream/main' into df-repr-html
dangotbanned Jul 9, 2025
ea42e83
chore: add note on testing
dangotbanned Jul 9, 2025
3748f23
feat: Support `pl.Series._repr_html_`
dangotbanned Jul 9, 2025
5bb73d8
Merge branch 'main' into df-repr-html
dangotbanned Jul 11, 2025
e77c8de
Merge branch 'main' into df-repr-html
dangotbanned Jul 12, 2025
d8f09a4
Merge branch 'main' into df-repr-html
dangotbanned Jul 14, 2025
4227f10
Merge branch 'main' into df-repr-html
dangotbanned Jul 14, 2025
690e6e4
Merge branch 'main' into df-repr-html
dangotbanned Jul 15, 2025
6f2ab6b
Merge branch 'main' into df-repr-html
dangotbanned Jul 18, 2025
61497d6
Merge branch 'main' into df-repr-html
dangotbanned Jul 18, 2025
c4dca25
Merge remote-tracking branch 'upstream/main' into df-repr-html
dangotbanned Jul 25, 2025
f73d564
Merge branch 'main' into df-repr-html
dangotbanned Jul 28, 2025
e32e139
Merge remote-tracking branch 'upstream/main' into df-repr-html
dangotbanned Aug 3, 2025
57e333d
rename `header` -> `caption_text`
dangotbanned Aug 3, 2025
7758265
change `caption-side` to `top`
dangotbanned Aug 3, 2025
dfb5f5e
Merge remote-tracking branch 'upstream/main' into df-repr-html
dangotbanned Aug 8, 2025
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
42 changes: 42 additions & 0 deletions narwhals/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1606,6 +1606,48 @@ def generate_repr(header: str, native_repr: str) -> str:
)


# NOTE: Unsure on how to test this reliably
def generate_repr_html(
header: Literal["Narwhals DataFrame", "Narwhals LazyFrame", "Narwhals Series"],
native_html: str,
) -> str | None: # pragma: no cover
if header == "Narwhals LazyFrame" and "LazyFrame" in native_html:
html = native_html.replace("LazyFrame", "LazyFrame.to_native()")
return f"{html}<p><b>{header}</b></p>"
Copy link
Member Author

Choose a reason for hiding this comment

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

Had to add this branch for pl.LazyFrame as it wasn't parsing with my naive wrapper:

import io
import xml.etree.ElementTree as ET

import polars as pl

data = {"a": [1, 2, 3], "b": ["fdaf", "fda", "cf"]}
ldf = pl.LazyFrame(data)

>>> ET.parse(io.StringIO(ldf._repr_html_()))
ParseError: junk after document element: line 1, column 25

Seems to fail on the first <p> in https://github.com/pola-rs/polars/blob/dfa5efe71156c654a1ba3a54b865eae723a818e9/py-polars/polars/lazyframe/frame.py#L783

Copy link
Member

Choose a reason for hiding this comment

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

I am mostly nitpicking here but... isn't the header actually a footer? πŸ˜‚

Copy link
Member Author

Choose a reason for hiding this comment

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

You're quite right πŸ˜‚

It started as a header until I ran into (#2776 (comment))

I should've updated that to footer or caption

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh I forgot, the name header actually came from generate_repr

def generate_repr(header: str, native_repr: str) -> str:

Anyway - updated it in (57e333d)

import io
import xml.etree.ElementTree as ET

style_css = (
".dataframe caption { "
"caption-side: bottom; "
"text-align: center; "
"font-weight: bold; "
"padding-top: 8px;"
"}"
)
Copy link
Member Author

@dangotbanned dangotbanned Jul 3, 2025

Choose a reason for hiding this comment

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

If anyone has any suggestions for styling - feel free to experiment/comment πŸ™‚

The only decision I'd made so far was putting the <caption> below the table

With the default polars formatting, it appeared between the table and the shape tuple when above - which I thought looked odd

image

Copy link
Member

Choose a reason for hiding this comment

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

That's very reasonable!

Copy link
Member Author

Choose a reason for hiding this comment

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

Realized I never followed this up with an example

Now that I'm looking at it again, maybe above isn't so bad?

image

try:
tree = ET.parse(io.StringIO(native_html.replace("<style scoped>", "<style>"))) # noqa: S314
except (SyntaxError, TypeError):
return None
table = tree.find("table")
if table is None:
return None
caption = ET.Element("caption")
caption.text = header
table.insert(0, caption)
style = tree.find("style")
if style is not None:
text_existing = style.text or ""
style.text = f"{text_existing}\n{style_css}"
else:
style = ET.Element("style")
style.text = style_css
tree.getroot().insert(0, style)
buf = io.BytesIO()
tree.write(buf, "utf-8", method="html")
return buf.getvalue().decode()
Copy link
Member

Choose a reason for hiding this comment

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

Everything else in this function is a new language to me - I am not very helpful

Copy link
Member Author

@dangotbanned dangotbanned Aug 2, 2025

Choose a reason for hiding this comment

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

Ah yeah xml.etree.elementtree is a bit of a strange one

I had to learn a bit of lxml once to fix a particularly broken file.
The API of that is based on this stdlib module, but was more ergonoic than this mess πŸ˜„

To simplify this:

  • Element: Is a HTML Element
  • Tree: Refers to a document/webpage, but in this case it is just a table

So I'm essentially doing a fancy find/replace, but trying to preserve the structure of the document



def check_columns_exist(
subset: Collection[str], /, *, available: Collection[str]
) -> ColumnNotFoundError | None:
Expand Down
14 changes: 14 additions & 0 deletions narwhals/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
from narwhals._utils import (
Implementation,
Version,
_hasattr_static,
find_stacklevel,
flatten,
generate_repr,
generate_repr_html,
is_compliant_dataframe,
is_compliant_lazyframe,
is_eager_allowed,
Expand Down Expand Up @@ -690,6 +692,12 @@ def __array__(self, dtype: Any = None, copy: bool | None = None) -> _2DArray: #
def __repr__(self) -> str: # pragma: no cover
return generate_repr("Narwhals DataFrame", self.to_native().__repr__())

def _repr_html_(self) -> str | None: # pragma: no cover
native: Any = self.to_native()
if _hasattr_static(native, "_repr_html_") and (html := native._repr_html_()):
return generate_repr_html("Narwhals DataFrame", html)
return None

def __arrow_c_stream__(self, requested_schema: object | None = None) -> object:
"""Export a DataFrame via the Arrow PyCapsule Interface.

Expand Down Expand Up @@ -2438,6 +2446,12 @@ def __init__(self, df: Any, *, level: Literal["full", "lazy", "interchange"]) ->
def __repr__(self) -> str: # pragma: no cover
return generate_repr("Narwhals LazyFrame", self.to_native().__repr__())

def _repr_html_(self) -> str | None: # pragma: no cover
native: Any = self.to_native()
if _hasattr_static(native, "_repr_html_") and (html := native._repr_html_()):
return generate_repr_html("Narwhals LazyFrame", html)
return None

@property
def implementation(self) -> Implementation:
"""Return implementation of native frame.
Expand Down
8 changes: 8 additions & 0 deletions narwhals/series.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@

from narwhals._utils import (
Implementation,
_hasattr_static,
_validate_rolling_arguments,
ensure_type,
generate_repr,
generate_repr_html,
is_compliant_series,
is_index_selector,
supports_arrow_c_stream,
Expand Down Expand Up @@ -336,6 +338,12 @@ def pipe(self, function: Callable[[Any], Self], *args: Any, **kwargs: Any) -> Se
def __repr__(self) -> str: # pragma: no cover
return generate_repr("Narwhals Series", self.to_native().__repr__())

def _repr_html_(self) -> str | None: # pragma: no cover
native: Any = self.to_native()
if _hasattr_static(native, "_repr_html_") and (html := native._repr_html_()):
return generate_repr_html("Narwhals Series", html)
return None

def __len__(self) -> int:
return len(self._compliant_series)

Expand Down
Loading