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.
- pyhoglake-1.0.0/.gitignore +12 -0
- pyhoglake-1.0.0/LICENSE +21 -0
- pyhoglake-1.0.0/PKG-INFO +291 -0
- pyhoglake-1.0.0/README.md +270 -0
- pyhoglake-1.0.0/pyproject.toml +63 -0
- pyhoglake-1.0.0/src/pyhoglake/__init__.py +104 -0
- pyhoglake-1.0.0/src/pyhoglake/bounds.py +167 -0
- pyhoglake-1.0.0/src/pyhoglake/client.py +950 -0
- pyhoglake-1.0.0/src/pyhoglake/errors.py +87 -0
- pyhoglake-1.0.0/src/pyhoglake/models.py +417 -0
- pyhoglake-1.0.0/src/pyhoglake/ops.py +82 -0
- pyhoglake-1.0.0/src/pyhoglake/py.typed +0 -0
- pyhoglake-1.0.0/src/pyhoglake/stats.py +114 -0
- pyhoglake-1.0.0/src/pyhoglake/transforms.py +377 -0
- pyhoglake-1.0.0/src/pyhoglake/types.py +164 -0
- pyhoglake-1.0.0/tests/conftest.py +37 -0
- pyhoglake-1.0.0/tests/qe_contract.py +1120 -0
- pyhoglake-1.0.0/tests/qe_live_adversarial.py +645 -0
- pyhoglake-1.0.0/tests/qe_prop_bounds.py +437 -0
- pyhoglake-1.0.0/tests/qe_prop_models.py +441 -0
- pyhoglake-1.0.0/tests/qe_prop_stats.py +303 -0
- pyhoglake-1.0.0/tests/qe_prop_types.py +282 -0
- pyhoglake-1.0.0/tests/qe_vectors.py +205 -0
- pyhoglake-1.0.0/tests/test_append_unit.py +426 -0
- pyhoglake-1.0.0/tests/test_bounds.py +127 -0
- pyhoglake-1.0.0/tests/test_client.py +768 -0
- pyhoglake-1.0.0/tests/test_integration.py +515 -0
- pyhoglake-1.0.0/tests/test_partition_fanout_unit.py +353 -0
- pyhoglake-1.0.0/tests/test_stats.py +187 -0
- pyhoglake-1.0.0/tests/test_transforms.py +419 -0
- pyhoglake-1.0.0/tests/test_types.py +128 -0
- pyhoglake-1.0.0/tests/vectors/bounds_vectors.json +503 -0
pyhoglake-1.0.0/LICENSE
ADDED
|
@@ -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.
|
pyhoglake-1.0.0/PKG-INFO
ADDED
|
@@ -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"]
|