Skip to content

Commit 6c51890

Browse files
Add midlog to polars and a clone option to midlog
Fix pandas midlog (endlog() returns a bare frame, so it must restart via .raffa.startlog()), add the equivalent midlog to the polars Series and DataFrame accessors, and give midlog an optional clone argument forwarded to the restarted startlog. Add tests covering both backends. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 184571a commit 6c51890

6 files changed

Lines changed: 126 additions & 14 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ See the [Examples](https://raffalib-python.readthedocs.io) page for the full wal
147147
| Module | What it provides |
148148
| ----------------------- | ---------------------------------------------------------------------- |
149149
| `raffalib.pandas` | `.raffa` accessor: `startlog`/`endlog`/`midlog`, `join`, `freq`, `to_docx`, `add_prefix_if_not_exists`, `get_duplicates`, `sort_columns` |
150-
| `raffalib.polars` | `.raffa` namespace: logging, `freq`, `crosstab`, `join`, `replace_string_with_null`, `to_docx` |
150+
| `raffalib.polars` | `.raffa` namespace: `startlog`/`endlog`/`midlog`, `freq`, `crosstab`, `join`, `replace_string_with_null`, `to_docx` |
151151
| `raffalib.logging` | `create_logger` — opinionated logging setup (plain or `rich`) |
152152
| `raffalib.export_docx` | `DocxFile` — low-level Word document/table builder |
153153
| `raffalib.tqdm` | `tqdm_batch` — batched progress bars |

docs/source/examples.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ how many cell values changed.
3232
appends the elapsed time since ``startlog``. The signature is identical for
3333
pandas and polars.
3434

35-
``midlog(custom_msg=None, timeit=True)`` *(pandas only)*
35+
``midlog(custom_msg=None, timeit=True, clone=False)``
3636
A convenience alias for ``endlog().startlog()`` — log the current step and
37-
immediately start the next one.
37+
immediately start the next one. Available on both pandas and polars. ``clone``
38+
is forwarded to the restarted ``startlog``, so pass ``clone=True`` when the
39+
next segment should detect value-level changes.
3840

3941
Messages are emitted through the standard :mod:`logging` module. Configure it
4042
once with :func:`raffalib.create_logger` so the messages reach your console:

src/raffalib/pandas.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,22 @@ def startlog(self, clone=False):
7373
self._series._initial_series = None
7474
return self._series
7575

76-
def midlog(self, custom_msg: str | None = None, timeit: bool = True):
76+
def midlog(
77+
self, custom_msg: str | None = None, timeit: bool = True, clone: bool = False
78+
):
7779
"""
78-
Alias for `.endlog().startlog()`
80+
Alias for `.endlog().startlog(clone=clone)`.
81+
82+
:param custom_msg: A custom message to log before the actual log
83+
:type custom_msg: str | None
84+
:param timeit: Log the time it took for the operation
85+
:type timeit: bool
86+
:param clone: Forwarded to the restarted ``startlog``: clone the data so
87+
the next segment can detect value-level changes when the shape is
88+
unchanged.
89+
:type clone: bool
7990
"""
80-
return self.endlog(custom_msg, timeit=timeit).startlog()
91+
return self.endlog(custom_msg, timeit=timeit).raffa.startlog(clone=clone)
8192

8293
def endlog(self, custom_msg: str | None = None, timeit: bool = True):
8394
"""
@@ -179,12 +190,24 @@ def startlog(self, clone: bool = False) -> pd.DataFrame:
179190
return self._df
180191

181192
def midlog(
182-
self, custom_msg: str | None = None, timeit: bool = True
193+
self,
194+
custom_msg: str | None = None,
195+
timeit: bool = True,
196+
clone: bool = False,
183197
) -> pd.DataFrame:
184198
"""
185-
Alias for `.endlog().startlog()`
199+
Alias for `.endlog().startlog(clone=clone)`.
200+
201+
:param custom_msg: A custom message to log before the actual log
202+
:type custom_msg: str | None
203+
:param timeit: Log the time it took for the operation
204+
:type timeit: bool
205+
:param clone: Forwarded to the restarted ``startlog``: clone the data so
206+
the next segment can detect value-level changes when the shape is
207+
unchanged.
208+
:type clone: bool
186209
"""
187-
return self.endlog(custom_msg, timeit=timeit).startlog()
210+
return self.endlog(custom_msg, timeit=timeit).raffa.startlog(clone=clone)
188211

189212
def endlog(
190213
self, custom_msg: str | None = None, timeit: bool = True

src/raffalib/polars.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ class RaffaPolarsSeriesUtils:
6262
The ``.raffa`` namespace on a :class:`polars.Series`.
6363
6464
Registered automatically when :mod:`raffalib.polars` is imported. Provides
65-
STATA-like change logging (:meth:`startlog` / :meth:`endlog`) and tabulation
66-
helpers (:meth:`freq`, :meth:`crosstab`).
65+
STATA-like change logging (:meth:`startlog` / :meth:`endlog` / :meth:`midlog`)
66+
and tabulation helpers (:meth:`freq`, :meth:`crosstab`).
6767
"""
6868

6969
def __init__(self, series: pl.Series):
@@ -125,6 +125,23 @@ def endlog(self, custom_msg: str | None = None, timeit: bool = True) -> pl.Serie
125125
logger.info(msg)
126126
return self._series
127127

128+
def midlog(
129+
self, custom_msg: str | None = None, timeit: bool = True, clone: bool = False
130+
) -> pl.Series:
131+
"""
132+
Alias for `.endlog().startlog(clone=clone)`.
133+
134+
:param custom_msg: A custom message to log before the actual log
135+
:type custom_msg: str | None
136+
:param timeit: Log the time it took for the operation
137+
:type timeit: bool
138+
:param clone: Forwarded to the restarted ``startlog``: clone the data so
139+
the next segment can detect value-level changes when the shape is
140+
unchanged.
141+
:type clone: bool
142+
"""
143+
return self.endlog(custom_msg, timeit=timeit).raffa.startlog(clone=clone)
144+
128145
def freq(self) -> pl.DataFrame:
129146
"""
130147
Frequency table for the Series.
@@ -183,9 +200,9 @@ class RaffaPolarsDataFrameUtils:
183200
The ``.raffa`` namespace on a :class:`polars.DataFrame`.
184201
185202
Registered automatically when :mod:`raffalib.polars` is imported. Provides
186-
STATA-like change logging (:meth:`startlog` / :meth:`endlog`), a logging
187-
:meth:`join` wrapper, tabulation helpers (:meth:`freq`, :meth:`crosstab`),
188-
and :meth:`to_docx` export.
203+
STATA-like change logging (:meth:`startlog` / :meth:`endlog` / :meth:`midlog`),
204+
a logging :meth:`join` wrapper, tabulation helpers (:meth:`freq`,
205+
:meth:`crosstab`), and :meth:`to_docx` export.
189206
"""
190207

191208
def __init__(self, df: pl.DataFrame):
@@ -254,6 +271,26 @@ def endlog(
254271
logger.info(msg)
255272
return self._df
256273

274+
def midlog(
275+
self,
276+
custom_msg: str | None = None,
277+
timeit: bool = True,
278+
clone: bool = False,
279+
) -> pl.DataFrame:
280+
"""
281+
Alias for `.endlog().startlog(clone=clone)`.
282+
283+
:param custom_msg: A custom message to log before the actual log
284+
:type custom_msg: str | None
285+
:param timeit: Log the time it took for the operation
286+
:type timeit: bool
287+
:param clone: Forwarded to the restarted ``startlog``: clone the data so
288+
the next segment can detect value-level changes when the shape is
289+
unchanged.
290+
:type clone: bool
291+
"""
292+
return self.endlog(custom_msg, timeit=timeit).raffa.startlog(clone=clone)
293+
257294
def replace_string_with_null(self, s):
258295
"""
259296
Replace a sentinel string with null across all string columns.

tests/test_pandas.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,31 @@ def test_series_endlog_removed_values(caplog):
8282
)
8383

8484

85+
def test_midlog_logs_then_restarts(caplog):
86+
df = pd.DataFrame({"a": [1, 2, 3, 4]})
87+
with caplog.at_level(logging.INFO, logger="raffalib.pandas"):
88+
df.raffa.startlog()
89+
df = df[df["a"] > 1]
90+
df = df.raffa.midlog(timeit=False) # logs the first step, restarts logging
91+
df = df[df["a"] > 2]
92+
df.raffa.endlog(timeit=False)
93+
msgs = [r.message for r in caplog.records]
94+
assert any("Removed 1/4 (25.00%) rows. New shape: (3, 1)." in m for m in msgs)
95+
assert any("Removed 1/3 (33.33%) rows. New shape: (2, 1)." in m for m in msgs)
96+
97+
98+
def test_midlog_clone_enables_value_diff(caplog):
99+
df = pd.DataFrame({"a": [1, 2, 3, 4]})
100+
with caplog.at_level(logging.INFO, logger="raffalib.pandas"):
101+
df.raffa.startlog()
102+
df = df[df["a"] > 1]
103+
df = df.raffa.midlog(timeit=False, clone=True) # restart WITH a clone
104+
df = df.assign(a=[99, 3, 4])
105+
df.raffa.endlog(timeit=False)
106+
msgs = [r.message for r in caplog.records]
107+
assert any("Changed 1/3 (33.33%) values." in m for m in msgs)
108+
109+
85110
def test_join_inner_drops_source_columns(caplog):
86111
left = pd.DataFrame({"k": [1, 2, 3], "lv": [10, 20, 30]})
87112
right = pd.DataFrame({"k": [2, 3, 4], "rv": [200, 300, 400]})

tests/test_polars.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,31 @@ def test_series_endlog_matches_pandas_format(caplog):
5858
)
5959

6060

61+
def test_midlog_logs_then_restarts(caplog):
62+
df = pl.DataFrame({"a": [1, 2, 3, 4]})
63+
with caplog.at_level(logging.INFO, logger="raffalib.polars"):
64+
df.raffa.startlog()
65+
df = df.filter(pl.col("a") > 1)
66+
df = df.raffa.midlog(timeit=False) # logs the first step, restarts logging
67+
df = df.filter(pl.col("a") > 2)
68+
df.raffa.endlog(timeit=False)
69+
msgs = [r.message for r in caplog.records]
70+
assert any("Removed 1/4 (25.00%) rows. New shape: (3, 1)." in m for m in msgs)
71+
assert any("Removed 1/3 (33.33%) rows. New shape: (2, 1)." in m for m in msgs)
72+
73+
74+
def test_midlog_clone_enables_value_diff(caplog):
75+
df = pl.DataFrame({"a": [1, 2, 3, 4]})
76+
with caplog.at_level(logging.INFO, logger="raffalib.polars"):
77+
df.raffa.startlog()
78+
df = df.filter(pl.col("a") > 1)
79+
df = df.raffa.midlog(timeit=False, clone=True) # restart WITH a clone
80+
df = df.with_columns(pl.Series("a", [99, 3, 4]))
81+
df.raffa.endlog(timeit=False)
82+
msgs = [r.message for r in caplog.records]
83+
assert any("Changed 1/3 (33.33%) values." in m for m in msgs)
84+
85+
6186
def test_endlog_timeit_appends_duration(caplog):
6287
df = pl.DataFrame({"a": [1, 2, 3]})
6388
with caplog.at_level(logging.INFO, logger="raffalib.polars"):

0 commit comments

Comments
 (0)