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
1111import argparse
1212import pprint
1313import sys
1414import time
1515from dataclasses import dataclass , fields
1616from pathlib import Path
17+ from urllib .parse import urlsplit
1718
1819import numpy as np
1920
@@ -40,15 +41,38 @@ class Reading:
4041
4142
4243FULL_INDEX_UNSUPPORTED = {"message" , "tags" }
44+ TABLE_NAME = "readings"
4345
4446
4547def 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
5478def 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+
152276def 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"\n Sample 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 ("\n Query: (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+
321502def 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