Skip to content

Commit 0fdc62c

Browse files
committed
Consolidate remote table examples and improve writes
1 parent fbdd520 commit 0fdc62c

4 files changed

Lines changed: 350 additions & 233 deletions

File tree

‎doc/guides/remote_tables.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,12 @@ See `examples/ctable/remote_handling.py` for a batched archive writer with nulla
165165
multilingual UTF-8 and variable-length strings, a batch-backed list, and a
166166
dictionary. It reports ordinary batch cold/warm reads and dictionary code/vocabulary
167167
costs separately.
168+
The same script writes and reads Blosc2 with `--blosc2`, PyTables/HDF5 with
169+
`--pytables`, and Parquet with `--parquet`. Without a format flag, it selects the
170+
format from the `.b2z`, `.h5`, or `.parquet` extension of the output path or input URL.
171+
Use `--write FILE` to create a file locally, then pass its uploaded URL to read it remotely.
172+
The Parquet file uses the full Blosc2 example schema, including nullable values,
173+
multilingual strings, lists, and dictionary-encoded regions.
168174

169175
## Refresh a remote table
170176

‎examples/ctable/remote_handling.py‎

Lines changed: 236 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@
66
# SPDX-License-Identifier: BSD-3-Clause
77
#######################################################################
88

9-
"""Create or access a CTable with fixed-width, UTF-8, batch-backed and dictionary columns."""
9+
"""Create or access remote-friendly Blosc2, PyTables, and Parquet tables."""
1010

1111
import argparse
1212
import pprint
1313
import sys
1414
import time
1515
from dataclasses import dataclass, fields
1616
from pathlib import Path
17+
from urllib.parse import urlsplit
1718

1819
import numpy as np
1920

@@ -40,15 +41,38 @@ class Reading:
4041

4142

4243
FULL_INDEX_UNSUPPORTED = {"message", "tags"}
44+
TABLE_NAME = "readings"
4345

4446

