Skip to content

Commit cbc5eaa

Browse files
Better log for polars join
1 parent f4ebd96 commit cbc5eaa

5 files changed

Lines changed: 365 additions & 176 deletions

File tree

docs/source/examples.rst

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
########
12
Examples
2-
========
3+
########
34

5+
******
46
pandas
5-
------
7+
******
68

79
DataFrame example:
810

@@ -49,8 +51,12 @@ Shape is the same. No value-level comparison done because clone=False was used i
4951
>>> _ = s.raffa.startlog(clone=True).fillna(0).raffa.endlog()
5052
Changed 2/344 (0.58%) values.
5153

54+
******
5255
polars
53-
------
56+
******
57+
58+
Logging
59+
=======
5460

5561
Let' import the libraries and create a logger:
5662

@@ -63,8 +69,7 @@ Let' import the libraries and create a logger:
6369
Let's load a dataset
6470

6571
>>> df = pl.read_csv("https://raw.githubusercontent.com/allisonhorst/palmerpenguins/refs/heads/main/inst/extdata/penguins.csv")
66-
>>> df = df.raffa.startlog(clone=True).raffa.replace_string_with_null("NA").raffa.endlog(timeit=False)
67-
Changed 19/2,752 (0.69%) values.
72+
>>> df = df.raffa.replace_string_with_null("NA")
6873
>>> df = df.with_columns(pl.col(["bill_length_mm", "bill_depth_mm", "flipper_length_mm", "body_mass_g"]).cast(pl.Float32).name.keep())
6974
>>> df.head()
7075
shape: (5, 8)
@@ -80,7 +85,8 @@ shape: (5, 8)
8085
│ Adelie ┆ Torgersen ┆ 36.700001 ┆ 19.299999 ┆ 193.0 ┆ 3,450.0 ┆ female ┆ 2,007 │
8186
└─────────┴───────────┴────────────────┴───────────────┴───────────────────┴─────────────┴────────┴───────┘
8287

83-
Let's see some data wrangling on this data: remove rows with null values, filtering certain values, or selecting certain columns:
88+
Let's see some data wrangling on this data: remove rows with null values, filtering certain values, or selecting certain columns.
89+
The number of rows / columns changed is logged.
8490

8591
>>> _ = df.raffa.startlog().drop_nulls(subset=["bill_depth_mm"]).raffa.endlog(timeit=False)
8692
Removed 2/344 (0.58%) rows.
@@ -90,31 +96,78 @@ Removed 192/344 (55.81%) rows.
9096
Removed 2/8 (25.00%) columns.
9197

9298
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:
99+
in the `startlog` call to clone the entire initial dataframe for further comparisons:
94100

95101
>>> _ = df.raffa.startlog().fill_null(0).raffa.endlog(timeit=False)
96102
Shape is the same. No value-level comparison done because clone=False was used in startlog().
97103
>>> _ = df.raffa.startlog(clone=True).fill_null("0").raffa.endlog(timeit=False)
98104
Changed 11/2,752 (0.40%) values.
99105

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)
106+
Joins
107+
=====
108+
109+
Let's see an example with joins.
110+
111+
>>> df1 = pl.DataFrame({"A": ["a1", "a2", "a3", "a4"], "B": ["b1", "b2", "b3", "b4"]})
112+
>>> df2 = pl.DataFrame({"A": ["a1", "a2", "a3", "a5", "a6"], "C": ["c1", "c2", "c3", "c5", "c6"]})
113+
114+
Mutating joins
115+
--------------
116+
117+
Outer join:
118+
119+
>>> _ = df1.raffa.join(df2, on="A", how="outer")
120+
Total rows in output table: 6
121+
From left only: 1/6 (16.67%)
122+
From right only: 2/6 (33.33%)
123+
From both: 3/6 (50.00%) (left dups 0, right dups 0)
124+
Dropped rows from left: 0/4 (0.00%)
125+
Dropped rows from right: 0/5 (0.00%)
126+
127+
Inner join:
128+
129+
>>> _ = df1.raffa.join(df2, on="A", how="inner")
130+
Total rows in output table: 3
131+
From left only: 0/3 (0.00%)
132+
From right only: 0/3 (0.00%)
133+
From both: 3/3 (100.00%) (left dups 0, right dups 0)
134+
Dropped rows from left: 1/4 (25.00%)
135+
Dropped rows from right: 2/5 (40.00%)
136+
137+
Left join:
138+
139+
>>> _ = df1.raffa.join(df2, on="A", how="left")
140+
Total rows in output table: 4
141+
From left only: 1/4 (25.00%)
142+
From right only: 0/4 (0.00%)
143+
From both: 3/4 (75.00%) (left dups 0, right dups 0)
144+
Dropped rows from left: 0/4 (0.00%)
145+
Dropped rows from right: 2/5 (40.00%)
146+
147+
Right join:
148+
149+
>>> _ = df1.raffa.join(df2, on="A", how="right")
150+
Total rows in output table: 5
151+
From left only: 0/5 (0.00%)
152+
From right only: 2/5 (40.00%)
153+
From both: 3/5 (60.00%) (left dups 0, right dups 0)
154+
Dropped rows from left: 1/4 (25.00%)
155+
Dropped rows from right: 0/5 (0.00%)
156+
157+
Filtering joins
158+
---------------
114159

