pyhoglake 1.0.0__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+ .flox/run/
7
+ .flox/cache/
8
+ .flox/lib/
9
+ .flox/log/
10
+ .flox/telemetry_id
11
+ # uv.lock is tracked
12
+ .hypothesis/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PostHog, Inc.
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.
@@ -0,0 +1,291 @@
1
+ Metadata-Version: 2.5
2
+ Name: pyhoglake
3
+ Version: 1.0.0
4
+ Summary: Python client for hoglake, the Postgres-native lakehouse-catalog control plane
5
+ Project-URL: Repository, https://github.com/PostHog/hoglake
6
+ Project-URL: Documentation, https://github.com/PostHog/hoglake/tree/main/pyhoglake
7
+ Project-URL: Issues, https://github.com/PostHog/hoglake/issues
8
+ Author: PostHog
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: pyarrow>=17.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # pyhoglake
23
+
24
+ Python client for [hoglake](https://github.com/PostHog/hoglake#readme), the Postgres-native
25
+ lakehouse-catalog control plane. A **thin API wrapper**: no embedded
26
+ engine, no SQL, no direct catalog-database access — ever. The client
27
+ writes parquet to object storage itself and registers it with the
28
+ control plane via footer-shipping commits.
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ pip install pyhoglake # or: uv add pyhoglake
34
+ ```
35
+
36
+ Dependencies: `httpx`, `pyarrow`. Development uses the flox env in this
37
+ directory:
38
+
39
+ ```sh
40
+ flox activate -- uv sync
41
+ flox activate -- uv run pytest # unit + integration
42
+ flox activate -- uv run pytest -m "not integration" # unit only
43
+ ```
44
+
45
+ Integration tests need a live server (`HOGLAKE_URL`, default
46
+ `http://localhost:8080`) and S3 credentials (`HOGLAKE_S3_ENDPOINT`,
47
+ `HOGLAKE_S3_ACCESS_KEY`, `HOGLAKE_S3_SECRET_KEY`); they skip cleanly
48
+ when the server is unreachable.
49
+
50
+ ## Quickstart — the append path end to end
51
+
52
+ ```python
53
+ import pyarrow as pa
54
+ from pyhoglake import HoglakeClient, S3Config
55
+
56
+ client = HoglakeClient(
57
+ "http://localhost:8080",
58
+ s3=S3Config(
59
+ access_key="hoglake",
60
+ secret_key="hoglake123",
61
+ endpoint_override="http://localhost:19000", # MinIO; omit for AWS
62
+ region="us-east-1",
63
+ ),
64
+ )
65
+
66
+ catalog = client.create_catalog("demo", "s3://my-bucket/demo/")
67
+ ns = catalog.create_namespace("analytics")
68
+
69
+ table = ns.create_table(
70
+ "events",
71
+ pa.schema(
72
+ [
73
+ pa.field("id", pa.int64(), nullable=False),
74
+ pa.field("name", pa.string()),
75
+ pa.field("amount", pa.decimal128(10, 2)),
76
+ ]
77
+ ),
78
+ )
79
+
80
+ # THE writer path: writes one parquet file (with catalog field ids
81
+ # embedded in the parquet schema) to
82
+ # s3://my-bucket/demo/data/analytics/events/<uuid>.parquet
83
+ # extracts per-column footer stats (value/null counts, Iceberg
84
+ # single-value binary min/max bounds), and registers the file in one
85
+ # commit. The server never opens the file.
86
+ result = table.append(
87
+ pa.table({"id": [1, 2], "name": ["a", None], "amount": [None, None]}),
88
+ author="me",
89
+ message="first batch",
90
+ )
91
+ print(result.snapshot_id)
92
+
93
+ # reads are metadata-only planning; you fetch the parquet yourself
94
+ for f in table.files():
95
+ print(f.path, f.record_count, f.stats_state, f.row_id_start)
96
+
97
+ # changefeed + consumer offsets
98
+ plan = table.changes(from_snapshot=0)
99
+ catalog.commit_offset("my-consumer", table.table_uuid, plan.to_snapshot)
100
+ catalog.offset("my-consumer", table.table_uuid) # one offset; None if unset
101
+ ```
102
+
103
+ More surface:
104
+
105
+ ```python
106
+ from pyhoglake import ops
107
+
108
+ table.alter([ops.add_column("score", pa.float64())]) # schema evolution
109
+ table.info(snapshot=5) # time travel
110
+ table.files(at_timestamp=some_datetime) # by timestamp
111
+ table.append(big_table, deferred_stats=True) # register as pending
112
+ catalog.set_retention(7 * 86400, consumer_floor=True) # retention policy
113
+ catalog.expire()
114
+ catalog.cleanup() # maintenance sweeps
115
+ ns.create_view("v", "SELECT 1", dialect="trino")
116
+ for s in catalog.snapshots(limit=1000):
117
+ ... # auto-paginated
118
+ for s in catalog.snapshots(before=head + 1):
119
+ ... # descending walk
120
+ # (mutually exclusive
121
+ # with non-zero after)
122
+ ```
123
+
124
+ ## Configuration
125
+
126
+ | What | How |
127
+ |---|---|
128
+ | Server | `HoglakeClient(base_url, timeout=30.0)` — `/v1` is appended |
129
+ | Object store | `S3Config(access_key, secret_key, endpoint_override, region, allow_bucket_creation)`; the write path uses `pyarrow.fs.S3FileSystem` (path-style with an endpoint override) |
130
+ | Errors | Typed: `NotFoundError`, `AlreadyExistsError`, `CommitConflictError` (`retryable=True` — refresh read snapshot and retry), `ValidationError`, `OffsetRegressionError`, `ExpiredError` (410 — reconcile from a full scan), `IncarnationChangedError` (the append incarnation guard, enforced server-side at commit, see below), `MalformedResponseError` (every wire-parse failure — a structurally defective response body, an unexpected redirect (3xx is never success), a field of the wrong shape — one exception type naming the model and field), all under `HoglakeError` |
131
+
132
+ ## Type mapping
133
+
134
+ | pyarrow | hoglake |
135
+ |---|---|
136
+ | `bool_` | `boolean` |
137
+ | `int32` / `int64` | `int` / `long` |
138
+ | `float32` / `float64` | `float` / `double` |
139
+ | `string` / `large_string` | `string` |
140
+ | `binary` / `large_binary` | `binary` |
141
+ | `date32` | `date` |
142
+ | `time64("us")` | `time` |
143
+ | `timestamp("us")` / `timestamp("us", tz)` | `timestamp` / `timestamptz` |
144
+ | `decimal128(p, s)` | `decimal` (`type_params: {precision, scale}`) |
145
+ | `binary(16)` (fixed) or `pa.uuid()` | `uuid` — 16 big-endian bytes, i.e. `uuid.UUID(...).bytes` |
146
+
147
+ Anything else is rejected with an error listing the supported set.
148
+
149
+ Identifiers (namespace/table/view/column names) must match
150
+ `^[A-Za-z_][A-Za-z0-9_-]{0,127}$` — the server 422s anything else, and
151
+ the constraint is also a CHECK in its schema; catalog names are
152
+ stricter (lower-case start, max 63).
153
+
154
+ Column names starting with `_hog` are additionally reserved for hoglake
155
+ internals (`_hog_row_id` is compaction's row-id carrier) — the server
156
+ 422s them at create/add/rename, and the client fast-fails them at
157
+ table-create and append time before any request or upload;
158
+ namespace/table/view names are not affected. Deletion-vector positions
159
+ are per-file **physical row ordinals** (0-based position within that
160
+ parquet file), not row ids.
161
+
162
+ ## Partitioned writes — client-side transforms, fanout appends
163
+
164
+ When a table has a live partition spec, `Table.append` computes each
165
+ row's partition tuple client-side (the server never opens data files),
166
+ splits the batch by tuple, writes **one parquet file per partition**,
167
+ and registers them all in **one atomic commit** — each file carrying its
168
+ `partition_values` (transformed values as strings, by key_index). The
169
+ coarse-grained layout the near-term consumers use is months per team:
170
+
171
+ ```python
172
+ from pyhoglake import ops
173
+
174
+ table = ns.create_table(
175
+ "events",
176
+ pa.schema(
177
+ [
178
+ pa.field("team_id", pa.int64(), nullable=False),
179
+ pa.field("ts", pa.timestamp("us")),
180
+ pa.field("payload", pa.string()),
181
+ ]
182
+ ),
183
+ )
184
+ fid = {c.name: c.field_id for c in table.columns}
185
+ table.alter(
186
+ [
187
+ ops.set_partition_spec(
188
+ [
189
+ ops.partition_field(fid["team_id"], "identity"),
190
+ ops.partition_field(fid["ts"], "month"),
191
+ ]
192
+ )
193
+ ]
194
+ )
195
+
196
+ # a batch spanning 3 months x 2 teams -> 6 files, ONE commit
197
+ res = table.append(batch)
198
+ for f in res.files: # AppendResult exposes the computed tuples
199
+ print(f.path, f.record_count, f.partition_values)
200
+ # ('101', '672') = identity(team_id)=101, month(ts)=2026-01
201
+ # (months are epoch-relative ints per Iceberg: 672 = (2026-1970)*12)
202
+ ```
203
+
204
+ Transforms (`pyhoglake.transforms`) follow the Iceberg spec exactly and
205
+ are pinned against its published test vectors:
206
+
207
+ | Transform | Semantics |
208
+ |---|---|
209
+ | `identity` | the source value |
210
+ | `year` / `month` / `day` / `hour` | epoch-relative ints (years/months since 1970, days/hours since the epoch, floored — pre-1970 is negative) |
211
+ | `bucket` (param N) | `(murmur3_x86_32(iceberg_encode(v)) & Integer.MAX_VALUE) % N` — bit-compatible with Iceberg/server bucketing |
212
+ | `truncate` (param W) | ints floored to a multiple of W; strings to W codepoints (client-side, ahead of server vocabulary support) |
213
+
214
+ A null source value yields a null partition value forming its own
215
+ partition group (Iceberg semantics). The tuple is computed under the
216
+ table's **current** spec (re-resolved pre-flight); if the spec changes
217
+ before the commit lands, the server refuses the commit (409 concurrent
218
+ DDL / 422 arity mismatch) — the client never silently recomputes under
219
+ a different spec. Grouping runs arrow-native where possible and per
220
+ *unique* value (never per row) otherwise. Compaction groups only within
221
+ `(spec_id, partition_values)` server-side, so partition-local file
222
+ layout is preserved end to end.
223
+
224
+ ## The append incarnation guard — atomic at commit
225
+
226
+ Commit payloads are addressed by **(namespace, table) name**, so an
227
+ append lands on whatever table currently holds that name. If the table
228
+ is dropped and recreated (a new `table_uuid`) between your resolve and
229
+ your append, a naive client would write into the new incarnation's
230
+ history without noticing.
231
+
232
+ `Table.append(..., expected_table_uuid=...)` guards against this
233
+ **atomically, at commit time**. Every commit carries an
234
+ `expected_table_uuid` field (default: the `table_uuid` the `Table`
235
+ object was resolved as; pass one explicitly to pin a specific
236
+ incarnation), and the server rejects the whole commit with 409 — zero
237
+ writes — when the live table's uuid differs. The client maps that 409
238
+ (its message says "the table was recreated") to
239
+ `IncarnationChangedError`; ordinary commit conflicts remain
240
+ `CommitConflictError` (retryable). There is no window in which a
241
+ recreated table can accept rows from a guarded append.
242
+
243
+ The client also keeps **one** cheap pre-flight re-resolve before the
244
+ parquet upload. That is purely an optimization — it fast-fails an
245
+ already-dead incarnation before paying for the S3 write — not the
246
+ safety mechanism. A commit-time refusal orphans the uploaded parquet
247
+ (cleanup's problem, never the catalog's). A refused append never
248
+ rebases the `Table` object's pinned identity, so a blind retry trips
249
+ the guard again rather than silently adopting the new incarnation.
250
+
251
+ To opt out entirely (name-only resolution), pass
252
+ `expected_table_uuid=pyhoglake.UNGUARDED`; the commit then carries no
253
+ `expected_table_uuid` field and no pre-flight check runs.
254
+
255
+ ## Not in 0.1
256
+
257
+ Deletion-vector writes and Iceberg-facade reads.
258
+
259
+ ## Comparison with pyiceberg
260
+
261
+ pyhoglake follows pyiceberg's ergonomics where they fit, but the
262
+ architecture differs on purpose: the catalog is a *service* — pyhoglake
263
+ is a thin REST client that owns only the writer path (parquet + footer
264
+ stats), and everything transactional happens server-side.
265
+
266
+ | Feature | pyhoglake | pyiceberg |
267
+ |---------|-----------|-----------|
268
+ | **Metadata storage** | Postgres, behind a REST control plane (never touched by clients) | Files (JSON, Avro manifests) via catalog |
269
+ | **Catalog backends** | 1 (the hoglake service) | 7 (REST, Hive, Glue, DynamoDB, SQL, BigQuery, In-memory) |
270
+ | **Commit protocol** | Footer-shipping: client writes parquet, ships stats, server registers + OCC | Client writes manifests + metadata, catalog swaps pointer |
271
+ | **Deferred statistics** | Yes (`deferred_stats=True`; server hydrates async) | No |
272
+ | **Field IDs in files** | Written (`PARQUET:field_id`), verified round-trip | Written |
273
+ | **Append** | Yes (`Table.append(arrow_table)`) | Yes |
274
+ | **Streaming/batch inputs** | Arrow Table (batching via `row_group_size`) | Arrow only |
275
+ | **Row-level deletes** | Deletion vectors (server-registered, superseding, conflict-checked); DV *writing* not yet in the client | Position/equality delete files (v0.7+, partial) |
276
+ | **Upsert / merge / overwrite** | No (append + DV only, by design v1) | Overwrite yes; upsert yes (v0.7+) |
277
+ | **Schema evolution** | Typed ops: add, drop, rename, promote (int→long, float→double), rename table, set partition spec | Add, drop, rename, widen, reorder, union-by-name |
278
+ | **Partitioning** | identity, bucket (Murmur3, Iceberg-compatible), year/month/day/hour — spec DDL + partitioned appends (client-side transforms, one file per partition, one commit) | identity, bucket, truncate, year/month/day/hour |
279
+ | **Time travel** | Snapshot id or timestamp, on tables/files/scan | Snapshot id, ref name, or timestamp |
280
+ | **Snapshot branches/tags** | No | Yes |
281
+ | **Change data capture** | First-class: `changes()` plan (files + DVs per snapshot range), 410 on expired ranges | Not implemented |
282
+ | **Consumer offsets** | First-class catalog state, monotonic, retention-aware | Not implemented |
283
+ | **Row lineage** | Server-assigned contiguous row-id ranges, never reused | Row lineage (v3 spec, partial) |
284
+ | **Retention / expiry** | Catalog property; consumer-offset floor; client can trigger + tune | Expire snapshots (limited) |
285
+ | **Table maintenance** | Server-side (expiry, cleanup, compaction — DV-aware, row-id-preserving) — client just triggers | Client-side, limited |
286
+ | **Views** | Full CRUD (SQL stored verbatim + dialect) | Not implemented |
287
+ | **Multi-table transactions** | Yes — one commit may span tables (atomic) | Single-table only |
288
+ | **Concurrency** | Server-side OCC; typed 409s with `retryable=True`; appends never conflict with appends | Optimistic, client-side, no retry |
289
+ | **Metrics/observability** | Server `/metrics` + audit log; client stays thin | N/A |
290
+ | **Zero-infrastructure quickstart** | No — requires the service (docker compose up) | Yes with SQL/memory catalogs |
291
+ | **Package size** | 2 deps (httpx, pyarrow) | ~200MB with PyArrow + optional deps |
@@ -0,0 +1,270 @@
1
+ # pyhoglake
2
+
3
+ Python client for [hoglake](https://github.com/PostHog/hoglake#readme), the Postgres-native
4
+ lakehouse-catalog control plane. A **thin API wrapper**: no embedded
5
+ engine, no SQL, no direct catalog-database access — ever. The client
6
+ writes parquet to object storage itself and registers it with the
7
+ control plane via footer-shipping commits.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pip install pyhoglake # or: uv add pyhoglake
13
+ ```
14
+
15
+ Dependencies: `httpx`, `pyarrow`. Development uses the flox env in this
16
+ directory:
17
+
18
+ ```sh
19
+ flox activate -- uv sync
20
+ flox activate -- uv run pytest # unit + integration
21
+ flox activate -- uv run pytest -m "not integration" # unit only
22
+ ```
23
+
24
+ Integration tests need a live server (`HOGLAKE_URL`, default
25
+ `http://localhost:8080`) and S3 credentials (`HOGLAKE_S3_ENDPOINT`,
26
+ `HOGLAKE_S3_ACCESS_KEY`, `HOGLAKE_S3_SECRET_KEY`); they skip cleanly
27
+ when the server is unreachable.
28
+
29
+ ## Quickstart — the append path end to end
30
+
31
+ ```python
32
+ import pyarrow as pa
33
+ from pyhoglake import HoglakeClient, S3Config
34
+
35
+ client = HoglakeClient(
36
+ "http://localhost:8080",
37
+ s3=S3Config(
38
+ access_key="hoglake",
39
+ secret_key="hoglake123",
40
+ endpoint_override="http://localhost:19000", # MinIO; omit for AWS
41
+ region="us-east-1",
42
+ ),
43
+ )
44
+
45
+ catalog = client.create_catalog("demo", "s3://my-bucket/demo/")
46
+ ns = catalog.create_namespace("analytics")
47
+
48
+ table = ns.create_table(
49
+ "events",
50
+ pa.schema(
51
+ [
52
+ pa.field("id", pa.int64(), nullable=False),
53
+ pa.field("name", pa.string()),
54
+ pa.field("amount", pa.decimal128(10, 2)),
55
+ ]
56
+ ),
57
+ )
58
+
59
+ # THE writer path: writes one parquet file (with catalog field ids
60
+ # embedded in the parquet schema) to
61
+ # s3://my-bucket/demo/data/analytics/events/<uuid>.parquet
62
+ # extracts per-column footer stats (value/null counts, Iceberg
63
+ # single-value binary min/max bounds), and registers the file in one
64
+ # commit. The server never opens the file.
65
+ result = table.append(
66
+ pa.table({"id": [1, 2], "name": ["a", None], "amount": [None, None]}),
67
+ author="me",
68
+ message="first batch",
69
+ )
70
+ print(result.snapshot_id)
71
+
72
+ # reads are metadata-only planning; you fetch the parquet yourself
73
+ for f in table.files():
74
+ print(f.path, f.record_count, f.stats_state, f.row_id_start)
75
+
76
+ # changefeed + consumer offsets
77
+ plan = table.changes(from_snapshot=0)
78
+ catalog.commit_offset("my-consumer", table.table_uuid, plan.to_snapshot)
79
+ catalog.offset("my-consumer", table.table_uuid) # one offset; None if unset
80
+ ```
81
+
82
+ More surface:
83
+
84
+ ```python
85
+ from pyhoglake import ops
86
+
87
+ table.alter([ops.add_column("score", pa.float64())]) # schema evolution
88
+ table.info(snapshot=5) # time travel
89
+ table.files(at_timestamp=some_datetime) # by timestamp
90
+ table.append(big_table, deferred_stats=True) # register as pending
91
+ catalog.set_retention(7 * 86400, consumer_floor=True) # retention policy
92
+ catalog.expire()
93
+ catalog.cleanup() # maintenance sweeps
94
+ ns.create_view("v", "SELECT 1", dialect="trino")
95
+ for s in catalog.snapshots(limit=1000):
96
+ ... # auto-paginated
97
+ for s in catalog.snapshots(before=head + 1):
98
+ ... # descending walk
99
+ # (mutually exclusive
100
+ # with non-zero after)
101
+ ```
102
+
103
+ ## Configuration
104
+
105
+ | What | How |
106
+ |---|---|
107
+ | Server | `HoglakeClient(base_url, timeout=30.0)` — `/v1` is appended |
108
+ | Object store | `S3Config(access_key, secret_key, endpoint_override, region, allow_bucket_creation)`; the write path uses `pyarrow.fs.S3FileSystem` (path-style with an endpoint override) |
109
+ | Errors | Typed: `NotFoundError`, `AlreadyExistsError`, `CommitConflictError` (`retryable=True` — refresh read snapshot and retry), `ValidationError`, `OffsetRegressionError`, `ExpiredError` (410 — reconcile from a full scan), `IncarnationChangedError` (the append incarnation guard, enforced server-side at commit, see below), `MalformedResponseError` (every wire-parse failure — a structurally defective response body, an unexpected redirect (3xx is never success), a field of the wrong shape — one exception type naming the model and field), all under `HoglakeError` |
110
+
111
+ ## Type mapping
112
+
113
+ | pyarrow | hoglake |
114
+ |---|---|
115
+ | `bool_` | `boolean` |
116
+ | `int32` / `int64` | `int` / `long` |
117
+ | `float32` / `float64` | `float` / `double` |
118
+ | `string` / `large_string` | `string` |
119
+ | `binary` / `large_binary` | `binary` |
120
+ | `date32` | `date` |
121
+ | `time64("us")` | `time` |
122
+ | `timestamp("us")` / `timestamp("us", tz)` | `timestamp` / `timestamptz` |
123
+ | `decimal128(p, s)` | `decimal` (`type_params: {precision, scale}`) |
124
+ | `binary(16)` (fixed) or `pa.uuid()` | `uuid` — 16 big-endian bytes, i.e. `uuid.UUID(...).bytes` |
125
+
126
+ Anything else is rejected with an error listing the supported set.
127
+
128
+ Identifiers (namespace/table/view/column names) must match
129
+ `^[A-Za-z_][A-Za-z0-9_-]{0,127}$` — the server 422s anything else, and
130
+ the constraint is also a CHECK in its schema; catalog names are
131
+ stricter (lower-case start, max 63).
132
+
133
+ Column names starting with `_hog` are additionally reserved for hoglake
134
+ internals (`_hog_row_id` is compaction's row-id carrier) — the server
135
+ 422s them at create/add/rename, and the client fast-fails them at
136
+ table-create and append time before any request or upload;
137
+ namespace/table/view names are not affected. Deletion-vector positions
138
+ are per-file **physical row ordinals** (0-based position within that
139
+ parquet file), not row ids.
140
+
141
+ ## Partitioned writes — client-side transforms, fanout appends
142
+
143
+ When a table has a live partition spec, `Table.append` computes each
144
+ row's partition tuple client-side (the server never opens data files),
145
+ splits the batch by tuple, writes **one parquet file per partition**,
146
+ and registers them all in **one atomic commit** — each file carrying its
147
+ `partition_values` (transformed values as strings, by key_index). The
148
+ coarse-grained layout the near-term consumers use is months per team:
149
+
150
+ ```python
151
+ from pyhoglake import ops
152
+
153
+ table = ns.create_table(
154
+ "events",
155
+ pa.schema(
156
+ [
157
+ pa.field("team_id", pa.int64(), nullable=False),
158
+ pa.field("ts", pa.timestamp("us")),
159
+ pa.field("payload", pa.string()),
160
+ ]
161
+ ),
162
+ )
163
+ fid = {c.name: c.field_id for c in table.columns}
164
+ table.alter(
165
+ [
166
+ ops.set_partition_spec(
167
+ [
168
+ ops.partition_field(fid["team_id"], "identity"),
169
+ ops.partition_field(fid["ts"], "month"),
170
+ ]
171
+ )
172
+ ]
173
+ )
174
+
175
+ # a batch spanning 3 months x 2 teams -> 6 files, ONE commit
176
+ res = table.append(batch)
177
+ for f in res.files: # AppendResult exposes the computed tuples
178
+ print(f.path, f.record_count, f.partition_values)
179
+ # ('101', '672') = identity(team_id)=101, month(ts)=2026-01
180
+ # (months are epoch-relative ints per Iceberg: 672 = (2026-1970)*12)
181
+ ```
182
+
183
+ Transforms (`pyhoglake.transforms`) follow the Iceberg spec exactly and
184
+ are pinned against its published test vectors:
185
+
186
+ | Transform | Semantics |
187
+ |---|---|
188
+ | `identity` | the source value |
189
+ | `year` / `month` / `day` / `hour` | epoch-relative ints (years/months since 1970, days/hours since the epoch, floored — pre-1970 is negative) |
190
+ | `bucket` (param N) | `(murmur3_x86_32(iceberg_encode(v)) & Integer.MAX_VALUE) % N` — bit-compatible with Iceberg/server bucketing |
191
+ | `truncate` (param W) | ints floored to a multiple of W; strings to W codepoints (client-side, ahead of server vocabulary support) |
192
+
193
+ A null source value yields a null partition value forming its own
194
+ partition group (Iceberg semantics). The tuple is computed under the
195
+ table's **current** spec (re-resolved pre-flight); if the spec changes
196
+ before the commit lands, the server refuses the commit (409 concurrent
197
+ DDL / 422 arity mismatch) — the client never silently recomputes under
198
+ a different spec. Grouping runs arrow-native where possible and per
199
+ *unique* value (never per row) otherwise. Compaction groups only within
200
+ `(spec_id, partition_values)` server-side, so partition-local file
201
+ layout is preserved end to end.
202
+
203
+ ## The append incarnation guard — atomic at commit
204
+
205
+ Commit payloads are addressed by **(namespace, table) name**, so an
206
+ append lands on whatever table currently holds that name. If the table
207
+ is dropped and recreated (a new `table_uuid`) between your resolve and
208
+ your append, a naive client would write into the new incarnation's
209
+ history without noticing.
210
+
211
+ `Table.append(..., expected_table_uuid=...)` guards against this
212
+ **atomically, at commit time**. Every commit carries an
213
+ `expected_table_uuid` field (default: the `table_uuid` the `Table`
214
+ object was resolved as; pass one explicitly to pin a specific
215
+ incarnation), and the server rejects the whole commit with 409 — zero
216
+ writes — when the live table's uuid differs. The client maps that 409
217
+ (its message says "the table was recreated") to
218
+ `IncarnationChangedError`; ordinary commit conflicts remain
219
+ `CommitConflictError` (retryable). There is no window in which a
220
+ recreated table can accept rows from a guarded append.
221
+
222
+ The client also keeps **one** cheap pre-flight re-resolve before the
223
+ parquet upload. That is purely an optimization — it fast-fails an
224
+ already-dead incarnation before paying for the S3 write — not the
225
+ safety mechanism. A commit-time refusal orphans the uploaded parquet
226
+ (cleanup's problem, never the catalog's). A refused append never
227
+ rebases the `Table` object's pinned identity, so a blind retry trips
228
+ the guard again rather than silently adopting the new incarnation.
229
+
230
+ To opt out entirely (name-only resolution), pass
231
+ `expected_table_uuid=pyhoglake.UNGUARDED`; the commit then carries no
232
+ `expected_table_uuid` field and no pre-flight check runs.
233
+
234
+ ## Not in 0.1
235
+
236
+ Deletion-vector writes and Iceberg-facade reads.
237
+
238
+ ## Comparison with pyiceberg
239
+
240
+ pyhoglake follows pyiceberg's ergonomics where they fit, but the
241
+ architecture differs on purpose: the catalog is a *service* — pyhoglake
242
+ is a thin REST client that owns only the writer path (parquet + footer
243
+ stats), and everything transactional happens server-side.
244
+
245
+ | Feature | pyhoglake | pyiceberg |
246
+ |---------|-----------|-----------|
247
+ | **Metadata storage** | Postgres, behind a REST control plane (never touched by clients) | Files (JSON, Avro manifests) via catalog |
248
+ | **Catalog backends** | 1 (the hoglake service) | 7 (REST, Hive, Glue, DynamoDB, SQL, BigQuery, In-memory) |
249
+ | **Commit protocol** | Footer-shipping: client writes parquet, ships stats, server registers + OCC | Client writes manifests + metadata, catalog swaps pointer |
250
+ | **Deferred statistics** | Yes (`deferred_stats=True`; server hydrates async) | No |
251
+ | **Field IDs in files** | Written (`PARQUET:field_id`), verified round-trip | Written |
252
+ | **Append** | Yes (`Table.append(arrow_table)`) | Yes |
253
+ | **Streaming/batch inputs** | Arrow Table (batching via `row_group_size`) | Arrow only |
254
+ | **Row-level deletes** | Deletion vectors (server-registered, superseding, conflict-checked); DV *writing* not yet in the client | Position/equality delete files (v0.7+, partial) |
255
+ | **Upsert / merge / overwrite** | No (append + DV only, by design v1) | Overwrite yes; upsert yes (v0.7+) |
256
+ | **Schema evolution** | Typed ops: add, drop, rename, promote (int→long, float→double), rename table, set partition spec | Add, drop, rename, widen, reorder, union-by-name |
257
+ | **Partitioning** | identity, bucket (Murmur3, Iceberg-compatible), year/month/day/hour — spec DDL + partitioned appends (client-side transforms, one file per partition, one commit) | identity, bucket, truncate, year/month/day/hour |
258
+ | **Time travel** | Snapshot id or timestamp, on tables/files/scan | Snapshot id, ref name, or timestamp |
259
+ | **Snapshot branches/tags** | No | Yes |
260
+ | **Change data capture** | First-class: `changes()` plan (files + DVs per snapshot range), 410 on expired ranges | Not implemented |
261
+ | **Consumer offsets** | First-class catalog state, monotonic, retention-aware | Not implemented |
262
+ | **Row lineage** | Server-assigned contiguous row-id ranges, never reused | Row lineage (v3 spec, partial) |
263
+ | **Retention / expiry** | Catalog property; consumer-offset floor; client can trigger + tune | Expire snapshots (limited) |
264
+ | **Table maintenance** | Server-side (expiry, cleanup, compaction — DV-aware, row-id-preserving) — client just triggers | Client-side, limited |
265
+ | **Views** | Full CRUD (SQL stored verbatim + dialect) | Not implemented |
266
+ | **Multi-table transactions** | Yes — one commit may span tables (atomic) | Single-table only |
267
+ | **Concurrency** | Server-side OCC; typed 409s with `retryable=True`; appends never conflict with appends | Optimistic, client-side, no retry |
268
+ | **Metrics/observability** | Server `/metrics` + audit log; client stays thin | N/A |
269
+ | **Zero-infrastructure quickstart** | No — requires the service (docker compose up) | Yes with SQL/memory catalogs |
270
+ | **Package size** | 2 deps (httpx, pyarrow) | ~200MB with PyArrow + optional deps |
@@ -0,0 +1,63 @@
1
+ [project]
2
+ name = "pyhoglake"
3
+ version = "1.0.0"
4
+ description = "Python client for hoglake, the Postgres-native lakehouse-catalog control plane"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [{ name = "PostHog" }]
8
+ requires-python = ">=3.11"
9
+ classifiers = [
10
+ "Operating System :: OS Independent",
11
+ "Programming Language :: Python :: 3",
12
+ "Programming Language :: Python :: 3.11",
13
+ "Programming Language :: Python :: 3.12",
14
+ "Programming Language :: Python :: 3.13",
15
+ "Typing :: Typed",
16
+ ]
17
+ dependencies = [
18
+ "httpx>=0.27",
19
+ "pyarrow>=17.0",
20
+ ]
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/PostHog/hoglake"
24
+ Documentation = "https://github.com/PostHog/hoglake/tree/main/pyhoglake"
25
+ Issues = "https://github.com/PostHog/hoglake/issues"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "pytest>=8.0",
30
+ "pytest-httpx>=0.30",
31
+ "hypothesis>=6.100",
32
+ # Pinned: an unpinned ruff resolves a different rule set per machine
33
+ # (and silently ignored the per-file-ignores below).
34
+ "ruff==0.16.6",
35
+ ]
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/pyhoglake"]
43
+
44
+ [tool.hatch.build.targets.sdist]
45
+ # Keeps the flox env, justfile, and uv.lock out of the sdist. Hatchling
46
+ # adds pyproject.toml, README.md, and LICENSE on its own.
47
+ only-include = ["src", "tests"]
48
+
49
+ [tool.ruff.lint.per-file-ignores]
50
+ # Default rule selection applies; src/ stays strict.
51
+ "tests/**" = [
52
+ "PLC3002", # table-driven lambda-call test style is deliberate
53
+ "DTZ001", # tests exercise naive datetimes on purpose
54
+ "BLE001", # conftest server-probe catch is deliberate
55
+ ]
56
+
57
+ [tool.pytest.ini_options]
58
+ markers = [
59
+ "integration: tests that require a live hoglake server (HOGLAKE_URL)",
60
+ ]
61
+ testpaths = ["tests"]
62
+ # qe_*.py: QE/property-based fuzzing suites live alongside test_*.py
63
+ python_files = ["test_*.py", "qe_*.py"]