Skip to content

Commit f4ebd96

Browse files
Optional dependencies. Better log for polars joins
1 parent 4e1fe3b commit f4ebd96

6 files changed

Lines changed: 107 additions & 75 deletions

File tree

docs/source/examples.rst

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,16 @@ Changed 2/344 (0.58%) values.
5252
polars
5353
------
5454

55-
Example of usage
55+
Let' import the libraries and create a logger:
5656

5757
>>> import polars as pl
5858
>>> import polars.selectors as cs
5959
>>> import raffalib
6060
>>> import raffalib.polars
61-
>>>
6261
>>> logger = raffalib.create_logger(rich=False, fmt="{message}")
62+
63+
Let's load a dataset
64+
6365
>>> df = pl.read_csv("https://raw.githubusercontent.com/allisonhorst/palmerpenguins/refs/heads/main/inst/extdata/penguins.csv")
6466
>>> df = df.raffa.startlog(clone=True).raffa.replace_string_with_null("NA").raffa.endlog(timeit=False)
6567
Changed 19/2,752 (0.69%) values.
@@ -77,13 +79,42 @@ shape: (5, 8)
7779
│ Adelie ┆ Torgersen ┆ null ┆ null ┆ null ┆ null ┆ null ┆ 2,007 │
7880
│ Adelie ┆ Torgersen ┆ 36.700001 ┆ 19.299999 ┆ 193.0 ┆ 3,450.0 ┆ female ┆ 2,007 │
7981
└─────────┴───────────┴────────────────┴───────────────┴───────────────────┴─────────────┴────────┴───────┘
82+
83+
Let's see some data wrangling on this data: remove rows with null values, filtering certain values, or selecting certain columns:
84+
8085
>>> _ = df.raffa.startlog().drop_nulls(subset=["bill_depth_mm"]).raffa.endlog(timeit=False)
8186
Removed 2/344 (0.58%) rows.
8287
>>> _ = df.raffa.startlog().filter(pl.col("species")=="Adelie").raffa.endlog(timeit=False)
8388
Removed 192/344 (55.81%) rows.
8489
>>> _ = df.raffa.startlog().select(pl.exclude(["bill_length_mm", "bill_depth_mm"])).raffa.endlog(timeit=False)
8590
Removed 2/8 (25.00%) columns.
91+
92+
Operations that do not change the shape of the DataFrame but only its values requires `clone=True`
93+
in the `startlog` call to clone the entire initial dataframe for further comparison:
94+
8695
>>> _ = df.raffa.startlog().fill_null(0).raffa.endlog(timeit=False)
8796
Shape is the same. No value-level comparison done because clone=False was used in startlog().
8897
>>> _ = df.raffa.startlog(clone=True).fill_null("0").raffa.endlog(timeit=False)
89-
Changed 11/2,752 (0.40%) values.
98+
Changed 11/2,752 (0.40%) values.
99+
100+
Let's see an example with joins:
101+
102+
>>> df1 = pl.DataFrame({"left_a": ["A", "B", "B", "C", "D"], "left_b": ["a", "b1", "b2", "c", "d"]})
103+
>>> df2 = pl.DataFrame({"right_a": ["A", "A", "A", "B", "E"], "right_d": ["alpha1", "alpha2", "alpha3", "beta", "gamma"]})
104+
>>> _ = df1.raffa.join(df2, left_on="left_a", right_on="right_a", how="full", keep_row_index=False)
105+
Total rows in output table: 8
106+
From left only: 2/8 (25.00%)
107+
From right only: 1/8 (12.50%)
108+
From both: 5/8 (62.50%) (left dups 3, right dups 2)
109+
>>> _ = df1.raffa.join(df2, left_on="left_a", right_on="right_a", how="left", keep_row_index=False)
110+
Total rows in output table: 7
111+
From left only: 2/7 (28.57%)
112+
From right only: 0/7 (0.00%)
113+
From both: 5/7 (71.43%) (left dups 3, right dups 2)
114+
115+
Filtering joins are automatically detected:
116+
117+
>>> _ = df1.raffa.join(df2, left_on="left_a", right_on="right_a", how="semi", keep_row_index=False)
118+
Detected filtering join. Rows variation -2/5 (-40.00%), total rows after join: 3/5 (60.00%)
119+
>>> _ = df1.raffa.join(df2, left_on="left_a", right_on="right_a", how="anti", keep_row_index=False)
120+
Detected filtering join. Rows variation -3/5 (-60.00%), total rows after join: 2/5 (40.00%)

pyproject.toml

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,14 @@ dependencies = [
1818
"tqdm>=4.67.1",
1919
]
2020