4547
def make_notes(ids):
4648
notes = np.array(["", "café", "東京の観測", "🌦️ weather improving"], dtype=object)[ids % 4]
47-
notes = np.array(
49+
return np.array(
4850
[f"{text} #{i}" if text else "" for i, text in zip(ids, notes, strict=True)], dtype=object
4951
)
50-
notes[ids % 43 == 0] = None
51-
return notes
52+
53+
54+
def rich_batches(rows, batch_size):
55+
rng = np.random.default_rng(42)
56+
statuses = np.array(["ok", "warning", "offline", ""], dtype=object)
57+
for start in range(0, rows, batch_size):
58+
ids = np.arange(start, min(start + batch_size, rows), dtype=np.int64)
59+
temperature = rng.normal(18, 10, len(ids)).astype(np.float32)
60+
humidity = rng.integers(0, 101, len(ids), dtype=np.int16)
61+
status = statuses[(ids // 7) % len(statuses)]
62+
regions = np.array(["north", "south", "east", "west"], dtype=object)[ids % 4]
63+
regions[ids % 59 == 0] = None
64+
yield {
65+
"id": ids,
66+
"station_id": (ids % 100).astype(np.int32),
67+
"temperature": np.ma.array(temperature, mask=ids % 17 == 0),
68+
"humidity": np.ma.array(humidity, mask=ids % 29 == 0),
69+
"status": np.ma.array(status, mask=ids % 41 == 0),
70+
"active": ids % 5 != 0,
71+
"note": np.ma.array(make_notes(ids), mask=ids % 43 == 0),
72+
"message": [None if i % 47 == 0 else f"sensor {i}: café 東京" for i in ids],
73+
"tags": [None if i % 53 == 0 else [int(i % 7), int(i % 11)] for i in ids],
74+
"region": regions,
75+
}
5276

5377

5478
def write_table(args) -> None:
@@ -77,8 +101,6 @@ def write_table(args) -> None:
77101
raise ValueError("--full columns must not contain duplicates")
78102

79103
output.parent.mkdir(parents=True, exist_ok=True)
80-
rng = np.random.default_rng(42)
81-
statuses = np.array(["ok", "warning", "offline", ""], dtype=object)
82104
attrs = {
83105
"version": 1,
84106
"sampling_interval": 0.5,
@@ -96,34 +118,8 @@ def write_table(args) -> None:
96118
) as table:
97119
for name, value in attrs.items():
98120
table.attrs[name] = value
99-
for start in range(0, args.rows, args.batch_size):
100-
stop = min(start + args.batch_size, args.rows)
101-
ids = np.arange(start, stop, dtype=np.int64)
102-
temperature = rng.normal(18, 10, len(ids)).astype(np.float32).astype(object)
103-
humidity = rng.integers(0, 101, len(ids), dtype=np.int16).astype(object)
104-
status = statuses[(ids // 7) % len(statuses)].copy()
105-
temperature[ids % 17 == 0] = None
106-
humidity[ids % 29 == 0] = None
107-
status[ids % 41 == 0] = None
108-
messages = [None if i % 47 == 0 else f"sensor {i}: café 東京" for i in ids]
109-
tags = [None if i % 53 == 0 else [int(i % 7), int(i % 11)] for i in ids]
110-
regions = np.array(["north", "south", "east", "west"], dtype=object)[ids % 4]
111-
regions[ids % 59 == 0] = None
112-
table.extend(
113-
{
114-
"id": ids,
115-
"station_id": (ids % 100).astype(np.int32),
116-
"temperature": temperature,
117-
"humidity": humidity,
118-
"status": status,
119-
"active": ids % 5 != 0,
120-
"note": make_notes(ids),
121-
"message": messages,
122-
"tags": tags,
123-
"region": regions,
124-
},
125-
validate=False,
126-
)
121+
for batch in rich_batches(args.rows, args.batch_size):
122+
table.extend(batch, validate=False)
127123
if indexed_columns:
128124
started = time.perf_counter()
129125
for name in indexed_columns:
@@ -149,6 +145,134 @@ def write_table(args) -> None:
149145
print(f"Now upload {output} to your cloud object storage.")
150146

151147

148+
def simple_batches(rows, batch_size):
149+
rng = np.random.default_rng(42)
150+
statuses = np.array(["ok", "warning", "offline", ""])
151+
notes = np.array(["", "clear", "cloudy", "rain"])
152+
for start in range(0, rows, batch_size):
153+
ids = np.arange(start, min(start + batch_size, rows), dtype=np.int64)
154+
yield {
155+
"id": ids,
156+
"station_id": (ids % 100).astype(np.int32),
157+
"temperature": rng.normal(18, 10, len(ids)).astype(np.float32),
158+
"humidity": rng.integers(0, 101, len(ids), dtype=np.int16),
159+
"status": statuses[(ids // 7) % len(statuses)],
160+
"active": ids % 5 != 0,
161+
"note": notes[ids % len(notes)],
162+
}
163+
164+
165+
def write_pytables(args) -> None:
166+
import tables
167+
168+
class PyTablesReading(tables.IsDescription):
169+
id = tables.Int64Col(pos=0)
170+
station_id = tables.Int32Col(pos=1)
171+
temperature = tables.Float32Col(pos=2)
172+
humidity = tables.Int16Col(pos=3)
173+
status = tables.StringCol(8, pos=4)
174+
active = tables.BoolCol(pos=5)
175+
note = tables.StringCol(32, pos=6)
176+
177+
output = args.write
178+
if output.suffix != ".h5":
179+
raise ValueError("output must end in .h5")
180+
if args.rows < 1 or args.batch_size < 1:
181+
raise ValueError("--rows and --batch-size must be positive")
182+
if output.exists() and not args.overwrite:
183+
raise FileExistsError(f"{output} already exists; pass --overwrite to replace it")
184+
indexed_columns = []
185+
if args.full is not None:
186+
indexed_columns = (
187+
list(PyTablesReading.columns)
188+
if args.full == "*"
189+
else [name.strip() for name in args.full.split(",")]
190+
)
191+
unknown = set(indexed_columns) - set(PyTablesReading.columns)
192+
if not all(indexed_columns) or unknown:
193+
raise ValueError(f"invalid --full columns: {', '.join(sorted(unknown)) or args.full!r}")
194+
if len(indexed_columns) != len(set(indexed_columns)):
195+
raise ValueError("--full columns must not contain duplicates")
196+
197+
output.parent.mkdir(parents=True, exist_ok=True)
198+
with tables.open_file(output, mode="w") as h5file:
199+
filters = tables.Filters(complevel=5, complib="blosc2:zstd", shuffle=True)
200+
table = h5file.create_table(
201+
"/",
202+
TABLE_NAME,
203+
PyTablesReading,
204+
title="Synthetic weather-station readings",
205+
filters=filters,
206+
expectedrows=args.rows,
207+
)
208+
table.attrs.version = 1
209+
table.attrs.sampling_interval = 0.5
210+
table.attrs.description = "Synthetic weather-station readings"
211+
212+
for batch in simple_batches(args.rows, args.batch_size):
213+
data = np.empty(len(batch["id"]), dtype=table.dtype)
214+
for name, values in batch.items():
215+
data[name] = values
216+
table.append(data)
217+
table.flush()
218+
219+
if indexed_columns:
220+
started = time.perf_counter()
221+
for name in indexed_columns:
222+
getattr(table.cols, name).create_csindex(filters=filters)
223+
print(f"Created FULL (CSI) indexes in {time.perf_counter() - started:.2f} s")
224+
225+
with tables.open_file(output) as h5file:
226+
table = h5file.root.readings
227+
assert table.nrows == args.rows
228+
assert all(getattr(table.cols, name).index.is_csi for name in indexed_columns)
229+
230+
print(f"Created {output} ({output.stat().st_size / 1_000_000:.1f} MB, {args.rows:,} rows)")
231+
print(f"FULL (CSI) indexes: {', '.join(indexed_columns) if indexed_columns else 'none'}")
232+
print(f"Now upload {output} to your cloud object storage.")
233+
234+
235+
def write_parquet(args) -> None:
236+
import pyarrow as pa
237+
import pyarrow.parquet as pq
238+
239+
output = args.write
240+
if output.suffix != ".parquet":
241+
raise ValueError("output must end in .parquet")
242+
if args.rows < 1 or args.batch_size < 1:
243+
raise ValueError("--rows and --batch-size must be positive")
244+
if output.exists() and not args.overwrite:
245+
raise FileExistsError(f"{output} already exists; pass --overwrite to replace it")
246+
247+
output.parent.mkdir(parents=True, exist_ok=True)
248+
schema = pa.schema(
249+
[
250+
("id", pa.int64()),
251+
("station_id", pa.int32()),
252+
("temperature", pa.float32()),
253+
("humidity", pa.int16()),
254+
("status", pa.string()),
255+
("active", pa.bool_()),
256+
("note", pa.large_string()),
257+
("message", pa.large_string()),
258+
("tags", pa.list_(pa.int16())),
259+
("region", pa.dictionary(pa.int32(), pa.string())),
260+
]
261+
)
262+
with pq.ParquetWriter(
263+
output,
264+
schema,
265+
compression="zstd",
266+
use_dictionary=["station_id", "humidity", "status", "tags.list.element", "region"],
267+
) as writer:
268+
for batch in rich_batches(args.rows, args.batch_size):
269+
writer.write_table(pa.table(batch, schema=schema), row_group_size=args.batch_size)
270+
271+
assert pq.read_metadata(output).num_rows == args.rows
272+
print(f"Created {output} ({output.stat().st_size / 1_000_000:.1f} MB, {args.rows:,} rows)")
273+
print(f"Now upload {output} to your cloud object storage.")
274+
275+
152276
def access_table(args) -> None:
153277
storage_options = {}
154278
if args.url.startswith("s3://"):
@@ -318,12 +442,71 @@ def access_table(args) -> None:
318442
print(f"Retained cache: {table.cache_bytes / 1024:8.2f} KB")
319443

320444

445+
def access_external_table(args) -> None:
446+
storage_options = None
447+
if args.url.startswith("s3://"):
448+
storage_options = {
449+
"profile": args.profile,
450+
"client_kwargs": {"endpoint_url": args.endpoint_url},
451+
}
452+
453+
cache_options = {"cache_dir": args.cache_dir} if args.cache_dir is not None else {}
454+
print(f"Accessing: {args.url}{f'::{TABLE_NAME}' if args.pytables else ''}")
455+
started = time.perf_counter()
456+
if args.pytables:
457+
table = blosc2.RemoteCTable(
458+
args.url, dataset=TABLE_NAME, storage_options=storage_options, **cache_options
459+
)
460+
else:
461+
table = blosc2.open(
462+
args.url, source_format="parquet", storage_options=storage_options, **cache_options
463+
)
464+
with table:
465+
metadata_time = time.perf_counter() - started
466+
metadata_bytes = table.traffic.nbytes
467+
metadata_requests = table.traffic.requests
468+
metadata = {
469+
"type": type(table).__name__,
470+
"rows": table.nrows,
471+
"columns": table.col_names,
472+
"schema": table.schema_dict(),
473+
"attrs": dict(table.attrs),
474+
"indexes": sorted(table._get_index_catalog()),
475+
}
476+
print(f"\n[Format: {'PyTables/HDF5' if args.pytables else 'Parquet'}]")
477+
for name, value in metadata.items():
478+
rendered = pprint.pformat(value) if isinstance(value, (dict, list)) else value
479+
print(f"{name:<9}: {rendered}")
480+
481+
sample_start = max(0, table.nrows // 2 - 2)
482+
sample_stop = min(sample_start + 5, table.nrows)
483+
print(f"\nSample rows [{sample_start}:{sample_stop}]:")
484+
print(table[sample_start:sample_stop])
485+
486+
started = time.perf_counter()
487+
ids = table.where("(station_id == 42) & active").id[:5]
488+
query_time = time.perf_counter() - started
489+
print("\nQuery: (station_id == 42) & active")
490+
print(f"first ids: {ids}")
491+
print(
492+
f"metadata: {metadata_time * 1000:.1f} ms, "
493+
f"{metadata_requests} requests, {metadata_bytes / 1024:.2f} KiB"
494+
)
495+
print(
496+
f"query: {query_time * 1000:.1f} ms, "
497+
f"{table.traffic.requests - metadata_requests} requests, "
498+
f"{(table.traffic.nbytes - metadata_bytes) / 1024:.2f} KiB"
499+
)
500+
501+
321502
def main() -> int:
322503
parser = argparse.ArgumentParser(description=__doc__)
323-
parser.add_argument(
324-
"url", nargs="?", help="Local .b2z CTable path or remote URL (s3://, http://, https://)"
325-
)
326-
parser.add_argument("--write", type=Path, metavar="FILE", help="Create a local .b2z CTable instead")
504+
parser.add_argument("url", nargs="?", help="Local table path or remote URL (s3://, http://, https://)")
505+
parser.add_argument("--write", type=Path, metavar="FILE", help="Create a local table")
506+
formats = parser.add_mutually_exclusive_group()
507+
formats.add_argument("--blosc2", action="store_true", help="Use Blosc2 CTable (.b2z)")
508+
formats.add_argument("--pytables", action="store_true", help="Use PyTables/HDF5 (.h5)")
509+
formats.add_argument("--parquet", action="store_true", help="Use Parquet (.parquet)")
327510
parser.add_argument(
328511
"--full",
329512
nargs="?",
@@ -344,12 +527,25 @@ def main() -> int:
344527
if args.write is not None and args.url is not None:
345528
parser.error("URL cannot be combined with --write")
346529
if args.write is None and args.url is None:
347-
parser.error("provide a local path or remote URL, or use --write FILE.b2z")
530+
parser.error("provide a local path or remote URL, or use --write FILE")
531+
if not (args.blosc2 or args.pytables or args.parquet):
532+
source = args.write or Path(urlsplit(args.url).path or urlsplit(args.url).netloc)
533+
format_name = {".b2z": "blosc2", ".h5": "pytables", ".parquet": "parquet"}.get(source.suffix)
534+
if format_name is None:
535+
parser.error("cannot infer format; use .b2z, .h5, .parquet, or a format flag")
536+
setattr(args, format_name, True)
348537
if args.full is not None and args.write is None:
349538
parser.error("--full requires --write")
539+
if args.full is not None and args.parquet:
540+
parser.error("--full is not supported for Parquet")
350541

351542
try:
352-
write_table(args) if args.write is not None else access_table(args)
543+
if args.write is not None:
544+
(write_pytables if args.pytables else write_parquet if args.parquet else write_table)(args)
545+
elif args.pytables or args.parquet:
546+
access_external_table(args)
547+
else:
548+
access_table(args)
353549
except Exception as exc:
354550
print(f"Error: {exc}", file=sys.stderr)
355551
return 1

0 commit comments

Comments
 (0)