csvledger 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
csvledger/__init__.py ADDED
@@ -0,0 +1,326 @@
1
+ """Long-form CSV tables that merge idempotently: a git-friendly history store.
2
+
3
+ A table is described by a :class:`TableSpec` (columns, key, merge policy,
4
+ partitioning). Rows are merged into CSV files, one per table (or one per year
5
+ of its time column), sorted so new rows land at the end of a file, through any
6
+ ``MutableMapping[str, str]`` (relative path to text): a local folder
7
+ (:class:`Files`, the default), a git checkout, or any ``dol`` store.
8
+
9
+ Merge policies are chosen so that re-running a collection never changes the
10
+ result (idempotent), and the order of runs does not matter for daily series:
11
+
12
+ - ``max``: upsert by key; integer fields keep the larger value. A day's count
13
+ only grows as the day completes, so the fullest count wins whichever run saw it.
14
+ - ``replace_group``: rows of a snapshot (``group`` columns, e.g. one repo and
15
+ one fetch date) replace any earlier rows of the same group.
16
+ - ``change_only``: a row is stored only when its values differ from the
17
+ previous row of the same ``entity``; the key is the entity columns plus one
18
+ date column. For slowly changing values, where daily copies would be noise.
19
+ - ``upsert``: upsert by key, the new row wins (current metadata).
20
+
21
+ >>> spec = TableSpec("views", ("date", "repo", "count"), key=("date", "repo"),
22
+ ... merge="max", int_columns=("count",), time_column="date")
23
+ >>> store = {}
24
+ >>> write_rows(store, spec, [{"date": "2026-01-01", "repo": "a/b", "count": 3}])
25
+ 1
26
+ >>> write_rows(store, spec, [{"date": "2026-01-01", "repo": "a/b", "count": 2}])
27
+ 0
28
+ >>> print(store["views/2026.csv"], end="")
29
+ date,repo,count
30
+ 2026-01-01,a/b,3
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import csv
36
+ import io
37
+ import json
38
+ import os
39
+ from collections.abc import Iterable, Iterator, Mapping, MutableMapping
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+ from typing import Literal
43
+
44
+ __all__ = [
45
+ "Files",
46
+ "MANIFEST_KEY",
47
+ "MergePolicy",
48
+ "TableSpec",
49
+ "dump_csv",
50
+ "manifest",
51
+ "merge_rows",
52
+ "parse_csv",
53
+ "read_table",
54
+ "table_files",
55
+ "write_manifest",
56
+ "write_rows",
57
+ ]
58
+
59
+ MergePolicy = Literal["max", "replace_group", "change_only", "upsert"]
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class TableSpec:
64
+ """How one table is laid out on disk and merged."""
65
+
66
+ name: str
67
+ columns: tuple[str, ...]
68
+ key: tuple[str, ...]
69
+ merge: MergePolicy
70
+ int_columns: tuple[str, ...] = ()
71
+ time_column: str | None = None # partition by its year when set
72
+ group: tuple[str, ...] = () # for 'replace_group'
73
+ entity: tuple[str, ...] = () # for 'change_only': what a row tracks
74
+ description: str = ""
75
+ sort: tuple[str, ...] = field(default=())
76
+
77
+ def __post_init__(self):
78
+ if self.merge == "change_only":
79
+ if self.time_column is not None:
80
+ raise ValueError("A 'change_only' table cannot be partitioned by time.")
81
+ if len([c for c in self.key if c not in self.entity]) != 1:
82
+ raise ValueError(
83
+ "A 'change_only' table's key must be its entity columns "
84
+ f"plus one date column; got key={self.key}, entity={self.entity}."
85
+ )
86
+
87
+ @property
88
+ def change_column(self) -> str:
89
+ """For 'change_only': the key column that orders an entity's rows (a date)."""
90
+ return next(c for c in self.key if c not in self.entity)
91
+
92
+ @property
93
+ def sort_columns(self) -> tuple[str, ...]:
94
+ """Sort order on disk: time first, so new rows land at the end of a file."""
95
+ return self.sort or self.key
96
+
97
+ def path_for(self, row: dict) -> str:
98
+ """The relative path of the file holding ``row``."""
99
+ if self.time_column is None:
100
+ return f"{self.name}.csv"
101
+ return f"{self.name}/{str(row[self.time_column])[:4]}.csv"
102
+
103
+
104
+ # --------------------------------------------------------------------------- store
105
+
106
+
107
+ class Files(MutableMapping):
108
+ """Text files under ``rootdir``, keyed by relative POSIX path."""
109
+
110
+ def __init__(self, rootdir: str | os.PathLike):
111
+ self.rootdir = Path(rootdir).expanduser()
112
+
113
+ def _path(self, k: str) -> Path:
114
+ return self.rootdir / k
115
+
116
+ def __getitem__(self, k: str) -> str:
117
+ try:
118
+ return self._path(k).read_text(encoding="utf-8")
119
+ except FileNotFoundError:
120
+ raise KeyError(k) from None
121
+
122
+ def __setitem__(self, k: str, v: str) -> None:
123
+ p = self._path(k)
124
+ p.parent.mkdir(parents=True, exist_ok=True)
125
+ tmp = p.with_name(p.name + ".tmp")
126
+ tmp.write_text(v, encoding="utf-8", newline="\n")
127
+ os.replace(tmp, p) # atomic: a crash never leaves half a file
128
+
129
+ def __delitem__(self, k: str) -> None:
130
+ try:
131
+ self._path(k).unlink()
132
+ except FileNotFoundError:
133
+ raise KeyError(k) from None
134
+
135
+ def __iter__(self) -> Iterator[str]:
136
+ if not self.rootdir.exists():
137
+ return
138
+ for p in sorted(self.rootdir.rglob("*")):
139
+ if p.is_file() and ".git" not in p.parts:
140
+ yield p.relative_to(self.rootdir).as_posix()
141
+
142
+ def __len__(self) -> int:
143
+ return sum(1 for _ in self)
144
+
145
+ def __repr__(self) -> str:
146
+ return f"{type(self).__name__}({str(self.rootdir)!r})"
147
+
148
+
149
+ # ------------------------------------------------------------------ csv in and out
150
+
151
+
152
+ def _coerce(spec: TableSpec, row: dict) -> dict:
153
+ out = {c: row.get(c, "") for c in spec.columns}
154
+ for c in spec.int_columns:
155
+ v = out[c]
156
+ out[c] = int(v) if v not in ("", None) else ""
157
+ for c, v in out.items():
158
+ if isinstance(v, bool):
159
+ out[c] = "true" if v else "false"
160
+ return out
161
+
162
+
163
+ def parse_csv(spec: TableSpec, text: str) -> list[dict]:
164
+ """Rows of a table file, with integer columns as ``int``."""
165
+ return [_coerce(spec, r) for r in csv.DictReader(io.StringIO(text))]
166
+
167
+
168
+ def _sort_key(spec: TableSpec):
169
+ return lambda r: tuple(str(r[c]) for c in spec.sort_columns)
170
+
171
+
172
+ def dump_csv(spec: TableSpec, rows: Iterable[dict]) -> str:
173
+ """CSV text of ``rows``, sorted by the spec's sort columns, ``\\n`` line ends."""
174
+ buf = io.StringIO()
175
+ w = csv.DictWriter(buf, fieldnames=spec.columns, lineterminator="\n")
176
+ w.writeheader()
177
+ w.writerows(sorted((_coerce(spec, r) for r in rows), key=_sort_key(spec)))
178
+ return buf.getvalue()
179
+
180
+
181
+ # -------------------------------------------------------------------------- merging
182
+
183
+
184
+ def _values(spec: TableSpec, row: dict) -> tuple:
185
+ return tuple(row[c] for c in spec.columns if c not in spec.key)
186
+
187
+
188
+ def merge_rows(spec: TableSpec, old: list[dict], new: list[dict]) -> list[dict]:
189
+ """Merge ``new`` rows into ``old`` according to ``spec.merge``.
190
+
191
+ >>> v = TableSpec("views", ("date", "repo", "count", "uniques"),
192
+ ... key=("date", "repo"), merge="max",
193
+ ... int_columns=("count", "uniques"), time_column="date")
194
+ >>> old = [{"date": "2026-01-01", "repo": "a/b", "count": 3, "uniques": 1}]
195
+ >>> new = [{"date": "2026-01-01", "repo": "a/b", "count": 5, "uniques": 1},
196
+ ... {"date": "2026-01-02", "repo": "a/b", "count": 1, "uniques": 1}]
197
+ >>> [r["count"] for r in merge_rows(v, old, new)]
198
+ [5, 1]
199
+ >>> merge_rows(v, merge_rows(v, old, new), new) == merge_rows(v, old, new)
200
+ True
201
+ """
202
+ old = [_coerce(spec, r) for r in old]
203
+ new = [_coerce(spec, r) for r in new]
204
+
205
+ def key(r):
206
+ return tuple(str(r[c]) for c in spec.key)
207
+
208
+ if spec.merge in ("max", "upsert"):
209
+ merged = {key(r): r for r in old}
210
+ for r in new:
211
+ k = key(r)
212
+ if spec.merge == "max" and k in merged:
213
+ prev = merged[k]
214
+ r = {
215
+ c: (
216
+ max(prev[c], r[c])
217
+ if c in spec.int_columns and prev[c] != "" and r[c] != ""
218
+ else r[c]
219
+ if r[c] != ""
220
+ else prev[c]
221
+ )
222
+ for c in spec.columns
223
+ }
224
+ merged[k] = r
225
+ return list(merged.values())
226
+
227
+ if spec.merge == "replace_group":
228
+ groups = {tuple(str(r[c]) for c in spec.group) for r in new}
229
+ kept = [r for r in old if tuple(str(r[c]) for c in spec.group) not in groups]
230
+ return kept + new
231
+
232
+ if spec.merge == "change_only":
233
+ merged: dict[tuple, dict] = {}
234
+ for r in old + new: # same key: field-wise, a non-empty newer value wins
235
+ prev = merged.get(key(r), {})
236
+ merged[key(r)] = {
237
+ c: r[c] if r[c] != "" else prev.get(c, "") for c in spec.columns
238
+ }
239
+
240
+ def entity(r):
241
+ return tuple(str(r[c]) for c in spec.entity)
242
+
243
+ kept, last_values = [], {}
244
+ when = spec.change_column
245
+ for r in sorted(merged.values(), key=lambda r: (entity(r), str(r[when]))):
246
+ if last_values.get(entity(r)) != _values(spec, r):
247
+ kept.append(r)
248
+ last_values[entity(r)] = _values(spec, r)
249
+ return kept
250
+
251
+ raise ValueError(f"Unknown merge policy {spec.merge!r} for table {spec.name!r}")
252
+
253
+
254
+ def read_table(
255
+ store: MutableMapping, spec: TableSpec, *, years: Iterable[str] | None = None
256
+ ) -> list[dict]:
257
+ """All rows of a table (optionally only some year partitions)."""
258
+ keys = table_files(store, spec)
259
+ if years is not None:
260
+ wanted = {f"{spec.name}/{y}.csv" for y in years}
261
+ keys = [k for k in keys if k in wanted]
262
+ return [r for k in keys for r in parse_csv(spec, store[k])]
263
+
264
+
265
+ def table_files(store: MutableMapping, spec: TableSpec) -> list[str]:
266
+ """The relative paths of a table's files present in ``store``, sorted."""
267
+ if spec.time_column is None:
268
+ return [f"{spec.name}.csv"] if f"{spec.name}.csv" in store else []
269
+ prefix = f"{spec.name}/"
270
+ return sorted(k for k in store if k.startswith(prefix) and k.endswith(".csv"))
271
+
272
+
273
+ def write_rows(store: MutableMapping, spec: TableSpec, rows: Iterable[dict]) -> int:
274
+ """Merge ``rows`` into the table's files; return how many files changed."""
275
+ by_path: dict[str, list[dict]] = {}
276
+ for r in rows:
277
+ by_path.setdefault(spec.path_for(r), []).append(r)
278
+ changed = 0
279
+ for path, new in by_path.items():
280
+ old_text = store.get(path)
281
+ old = parse_csv(spec, old_text) if old_text else []
282
+ text = dump_csv(spec, merge_rows(spec, old, new))
283
+ if text != old_text:
284
+ store[path] = text
285
+ changed += 1
286
+ return changed
287
+
288
+
289
+ # ------------------------------------------------------------------------ manifest
290
+
291
+ MANIFEST_KEY = "manifest.json"
292
+
293
+
294
+ def manifest(store: MutableMapping, tables: Mapping[str, TableSpec], **extra) -> dict:
295
+ """A description of the tables in ``store``: files, columns, keys.
296
+
297
+ ``extra`` keys (a schema version, an update date) come first. A reader (a
298
+ dashboard) can discover every file of every table from it in one request.
299
+ """
300
+ return {
301
+ **extra,
302
+ "tables": {
303
+ name: {
304
+ "files": table_files(store, spec),
305
+ "columns": list(spec.columns),
306
+ "key": list(spec.key),
307
+ "description": spec.description,
308
+ }
309
+ for name, spec in tables.items()
310
+ },
311
+ }
312
+
313
+
314
+ def write_manifest(
315
+ store: MutableMapping,
316
+ tables: Mapping[str, TableSpec],
317
+ *,
318
+ key: str = MANIFEST_KEY,
319
+ **extra,
320
+ ) -> dict:
321
+ """Write :func:`manifest` as JSON under ``key`` (only if it changed)."""
322
+ m = manifest(store, tables, **extra)
323
+ text = json.dumps(m, indent=2) + "\n"
324
+ if store.get(key) != text:
325
+ store[key] = text
326
+ return m
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.5
2
+ Name: csvledger
3
+ Version: 0.0.2
4
+ Summary: Long-form CSV tables that merge idempotently (max, replace-group, change-only, upsert), partitioned by year, in any MutableMapping store: a git-friendly history store with no dependencies.
5
+ Project-URL: Homepage, https://github.com/thorwhalen/csvledger
6
+ Project-URL: Repository, https://github.com/thorwhalen/csvledger
7
+ Author: Thor Whalen
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: csv,dol,git,history,idempotent,merge,time-series
11
+ Requires-Python: >=3.11
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
14
+ Requires-Dist: pytest>=7.0; extra == 'dev'
15
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
16
+ Provides-Extra: docs
17
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
18
+ Requires-Dist: sphinx>=6.0; extra == 'docs'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # csvledger
22
+
23
+ Long-form CSV tables that merge idempotently: a git-friendly history store with no dependencies.
24
+
25
+ You collect numbers on a schedule (traffic, downloads, prices), each run sees an overlapping window, and you want one growing history in plain files: re-runs harmless, the order of runs irrelevant, small diffs, past years never rewritten. `csvledger` is that store.
26
+
27
+ ```python
28
+ from csvledger import TableSpec, Files, write_rows, read_table
29
+
30
+ views = TableSpec(
31
+ "views", ("date", "repo", "count"),
32
+ key=("date", "repo"), merge="max", int_columns=("count",), time_column="date",
33
+ )
34
+ store = Files("history") # or any MutableMapping[str, str]
35
+ write_rows(store, views, [{"date": "2026-01-01", "repo": "a/b", "count": 3}])
36
+ write_rows(store, views, [{"date": "2026-01-01", "repo": "a/b", "count": 2}]) # no change
37
+ read_table(store, views) # [{'date': '2026-01-01', 'repo': 'a/b', 'count': 3}]
38
+ ```
39
+
40
+ This writes `history/views/2026.csv`, sorted by date, so each run appends at the end of the current year's file.
41
+
42
+ ## Merge policies
43
+
44
+ | `merge=` | per key | for |
45
+ |---|---|---|
46
+ | `max` | integer fields keep the larger value | daily counts seen by overlapping windows (a partial day only grows) |
47
+ | `replace_group` | rows of a `group` (e.g. one repo, one fetch date) replace that group's earlier rows | top-N snapshots |
48
+ | `change_only` | a row is kept only when its values differ from the previous row of its `entity` (key = entity + one date column) | slowly changing values (stars, versions) |
49
+ | `upsert` | the new row wins | current metadata |
50
+
51
+ All four are idempotent: merging the same rows again changes nothing.
52
+
53
+ ## The store
54
+
55
+ Any `MutableMapping[str, str]` (relative path to text). `Files(root)` is a folder with atomic writes; a [dol](https://github.com/i2mint/dol) store puts the same tables in S3 or a database. `write_manifest(store, tables, **extra)` writes a `manifest.json` listing every table's files and columns, so a static dashboard can discover the data in one request.
56
+
57
+ Used by [ghtraffic](https://github.com/thorwhalen/ghtraffic) (GitHub traffic) and [pkgreach](https://github.com/thorwhalen/pkgreach) (package downloads), where it was extracted from.
@@ -0,0 +1,5 @@
1
+ csvledger/__init__.py,sha256=USpq4Aj2KMxMr_HPUGMAaAvDv8NQ79Anq-JcqjZ8ijw,11640
2
+ csvledger-0.0.2.dist-info/METADATA,sha256=j3QVjcnQK8guQ6IZji3QRJVXF7jCfYs9Rvj9cbD8sro,3044
3
+ csvledger-0.0.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ csvledger-0.0.2.dist-info/licenses/LICENSE,sha256=ekE13yCE9fWWEJfP1NPnUJabZ_Wwrc6coKJV2ZZx27w,1068
5
+ csvledger-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thor Whalen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.