115160
Filtering joins are automatically detected:
116161

117162
>>> _ = df1.raffa.join(df2, left_on="left_a", right_on="right_a", how="semi", keep_row_index=False)
118163
Detected filtering join. Rows variation -2/5 (-40.00%), total rows after join: 3/5 (60.00%)
119164
>>> _ = 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%)
165+
Detected filtering join. Rows variation -3/5 (-60.00%), total rows after join: 2/5 (40.00%)
166+
167+
Word export
168+
===========
169+
170+
Let's export a DataFrame in a Word ``.docx`` file:
171+
172+
>>> df = pl.DataFrame({"a": [1,2,3], "b":["AAA", "BBB", "CCC"]})
173+
>>> df.raffa.to_docx("main.docx")

src/raffalib/export_docx.py

Lines changed: 137 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -5,89 +5,150 @@
55
from docx.enum.text import WD_LINE_SPACING
66
from docx.enum.section import WD_SECTION
77
from docx.enum.section import WD_ORIENT
8+
from docx.enum.style import WD_STYLE_TYPE
89
from dataclasses import dataclass
10+
from pathlib import Path
911

10-
@dataclass
11-
class DocxOptions:
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
19-
heading_text:str|None = None
20-
heading_font_name:str = "Aptos"
21-
heading_font_size = 12
22-
heading_bold = True
23-
heading_italic = False
24-
heading_underline = False
25-
heading_color = (0x00, 0x00, 0x00)
26-
heading_space_before = 0
27-
heading_space_after = 6
28-
29-
def prepare_docx(options:DocxOptions):
30-
31-
# create new document
32-
doc = Document()
33-
34-
# Page layout
35-
section = doc.sections[0]
36-
if options.landscape:
37-
section.page_height = Mm(options.page_height)
38-
section.page_width = Mm(options.page_width)
39-
section.orientation = WD_ORIENT.LANDSCAPE
40-
41-
else:
42-
section.page_height = Mm(options.page_width)
43-
section.page_width = Mm(options.page_height)
44-
section.orientation = WD_ORIENT.PORTRAIT
12+
13+
class DocxFile:
14+
def __init__(
15+
self,
16+
page_height: float = 210,
17+
page_width: float = 297,
18+
landscape: bool = False,
19+
left_margin: float = 25.4,
20+
right_margin: float = 25.4,
21+
top_margin: float = 25.4,
22+
bottom_margin: float = 25.4,
23+
heading_text: str | None = None,
24+
heading_font_name: str = "Aptos",
25+
heading_font_size: int = 12,
26+
heading_bold: bool = True,
27+
heading_italic: bool = False,
28+
heading_underline: bool = False,
29+
heading_color: [int, int, int] = (0x00, 0x00, 0x00),
30+
heading_space_before: int = 0,
31+
heading_space_after: int = 6,
32+
):
33+
34+
# create new document
35+
self.doc = Document()
36+
37+
# Page layout
38+
section = self.doc.sections[0]
39+
if landscape:
40+
self.page_height = page_height
41+
self.page_width = page_width
42+
section.orientation = WD_ORIENT.LANDSCAPE
43+
else:
44+
self.page_height = page_width
45+
self.page_width = page_height
46+
section.orientation = WD_ORIENT.PORTRAIT
47+
48+
section.page_height = Mm(self.page_height)
49+
section.page_width = Mm(self.page_width)
50+
51+
# Page margins
52+
section.left_margin = Mm(left_margin)
53+
section.right_margin = Mm(right_margin)
54+
section.top_margin = Mm(top_margin)
55+
section.bottom_margin = Mm(bottom_margin)
56+
section.header_distance = Mm(12.7)
57+
section.footer_distance = Mm(12.7)
58+
59+
# Add section heading
60+
if heading_text:
61+
# Add heading
62+
self.doc.add_heading(heading_text)
63+
64+
# Set style of the header
65+
style = self.doc.styles["Heading 1"]
66+
67+
font = style.font
68+
69+
font.name = heading_font_name
70+
# Setting the style of the header requires this additional workaround
71+
# see: https://stackoverflow.com/a/60922725/1719931
72+
rFonts = style.element.rPr.rFonts
73+
rFonts.set(qn("w:asciiTheme"), heading_font_name)
74+
75+
font.size = Pt(heading_font_size)
76+
font.bold = heading_bold
77+
font.italic = heading_italic
78+
font.underline = heading_underline
79+
80+
font.color.rgb = RGBColor(*heading_color)
81+
82+
paragraph_format = style.paragraph_format
83+
paragraph_format.space_before = Pt(heading_space_before)
84+
paragraph_format.space_after = Pt(heading_space_after)
85+
paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
4586