21-
[build-system]
22-
requires = ["uv_build"]
23-
build-backend = "uv_build"
24-
25-
[dependency-groups]
21+
[project.optional-dependencies]
2622
pandas = [
2723
"pandas>=3.0.1",
2824
]
2925
polars = [
3026
"polars>=1.38.1",
31-
"polars-config-meta>=0.3.1",
27+
"polars-config-meta>=0.3.1",
28+
"polars-permute>=0.1.6",
3229
]
3330
bibliometrics = [
3431
"beautifulsoup4>=4.14.3",
@@ -54,3 +51,7 @@ docs = [
5451
web = [
5552
"selenium>=4.41.0",
5653
]
54+
55+
[build-system]
56+
requires = ["uv_build"]
57+
build-backend = "uv_build"

src/raffalib/export_docx.py

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@
99

1010
@dataclass
1111
class DocxOptions:
12-
page_height = 210
13-
page_width = 297
14-
landscape = False
15-
left_margin = 25.4
16-
right_margin = 25.4
17-
top_margin = 25.4
18-
bottom_margin = 25.4
12+
page_height:float = 210
13+
page_width:float = 297
14+
landscape:bool = False
15+
left_margin:float = 25.4
16+
right_margin:float = 25.4
17+
top_margin:float = 25.4
18+
bottom_margin:float = 25.4
1919
heading_text:str|None = None
2020
heading_font_name:str = "Aptos"
2121
heading_font_size = 12
@@ -77,30 +77,17 @@ def prepare_docx(options:DocxOptions):
7777
paragraph_format = style.paragraph_format
7878
paragraph_format.space_before = Pt(options.heading_space_before)
7979
paragraph_format.space_after = Pt(options.heading_space_after)
80-
paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
80+
paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
8181

8282
return doc
8383

8484
# Additional workaround to make autofit actually work
85-
# See: https://github.com/python-openxml/python-docx/issues/209#issuecomment-566128709
86-
def set_autofit(doc: Document) -> Document:
87-
"""
88-
Hotfix for autofit.
89-
"""
90-
for t_idx, table in enumerate(doc.tables):
91-
doc.tables[t_idx].autofit = True
92-
doc.tables[t_idx].allow_autofit = True
93-
doc.tables[t_idx]._tblPr.xpath("./w:tblW")[0].attrib[
94-
"{http://schemas.openxmlformats.org/wordprocessingml/2006/main}type"
95-
] = "auto"
96-
for row_idx, r_val in enumerate(doc.tables[t_idx].rows):
97-
for cell_idx, c_val in enumerate(
98-
doc.tables[t_idx].rows[row_idx].cells
99-
):
100-
doc.tables[t_idx].rows[row_idx].cells[
101-
cell_idx
102-
]._tc.tcPr.tcW.type = "auto"
103-
doc.tables[t_idx].rows[row_idx].cells[
104-
cell_idx
105-
]._tc.tcPr.tcW.w = 0
106-
return doc
85+
# See: https://github.com/python-openxml/python-docx/issues/209#issuecomment-566128709
86+
def table_autofit_hotfix(table):
87+
for column in table.columns:
88+
for cell in column.cells:
89+
tc = cell._tc
90+
tcPr = tc.get_or_add_tcPr()
91+
tcW = tcPr.get_or_add_tcW()
92+
tcW.type = 'auto'
93+
return table

src/raffalib/pandas.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import logging
2323
from natsort import natsorted
2424
from pathlib import Path
25-
from .export_docx import prepare_docx, set_autofit, DocxOptions
25+
from .export_docx import prepare_docx, DocxOptions, table_autofit_hotfix
2626

2727
logger = logging.getLogger(__name__)
2828

@@ -292,7 +292,7 @@ def to_docx(
292292
t.cell(i + 1, j).text = str(df.values[i, j])
293293

294294
if autofit:
295-
doc = set_autofit(doc)
295+
t = table_autofit_hotfix(t)
296296

297297
# Set table style
298298
# See: https://github.com/python-openxml/python-docx/issues/9
@@ -301,10 +301,3 @@ def to_docx(
301301
# save the doc
302302
doc.save(outfp)
303303

304-
305-
if __name__ == "__main__":
306-
# Run with `uv run python -P pandas.py -v`
307-
import doctest
308-
309-
# doctest.testmod(optionflags=doctest.REPORT_NDIFF)
310-
doctest.testmod()

src/raffalib/polars.py

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import polars as pl
1818
import polars.selectors as cs
1919
import polars_config_meta
20+
import polars_permute
2021
from enum import Enum, auto
2122
import logging
2223
import copy
@@ -136,6 +137,7 @@ def startlog(self, clone=False) -> pl.DataFrame:
136137
:return: The same DataFrame for piping.
137138
:rtype: pl.DataFrame
138139
"""
140+
139141
self._df.config_meta.set(initial_shape=self._df.shape, start_time=time.perf_counter_ns())
140142
if clone:
141143
self._df.config_meta.set(initial_df=self._df.clone())
@@ -243,12 +245,29 @@ def crosstab(
243245

244246
return ct.with_columns(values / options[perc] * 100)
245247

246-
def join(self, df2, *args, keep_source: bool = False, log_col_changes=False, **kwargs):
248+
def join(self, df2:pl.DataFrame, *args, keep_row_index: bool = False, log_col_changes=False, **kwargs):
249+
"""
250+
Wrapper around `pl.DataFrame.join` to log join operations.
251+
252+
:param df2: The dataframe on the right of the join
253+
:type df2: pl.DataFrame
254+
:param *args: Additional position arguments passed to `pl.DataFrame.join`
255+
:type *args: Any
256+
:param keep_row_index: Whether to keep columns indicating the row index of the source table in the output table
257+
:type keep_row_index: bool
258+
:param log_col_changes: Whether to log column changes
259+
:type log_col_changes: bool
260+
:param *kwargs: Additional keyword arguments passed to `pl.DataFrame.join`
261+
:type *kwargs: Any
262+
:return: The joined DataFrame
263+
:rtype: pl.DataFrame
264+
"""
265+
247266
left_col = "source_left"
248267
right_col = "source_right"
249268
# Get DataFrame to join, add source column
250-
df1 = self._df.with_columns(pl.lit(True).alias(left_col))
251-
df2 = df2.with_columns(pl.lit(True).alias(right_col))
269+
df1 = self._df.with_row_index(left_col)
270+
df2 = df2.with_row_index(right_col)
252271
# Join DataFrames
253272
joined = df1.join(df2, *args, **kwargs)
254273
n_rows_joined = joined.shape[0]
@@ -269,21 +288,21 @@ def join(self, df2, *args, keep_source: bool = False, log_col_changes=False, **k
269288
joined = joined.drop([left_col])
270289
return joined
271290
# Detect how many rows in the output table are present in the input tables
272-
joined = joined.with_columns(
273-
pl.col([left_col, right_col]).fill_null(False)
274-
)
275-
n_both = joined.filter(pl.col(left_col), pl.col(right_col)).shape[0]
291+
joined_both = joined.filter(~pl.col(left_col).is_null(), ~pl.col(right_col).is_null())
292+
n_both = joined_both.shape[0]
293+
n_left_dups = joined_both.get_column(left_col).is_duplicated().sum()
294+
n_right_dups = joined_both.get_column(right_col).is_duplicated().sum()
276295
n_left_only = joined.filter(
277-
pl.col(left_col), ~pl.col(right_col)
296+
~pl.col(left_col).is_null(), pl.col(right_col).is_null()
278297
).shape[0]
279298
n_right_only = joined.filter(
280-
~pl.col(left_col), pl.col(right_col)
299+
pl.col(left_col).is_null(), ~pl.col(right_col).is_null()
281300
).shape[0]
282301
# Log rows information
283302
msg = f"Total rows in output table: {n_rows_joined:,d}\n"
284-
msg += f"\tFrom left: {n_left_only:,d} ({n_left_only / n_rows_joined:.2%})\n"
285-
msg += f"\tFrom right: {n_right_only:,d} ({n_right_only / n_rows_joined:.2%})\n"
286-
msg += f"\tFrom both: {n_both:,d} ({n_both / n_rows_joined:.2%})\n"
303+
msg += f"From left only: {n_left_only:,d}/{n_rows_joined:,d} ({n_left_only / n_rows_joined:.2%})\n"
304+
msg += f"From right only: {n_right_only:,d}/{n_rows_joined:,d} ({n_right_only / n_rows_joined:.2%})\n"
305+
msg += f"From both: {n_both:,d}/{n_rows_joined:,d} ({n_both / n_rows_joined:.2%}) (left dups {n_left_dups}, right dups {n_right_dups})\n"
287306
# Detect added and removed columns
288307
if log_col_changes:
289308
cols_out = set(joined.columns)
@@ -292,21 +311,11 @@ def join(self, df2, *args, keep_source: bool = False, log_col_changes=False, **k
292311
msg += f"Columns in output table not in left table: {cols_out - cols_left})\n"
293312
msg += f"Columns in output table not in right table: {cols_out - cols_right})"
294313
logger.info(msg)
295-
# Add a column that indicate where that row comes from
296-
if keep_source:
297-
joined = joined.with_columns(
298-
pl.when(pl.col.source_left & pl.col.source_right)
299-
.then(pl.lit("both"))
300-
.when(pl.col.source_left & ~pl.col.source_right)
301-
.then(pl.lit("left_only"))
302-
.when(~pl.col.source_left & pl.col.source_right)
303-
.then(pl.lit("right_only"))
304-
.otherwise(pl.lit("error"))
305-
.alias("source")
306-
)
307-
joined = joined.permute.append(["source"])
308-
# Drop source columns and return
309-
joined = joined.drop([left_col, right_col])
314+
# Drop row indices
315+
if not keep_row_index:
316+
joined = joined.drop([left_col, right_col])
317+
else:
318+
joined = joined.permute.append([left_col, right_col])
310319
return joined
311320

312321
if __name__ == "__main__":

uv.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)