|
| 1 | +# CLAUDE.md |
| 2 | + |
| 3 | +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. |
| 4 | + |
| 5 | +## What this is |
| 6 | + |
| 7 | +`raffalib` is a Python (≥3.13) helper library for data wrangling. Its headline |
| 8 | +feature is **STATA-like change logging** layered onto pandas and polars via a |
| 9 | +`.raffa` accessor/namespace, plus `.docx` export. It also bundles small, |
| 10 | +mostly-independent utilities (logging setup, pickling, progress bars, Selenium, |
| 11 | +SQLAlchemy view helpers, bibliometrics/Scopus/OpenAlex helpers). |
| 12 | + |
| 13 | +Packaged with the `uv_build` backend in a **src layout** (`src/raffalib/`). |
| 14 | + |
| 15 | +## Commands |
| 16 | + |
| 17 | +All workflows go through `uv`: |
| 18 | + |
| 19 | +```console |
| 20 | +uv sync --extra dev # install dev deps (ruff + pytest) |
| 21 | +uv run pytest # run the test suite |
| 22 | +uv run pytest tests/test_polars.py # one file |
| 23 | +uv run pytest tests/test_polars.py::test_endlog_removed_rows # one test |
| 24 | +uv run ruff check # lint |
| 25 | +uv run ruff format # format |
| 26 | +``` |
| 27 | + |
| 28 | +Doctests in the narrative docs are executable and run in CI (`.github/workflows/docs.yml`). |
| 29 | +Run them locally with the pandas + polars + docs extras: |
| 30 | + |
| 31 | +```console |
| 32 | +uv run --extra docs --extra pandas --extra polars \ |
| 33 | + sphinx-build -b doctest docs/source docs/_build/doctest |
| 34 | +``` |
| 35 | + |
| 36 | +CI runs in two workflows: `.github/workflows/tests.yml` (pytest, with the |
| 37 | +pandas/polars/db/web/bibliometrics extras so nothing is skipped) and |
| 38 | +`.github/workflows/docs.yml` (the doctests above). |
| 39 | + |
| 40 | +## Architecture |
| 41 | + |
| 42 | +### The two backends share one message builder — this is the central invariant |
| 43 | + |
| 44 | +`src/raffalib/_logutils.py` is the **single source of truth for all |
| 45 | +human-readable log text** (row/column deltas, changed-cell counts, join |
| 46 | +provenance, elapsed time, the `JoinCounts` dataclass). `pandas.py` and |
| 47 | +`polars.py` only do backend-specific mechanics (state storage, cloning, value |
| 48 | +comparison) and then call into `_logutils` for the wording. |
| 49 | + |
| 50 | +Consequence: the pandas and polars accessors are designed to emit **byte-identical |
| 51 | +log output**. When changing any log message, edit `_logutils.py` only — never |
| 52 | +hand-edit a string in one backend. Doing so silently breaks parity. |
| 53 | + |
| 54 | +Tests and docs are tightly coupled to the exact wording: `tests/test_pandas.py` |
| 55 | +and `tests/test_polars.py` assert on literal substrings via `caplog`, and the |
| 56 | +`.rst` doctests in `docs/source/` reproduce exact output. Any message change |
| 57 | +requires updating those assertions and doctests together. |
| 58 | + |
| 59 | +### How the `.raffa` accessors persist state across a pipeline |
| 60 | + |
| 61 | +`startlog()` stashes the initial shape / optional clone / start time, then |
| 62 | +`endlog()` reads it back to compute the diff. The two backends store that state |
| 63 | +differently: |
| 64 | + |
| 65 | +- **pandas** (`pandas.py`): registers via `@pd.api.extensions.register_*_accessor("raffa")` |
| 66 | + and appends `_initial_shape` / `_initial_df` / `_start_time` to the object's |
| 67 | + `_metadata` so they survive pandas operations. `endlog()` `del`s them when done. |
| 68 | +- **polars** (`polars.py`): polars frames have no attribute storage, so state |
| 69 | + lives in the `config_meta` namespace from the **`polars-config-meta`** package |
| 70 | + (imported for its import side effect). `endlog()` first checks the metadata |
| 71 | + exists and warns "You have to call startlog() before..." if not. |
| 72 | + |
| 73 | +Both register on import: `import raffalib.pandas` / `import raffalib.polars` is |
| 74 | +what installs the `.raffa` accessor. Each module deletes any pre-existing |
| 75 | +`.raffa` accessor at import time to suppress pandas/polars override warnings. |
| 76 | + |
| 77 | +`clone=True` in `startlog()` keeps a full copy so value-level changes can be |
| 78 | +detected when the shape is unchanged; `clone=False` (default) only tracks shape |
| 79 | +and emits `_logutils.CLONE_FALSE_MSG`. Null-vs-null is treated as *unchanged* in |
| 80 | +both backends (pandas masks `isna() & isna()`; polars uses `ne_missing`). |
| 81 | + |
| 82 | +### The logging `join` wrapper |
| 83 | + |
| 84 | +`df.raffa.join(...)` wraps `pd.DataFrame.merge` / `pl.DataFrame.join`, adding |
| 85 | +hidden `source_left` / `source_right` row-index columns to reconstruct row |
| 86 | +provenance (left-only / right-only / both, duplicate keys, dropped rows), then |
| 87 | +logging it via `_logutils.join_log`. Filtering joins (`how="semi"`/`"anti"`) are |
| 88 | +detected and logged separately via `filtering_join_log`. pandas has no native |
| 89 | +semi/anti join so it is emulated with an inner merge; pandas `"full"` is |
| 90 | +translated to merge's `"outer"`. `keep_row_index=True` keeps the source-index |
| 91 | +columns in the output (polars uses the `polars-permute` `permute` namespace to |
| 92 | +move them to the end). |
| 93 | + |
| 94 | +### `.docx` export |
| 95 | + |
| 96 | +`export_docx.py` holds `DocxFile`, a python-docx wrapper for page setup, |
| 97 | +styled tables, and matplotlib/plotly figures. The `DataFrame.raffa.to_docx()` |
| 98 | +methods build the table cell-by-cell on top of it. Note `DocxFile.with_table()` |
| 99 | +introspects the signatures of `__init__` and `add_table` to **route each kwarg** |
| 100 | +to the right method, raising `TypeError` on unknown keys — this is why |
| 101 | +`to_docx(..., heading_text=..., table_style=...)` "just works" with mixed |
| 102 | +document- and table-level options. |
| 103 | + |
| 104 | +### `logging.py` |
| 105 | + |
| 106 | +`create_logger()` is the opinionated entry point users call to actually see the |
| 107 | +output (plain `StreamHandler` or `rich.RichHandler`). It re-enables the |
| 108 | +`raffalib.pandas` / `raffalib.polars` loggers explicitly after `dictConfig`. |
| 109 | + |
| 110 | +## Conventions |
| 111 | + |
| 112 | +- **Optional deps are real**: only `humanize`, `jsonpickle`, `natsort`, |
| 113 | + `python-docx`, `rich`, `tqdm` are core. pandas, polars, sqlalchemy, selenium, |
| 114 | + pyalex, etc. live behind extras (see `pyproject.toml`). Tests guard their |
| 115 | + imports with `pytest.importorskip(...)` so the suite passes without every |
| 116 | + extra installed — follow that pattern for any new optional-dep test. |
| 117 | +- Every source file carries the **GPL-3.0-or-later license header**. Keep it on |
| 118 | + new files. |
| 119 | +- Docstrings use **reStructuredText field lists** (`:param:` / `:type:` / |
| 120 | + `:return:` / `:rtype:`) and are rendered by `sphinx-autoapi`. |
| 121 | +- No `[tool.ruff]` config — ruff runs on defaults. |
0 commit comments