46-
section.left_margin = Mm(options.left_margin)
47-
section.right_margin = Mm(options.right_margin)
48-
section.top_margin = Mm(options.top_margin)
49-
section.bottom_margin = Mm(options.bottom_margin)
50-
section.header_distance = Mm(12.7)
51-
section.footer_distance = Mm(12.7)
52-
53-
# Add section heading
54-
if options.heading_text:
55-
56-
# Add heading
57-
doc.add_heading(options.heading_text)
87+
self.table = None
88+
89+
def add_table(
90+
self,
91+
n_rows,
92+
n_cols,
93+
table_autofit: bool = True,
94+
table_style: str = "Table Grid",
95+
table_font_name: str = "Aptos",
96+
table_font_size: int = 12,
97+
table_header_font_name: str = "Aptos",
98+
table_header_font_size: int = 12,
99+
table_header_font_bold: bool = True,
100+
):
101+
102+
# Create table
103+
self.table_autofit = table_autofit
104+
self.table = self.doc.add_table(n_rows, n_cols)
58105

59-
# Set style of the header
60-
style = doc.styles["Heading 1"]
106+
# Set table style
107+
# See: https://github.com/python-openxml/python-docx/issues/9
108+
self.table.style = table_style
61109

110+
# Create style for table header (first row)
111+
style = self.doc.styles.add_style("CellHeader", WD_STYLE_TYPE.PARAGRAPH)
112+
style.base_style = self.doc.styles["Normal"]
62113
font = style.font
114+
font.name = table_header_font_name
115+
font.size = Pt(table_header_font_size)
116+
font.bold = table_header_font_bold
63117

64-
font.name = options.heading_font_name
65-
# Setting the style of the header requires this additional workaround
66-
# see: https://stackoverflow.com/a/60922725/1719931
67-
rFonts = style.element.rPr.rFonts
68-
rFonts.set(qn("w:asciiTheme"), options.heading_font_name)
118+
# Create style for table cells (second row onwards)
119+
style = self.doc.styles.add_style("CellText", WD_STYLE_TYPE.PARAGRAPH)
120+
style.base_style = self.doc.styles["Normal"]
121+
font = style.font
122+
font.name = table_font_name
123+
font.size = Pt(table_font_size)
69124

70-
font.size = Pt(options.heading_font_size)
71-
font.bold = options.heading_bold
72-
font.italic = options.heading_italic
73-
font.underline = options.heading_underline
125+
# Additional workaround to make autofit actually work
126+
# See: https://github.com/python-openxml/python-docx/issues/209#issuecomment-566128709
127+
def _table_autofit_hotfix(self):
128+
for column in self.table.columns:
129+
for cell in column.cells:
130+
tc = cell._tc
131+
tcPr = tc.get_or_add_tcPr()
132+
tcW = tcPr.get_or_add_tcW()
133+
tcW.type = "auto"
74134

75-
font.color.rgb = RGBColor(*options.heading_color)
135+
def save(self, outfp: Path):
136+
137+
if self.table:
138+
# Hotfix for table autofit
139+
if self.table_autofit:
140+
self._table_autofit_hotfix()
76141

77-
paragraph_format = style.paragraph_format
78-
paragraph_format.space_before = Pt(options.heading_space_before)
79-
paragraph_format.space_after = Pt(options.heading_space_after)
80-
paragraph_format.line_spacing_rule = WD_LINE_SPACING.SINGLE
81-
82-
return doc
83-
84-
# Additional workaround to make autofit actually work
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
142+
# Set table text style for header (row 0)
143+
for cell in self.table.rows[0].cells:
144+
for paragraph in cell.paragraphs:
145+
paragraph.style = "CellHeader"
146+
147+
# Set table text style for data cells (rows >= 1)
148+
for row in self.table.rows[1:]:
149+
for cell in row.cells:
150+
for paragraph in cell.paragraphs:
151+
paragraph.style = "CellText" # assuming style named "Cell Text"
152+
153+
# save the doc
154+
self.doc.save(outfp)

0 commit comments

Comments
 (0)