diff --git a/bench/ctable/bench_persistency.py b/bench/ctable/bench_persistency.py index 71d26bee7..9cf4a54f2 100644 --- a/bench/ctable/bench_persistency.py +++ b/bench/ctable/bench_persistency.py @@ -182,10 +182,10 @@ def bench_append_file(): t_ro = blosc2.CTable.open(path, mode="r") def bench_read_mem(t=t_mem_table): - _ = t["id"].to_numpy() + _ = t["id"][:] def bench_read_file(t=t_ro): - _ = t["id"].to_numpy() + _ = t["id"][:] t_m = tmin(bench_read_mem) t_f = tmin(bench_read_file) diff --git a/bench/ctable/compact.py b/bench/ctable/compact.py index a4817b0a3..5ac36ade0 100644 --- a/bench/ctable/compact.py +++ b/bench/ctable/compact.py @@ -9,7 +9,7 @@ # of varying fractions of the table. from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np diff --git a/bench/ctable/ctable_v_pandas.py b/bench/ctable/ctable_v_pandas.py index 3b7a6d528..c63eaf53e 100644 --- a/bench/ctable/ctable_v_pandas.py +++ b/bench/ctable/ctable_v_pandas.py @@ -12,7 +12,7 @@ # 4. Row iteration from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np import pandas as pd @@ -75,7 +75,7 @@ class Row: # 2.5 Column access (full column) t0 = time() -arr = ct["score"].to_numpy() +arr = ct["score"][:] t_ct_col = time() - t0 t0 = time() @@ -86,7 +86,7 @@ class Row: # 3. Filtering t0 = time() -result_ct = ct.where((ct["id"] > 250_000) & (ct["id"] < 750_000)) +result_ct = ct.where((ct.id > 250_000) & (ct.id < 750_000)) t_ct_filter = time() - t0 t0 = time() diff --git a/bench/ctable/delete.py b/bench/ctable/delete.py index 79f595804..13db3fd4e 100644 --- a/bench/ctable/delete.py +++ b/bench/ctable/delete.py @@ -9,7 +9,7 @@ # int, slice, and list — with varying sizes. from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np diff --git a/bench/ctable/expected_size.py b/bench/ctable/expected_size.py index e199d5899..107e47a51 100644 --- a/bench/ctable/expected_size.py +++ b/bench/ctable/expected_size.py @@ -9,7 +9,7 @@ # is too small (M rows) vs correctly sized (N rows) during extend(). from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np diff --git a/bench/ctable/extend.py b/bench/ctable/extend.py index 5e1090ba3..2f5657fa6 100644 --- a/bench/ctable/extend.py +++ b/bench/ctable/extend.py @@ -11,7 +11,7 @@ # 3. An existing CTable (previously created from Python lists, 1M rows) from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np diff --git a/bench/ctable/extend_vs_append.py b/bench/ctable/extend_vs_append.py index db63206b9..bd5617d85 100644 --- a/bench/ctable/extend_vs_append.py +++ b/bench/ctable/extend_vs_append.py @@ -5,72 +5,55 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### -# Benchmark for comparing append() (row by row) vs extend() (bulk), -# to find the crossover point where extend() becomes worth it. +# Benchmark: append() row-by-row vs extend() bulk insert. +# +# Compares three strategies at increasing N to find where extend() wins: +# 1. append() x N — one call per row, Pydantic path +# 2. extend() x N — extend([row]) per row, one at a time +# 3. extend() x 1 — single bulk call with all N rows from dataclasses import dataclass -from time import time +from time import perf_counter import blosc2 @dataclass class Row: - id: int = blosc2.field(blosc2.int64(ge=0)) - c_val: complex = blosc2.field(blosc2.complex128(), default=0j) - score: float = blosc2.field(blosc2.float64(ge=0, le=100), default=0.0) - active: bool = blosc2.field(blosc2.bool(), default=True) + id: int = blosc2.field(blosc2.int64(ge=0)) + score: float = blosc2.field(blosc2.float64(ge=0, le=100), default=0.0) + active: bool = blosc2.field(blosc2.bool(), default=True) -# Parameter — change N to test different crossover points -N = 2 -print("append() vs extend() benchmark") -for i in range(6): - print("\n") - print("%" * 100) +SIZES = [10, 100, 1_000, 10_000, 100_000] +print(f"append() vs extend() | sizes: {SIZES}") +print() +print(f"{'N':>10} {'append×N (s)':>14} {'extend×N (s)':>14} {'extend×1 (s)':>14} {'speedup bulk':>13}") +print(f"{'─'*10} {'─'*14} {'─'*14} {'─'*14} {'─'*13}") - # Base data generation - data_list = [ - [i, complex(i * 0.1, i * 0.01), 10.0 + (i % 100) * 0.4, i % 3 == 0] for i in range(N) - ] +for N in SIZES: + data = [[i, float(i % 100), i % 2 == 0] for i in range(N)] - # 1. N individual append() calls - print(f"{N} individual append() calls") - ct_append = blosc2.CTable(Row, expected_size=N) - t0 = time() - for row in data_list: - ct_append.append(row) - t_append = time() - t0 - print(f" Time: {t_append:.6f} s") - print(f" Rows: {len(ct_append):,}") + ct = blosc2.CTable(Row, expected_size=N) + t0 = perf_counter() + for row in data: + ct.append(row) + t_append = perf_counter() - t0 - # 2. N individual extend() calls (one row at a time) - print(f"{N} individual extend() calls (one row at a time)") - ct_extend_one = blosc2.CTable(Row, expected_size=N) - t0 = time() - for row in data_list: - ct_extend_one.extend([row]) - t_extend_one = time() - t0 - print(f" Time: {t_extend_one:.6f} s") - print(f" Rows: {len(ct_extend_one):,}") + ct = blosc2.CTable(Row, expected_size=N) + t0 = perf_counter() + for row in data: + ct.extend([row]) + t_extend_one = perf_counter() - t0 - # 3. Single extend() call with all N rows at once - print(f"Single extend() call with all {N} rows at once") - ct_extend_bulk = blosc2.CTable(Row, expected_size=N) - t0 = time() - ct_extend_bulk.extend(data_list) - t_extend_bulk = time() - t0 - print(f" Time: {t_extend_bulk:.6f} s") - print(f" Rows: {len(ct_extend_bulk):,}") + ct = blosc2.CTable(Row, expected_size=N) + t0 = perf_counter() + ct.extend(data) + t_extend_bulk = perf_counter() - t0 - # Summary - print("=" * 70) - print(f"{'METHOD':<35} {'TIME (s)':>12} {'SPEEDUP vs append':>20}") - print("-" * 70) - print(f"{'append() x N':<35} {t_append:>12.6f} {'1.00x':>20}") - print(f"{'extend() x N (one row each)':<35} {t_extend_one:>12.6f} {t_append / t_extend_one:>19.2f}x") - print(f"{'extend() x 1 (all at once)':<35} {t_extend_bulk:>12.6f} {t_append / t_extend_bulk:>19.2f}x") - print("-" * 70) + speedup = t_append / t_extend_bulk if t_extend_bulk > 0 else float("inf") + print(f"{N:>10,} {t_append:>14.6f} {t_extend_one:>14.6f} {t_extend_bulk:>14.6f} {speedup:>12.1f}×") - N=N*2 +print() +print("speedup bulk = append×N time / extend×1 time (higher is better for extend)") diff --git a/bench/ctable/indexin.md b/bench/ctable/indexin.md new file mode 100644 index 000000000..9b06218d6 --- /dev/null +++ b/bench/ctable/indexin.md @@ -0,0 +1,191 @@ +# CTable Index Benchmark | N=1,000,000 REPS=5 + +> Random data: sensor_id uniform random in [0, 100,000) +> Sorted data: sensor_id = 0,0,…,1,1,…,2,2,… (clustered, ~10 rows/value) + + +## BUCKET + +> Stores min/max per chunk. Can skip chunks whose range doesn't overlap the +> query. Only effective when data is sorted/clustered. Useless on random data. + +### Range query — random data +``` +────────────────────────────────────────────────────────────────────── + Random data — BUCKET index +────────────────────────────────────────────────────────────────────── + SELECTIVITY ROWS SCAN(ms) IDX(ms) SPEEDUP + ────────────── ───────── ───────── ───────── ──────── + 0.1% 922 12.8 10.1 1.3× + 1% 9,879 13.7 14.7 0.9× + 5% 49,991 17.1 17.9 1.0× + 10% 99,775 19.8 21.0 0.9× + 25% 249,376 24.0 25.0 1.0× + 50% 499,826 24.0 27.2 0.9× (slower) + 75% 749,665 23.2 27.5 0.8× (slower) +────────────────────────────────────────────────────────────────────── +``` + +### Range query — sorted data +``` +────────────────────────────────────────────────────────────────────── + Sorted data — BUCKET index +────────────────────────────────────────────────────────────────────── + SELECTIVITY ROWS SCAN(ms) IDX(ms) SPEEDUP + ────────────── ───────── ───────── ───────── ──────── + 0.1% 990 11.9 2.5 4.8× ← + 1% 9,990 11.9 2.2 5.5× ← + 5% 49,990 12.0 3.1 3.9× ← + 10% 99,990 12.1 5.1 2.4× ← + 25% 249,990 11.7 9.3 1.3× + 50% 499,990 12.3 19.0 0.6× (slower) + 75% 749,990 11.9 35.9 0.3× (slower) +────────────────────────────────────────────────────────────────────── +``` + + +## PARTIAL + +> Stores exact row positions. Works on any data layout. +> Smaller index than FULL; slightly less overhead to build. + +### Range query — random data +``` +────────────────────────────────────────────────────────────────────── + Random data — PARTIAL index +────────────────────────────────────────────────────────────────────── + SELECTIVITY ROWS SCAN(ms) IDX(ms) SPEEDUP + ────────────── ───────── ───────── ───────── ──────── + 0.1% 922 12.4 1.9 6.4× ← + 1% 9,879 14.4 2.5 5.8× ← + 5% 49,991 17.3 5.3 3.3× ← + 10% 99,775 20.1 8.8 2.3× ← + 25% 249,376 23.6 21.4 1.1× + 50% 499,826 26.2 46.4 0.6× (slower) + 75% 749,665 22.8 75.2 0.3× (slower) +────────────────────────────────────────────────────────────────────── +``` + +### Range query — sorted data +``` +────────────────────────────────────────────────────────────────────── + Sorted data — PARTIAL index +────────────────────────────────────────────────────────────────────── + SELECTIVITY ROWS SCAN(ms) IDX(ms) SPEEDUP + ────────────── ───────── ───────── ───────── ──────── + 0.1% 990 13.2 2.4 5.5× ← + 1% 9,990 12.8 2.0 6.4× ← + 5% 49,990 12.5 2.6 4.9× ← + 10% 99,990 12.7 4.0 3.1× ← + 25% 249,990 12.0 8.1 1.5× + 50% 499,990 11.9 18.5 0.6× (slower) + 75% 749,990 13.1 33.4 0.4× (slower) +────────────────────────────────────────────────────────────────────── +``` + +### Equality query — random data +``` + VALUE ROWS SCAN(ms) IDX(ms) SPEEDUP + ──────────── ────── ───────── ───────── ──────── + ==0 12 12.6 2.0 6.3× ← + ==25,000 13 14.2 1.9 7.5× ← + ==50,000 9 12.6 1.9 6.7× ← + ==99,999 4 12.4 1.9 6.7× ← +``` + +### Equality query — sorted data +``` + VALUE ROWS SCAN(ms) IDX(ms) SPEEDUP + ──────────── ────── ───────── ───────── ──────── + ==0 10 11.8 1.9 6.3× ← + ==25,000 10 11.7 1.8 6.7× ← + ==50,000 10 12.0 1.7 7.0× ← + ==99,999 10 12.1 1.7 7.1× ← +``` + + +## FULL + +> Stores exact row positions with full chunk coverage. +> Best query performance; larger index than PARTIAL. + +### Range query — random data +``` +────────────────────────────────────────────────────────────────────── + Random data — FULL index +────────────────────────────────────────────────────────────────────── + SELECTIVITY ROWS SCAN(ms) IDX(ms) SPEEDUP + ────────────── ───────── ───────── ───────── ──────── + 0.1% 922 13.2 2.1 6.4× ← + 1% 9,879 15.3 2.8 5.5× ← + 5% 49,991 18.1 5.1 3.5× ← + 10% 99,775 20.5 11.0 1.9× + 25% 249,376 23.5 21.5 1.1× + 50% 499,826 25.4 46.1 0.6× (slower) + 75% 749,665 23.2 86.9 0.3× (slower) +────────────────────────────────────────────────────────────────────── +``` + +### Range query — sorted data +``` +────────────────────────────────────────────────────────────────────── + Sorted data — FULL index +────────────────────────────────────────────────────────────────────── + SELECTIVITY ROWS SCAN(ms) IDX(ms) SPEEDUP + ────────────── ───────── ───────── ───────── ──────── + 0.1% 990 12.0 1.9 6.4× ← + 1% 9,990 12.0 2.0 6.1× ← + 5% 49,990 11.5 2.8 4.1× ← + 10% 99,990 12.0 4.2 2.9× ← + 25% 249,990 11.9 7.8 1.5× + 50% 499,990 11.8 18.5 0.6× (slower) + 75% 749,990 11.5 44.5 0.3× (slower) +────────────────────────────────────────────────────────────────────── +``` + +### Equality query — random data +``` + VALUE ROWS SCAN(ms) IDX(ms) SPEEDUP + ──────────── ────── ───────── ───────── ──────── + ==0 12 12.1 2.5 4.8× ← + ==25,000 13 12.0 2.0 6.1× ← + ==50,000 9 12.4 2.0 6.2× ← + ==99,999 4 12.6 2.0 6.4× ← +``` + +### Equality query — sorted data +``` + VALUE ROWS SCAN(ms) IDX(ms) SPEEDUP + ──────────── ────── ───────── ───────── ──────── + ==0 10 11.7 1.8 6.5× ← + ==25,000 10 11.5 1.7 6.6× ← + ==50,000 10 12.4 1.7 7.1× ← + ==99,999 10 12.3 1.8 7.0× ← +``` + +### Cardinality comparison — sorted data, FULL index + +> Shows how repetition level affects speedup (data always sorted). +``` + CARDINALITY 0.1% sel 1% sel 5% sel 10% sel + ────────────────────────────────────────────────────────────────────── + High rep (10 uniq) 9.1× 9.6× 8.9× 10.1× + Med rep (1k uniq) 8.5× 6.2× 4.3× 3.5× + Low rep (1M uniq) 6.4× 5.9× 4.2× 3.2× + ────────────────────────────────────────────────────────────────────── + (speedup — higher is better) +``` + +### Compound filter — sorted data, FULL index + +> sensor_id > X AND region == Y | region in [0,8) → ~12.5% per value +``` +──────────────────────────────────────────────────────────────────────────────── + QUERY ROWS NO IDX IDX:sid IDX:reg 2 IDX BEST + ────────────── ──────── ───────── ───────── ───────── ───────── ──────────── + 0.1%+12.5% 127 14.6ms 2.6ms 15.0ms 14.4ms sid(5.6×) + 1%+12.5% 1,297 14.7ms 2.6ms 15.2ms 17.2ms sid(5.7×) + 5%+12.5% 6,268 16.2ms 4.5ms 16.8ms 20.3ms sid(3.6×) + 10%+12.5% 12,377 19.5ms 6.2ms 19.6ms 21.0ms sid(3.2×) +──────────────────────────────────────────────────────────────────────────────── +``` diff --git a/bench/ctable/indexin.py b/bench/ctable/indexin.py new file mode 100644 index 000000000..c022a3e03 --- /dev/null +++ b/bench/ctable/indexin.py @@ -0,0 +1,302 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +# Benchmark: CTable index kinds vs full-scan speedup. +# +# Sections +# ──────── +# ## BUCKET — min/max per chunk; only helps on sorted/clustered data +# ## PARTIAL — exact positions; works on any data layout +# ## FULL — exact positions, best performance; works on any data layout +# +# Each section benchmarks range queries (sensor_id > X) on random and +# sorted data, plus equality queries (sensor_id == X) where applicable. +# FULL also covers cardinality and compound filters. + +from dataclasses import dataclass +from time import perf_counter + +import numpy as np + +import blosc2 + +# ── Config ──────────────────────────────────────────────────────────────────── + +N = 1_000_000 +REPS = 5 + +# ── Schema ──────────────────────────────────────────────────────────────────── + +@dataclass +class Row: + sensor_id: int = blosc2.field(blosc2.int32()) + temperature: float = blosc2.field(blosc2.float64()) + region: int = blosc2.field(blosc2.int32()) + +np_dtype = np.dtype([ + ("sensor_id", np.int32), + ("temperature", np.float64), + ("region", np.int32), +]) + +rng = np.random.default_rng(42) + +SID_MAX = N // 10 # 100_000 unique sensor_id values, ~10 rows each + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def make_table(sensor_ids): + DATA = np.empty(N, dtype=np_dtype) + DATA["sensor_id"] = sensor_ids + DATA["temperature"] = 15.0 + rng.random(N) * 25 + DATA["region"] = rng.integers(0, 8, size=N, dtype=np.int32) + ct = blosc2.CTable(Row, expected_size=N) + ct.extend(DATA) + return ct + +def bench_gt(table, threshold, reps=REPS): + times = [] + for _ in range(reps): + t0 = perf_counter() + result = table.where(table.sensor_id > threshold) + times.append(perf_counter() - t0) + return float(np.median(times)), len(result) + +def bench_eq(table, value, reps=REPS): + times = [] + for _ in range(reps): + t0 = perf_counter() + result = table.where(table.sensor_id == value) + times.append(perf_counter() - t0) + return float(np.median(times)), len(result) + +def bench_compound(table, threshold, region, reps=REPS): + times = [] + for _ in range(reps): + t0 = perf_counter() + result = table.where( + (table.sensor_id > threshold) & (table.region == region) + ) + times.append(perf_counter() - t0) + return float(np.median(times)), len(result) + +def drop_all_indexes(ct): + for col in ("sensor_id", "region"): + try: + ct.drop_index(col) + except Exception: + pass + +FRACS = [0.999, 0.99, 0.95, 0.90, 0.75, 0.50, 0.25] +LABELS = ["0.1%", "1%", "5%", "10%", "25%", "50%", "75%"] + +def print_range_table(results, title, width=70): + print("```") + print(f"{'─' * width}") + print(f" {title}") + print(f"{'─' * width}") + print(f" {'SELECTIVITY':<14} {'ROWS':>9} {'SCAN(ms)':>9} {'IDX(ms)':>9} {'SPEEDUP':>8}") + print(f" {'─'*14} {'─'*9} {'─'*9} {'─'*9} {'─'*8}") + for label, n, t_scan, t_idx in results: + speedup = t_scan / t_idx if t_idx > 0 else float("inf") + marker = " ←" if speedup >= 2.0 else (" (slower)" if speedup < 0.9 else "") + print(f" {label:<14} {n:>9,} {t_scan*1e3:>9.1f} {t_idx*1e3:>9.1f} {speedup:>7.1f}×{marker}") + print(f"{'─' * width}") + print("```") + +def bench_range_section(ct, kind, label): + thresholds = [(lbl, int(SID_MAX * f)) for lbl, f in zip(LABELS, FRACS)] + drop_all_indexes(ct) + scan = {lbl: bench_gt(ct, thr) for lbl, thr in thresholds} + ct.create_index("sensor_id", kind=kind) + results = [] + for lbl, thr in thresholds: + t_idx, n = bench_gt(ct, thr) + t_scan, _ = scan[lbl] + results.append((lbl, n, t_scan, t_idx)) + print_range_table(results, label) + +def bench_eq_section(ct, kind): + EQ_VALS = [0, SID_MAX // 4, SID_MAX // 2, SID_MAX - 1] + drop_all_indexes(ct) + scan_eq = {v: bench_eq(ct, v) for v in EQ_VALS} + ct.create_index("sensor_id", kind=kind) + idx_eq = {v: bench_eq(ct, v) for v in EQ_VALS} + print("```") + print(f" {'VALUE':<12} {'ROWS':>6} {'SCAN(ms)':>9} {'IDX(ms)':>9} {'SPEEDUP':>8}") + print(f" {'─'*12} {'─'*6} {'─'*9} {'─'*9} {'─'*8}") + for v in EQ_VALS: + t_s, n = scan_eq[v] + t_i, _ = idx_eq[v] + spd = t_s / t_i if t_i > 0 else float("inf") + marker = " ←" if spd >= 2.0 else "" + print(f" =={v:<10,} {n:>6,} {t_s*1e3:>9.1f} {t_i*1e3:>9.1f} {spd:>7.1f}×{marker}") + print("```") + +# ── Data ────────────────────────────────────────────────────────────────────── + +rand_ids = rng.integers(0, SID_MAX, size=N, dtype=np.int32) +sorted_ids = np.repeat(np.arange(SID_MAX, dtype=np.int32), N // SID_MAX) + +ct_rand = make_table(rand_ids) +ct_sorted = make_table(sorted_ids) + +print(f"# CTable Index Benchmark | N={N:,} REPS={REPS}") +print(f"\n> Random data: sensor_id uniform random in [0, {SID_MAX:,})") +print(f"> Sorted data: sensor_id = 0,0,…,1,1,…,2,2,… (clustered, ~10 rows/value)") + + +# ══════════════════════════════════════════════════════════════════════════════ +# ## BUCKET +# ══════════════════════════════════════════════════════════════════════════════ + +print("\n\n## BUCKET") +print("\n> Stores min/max per chunk. Can skip chunks whose range doesn't overlap the") +print("> query. Only effective when data is sorted/clustered. Useless on random data.") + +print("\n### Range query — random data") +bench_range_section(ct_rand, blosc2.IndexKind.BUCKET, "Random data — BUCKET index") + +print("\n### Range query — sorted data") +bench_range_section(ct_sorted, blosc2.IndexKind.BUCKET, "Sorted data — BUCKET index") + + +# ══════════════════════════════════════════════════════════════════════════════ +# ## PARTIAL +# ══════════════════════════════════════════════════════════════════════════════ + +print("\n\n## PARTIAL") +print("\n> Stores exact row positions. Works on any data layout.") +print("> Smaller index than FULL; slightly less overhead to build.") + +print("\n### Range query — random data") +bench_range_section(ct_rand, blosc2.IndexKind.PARTIAL, "Random data — PARTIAL index") + +print("\n### Range query — sorted data") +bench_range_section(ct_sorted, blosc2.IndexKind.PARTIAL, "Sorted data — PARTIAL index") + +print("\n### Equality query — random data") +drop_all_indexes(ct_rand) +bench_eq_section(ct_rand, blosc2.IndexKind.PARTIAL) + +print("\n### Equality query — sorted data") +drop_all_indexes(ct_sorted) +bench_eq_section(ct_sorted, blosc2.IndexKind.PARTIAL) + + +# ══════════════════════════════════════════════════════════════════════════════ +# ## FULL +# ══════════════════════════════════════════════════════════════════════════════ + +print("\n\n## FULL") +print("\n> Stores exact row positions with full chunk coverage.") +print("> Best query performance; larger index than PARTIAL.") + +print("\n### Range query — random data") +bench_range_section(ct_rand, blosc2.IndexKind.FULL, "Random data — FULL index") + +print("\n### Range query — sorted data") +bench_range_section(ct_sorted, blosc2.IndexKind.FULL, "Sorted data — FULL index") + +print("\n### Equality query — random data") +drop_all_indexes(ct_rand) +bench_eq_section(ct_rand, blosc2.IndexKind.FULL) + +print("\n### Equality query — sorted data") +drop_all_indexes(ct_sorted) +bench_eq_section(ct_sorted, blosc2.IndexKind.FULL) + +# ── Cardinality (FULL, sorted) ──────────────────────────────────────────────── + +print("\n### Cardinality comparison — sorted data, FULL index") +print("\n> Shows how repetition level affects speedup (data always sorted).") + +CARD_CASES = [ + ("High rep (10 uniq)", 10), + ("Med rep (1k uniq)", 1_000), + ("Low rep (1M uniq)", N), +] +SEL_FRACS = [0.999, 0.99, 0.95, 0.90] +SEL_LABELS = ["0.1%", "1%", "5%", "10%"] + +W2 = 72 +print("```") +print(f" {'CARDINALITY':<24}", end="") +for lbl in SEL_LABELS: + print(f" {lbl+' sel':>12}", end="") +print() +print(" " + "─" * (W2 - 2)) + +for case_lbl, n_unique in CARD_CASES: + reps_per_val = N // n_unique + ids = np.repeat(np.arange(n_unique, dtype=np.int32), reps_per_val) + if len(ids) < N: + ids = np.concatenate([ids, np.zeros(N - len(ids), dtype=np.int32)]) + sid_max = n_unique + ct_c = make_table(ids) + thr_list = [(lbl, int(sid_max * f)) for lbl, f in zip(SEL_LABELS, SEL_FRACS)] + scan_c = {lbl: bench_gt(ct_c, thr) for lbl, thr in thr_list} + ct_c.create_index("sensor_id", kind=blosc2.IndexKind.FULL) + print(f" {case_lbl:<24}", end="") + for lbl, thr in thr_list: + t_idx, _ = bench_gt(ct_c, thr) + t_scan, _ = scan_c[lbl] + spd = t_scan / t_idx if t_idx > 0 else float("inf") + print(f" {spd:>10.1f}× ", end="") + print() + +print(" " + "─" * (W2 - 2)) +print(" (speedup — higher is better)") +print("```") + +# ── Compound filters (FULL, sorted) ────────────────────────────────────────── + +print("\n### Compound filter — sorted data, FULL index") +print("\n> sensor_id > X AND region == Y | region in [0,8) → ~12.5% per value") + +REGION_TARGET = 3 +COMPOUND_THRESHOLDS = [ + ("0.1%+12.5%", int(SID_MAX * 0.999)), + ("1%+12.5%", int(SID_MAX * 0.99)), + ("5%+12.5%", int(SID_MAX * 0.95)), + ("10%+12.5%", int(SID_MAX * 0.90)), +] + +drop_all_indexes(ct_sorted) +no_idx = {lbl: bench_compound(ct_sorted, thr, REGION_TARGET) for lbl, thr in COMPOUND_THRESHOLDS} +ct_sorted.create_index("sensor_id", kind=blosc2.IndexKind.FULL) +one_idx_sid = {lbl: bench_compound(ct_sorted, thr, REGION_TARGET) for lbl, thr in COMPOUND_THRESHOLDS} +ct_sorted.drop_index("sensor_id") +ct_sorted.create_index("region", kind=blosc2.IndexKind.FULL) +one_idx_reg = {lbl: bench_compound(ct_sorted, thr, REGION_TARGET) for lbl, thr in COMPOUND_THRESHOLDS} +ct_sorted.create_index("sensor_id", kind=blosc2.IndexKind.FULL) +two_idx = {lbl: bench_compound(ct_sorted, thr, REGION_TARGET) for lbl, thr in COMPOUND_THRESHOLDS} + +W3 = 80 +print("```") +print(f"{'─' * W3}") +print(f" {'QUERY':<14} {'ROWS':>8} {'NO IDX':>9} {'IDX:sid':>9} {'IDX:reg':>9} {'2 IDX':>9} {'BEST'}") +print(f" {'─'*14} {'─'*8} {'─'*9} {'─'*9} {'─'*9} {'─'*9} {'─'*12}") +for lbl, thr in COMPOUND_THRESHOLDS: + n = no_idx[lbl][1] + t_none = no_idx[lbl][0] + t_sid = one_idx_sid[lbl][0] + t_reg = one_idx_reg[lbl][0] + t_two = two_idx[lbl][0] + best_t = min(t_none, t_sid, t_reg, t_two) + spd = t_none / best_t + winner = ["none", "sid", "reg", "2idx"][[t_none, t_sid, t_reg, t_two].index(best_t)] + print( + f" {lbl:<14} {n:>8,}" + f" {t_none*1e3:>8.1f}ms" + f" {t_sid*1e3:>8.1f}ms" + f" {t_reg*1e3:>8.1f}ms" + f" {t_two*1e3:>8.1f}ms" + f" {winner}({spd:.1f}×)" + ) +print(f"{'─' * W3}") +print("```") diff --git a/bench/ctable/iter_rows.py b/bench/ctable/iter_rows.py index 51203ba41..7271048ab 100644 --- a/bench/ctable/iter_rows.py +++ b/bench/ctable/iter_rows.py @@ -5,93 +5,54 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### +# Benchmark: row iteration cost with different access patterns. +# +# Measures: +# 1. Iteration without column access (loop overhead only) +# 2. Iteration accessing 1 column per row +# 3. Iteration accessing 3 columns per row +# 4. Iteration with deleted rows (holes in _valid_rows) + from dataclasses import dataclass -from time import time +from time import perf_counter import blosc2 -from blosc2 import CTable @dataclass class Row: - id: int = blosc2.field(blosc2.int64(ge=0)) - score: float = blosc2.field(blosc2.float64(ge=0, le=100)) - active: bool = blosc2.field(blosc2.bool(), default=True) + id: int = blosc2.field(blosc2.int64(ge=0)) + score: float = blosc2.field(blosc2.float64(ge=0, le=100)) + active: bool = blosc2.field(blosc2.bool(), default=True) -N = 1_000 # start small, increase when confident +N = 100_000 data = [(i, float(i % 100), i % 2 == 0) for i in range(N)] -tabla = CTable(Row, new_data=data) - -print(f"Table created with {len(tabla)} rows\n") - -# ------------------------------------------------------------------- -# Test 1: iterate without accessing any column (minimum cost) -# ------------------------------------------------------------------- -t0 = time() -for _row in tabla: - pass -t1 = time() -print(f"[Test 1] Iter without accessing columns: {(t1 - t0)*1000:.3f} ms") - -# ------------------------------------------------------------------- -# Test 2: iterate accessing a single column (real_pos cached once) -# ------------------------------------------------------------------- -t0 = time() -for row in tabla: - _ = row["id"] -t1 = time() -print(f"[Test 2] Iter accessing 'id': {(t1 - t0)*1000:.3f} ms") - -# ------------------------------------------------------------------- -# Test 3: iterate accessing all columns (real_pos cached once per row) -# ------------------------------------------------------------------- -t0 = time() -for row in tabla: - _ = row["id"] - _ = row["score"] - _ = row["active"] -t1 = time() -print(f"[Test 3] Iter accessing 3 columns: {(t1 - t0)*1000:.3f} ms") - -# ------------------------------------------------------------------- -# Test 4: correctness — values match expected -# ------------------------------------------------------------------- -errors = 0 -for row in tabla: - if row["id"] != row._nrow: - errors += 1 - if row["score"] != float(row._nrow % 100): - errors += 1 - if row["active"] != (row._nrow % 2 == 0): - errors += 1 - -print(f"\n[Test 4] Correctness errors: {errors} (expected: 0)") - -# ------------------------------------------------------------------- -# Test 5: with holes (deleted rows) -# ------------------------------------------------------------------- -tabla2 = CTable(Row, new_data=data) -tabla2.delete(list(range(0, N, 2))) # delete even rows, keep odd ones - -print(f"\nTable with holes: {len(tabla2)} rows (expected: {N // 2})") - -t0 = time() -ids = [] -for row in tabla2: - ids.append(row["id"]) -t1 = time() - -expected_ids = [i for i in range(N) if i % 2 != 0] -ok = ids == expected_ids -print(f"[Test 5] Iter with holes ({N//2} rows): {(t1 - t0)*1000:.3f} ms | correctness: {ok}") - -# ------------------------------------------------------------------- -# Test 6: real_pos is cached correctly (not recomputed) -# ------------------------------------------------------------------- -row0 = next(iter(tabla)) -assert row0._real_pos is None, "real_pos should be None before first access" -_ = row0["id"] -assert row0._real_pos is not None, "real_pos should be cached after first access" -print(f"\n[Test 6] real_pos caching: OK (real_pos={row0._real_pos})") +ct = blosc2.CTable(Row, expected_size=N) +ct.extend(data) + +ct_holes = blosc2.CTable(Row, expected_size=N) +ct_holes.extend(data) +ct_holes.delete(list(range(0, N, 2))) # keep only odd rows + +print(f"Row iteration benchmark | N = {N:,} | holes table: {len(ct_holes):,} rows") +print() +print(f" {'PATTERN':<35} {'TIME (ms)':>10} {'µs/row':>8}") +print(f" {'─'*35} {'─'*10} {'─'*8}") + +cases = [ + ("no column access", ct, lambda row: None), + ("access 1 col (id)", ct, lambda row: row["id"]), + ("access 3 cols", ct, lambda row: (row["id"], row["score"], row["active"])), + ("access 1 col — with holes", ct_holes, lambda row: row["id"]), +] + +for label, table, accessor in cases: + n = len(table) + t0 = perf_counter() + for row in table: + accessor(row) + elapsed = perf_counter() - t0 + us = elapsed / n * 1e6 + print(f" {label:<35} {elapsed*1e3:>10.2f} {us:>8.2f}") diff --git a/bench/ctable/iteration_column.py b/bench/ctable/iteration_column.py index b1ac3703d..a55440526 100644 --- a/bench/ctable/iteration_column.py +++ b/bench/ctable/iteration_column.py @@ -11,7 +11,7 @@ # 3. ct["score"][0:N].to_array() — slice view + to_array() from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np @@ -68,11 +68,11 @@ class Row: # 3. slice view + to_array() t0 = time() -arr = col[0:N].to_numpy() +arr = col[0:N] for _val in arr: pass t_toarray = time() - t0 -print(f"col[0:N].to_array(): {t_toarray:.4f} s") +print(f"col[0:N] (full slice): {t_toarray:.4f} s") print("=" * 60) print(f"Speedup to_array vs iter: {t_iter / t_toarray:.2f}x") diff --git a/bench/ctable/print.py b/bench/ctable/print.py index 6efb80bff..fe7577b94 100644 --- a/bench/ctable/print.py +++ b/bench/ctable/print.py @@ -5,11 +5,14 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### -# Benchmark: iterative ingestion comparison — Pandas vs CTable -# Data source: randomly generated numpy structured array +# Benchmark: CTable str() / head() rendering time vs pandas. +# +# Measures how long it takes to render the first 10 rows as a table +# for both CTable (head()) and pandas (DataFrame.to_string()), +# plus ingestion time and memory footprint comparison. -import time from dataclasses import dataclass +from time import perf_counter import numpy as np import pandas as pd @@ -19,8 +22,8 @@ @dataclass class Row: - id: int = blosc2.field(blosc2.int64()) - name: str = blosc2.field(blosc2.string(max_length=9), default="") + id: int = blosc2.field(blosc2.int64()) + name: str = blosc2.field(blosc2.string(max_length=9), default="") score: float = blosc2.field(blosc2.float64(ge=0), default=0.0) @@ -41,68 +44,38 @@ def make_data(n: int) -> np.ndarray: return arr -print(f"=== BENCHMARK: Iterative Ingestion ({N:,} rows) ===\n") - -# ───────────────────────────────────────────────────────────── -# 1. PANDAS -# ───────────────────────────────────────────────────────────── -print("--- 1. PANDAS (structured array -> DataFrame) ---") data = make_data(N) -t0 = time.perf_counter() +t0 = perf_counter() df = pd.DataFrame(data) -t_pandas = time.perf_counter() - t0 - -mem_pandas = df.memory_usage(deep=True).sum() / (1024 ** 2) -print(f"Total time: {t_pandas:.4f} s") -print(f"Memory (RAM): {mem_pandas:.2f} MB") - -print("\n--- PANDAS: First 10 rows ---") -t0_print = time.perf_counter() -print(df.head(10).to_string()) -t_print_pandas = time.perf_counter() - t0_print -print(f"\nPrint time: {t_print_pandas:.6f} s") - -# ───────────────────────────────────────────────────────────── -# 2. BLOSC2 CTable -# ───────────────────────────────────────────────────────────── -print("\n" + "=" * 60) -print("--- 2. BLOSC2 CTable (structured array -> extend) ---") -data = make_data(N) +t_pandas = perf_counter() - t0 -t0 = time.perf_counter() +t0 = perf_counter() ct = blosc2.CTable(Row, expected_size=N) ct.extend(data) -t_blosc = time.perf_counter() - t0 - -fields = ct.col_names -mem_blosc_c = (sum(col.cbytes for col in ct._cols.values()) + ct._valid_rows.cbytes) / (1024 ** 2) -mem_blosc_uc = (sum(col.nbytes for col in ct._cols.values()) + ct._valid_rows.nbytes) / (1024 ** 2) - -print(f"Total time: {t_blosc:.4f} s") -print(f"Memory (uncompressed): {mem_blosc_uc:.2f} MB") -print(f"Memory (compressed): {mem_blosc_c:.2f} MB") - -print("\n--- BLOSC2: First 10 rows ---") -t0_print = time.perf_counter() -print(ct.head(10)) -t_print_blosc = time.perf_counter() - t0_print -print(f"\nPrint time: {t_print_blosc:.6f} s") - -# ───────────────────────────────────────────────────────────── -# SUMMARY -# ───────────────────────────────────────────────────────────── -print("\n" + "=" * 60) -print("--- SUMMARY ---") -speedup = t_pandas / t_blosc -direction = "faster" if t_blosc < t_pandas else "slower" - -print(f"{'METRIC':<30} {'Pandas':>12} {'Blosc2':>12}") -print("-" * 55) -print(f"{'Ingestion time (s)':<30} {t_pandas:>12.4f} {t_blosc:>12.4f}") -print(f"{'Memory (MB)':<30} {mem_pandas:>12.2f} {mem_blosc_c:>12.2f}") -print(f"{'Print time (s)':<30} {t_print_pandas:>12.6f} {t_print_blosc:>12.6f}") -print("-" * 55) -print(f"\nSpeedup: {speedup:.2f}x {direction}") -print(f"Compression ratio: {mem_blosc_uc / mem_blosc_c:.2f}x") -print(f"Blosc2 vs Pandas size: {mem_blosc_c / mem_pandas * 100:.1f}%") +t_blosc = perf_counter() - t0 + +t0 = perf_counter() +_ = df.head(10).to_string() +t_print_pandas = perf_counter() - t0 + +t0 = perf_counter() +_ = ct.head(10) +t_print_blosc = perf_counter() - t0 + +mem_pandas = df.memory_usage(deep=True).sum() / 1024**2 +mem_blosc_c = (sum(c.cbytes for c in ct._cols.values()) + ct._valid_rows.cbytes) / 1024**2 +mem_blosc_uc = (sum(c.nbytes for c in ct._cols.values()) + ct._valid_rows.nbytes) / 1024**2 + +print(f"CTable vs pandas — ingestion + print | N = {N:,}") +print() +print(f" {'METRIC':<30} {'pandas':>10} {'CTable':>10}") +print(f" {'─'*30} {'─'*10} {'─'*10}") +print(f" {'Ingestion time (s)':<30} {t_pandas:>10.4f} {t_blosc:>10.4f}") +print(f" {'Memory compressed (MB)':<30} {mem_pandas:>10.2f} {mem_blosc_c:>10.2f}") +print(f" {'Memory uncompressed (MB)':<30} {mem_pandas:>10.2f} {mem_blosc_uc:>10.2f}") +print(f" {'head(10) render time (s)':<30} {t_print_pandas:>10.6f} {t_print_blosc:>10.6f}") +print() +print(f" Ingestion speedup CTable vs pandas: {t_pandas / t_blosc:.2f}×") +print(f" Compression ratio CTable: {mem_blosc_uc / mem_blosc_c:.2f}×") +print(f" CTable compressed vs pandas RAM: {mem_blosc_c / mem_pandas * 100:.1f}%") diff --git a/bench/ctable/row_access.py b/bench/ctable/row_access.py index 050d0309b..c77b9196c 100644 --- a/bench/ctable/row_access.py +++ b/bench/ctable/row_access.py @@ -5,11 +5,11 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### -# Benchmark for measuring row[int] access (full row via _RowIndexer), +# Benchmark for measuring row[int] access via CTable.__getitem__, # testing access at different positions across the array. from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np @@ -54,7 +54,7 @@ class Row: for idx in indices: t0 = time() - row = ct.row[idx] + row = ct[idx] t_access = time() - t0 position = f"{idx / N * 100:.0f}% into array" print(f"{idx:<15,} {position:>12} {t_access:.6f}") diff --git a/bench/ctable/slice.py b/bench/ctable/slice.py index a41c50a6c..ad1b60bf1 100644 --- a/bench/ctable/slice.py +++ b/bench/ctable/slice.py @@ -9,7 +9,7 @@ # sizes and positions: small, large, and middle of the array. from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np diff --git a/bench/ctable/slice_steps.py b/bench/ctable/slice_steps.py index 0a3fb3583..0bd219a42 100644 --- a/bench/ctable/slice_steps.py +++ b/bench/ctable/slice_steps.py @@ -8,7 +8,7 @@ # Benchmark for measuring Column[::step].to_array() with varying step sizes. from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np @@ -54,7 +54,7 @@ class Row: col = ct["score"] for step in steps: t0 = time() - arr = col[::step].to_numpy() + arr = col[::step] t_total = time() - t0 print(f"::{ step:<8} {len(arr):>15,} {t_total:>12.6f}") diff --git a/bench/ctable/slice_to_array.py b/bench/ctable/slice_to_array.py index 7c58080ec..2b21d5d38 100644 --- a/bench/ctable/slice_to_array.py +++ b/bench/ctable/slice_to_array.py @@ -9,7 +9,7 @@ # different sizes and positions: small, large, and middle of the array. from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np @@ -63,7 +63,7 @@ class Row: col = ct["score"] for label, s in slices: t0 = time() - arr = col[s].to_numpy() + arr = col[s] t_total = time() - t0 n_rows = s.stop - s.start print(f"{label:<25} {n_rows:>8,} {t_total:>12.6f}") diff --git a/bench/ctable/sort_by.py b/bench/ctable/sort_by.py new file mode 100644 index 000000000..5c5d5e06b --- /dev/null +++ b/bench/ctable/sort_by.py @@ -0,0 +1,158 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +# Benchmark: sort_by() performance. +# +# Sections: +# 1. Single-column sort at increasing N (random data) +# 2. Multi-column sort (1, 2, 3 keys) at fixed N +# 3. Already-sorted vs random vs reverse-sorted input +# 4. sort_by() with deletions (holes in _valid_rows) +# 5. inplace=True vs inplace=False + +from dataclasses import dataclass +from time import perf_counter + +import numpy as np + +import blosc2 + + +@dataclass +class Row: + sensor_id: int = blosc2.field(blosc2.int32()) + temperature: float = blosc2.field(blosc2.float64()) + region: int = blosc2.field(blosc2.int32()) + active: bool = blosc2.field(blosc2.bool()) + +np_dtype = np.dtype([ + ("sensor_id", np.int32), + ("temperature", np.float64), + ("region", np.int32), + ("active", np.bool_), +]) + +rng = np.random.default_rng(42) + +W = 70 + +def sep(title): + print(f"\n{'─' * W}") + print(f" {title}") + print(f"{'─' * W}") + +def make_table(n, sensor_ids=None): + data = np.empty(n, dtype=np_dtype) + data["sensor_id"] = rng.integers(0, n // 10, size=n, dtype=np.int32) if sensor_ids is None else sensor_ids + data["temperature"] = 15.0 + rng.random(n) * 25 + data["region"] = rng.integers(0, 8, size=n, dtype=np.int32) + data["active"] = rng.integers(0, 2, size=n, dtype=np.bool_) + ct = blosc2.CTable(Row, expected_size=n) + ct.extend(data) + return ct + + +# ── 1. Single-column sort at increasing N ──────────────────────────────────── + +sep("1. Single-column sort (sensor_id, random input)") +print(f" {'N':>12} {'TIME (s)':>10} {'ms/Mrow':>10}") +print(f" {'─'*12} {'─'*10} {'─'*10}") + +for n in [10_000, 100_000, 500_000, 1_000_000]: + ct = make_table(n) + t0 = perf_counter() + ct.sort_by(["sensor_id"], inplace=True) + elapsed = perf_counter() - t0 + ms_per_mrow = elapsed / n * 1e9 / 1e3 + print(f" {n:>12,} {elapsed:>10.4f} {ms_per_mrow:>10.1f}") + + +# ── 2. Multi-column sort at fixed N ────────────────────────────────────────── + +N = 1_000_000 +sep(f"2. Multi-column sort (N={N:,})") +print(f" {'KEYS':<30} {'TIME (s)':>10} {'SPEEDUP vs 1-key':>18}") +print(f" {'─'*30} {'─'*10} {'─'*18}") + +ct_base = make_table(N) +results = {} + +for keys in [ + ["sensor_id"], + ["sensor_id", "region"], + ["sensor_id", "region", "active"], +]: + ct = make_table(N) + t0 = perf_counter() + ct.sort_by(keys, inplace=True) + elapsed = perf_counter() - t0 + results[len(keys)] = elapsed + spd = results[1] / elapsed if elapsed > 0 else float("inf") + label = " + ".join(keys) + print(f" {label:<30} {elapsed:>10.4f} {spd:>17.2f}×") + + +# ── 3. Input order: random vs sorted vs reverse ─────────────────────────────── + +sep(f"3. Input order effect (N={N:,}, sort by sensor_id)") +print(f" {'INPUT ORDER':<20} {'TIME (s)':>10}") +print(f" {'─'*20} {'─'*10}") + +sid_max = N // 10 + +rand_ids = rng.integers(0, sid_max, size=N, dtype=np.int32) +sorted_ids = np.repeat(np.arange(sid_max, dtype=np.int32), N // sid_max) +reverse_ids = sorted_ids[::-1].copy() + +for label, ids in [ + ("random", rand_ids), + ("sorted", sorted_ids), + ("reversed", reverse_ids), +]: + ct = make_table(N, sensor_ids=ids) + t0 = perf_counter() + ct.sort_by(["sensor_id"], inplace=True) + elapsed = perf_counter() - t0 + print(f" {label:<20} {elapsed:>10.4f}") + + +# ── 4. Sort with deletions (holes) ──────────────────────────────────────────── + +sep(f"4. Sort with deletions (N={N:,}, sort by sensor_id)") +print(f" {'DELETED':>10} {'LIVE ROWS':>10} {'TIME (s)':>10}") +print(f" {'─'*10} {'─'*10} {'─'*10}") + +for frac in [0.0, 0.1, 0.25, 0.5]: + ct = make_table(N) + n_del = int(N * frac) + if n_del: + ct.delete(list(range(0, N, max(1, N // n_del)))[:n_del]) + live = len(ct) + t0 = perf_counter() + ct.sort_by(["sensor_id"], inplace=True) + elapsed = perf_counter() - t0 + print(f" {frac*100:>9.0f}% {live:>10,} {elapsed:>10.4f}") + + +# ── 5. inplace=True vs inplace=False ───────────────────────────────────────── + +sep(f"5. inplace=True vs inplace=False (N={N:,})") +print(f" {'MODE':<20} {'TIME (s)':>10} {'NOTE'}") +print(f" {'─'*20} {'─'*10} {'─'*20}") + +ct = make_table(N) +t0 = perf_counter() +ct.sort_by(["sensor_id"], inplace=True) +t_inplace = perf_counter() - t0 +print(f" {'inplace=True':<20} {t_inplace:>10.4f} modifies table in-place") + +ct = make_table(N) +t0 = perf_counter() +ct2 = ct.sort_by(["sensor_id"], inplace=False) +t_copy = perf_counter() - t0 +print(f" {'inplace=False':<20} {t_copy:>10.4f} returns new table") +print(f"\n Copy overhead: {t_copy / t_inplace:.2f}×") diff --git a/bench/ctable/speed_iter.py b/bench/ctable/speed_iter.py index 10afdc367..0363de725 100644 --- a/bench/ctable/speed_iter.py +++ b/bench/ctable/speed_iter.py @@ -5,36 +5,45 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### +# Benchmark: row iteration speed — how fast is iterating over CTable rows +# when most rows are skipped (only accessing every K-th row's value). +# +# Models a real-world "sample scan" pattern where not every row needs +# a column read, but you still iterate the whole table. + from dataclasses import dataclass -from time import time +from time import perf_counter import blosc2 -from blosc2 import CTable @dataclass class Row: - id: int = blosc2.field(blosc2.int64(ge=0)) - score: float = blosc2.field(blosc2.float64(ge=0, le=100)) - active: bool = blosc2.field(blosc2.bool(), default=True) + id: int = blosc2.field(blosc2.int64(ge=0)) + score: float = blosc2.field(blosc2.float64(ge=0, le=100)) + active: bool = blosc2.field(blosc2.bool(), default=True) -N = 1_000_000 # start small, increase when confident +N = 1_000_000 +SAMPLE_EVERY = [1, 10, 100, 1_000, 10_000] data = [(i, float(i % 100), i % 2 == 0) for i in range(N)] -tabla = CTable(Row, new_data=data) - -print(f"Table created with {len(tabla)} rows\n") - -# ------------------------------------------------------------------- -# Test 1: iterate without accessing any column (minimum cost) -# ------------------------------------------------------------------- -i=0 -t0 = time() -for row in tabla: - i=(i+1)%10000 - if i==0: - _ = row["score"] - -t1 = time() -print(f"[Test 1] Iter without accessing columns: {(t1 - t0):.3f} s") +ct = blosc2.CTable(Row, expected_size=N) +ct.extend(data) + +print(f"Row iteration sample-scan benchmark | N = {N:,}") +print() +print(f" {'SAMPLE_EVERY':>13} {'READS':>9} {'TIME (s)':>10} {'µs/read':>9}") +print(f" {'─'*13} {'─'*9} {'─'*10} {'─'*9}") + +for k in SAMPLE_EVERY: + n_reads = N // k + i = 0 + t0 = perf_counter() + for row in ct: + i = (i + 1) % k + if i == 0: + _ = row["score"] + elapsed = perf_counter() - t0 + us_per_read = elapsed / n_reads * 1e6 if n_reads > 0 else 0 + print(f" {k:>13,} {n_reads:>9,} {elapsed:>10.4f} {us_per_read:>9.2f}") diff --git a/bench/ctable/varlen.py b/bench/ctable/varlen.py new file mode 100644 index 000000000..549f8a083 --- /dev/null +++ b/bench/ctable/varlen.py @@ -0,0 +1,257 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +"""Benchmark variable-length CTable columns. + +Covers: +1. append / extend performance +2. full-scan query performance +3. getitem performance for single rows and small slices + +Examples +-------- +python bench/ctable/varlen.py --rows 200000 --batch-size 1000 +python bench/ctable/varlen.py --rows 500000 --storages batch vl --repeats 5 +""" + +from __future__ import annotations + +import argparse +import gc +import statistics +import time +from dataclasses import dataclass + +import blosc2 + + +@dataclass +class RowBatch: + id: int = blosc2.field(blosc2.int64()) + group: int = blosc2.field(blosc2.int32()) + score: float = blosc2.field(blosc2.float64()) + tags: list[str] = blosc2.field( # noqa: RUF009 + blosc2.list(blosc2.string(max_length=24), nullable=True, storage="batch", batch_rows=1024) + ) + + +@dataclass +class RowVL: + id: int = blosc2.field(blosc2.int64()) + group: int = blosc2.field(blosc2.int32()) + score: float = blosc2.field(blosc2.float64()) + tags: list[str] = blosc2.field( # noqa: RUF009 + blosc2.list(blosc2.string(max_length=24), nullable=True, storage="vl") + ) + + +def make_row(i: int) -> tuple[int, int, float, list[str] | None]: + group = i % 97 + score = float((i * 13) % 1000) / 10.0 + mod = i % 11 + if mod == 0: + tags = None + elif mod == 1: + tags = [] + elif mod <= 4: + tags = [f"t{i % 1000}"] + elif mod <= 7: + tags = [f"t{i % 1000}", f"g{group}"] + else: + tags = [f"t{i % 1000}", f"g{group}", f"s{int(score)}"] + return i, group, score, tags + + +def make_rows(nrows: int) -> list[tuple[int, int, float, list[str] | None]]: + return [make_row(i) for i in range(nrows)] + + +def choose_row_type(storage: str): + if storage == "batch": + return RowBatch + if storage == "vl": + return RowVL + raise ValueError(f"Unsupported storage: {storage!r}") + + +def best_time(fn, *, repeats: int) -> float: + best = float("inf") + for _ in range(repeats): + gc.collect() + t0 = time.perf_counter() + fn() + dt = time.perf_counter() - t0 + best = min(best, dt) + return best + + +def median_time(fn, *, repeats: int) -> float: + samples = [] + for _ in range(repeats): + gc.collect() + t0 = time.perf_counter() + fn() + samples.append(time.perf_counter() - t0) + return statistics.median(samples) + + +def build_table_by_append(row_type, rows) -> blosc2.CTable: + t = blosc2.CTable(row_type, expected_size=len(rows)) + for row in rows: + t.append(row) + return t + + +def build_table_by_extend(row_type, rows, batch_size: int) -> blosc2.CTable: + t = blosc2.CTable(row_type, expected_size=len(rows)) + for start in range(0, len(rows), batch_size): + t.extend(rows[start : start + batch_size]) + return t + + +def query_count(table: blosc2.CTable) -> int: + view = table.where((table.group >= 10) & (table.group < 50) & (table.score >= 25.0)) + return len(view) + + +def query_with_list_touch(table: blosc2.CTable) -> int: + view = table.where((table.group >= 10) & (table.group < 50) & (table.score >= 25.0)) + total = 0 + for cell in view.tags: + total += 0 if cell is None else len(cell) + return total + + +def bench_getitem_single(table: blosc2.CTable, indices: list[int]) -> int: + total = 0 + col = table.tags + for idx in indices: + cell = col[idx] + total += 0 if cell is None else len(cell) + return total + + +def bench_getitem_slices(table: blosc2.CTable, starts: list[int], width: int) -> int: + total = 0 + col = table.tags + for start in starts: + cells = col[start : start + width] + for cell in cells: + total += 0 if cell is None else len(cell) + return total + + +def format_rate(n: int, seconds: float) -> str: + if seconds <= 0: + return "inf" + return f"{n / seconds:,.0f}/s" + + +def run_storage_bench(storage: str, rows, *, batch_size: int, repeats: int, nsamples: int, slice_width: int) -> None: + row_type = choose_row_type(storage) + print(f"\n=== storage={storage} ===") + + append_time = best_time(lambda: build_table_by_append(row_type, rows), repeats=repeats) + extend_time = best_time(lambda: build_table_by_extend(row_type, rows, batch_size), repeats=repeats) + + table = build_table_by_extend(row_type, rows, batch_size) + + q1 = query_count(table) + scan_count_time = median_time(lambda: query_count(table), repeats=repeats) + + q2 = query_with_list_touch(table) + scan_touch_time = median_time(lambda: query_with_list_touch(table), repeats=repeats) + + max_start = max(1, len(table) - slice_width - 1) + indices = [((i * 104729) % max_start) for i in range(nsamples)] + starts = [((i * 8191) % max_start) for i in range(nsamples)] + clustered_indices = [i % min(max_start, 4096) for i in range(nsamples)] + clustered_starts = [i % min(max_start, 2048) for i in range(nsamples)] + + single_sum = bench_getitem_single(table, indices) + single_time = median_time(lambda: bench_getitem_single(table, indices), repeats=repeats) + single_clustered_time = median_time( + lambda: bench_getitem_single(table, clustered_indices), repeats=repeats + ) + + slice_sum = bench_getitem_slices(table, starts, slice_width) + slice_time = median_time(lambda: bench_getitem_slices(table, starts, slice_width), repeats=repeats) + slice_clustered_time = median_time( + lambda: bench_getitem_slices(table, clustered_starts, slice_width), repeats=repeats + ) + + print("append/extend") + print(f" append rows: {append_time:8.4f} s {format_rate(len(rows), append_time)}") + print(f" extend rows: {extend_time:8.4f} s {format_rate(len(rows), extend_time)}") + print(f" append/extend: {append_time / extend_time:8.2f}x slower") + + print("scan queries") + print(f" count only: {scan_count_time:8.4f} s matches={q1:,}") + print(f" count+list use: {scan_touch_time:8.4f} s payload={q2:,}") + + print("getitem") + print( + f" single row random: {single_time:8.4f} s " + f"{format_rate(nsamples, single_time)} checksum={single_sum}" + ) + print( + f" single row local: {single_clustered_time:8.4f} s " + f"{format_rate(nsamples, single_clustered_time)}" + ) + print( + f" slice[{slice_width}] random: {slice_time:8.4f} s " + f"{format_rate(nsamples, slice_time)} checksum={slice_sum}" + ) + print( + f" slice[{slice_width}] local: {slice_clustered_time:8.4f} s " + f"{format_rate(nsamples, slice_clustered_time)}" + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--rows", type=int, default=200_000, help="Number of rows to generate.") + parser.add_argument("--batch-size", type=int, default=1_000, help="Batch size used for extend().") + parser.add_argument("--repeats", type=int, default=5, help="Repetitions per benchmark.") + parser.add_argument( + "--storages", + nargs="+", + choices=("batch", "vl"), + default=["batch", "vl"], + help="List-column storage backends to benchmark.", + ) + parser.add_argument( + "--getitem-samples", + type=int, + default=20_000, + help="Number of random single-row / slice probes.", + ) + parser.add_argument("--slice-width", type=int, default=8, help="Width of small-slice getitem benchmark.") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + rows = make_rows(args.rows) + + print("CTable variable-length column benchmark") + print(f"rows={args.rows:,} batch_size={args.batch_size:,} repeats={args.repeats}") + print(f"getitem_samples={args.getitem_samples:,} slice_width={args.slice_width}") + + for storage in args.storages: + run_storage_bench( + storage, + rows, + batch_size=args.batch_size, + repeats=args.repeats, + nsamples=args.getitem_samples, + slice_width=args.slice_width, + ) + + +if __name__ == "__main__": + main() diff --git a/bench/ctable/where_chain.py b/bench/ctable/where_chain.py index d2a6092d0..15332da72 100644 --- a/bench/ctable/where_chain.py +++ b/bench/ctable/where_chain.py @@ -9,7 +9,7 @@ # Filters: 250k < id < 750k, active == False, 25.0 < score < 75.0 from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np @@ -51,20 +51,20 @@ class Row: # 1. Three chained where() calls t0 = time() -r1 = ct.where(ct["id"] > 250_000) -r2 = r1.where(ct["id"] < 750_000) -r3 = r2.where(ct["score"] > 25.0) -r4 = r3.where(ct["score"] < 75.0) -r5 = r4.where(not ct["active"]) +r1 = ct.where(ct.id > 250_000) +r2 = r1.where(ct.id < 750_000) +r3 = r2.where(ct.score > 25.0) +r4 = r3.where(ct.score < 75.0) +r5 = r4.where(ct.active == False) t_chained = time() - t0 print(f"Chained where() (5 calls): {t_chained:.6f} s rows: {len(r5):,}") # 2. Single combined where() call t0 = time() result = ct.where( - (ct["id"] > 250_000) & (ct["id"] < 750_000) & - (not ct["active"]) & - (ct["score"] > 25.0) & (ct["score"] < 75.0) + (ct.id > 250_000) & (ct.id < 750_000) & + (ct.active == False) & + (ct.score > 25.0) & (ct.score < 75.0) ) t_combined = time() - t0 print(f"Combined where() (1 call): {t_combined:.6f} s rows: {len(result):,}") diff --git a/bench/ctable/where_selective.py b/bench/ctable/where_selective.py index c0ba6f78e..155f2d4a7 100644 --- a/bench/ctable/where_selective.py +++ b/bench/ctable/where_selective.py @@ -9,7 +9,7 @@ # Filter: id < threshold, with thresholds covering 1%, 10%, 50%, 90%, 100% from dataclasses import dataclass -from time import time +from time import perf_counter as time import numpy as np @@ -54,7 +54,7 @@ class Row: for threshold in thresholds: t0 = time() - result = ct.where(ct["id"] < threshold) + result = ct.where(ct.id < threshold) t_where = time() - t0 selectivity = threshold / N * 100 print(f"id < {threshold:<10,} {len(result):>15,} {selectivity:>12.1f}% {t_where:>12.6f}") diff --git a/bench/indexing/query_cache_store_bench.py b/bench/indexing/query_cache_store_bench.py index 46f2cbafb..bd1b3b7c0 100644 --- a/bench/indexing/query_cache_store_bench.py +++ b/bench/indexing/query_cache_store_bench.py @@ -257,7 +257,7 @@ def _active_cache_store_cparams(arr: blosc2.NDArray) -> blosc2.CParams: coords = np.asarray([0], dtype=np.int64) indexing.store_cached_coords(arr, "(id >= 0) & (id <= 0)", [indexing.SELF_TARGET_NAME], None, coords) payload_path = indexing._query_cache_payload_path(arr) - store = blosc2.VLArray(storage=blosc2.Storage(urlpath=payload_path, mode="r")) + store = blosc2.ObjectArray(storage=blosc2.Storage(urlpath=payload_path, mode="r")) return store.cparams diff --git a/doc/getting_started/tutorials.rst b/doc/getting_started/tutorials.rst index c446589b7..143ecb421 100644 --- a/doc/getting_started/tutorials.rst +++ b/doc/getting_started/tutorials.rst @@ -16,7 +16,7 @@ Tutorials tutorials/08.schunk-slicing_and_beyond tutorials/09.ucodecs-ufilters tutorials/10.prefilters - tutorials/11.vlarray + tutorials/11.objectarray tutorials/12.batcharray tutorials/13.containers tutorials/14.indexing-arrays diff --git a/doc/getting_started/tutorials/11.vlarray.ipynb b/doc/getting_started/tutorials/11.objectarray.ipynb similarity index 91% rename from doc/getting_started/tutorials/11.vlarray.ipynb rename to doc/getting_started/tutorials/11.objectarray.ipynb index e78733fb3..15fc9708b 100644 --- a/doc/getting_started/tutorials/11.vlarray.ipynb +++ b/doc/getting_started/tutorials/11.objectarray.ipynb @@ -4,11 +4,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Working with VLArray\n", + "# Working with ObjectArray\n", "\n", - "A `VLArray` is a list-like container for variable-length Python values backed by a single `SChunk`. Each entry is stored in its own compressed chunk, and values are serialized with msgpack before reaching storage.\n", + "A `ObjectArray` is a list-like container for variable-length Python values backed by a single `SChunk`. Each entry is stored in its own compressed chunk, and values are serialized with msgpack before reaching storage.\n", "\n", - "This makes `VLArray` a good fit for heterogeneous, variable-length payloads such as small dictionaries, strings, tuples, byte blobs, or nested list/dict structures." + "This makes `ObjectArray` a good fit for heterogeneous, variable-length payloads such as small dictionaries, strings, tuples, byte blobs, or nested list/dict structures." ], "id": "ceb4789a488cc07f" }, @@ -41,7 +41,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Creating and populating a VLArray\n", + "## Creating and populating a ObjectArray\n", "\n", "Entries can be appended one by one or in batches with `extend()`. The container accepts the msgpack-safe Python types supported by the implementation: `bytes`, `str`, `int`, `float`, `bool`, `None`, `list`, `tuple`, and `dict`." ], @@ -56,7 +56,7 @@ } }, "source": [ - "vla = blosc2.VLArray(urlpath=urlpath, mode=\"w\")\n", + "vla = blosc2.ObjectArray(urlpath=urlpath, mode=\"w\")\n", "vla.append({\"name\": \"alpha\", \"count\": 1})\n", "vla.extend([b\"bytes\", (\"a\", 2), [\"x\", \"y\"], 42, None])\n", "vla.insert(1, \"between\")\n", @@ -211,7 +211,7 @@ "source": [ "## Round-tripping through cframes and reopening from disk\n", "\n", - "Tagged persistent stores automatically reopen as `VLArray`, and a serialized cframe buffer does too." + "Tagged persistent stores automatically reopen as `ObjectArray`, and a serialized cframe buffer does too." ], "id": "bb576497d4b6f537" }, @@ -239,9 +239,9 @@ "name": "stdout", "output_type": "stream", "text": [ - "from_cframe type: VLArray\n", + "from_cframe type: ObjectArray\n", "from_cframe entries: ['even-0', 'even-1', {'nested': True}]\n", - "Reopened type: VLArray\n", + "Reopened type: ObjectArray\n", "Reopened entries: ['even-0', 'even-1', {'nested': True}]\n" ] } diff --git a/doc/getting_started/tutorials/13.containers.ipynb b/doc/getting_started/tutorials/13.containers.ipynb index dc446111b..417d1e972 100644 --- a/doc/getting_started/tutorials/13.containers.ipynb +++ b/doc/getting_started/tutorials/13.containers.ipynb @@ -15,7 +15,7 @@ "\n", "1. `SChunk`\n", "2. `NDArray`\n", - "3. `VLArray`\n", + "3. `ObjectArray`\n", "4. `BatchArray`\n", "5. `EmbedStore`\n", "6. `DictStore`\n", @@ -84,7 +84,7 @@ "`SChunk` is the storage foundation. Higher-level containers either wrap it to provide a more convenient programming model, or use it as a building block inside larger stores.\n", "\n", "- `NDArray` adds N-dimensional array semantics on top of chunked compressed storage.\n", - "- `VLArray` stores one variable-length serialized item per entry.\n", + "- `ObjectArray` stores one variable-length serialized item per entry.\n", "- `BatchArray` stores batches of variable-length items.\n", "- `EmbedStore`, `DictStore`, and `TreeStore` organize multiple containers together.\n", "- `C2Array` is different: it is a remote array handle rather than a local storage container.\n", @@ -208,9 +208,9 @@ "id": "cell-08", "metadata": {}, "source": [ - "## `VLArray`: Variable-Length Items\n", + "## `ObjectArray`: Variable-Length Items\n", "\n", - "`VLArray` is a list-like container for variable-length Python values. Each entry is serialized and stored as its own compressed chunk in a backing `SChunk`.\n", + "`ObjectArray` is a list-like container for variable-length Python values. Each entry is serialized and stored as its own compressed chunk in a backing `SChunk`.\n", "\n", "Use it for ragged or heterogeneous values such as strings, dictionaries, tuples, lists, and byte payloads." ] @@ -232,14 +232,14 @@ "text": [ "entries: [{'kind': 'alpha', 'count': 1}, ['x', 'y'], b'abc']\n", "entry types: ['dict', 'list', 'bytes']\n", - "reopened type: VLArray\n", + "reopened type: ObjectArray\n", "reopened[1]: ['x', 'y']\n" ] } ], "source": [ "vl_path = reset(\"notes.b2frame\")\n", - "vla = blosc2.VLArray(urlpath=vl_path, mode=\"w\", contiguous=True)\n", + "vla = blosc2.ObjectArray(urlpath=vl_path, mode=\"w\", contiguous=True)\n", "vla.extend(\n", " [\n", " {\"kind\": \"alpha\", \"count\": 1},\n", @@ -500,7 +500,7 @@ "| --- | --- | --- |\n", "| `SChunk` | raw compressed chunks | direct chunk-level storage control |\n", "| `NDArray` | `SChunk` plus array metadata | dense numeric arrays |\n", - "| `VLArray` | one variable-length entry per chunk | ragged or heterogeneous Python values |\n", + "| `ObjectArray` | one variable-length entry per chunk | ragged or heterogeneous Python values |\n", "| `BatchArray` | one batch per chunk | batch-oriented ingestion and access |\n", "| `EmbedStore` | one bundled object store | packaging a few Blosc2 objects together |\n", "| `DictStore` | keyed collection of leaves | portable multi-object datasets |\n", @@ -511,7 +511,7 @@ "\n", "- start with `NDArray` for dense numeric data\n", "- drop down to `SChunk` if you need chunk-level control\n", - "- use `VLArray` or `BatchArray` for variable-length Python objects\n", + "- use `ObjectArray` or `BatchArray` for variable-length Python objects\n", "- use `EmbedStore`, `DictStore`, or `TreeStore` when your dataset contains multiple objects" ] }, @@ -526,11 +526,11 @@ "\n", "- understand `SChunk` first\n", "- use `NDArray` for most dense numeric workloads\n", - "- move to `VLArray` or `BatchArray` when entries stop being fixed-size arrays\n", + "- move to `ObjectArray` or `BatchArray` when entries stop being fixed-size arrays\n", "- use `EmbedStore`, `DictStore`, or `TreeStore` when you need to package multiple objects together\n", "- use `C2Array` when the data lives on a remote service\n", "\n", - "For deeper details on a specific class, continue with the reference docs and the dedicated tutorials for `VLArray`, `BatchArray`, and indexing." + "For deeper details on a specific class, continue with the reference docs and the dedicated tutorials for `ObjectArray`, `BatchArray`, and indexing." ] }, { diff --git a/doc/reference/classes.rst b/doc/reference/classes.rst index 39e22b6a3..20fcc368b 100644 --- a/doc/reference/classes.rst +++ b/doc/reference/classes.rst @@ -15,7 +15,8 @@ Main Classes C2Array Array BatchArray - VLArray + ListArray + ObjectArray SChunk DictStore TreeStore @@ -41,7 +42,8 @@ Main Classes tree_store embed_store batch_array - vlarray + list_array + objectarray ndfield ref proxy diff --git a/doc/reference/ctable.rst b/doc/reference/ctable.rst index 286778c75..191b736a1 100644 --- a/doc/reference/ctable.rst +++ b/doc/reference/ctable.rst @@ -3,10 +3,12 @@ CTable ====== -A columnar compressed table backed by one :class:`~blosc2.NDArray` per column. -Each column is stored, compressed, and queried independently; rows are never -materialised in their entirety unless you explicitly call :meth:`~blosc2.CTable.to_arrow` -or iterate with :meth:`~blosc2.CTable.__iter__`. +A columnar compressed table backed by one physical container per column. +Scalar columns use :class:`~blosc2.NDArray`; list-valued columns use +:class:`~blosc2.ListArray`. Each column is stored, compressed, and queried +independently; rows are never materialised in their entirety unless you +explicitly call :meth:`~blosc2.CTable.to_arrow` or iterate with +:meth:`~blosc2.CTable.__iter__`. .. currentmodule:: blosc2 @@ -40,15 +42,63 @@ Construction CTable.open CTable.load CTable.from_arrow + CTable.from_parquet CTable.from_csv .. automethod:: CTable.__init__ .. automethod:: CTable.open .. automethod:: CTable.load .. automethod:: CTable.from_arrow +.. automethod:: CTable.from_parquet .. automethod:: CTable.from_csv +Null policy +----------- + +Nullable scalar CTable columns are represented with per-column sentinel values, +not native validity bitmaps. When CTable has to infer those sentinels, the +selection can be customized with :class:`NullPolicy` and scoped with +:func:`null_policy`:: + + policy = blosc2.NullPolicy( + signed_int_strategy="max", + string_value="", + column_null_values={"user_id": -1, "country": "NA"}, + ) + + with blosc2.null_policy(policy): + table = blosc2.CTable.from_parquet("data.parquet") + +The same policy is used by explicit nullable schema specs when no +``null_value`` is supplied:: + + from dataclasses import dataclass + + @dataclass + class Row: + user_id: int = blosc2.field(blosc2.int64(nullable=True)) + country: str = blosc2.field(blosc2.string(nullable=True)) + + with blosc2.null_policy(policy): + table = blosc2.CTable(Row) + +Sentinels are resolved in this order: explicit ``null_value`` in the schema, +``NullPolicy.column_null_values`` for a matching column, then the type-wide +``NullPolicy`` default. Columns without ``nullable=True`` or an explicit +``null_value`` are not nullable. + +.. autosummary:: + + NullPolicy + null_policy + get_null_policy + +.. autoclass:: NullPolicy +.. autofunction:: null_policy +.. autofunction:: get_null_policy + + Attributes ---------- @@ -86,6 +136,53 @@ Inserting data Querying -------- +Boolean expressions +~~~~~~~~~~~~~~~~~~~ + +Use bitwise operators (``&``, ``|``, ``~``) or string expressions for +row-wise boolean logic. Python's logical operators ``and``, ``or`` and +``not`` cannot be overloaded and therefore do not build lazy column +expressions. + +Use column expressions with explicit parentheses around comparisons:: + + t.where((t.amount > 100) & (t.region == "North")) + t.where(~t.returned) + +or use string expressions when that reads better:: + + t.where("amount > 100 and region == 'North'") + t.where("not returned") + t["not returned"] + +The last three forms for negating a boolean column are equivalent: +``t.where(~t.returned)``, ``t.where("not returned")``, and +``t["not returned"]``. + +Indexing & projection +~~~~~~~~~~~~~~~~~~~~~ + +CTable indexing is type-driven:: + + t["amount"] # column access + t[3] # one row as a namedtuple-like object + t[3:8] # row view + t[[1, 4, 7]] # gathered-row view + t[mask] # filtered row view + t[["region", "amount"]] # projected column view + +String keys first try exact column-name lookup. If the string is not a +column name, it is interpreted as a boolean expression and behaves like +:meth:`CTable.where`. + +For explicit filtered projection, use:: + + t.where("amount > 100", columns=["region", "amount"]) + +When a NumPy structured array is needed, materialize explicitly:: + + np.asarray(t[:10]) + .. autosummary:: CTable.where @@ -364,6 +461,27 @@ Text & binary string bytes + list .. autoclass:: string .. autoclass:: bytes +.. autofunction:: list + +List columns +------------ + +List columns are declared with :func:`blosc2.list`, for example:: + + from dataclasses import dataclass + import blosc2 as b2 + + @dataclass + class Product: + code: str = b2.field(b2.string(max_length=8)) + tags: list[str] = b2.field(b2.list(b2.string(), nullable=True)) + +Whole-cell replacement is supported, so users should reassign modified lists:: + + row_tags = table.tags[0] + row_tags.append("extra") # local Python list only + table.tags[0] = row_tags # explicit write-back diff --git a/doc/reference/list_array.rst b/doc/reference/list_array.rst new file mode 100644 index 000000000..267c9f539 --- /dev/null +++ b/doc/reference/list_array.rst @@ -0,0 +1,78 @@ +.. _ListArray: + +ListArray +========= + +Overview +-------- +ListArray is a row-oriented container for variable-length list cells. +It is the natural public container for list-valued :class:`blosc2.CTable` +columns, but it is also useful on its own whenever you want typed, +row-addressable list data. + +Internally, ListArray uses one of two lower-level backends: + +- :class:`blosc2.BatchArray` for append/scan-oriented workloads +- :class:`blosc2.ObjectArray` for simpler row-level replacement semantics + +Quick example +------------- + +.. code-block:: python + + import blosc2 + + arr = blosc2.ListArray( + item_spec=blosc2.string(max_length=16), + nullable=True, + storage="batch", + urlpath="ingredients.b2b", + mode="w", + ) + arr.append(["salt", "sugar"]) + arr.append([]) + arr.append(None) + + print(arr[0]) + print(arr[1:]) + + reopened = blosc2.open("ingredients.b2b", mode="r") + print(type(reopened).__name__) + +.. note:: + Returned Python lists are detached values. Mutating them locally does not + write back to the container; reassign the whole cell instead. + +.. currentmodule:: blosc2 + +.. autoclass:: ListArray + + Constructors + ------------ + .. automethod:: __init__ + .. automethod:: from_arrow + + Row Interface + ------------- + .. automethod:: __getitem__ + .. automethod:: __setitem__ + .. automethod:: __len__ + .. automethod:: __iter__ + + Mutation + -------- + .. automethod:: append + .. automethod:: extend + .. automethod:: flush + .. automethod:: copy + .. automethod:: close + + Context Manager + --------------- + .. automethod:: __enter__ + .. automethod:: __exit__ + + Public Members + -------------- + .. automethod:: to_arrow + .. automethod:: to_cframe diff --git a/doc/reference/misc.rst b/doc/reference/misc.rst index 4eb1adbf3..0ca7a5115 100644 --- a/doc/reference/misc.rst +++ b/doc/reference/misc.rst @@ -138,8 +138,8 @@ This page documents the miscellaneous members of the ``blosc2`` module that do n TreeStore, DictStore, EmbedStore, - VLArray, - vlarray_from_cframe, + ObjectArray, + objectarray_from_cframe, abs, acos, acosh, diff --git a/doc/reference/msgpack_serialization.rst b/doc/reference/msgpack_serialization.rst new file mode 100644 index 000000000..9807a5fbe --- /dev/null +++ b/doc/reference/msgpack_serialization.rst @@ -0,0 +1,125 @@ +.. _MsgpackSerialization: + +Msgpack Serialization +===================== + +python-blosc2 uses msgpack as the default serializer for :class:`ObjectArray` and +for the default ``"msgpack"`` mode of :class:`BatchArray`. + +Two MessagePack extension codes are reserved by python-blosc2: + +- ``42``: Blosc2 objects serialized by value as CFrames +- ``43``: structured Blosc2 reference or recipe objects + +CFrame-backed objects +--------------------- + +The following objects are serialized by value using +:meth:`to_cframe` / :func:`blosc2.from_cframe`: + +- ``NDArray`` +- ``SChunk`` +- ``ObjectArray`` +- ``BatchArray`` +- ``EmbedStore`` + +Structured objects +------------------ + +Extension code ``43`` stores a msgpack-encoded mapping with a stable envelope: + +.. code-block:: text + + { + "kind": "...", + "version": 1, + ... + } + +Currently implemented structured kinds are: + +- ``"ref"`` +- ``"c2array"`` +- ``"urlpath"`` +- ``"dictstore_key"`` +- ``"lazyexpr"`` +- ``"lazyudf"`` + +The ``"urlpath"``, ``"dictstore_key"``, and ``"c2array"`` reference forms map +directly onto the public :class:`blosc2.Ref` type. + +``C2Array`` +----------- + +Remote arrays are serialized as lightweight references with: + +- ``path`` +- ``urlbase`` + +Authentication data is intentionally not serialized. + +Persistent local operands +------------------------- + +Persistent local Blosc2 operands can be serialized as: + +.. code-block:: python + + {"kind": "urlpath", "version": 1, "urlpath": "..."} + +and are reopened with :func:`blosc2.open`. + +DictStore members +----------------- + +Operands coming from :class:`DictStore` external leaves preserve store/member +identity via: + +.. code-block:: python + + {"kind": "dictstore_key", "version": 1, "urlpath": "...", "key": "/a"} + +This is used for members in both ``.b2d`` and ``.b2z`` stores, where the store +path alone is not enough to identify a specific member. + +``LazyExpr`` +------------ + +Expression-based lazy arrays are serialized as a recipe plus operand +references, following the same reference-preserving model as +:meth:`blosc2.LazyExpr.save`. + +Only durable reference-style operands are supported: + +- persistent local Blosc2 operands reopenable from ``urlpath`` +- remote ``C2Array`` operands +- ``DictStore`` members reopenable from ``(.b2d|.b2z, key)`` + +Purely in-memory operands are intentionally rejected. This keeps msgpack +serialization of ``LazyExpr`` reference-preserving rather than value-copying. + +``LazyUDF`` +----------- + +Currently, msgpack support for ``LazyUDF`` is limited to instances backed by a +``@blosc2.dsl_kernel`` function. + +These are serialized as: + +- ``function_kind="dsl"`` +- ``dsl_version`` for the DSL grammar/semantics +- the UDF source code +- the preserved DSL source +- output ``dtype`` and ``shape`` +- durable operand references +- execution kwargs needed to reconstruct the lazy object + +Supported operands are the same durable reference-style operands used for +``LazyExpr``: + +- persistent local Blosc2 operands reopenable from ``urlpath`` +- remote ``C2Array`` operands +- ``DictStore`` members reopenable from ``(.b2d|.b2z, key)`` + +Plain Python ``LazyUDF`` callables are intentionally not serialized by +msgpack yet. diff --git a/doc/reference/vlarray.rst b/doc/reference/objectarray.rst similarity index 70% rename from doc/reference/vlarray.rst rename to doc/reference/objectarray.rst index 6603c2608..1bee2586a 100644 --- a/doc/reference/vlarray.rst +++ b/doc/reference/objectarray.rst @@ -1,28 +1,31 @@ -.. _VLArray: +.. _ObjectArray: -VLArray -======= +ObjectArray +=========== Overview -------- -VLArray is a variable-length array container backed by a single Blosc2 ``SChunk``. +ObjectArray is a variable-length array container backed by a single Blosc2 ``SChunk``. Each entry is stored as one compressed chunk: - entries can be any serializable Python object - items are serialized with msgpack before compression -- Blosc2 containers (:class:`NDArray`, :class:`SChunk`, :class:`VLArray`, +- Blosc2 containers (:class:`NDArray`, :class:`SChunk`, :class:`ObjectArray`, :class:`BatchArray`, :class:`EmbedStore`) are serialized transparently via :meth:`to_cframe` / :func:`blosc2.from_cframe` - structured Blosc2 reference objects (:class:`C2Array`, :class:`LazyExpr`, and :class:`LazyUDF` backed by :func:`blosc2.dsl_kernel`) are also supported -VLArray is a good fit when you need: +ObjectArray is a good fit when you need: - a persistent, compressed list of arbitrary Python objects - per-item random access and mutation - compact summary information via ``.info`` +See :class:`blosc2.BatchArray` for the batched counterpart, where many objects +are packed into each chunk for higher throughput. + Quick example ------------- @@ -30,14 +33,14 @@ Quick example import blosc2 - vl = blosc2.VLArray(urlpath="example.b2z", mode="w", contiguous=True) - vl.append({"x": 1, "y": 2}) - vl.append([3, 4, 5]) - vl.append("hello") + oarr = blosc2.ObjectArray(urlpath="example.b2z", mode="w", contiguous=True) + oarr.append({"x": 1, "y": 2}) + oarr.append([3, 4, 5]) + oarr.append("hello") - print(vl[0]) # {'x': 1, 'y': 2} - print(vl[1]) # [3, 4, 5] - print(len(vl)) # 3 + print(oarr[0]) # {'x': 1, 'y': 2} + print(oarr[1]) # [3, 4, 5] + print(len(oarr)) # 3 reopened = blosc2.open("example.b2z", mode="r") print(type(reopened).__name__) @@ -45,7 +48,7 @@ Quick example .. currentmodule:: blosc2 -.. autoclass:: VLArray +.. autoclass:: ObjectArray Constructors ------------ @@ -80,4 +83,4 @@ Quick example Constructors ------------ -.. autofunction:: blosc2.vlarray_from_cframe +.. autofunction:: blosc2.objectarray_from_cframe diff --git a/doc/reference/schunk.rst b/doc/reference/schunk.rst index da31f062b..856463691 100644 --- a/doc/reference/schunk.rst +++ b/doc/reference/schunk.rst @@ -15,7 +15,7 @@ variable-length metadata can store not only ordinary msgpack-safe Python values, but also the currently supported Blosc2 objects and references, including: -- ``NDArray``, ``SChunk``, ``VLArray``, ``BatchArray``, ``EmbedStore`` +- ``NDArray``, ``SChunk``, ``ObjectArray``, ``BatchArray``, ``EmbedStore`` - ``Ref`` - ``C2Array`` - ``LazyExpr`` diff --git a/examples/ctable/arrow_interop.py b/examples/ctable/arrow_interop.py index 0139e0efa..d4769c19a 100644 --- a/examples/ctable/arrow_interop.py +++ b/examples/ctable/arrow_interop.py @@ -40,7 +40,7 @@ class Stock: at = t.to_arrow() print(f"Arrow table: {len(at)} rows, schema={at.schema}\n") -# -- from_arrow(): schema is inferred from Arrow types --------------------- +# -- from_arrow(): import an Arrow schema and record batches --------------- at2 = pa.table( { "x": pa.array([1.0, 2.0, 3.0], type=pa.float32()), @@ -48,7 +48,7 @@ class Stock: "label": pa.array(["a", "bb", "ccc"], type=pa.string()), } ) -t2 = blosc2.CTable.from_arrow(at2) +t2 = blosc2.CTable.from_arrow(at2.schema, at2.to_batches()) print("CTable from Arrow (inferred schema):") print(t2) print(f" label dtype: {t2['label'].dtype} (max_length inferred from data)") @@ -69,7 +69,8 @@ class Stock: print(df_original) # pandas → Arrow → CTable - t_from_pd = blosc2.CTable.from_arrow(pa.Table.from_pandas(df_original, preserve_index=False)) + at_pd = pa.Table.from_pandas(df_original, preserve_index=False) + t_from_pd = blosc2.CTable.from_arrow(at_pd.schema, at_pd.to_batches()) print("\nCTable from pandas:") print(t_from_pd) diff --git a/examples/ctable/basics.py b/examples/ctable/basics.py index 0c402cb12..d8108d8ea 100644 --- a/examples/ctable/basics.py +++ b/examples/ctable/basics.py @@ -5,7 +5,7 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### -# CTable basics: creation, append, extend, head/tail, len. +# CTable basics: creation, append, extend, row access, head/tail, len. from dataclasses import dataclass @@ -41,7 +41,8 @@ class Row: arr["price"] = np.linspace(10.0, 14.0, 5) arr["active"] = [True, False, True, False, True] t.extend(arr) -print(f"After numpy extend: {len(t)} rows\n") +print(f"After numpy extend: {len(t)} rows") +print(f"Row 0 via t[0]: {t[0]}\n") # -- display: head / tail / full table -------------------------------------- print("head(3):") diff --git a/examples/ctable/computed-columns.py b/examples/ctable/computed-columns.py index a93525121..fa3d7e46b 100644 --- a/examples/ctable/computed-columns.py +++ b/examples/ctable/computed-columns.py @@ -130,16 +130,13 @@ class Trade: # 5. Filtering via a computed column # --------------------------------------------------------------------------- -# Build a filter expression from the computed column's underlying LazyExpr -lazy_mv = t.computed_columns["market_value"]["lazy"] - -big_trades = t.where(lazy_mv >= 30_000) +# Build a filter expression by referencing the computed column directly +big_trades = t.where(t.market_value >= 30_000) print(f"Trades worth ≥ $30 000 : {len(big_trades)}") print(big_trades) # Compound filter: large trades AND low fee -lazy_fee_pct = t._cols["fee_pct"] -cheap_big = t.where((lazy_mv >= 20_000) & (lazy_fee_pct <= 0.10)) +cheap_big = t.where((t.market_value >= 20_000) & (t.fee_pct <= 0.10)) print(f"\nLarge trades (≥ $20 000) with fee ≤ 0.10 % : {len(cheap_big)}") print(cheap_big) @@ -212,7 +209,7 @@ class Trade: xt.add_computed_column("market_value", lambda c: c["price"] * c["shares"]) xt.create_index(expression="price * shares", kind=blosc2.IndexKind.FULL, name="mv_expr") -expr_view = xt.where((xt._cols["price"] * xt._cols["shares"]) >= 30_000) +expr_view = xt.where((xt.price * xt.shares) >= 30_000) print("\nRows matched via direct expression index:") print(expr_view.select(["ticker", "market_value"])) diff --git a/examples/ctable/csv_interop.py b/examples/ctable/csv_interop.py index 41a76fd9c..d2a766ace 100644 --- a/examples/ctable/csv_interop.py +++ b/examples/ctable/csv_interop.py @@ -59,7 +59,7 @@ class WeatherReading: print(t.head()) # -- apply a filter before exporting ---------------------------------------- -cold_days = t.where(t["temperature"] < 0) +cold_days = t.where(t.temperature < 0) print(f"\nCold days (temp < 0°C): {len(cold_days)} rows") print(cold_days.head()) diff --git a/examples/ctable/ctable_tutorial.ipynb b/examples/ctable/ctable_tutorial.ipynb index 0564beac4..53bd834df 100644 --- a/examples/ctable/ctable_tutorial.ipynb +++ b/examples/ctable/ctable_tutorial.ipynb @@ -7,7 +7,7 @@ "source": [ "# CTable Tutorial\n", "\n", - "**CTable** is a columnar compressed table built on top of `blosc2.NDArray`. \n", + "**CTable** is a columnar compressed table built on top of `blosc2.NDArray`.\n", "It stores each column independently as a compressed array, giving you:\n", "\n", "- **Compression** — data lives compressed in RAM and on disk.\n", @@ -20,21 +20,19 @@ }, { "cell_type": "code", - "execution_count": 36, "id": "a4073a3e", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:24.795843222Z", - "start_time": "2026-04-14T12:39:24.555798389Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:13.708034Z", "iopub.status.busy": "2026-04-07T12:06:13.707898Z", "iopub.status.idle": "2026-04-07T12:06:14.162620Z", "shell.execute_reply": "2026-04-07T12:06:14.161981Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:10.223073Z", + "start_time": "2026-05-01T05:57:09.983961Z" } }, - "outputs": [], "source": [ "from dataclasses import dataclass\n", "\n", @@ -43,7 +41,9 @@ "\n", "import blosc2\n", "from blosc2 import CTable" - ] + ], + "outputs": [], + "execution_count": 1 }, { "cell_type": "markdown", @@ -61,29 +61,19 @@ }, { "cell_type": "code", - "execution_count": 37, "id": "c97f9123", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:25.040587457Z", - "start_time": "2026-04-14T12:39:24.896936343Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:14.164585Z", "iopub.status.busy": "2026-04-07T12:06:14.164404Z", "iopub.status.idle": "2026-04-07T12:06:14.168886Z", "shell.execute_reply": "2026-04-07T12:06:14.168381Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:10.376646Z", + "start_time": "2026-05-01T05:57:10.257821Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Empty table: 0 rows, columns: ['id', 'location', 'temperature', 'active']\n" - ] - } - ], "source": [ "@dataclass\n", "class Sensor:\n", @@ -96,7 +86,17 @@ "# Create an empty in-memory table\n", "t = CTable(Sensor, expected_size=50)\n", "print(f\"Empty table: {len(t)} rows, columns: {t.col_names}\")" - ] + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Empty table: 0 rows, columns: ['id', 'location', 'temperature', 'active']\n" + ] + } + ], + "execution_count": 2 }, { "cell_type": "markdown", @@ -110,42 +110,42 @@ }, { "cell_type": "code", - "execution_count": 38, "id": "fdc64a5b", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:25.223196169Z", - "start_time": "2026-04-14T12:39:25.094103593Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:14.170432Z", "iopub.status.busy": "2026-04-07T12:06:14.170315Z", "iopub.status.idle": "2026-04-07T12:06:14.231985Z", "shell.execute_reply": "2026-04-07T12:06:14.231362Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:10.524391Z", + "start_time": "2026-05-01T05:57:10.384338Z" } }, + "source": [ + "t.append(Sensor(id=0, location=\"roof\", temperature=22.5, active=True))\n", + "t.append(Sensor(id=1, location=\"basement\", temperature=18.1, active=True))\n", + "t.append(Sensor(id=2, location=\"outdoor\", temperature=-3.2, active=False))\n", + "print(t)" + ], "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - " id location temperature active \n", - " int32 70)\n", + "warm_f = t.where(t.temperature_f > 70)\n", "print(\"\\nRows above 70 °F:\")\n", "print(warm_f.select([\"location\", \"temperature\", \"temperature_f\"]))" - ] + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " location temperature temperature_f \n", + " U16 (Unicode, max 16 chars) float64 float64 \n", + "───────────────────────────── ───────────────── ─────────────────\n", + " roof 22.5 72.5 \n", + " basement 18.1 64.58 \n", + " outdoor -3.2 26.24000000000… \n", + " lab-A 20.0 68.0 \n", + " lab-B 21.5 70.7 \n", + " server 35.8 96.44 \n", + " garden -1.0 30.2 \n", + "───────────────────────────── ───────────────── ─────────────────\n", + "7 rows × 3 columns\n", + "\n", + "Mean temperature in °F: 61.2 °F\n", + "\n", + "Rows above 70 °F:\n", + " location temperature temperature_f \n", + " U16 (Unicode, max 16 chars) float64 float64 \n", + "───────────────────────────── ───────────────── ─────────────────\n", + " roof 22.5 72.5 \n", + " lab-B 21.5 70.7 \n", + " server 35.8 96.44 \n", + "───────────────────────────── ───────────────── ─────────────────\n", + "3 rows × 3 columns\n" + ] + } + ], + "execution_count": 8 }, { "cell_type": "markdown", @@ -401,7 +437,7 @@ "\n", "Enough warm-up. Let's do something real.\n", "\n", - "We will simulate **one full year of daily weather readings** for **10 world cities**. \n", + "We will simulate **one full year of daily weather readings** for **10 world cities**.\n", "Each row is one day at one city: temperature, humidity, wind speed, atmospheric pressure.\n", "\n", "| City | Climate | Twist |\n", @@ -420,21 +456,19 @@ }, { "cell_type": "code", - "execution_count": 43, "id": "ce65cad9", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:25.965548320Z", - "start_time": "2026-04-14T12:39:25.937711110Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:14.279795Z", "iopub.status.busy": "2026-04-07T12:06:14.279663Z", "iopub.status.idle": "2026-04-07T12:06:14.283068Z", "shell.execute_reply": "2026-04-07T12:06:14.282640Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:11.076167Z", + "start_time": "2026-05-01T05:57:11.046215Z" } }, - "outputs": [], "source": [ "@dataclass\n", "class WeatherReading:\n", @@ -444,60 +478,25 @@ " humidity: float = blosc2.field(blosc2.float32(ge=0.0, le=100.0), default=50.0)\n", " wind_speed: float = blosc2.field(blosc2.float32(ge=0.0, le=200.0), default=0.0)\n", " pressure: float = blosc2.field(blosc2.float32(ge=800.0, le=1100.0), default=1013.0)" - ] + ], + "outputs": [], + "execution_count": 9 }, { "cell_type": "code", - "execution_count": 44, "id": "0627de42", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:26.061360828Z", - "start_time": "2026-04-14T12:39:25.968553107Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:14.284454Z", "iopub.status.busy": "2026-04-07T12:06:14.284339Z", "iopub.status.idle": "2026-04-07T12:06:14.322297Z", "shell.execute_reply": "2026-04-07T12:06:14.321695Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:11.156627Z", + "start_time": "2026-05-01T05:57:11.086168Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Climate table: 3,650 rows × 6 columns\n", - "Compressed: 42.1 KB (uncompressed: 295.8 KB)\n", - " city day temperature humidity wind_speed pressure \n", - " 35)\n", + "print(f\"Days above 35 °C: {len(very_hot)} ({len(very_hot) / len(climate) * 100:.1f}% of all readings)\")\n", + "print(very_hot.head(8))" + ], "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Days above 35 °C: 49 (1.3% of all readings)\n", - " city day temperature humidity wind_speed pressure \n", - " 35)\n", - "print(f\"Days above 35 °C: {len(very_hot)} ({len(very_hot) / len(climate) * 100:.1f}% of all readings)\")\n", - "print(very_hot.head(8))" - ] + "execution_count": 11 }, { "cell_type": "code", - "execution_count": 46, "id": "ba2d719b", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:26.233532577Z", - "start_time": "2026-04-14T12:39:26.164808834Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:14.342416Z", "iopub.status.busy": "2026-04-07T12:06:14.342298Z", "iopub.status.idle": "2026-04-07T12:06:14.358545Z", "shell.execute_reply": "2026-04-07T12:06:14.357991Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:11.314268Z", + "start_time": "2026-05-01T05:57:11.262185Z" } }, + "source": [ + "# Moscow in winter (below freezing)\n", + "moscow_frozen = climate.where((climate.city == \"Moscow\") & (climate.temperature < 0))\n", + "print(f\"Moscow below freezing: {len(moscow_frozen)} days out of 365\")\n", + "print(moscow_frozen.head())" + ], "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Moscow below freezing: 148 days out of 365\n", - " city day temperature humidity wind_speed pressure \n", - " 10} {'Min':>7} {'Max':>7} {'Std':>7}\")\n", + "print(\"-\" * 50)\n", + "for city in CITY_PROFILES:\n", + " v = climate.where(climate.city == city)\n", + " col = v[\"temperature\"]\n", + " print(f\"{city:<12} {col.mean():>9.1f}° {col.min():>6.1f}° {col.max():>6.1f}° {col.std():>6.1f}°\")" + ], "outputs": [ { "name": "stdout", @@ -927,14 +970,7 @@ ] } ], - "source": [ - "print(f\"{'City':<12} {'Mean temp':>10} {'Min':>7} {'Max':>7} {'Std':>7}\")\n", - "print(\"-\" * 50)\n", - "for city in CITY_PROFILES:\n", - " v = climate.where(climate[\"city\"] == city)\n", - " col = v[\"temperature\"]\n", - " print(f\"{city:<12} {col.mean():>9.1f}° {col.min():>6.1f}° {col.max():>6.1f}° {col.std():>6.1f}°\")" - ] + "execution_count": 16 }, { "cell_type": "markdown", @@ -946,20 +982,23 @@ }, { "cell_type": "code", - "execution_count": 51, "id": "7254f3b1", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:26.710706134Z", - "start_time": "2026-04-14T12:39:26.628790601Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:14.520839Z", "iopub.status.busy": "2026-04-07T12:06:14.520722Z", "iopub.status.idle": "2026-04-07T12:06:14.542317Z", "shell.execute_reply": "2026-04-07T12:06:14.541649Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:11.921424Z", + "start_time": "2026-05-01T05:57:11.814144Z" } }, + "source": [ + "# describe() on a select() view — only numeric columns\n", + "climate.select([\"temperature\", \"humidity\", \"wind_speed\", \"pressure\"]).describe()" + ], "outputs": [ { "name": "stdout", @@ -998,10 +1037,7 @@ ] } ], - "source": [ - "# describe() on a select() view — only numeric columns\n", - "climate.select([\"temperature\", \"humidity\", \"wind_speed\", \"pressure\"]).describe()" - ] + "execution_count": 17 }, { "cell_type": "markdown", @@ -1010,26 +1046,43 @@ "source": [ "### 4.3 Covariance matrix\n", "\n", - "`cov()` requires all columns to be numeric (int, float, or bool). \n", + "`cov()` requires all columns to be numeric (int, float, or bool).\n", "It returns a standard `numpy.ndarray`." ] }, { "cell_type": "code", - "execution_count": 52, "id": "6d0dd2c1", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:26.797016145Z", - "start_time": "2026-04-14T12:39:26.714612755Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:14.543869Z", "iopub.status.busy": "2026-04-07T12:06:14.543748Z", "iopub.status.idle": "2026-04-07T12:06:14.559277Z", "shell.execute_reply": "2026-04-07T12:06:14.558718Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:12.092759Z", + "start_time": "2026-05-01T05:57:11.933495Z" } }, + "source": [ + "numeric = climate.select([\"temperature\", \"humidity\", \"wind_speed\", \"pressure\"])\n", + "cov = numeric.cov()\n", + "\n", + "labels = [\"temp\", \"humidity\", \"wind\", \"pressure\"]\n", + "col_w = 12\n", + "print(\"Covariance matrix (all cities, full year):\")\n", + "print(\" \" * 10 + \"\".join(f\"{lbl:>{col_w}}\" for lbl in labels))\n", + "for i, lbl in enumerate(labels):\n", + " print(f\"{lbl:<10}\" + \"\".join(f\"{cov[i, j]:>{col_w}.3f}\" for j in range(4)))\n", + "\n", + "# And the correlation matrix for easier interpretation\n", + "corr = np.corrcoef(np.stack([numeric[c][:] for c in [\"temperature\", \"humidity\", \"wind_speed\", \"pressure\"]]))\n", + "print(\"\\nCorrelation matrix:\")\n", + "print(\" \" * 10 + \"\".join(f\"{lbl:>{col_w}}\" for lbl in labels))\n", + "for i, lbl in enumerate(labels):\n", + " print(f\"{lbl:<10}\" + \"\".join(f\"{corr[i, j]:>{col_w}.3f}\" for j in range(4)))" + ], "outputs": [ { "name": "stdout", @@ -1051,24 +1104,7 @@ ] } ], - "source": [ - "numeric = climate.select([\"temperature\", \"humidity\", \"wind_speed\", \"pressure\"])\n", - "cov = numeric.cov()\n", - "\n", - "labels = [\"temp\", \"humidity\", \"wind\", \"pressure\"]\n", - "col_w = 12\n", - "print(\"Covariance matrix (all cities, full year):\")\n", - "print(\" \" * 10 + \"\".join(f\"{lbl:>{col_w}}\" for lbl in labels))\n", - "for i, lbl in enumerate(labels):\n", - " print(f\"{lbl:<10}\" + \"\".join(f\"{cov[i, j]:>{col_w}.3f}\" for j in range(4)))\n", - "\n", - "# And the correlation matrix for easier interpretation\n", - "corr = np.corrcoef(np.stack([numeric[c][:] for c in [\"temperature\", \"humidity\", \"wind_speed\", \"pressure\"]]))\n", - "print(\"\\nCorrelation matrix:\")\n", - "print(\" \" * 10 + \"\".join(f\"{lbl:>{col_w}}\" for lbl in labels))\n", - "for i, lbl in enumerate(labels):\n", - " print(f\"{lbl:<10}\" + \"\".join(f\"{corr[i, j]:>{col_w}.3f}\" for j in range(4)))" - ] + "execution_count": 18 }, { "cell_type": "markdown", @@ -1078,7 +1114,7 @@ "---\n", "## Part 5 — Analysis: Summer in Madrid\n", "\n", - "Summer in the northern hemisphere runs roughly from the **summer solstice (day 172, June 21)** \n", + "Summer in the northern hemisphere runs roughly from the **summer solstice (day 172, June 21)**\n", "to the **autumnal equinox (day 264, September 22)**.\n", "\n", "Let's zoom in on Madrid during those months and compare it with a few other cities." @@ -1086,20 +1122,32 @@ }, { "cell_type": "code", - "execution_count": 53, "id": "89e89177", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:26.878728686Z", - "start_time": "2026-04-14T12:39:26.800640315Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:14.560797Z", "iopub.status.busy": "2026-04-07T12:06:14.560666Z", "iopub.status.idle": "2026-04-07T12:06:14.576880Z", "shell.execute_reply": "2026-04-07T12:06:14.576245Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:12.158697Z", + "start_time": "2026-05-01T05:57:12.106140Z" } }, + "source": [ + "SUMMER_START = 172 # June 21\n", + "SUMMER_END = 264 # September 22\n", + "\n", + "madrid = climate.where(climate.city == \"Madrid\")\n", + "madrid_summer = madrid.where((madrid.day >= SUMMER_START) & (madrid.day <= SUMMER_END))\n", + "\n", + "print(f\"Madrid summer readings : {len(madrid_summer)} days\")\n", + "print(f\" mean temperature : {madrid_summer.temperature.mean():.1f} °C\")\n", + "print(f\" max temperature : {madrid_summer.temperature.max():.1f} °C\")\n", + "print(f\" mean humidity : {madrid_summer['humidity'].mean():.1f} %\")\n", + "print(f\" mean wind speed : {madrid_summer['wind_speed'].mean():.1f} km/h\")" + ], "outputs": [ { "name": "stdout", @@ -1113,52 +1161,23 @@ ] } ], - "source": [ - "SUMMER_START = 172 # June 21\n", - "SUMMER_END = 264 # September 22\n", - "\n", - "madrid = climate.where(climate[\"city\"] == \"Madrid\")\n", - "madrid_summer = madrid.where((madrid[\"day\"] >= SUMMER_START) & (madrid[\"day\"] <= SUMMER_END))\n", - "\n", - "print(f\"Madrid summer readings : {len(madrid_summer)} days\")\n", - "print(f\" mean temperature : {madrid_summer['temperature'].mean():.1f} °C\")\n", - "print(f\" max temperature : {madrid_summer['temperature'].max():.1f} °C\")\n", - "print(f\" mean humidity : {madrid_summer['humidity'].mean():.1f} %\")\n", - "print(f\" mean wind speed : {madrid_summer['wind_speed'].mean():.1f} km/h\")" - ] + "execution_count": 19 }, { "cell_type": "code", - "execution_count": 54, "id": "a439fecd", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:27.020397605Z", - "start_time": "2026-04-14T12:39:26.880962534Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:14.578621Z", "iopub.status.busy": "2026-04-07T12:06:14.578475Z", "iopub.status.idle": "2026-04-07T12:06:14.693971Z", "shell.execute_reply": "2026-04-07T12:06:14.693318Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:12.293317Z", + "start_time": "2026-05-01T05:57:12.171562Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "City Summer mean Summer max Summer humidity\n", - "----------------------------------------------------------\n", - "Madrid 25.8°C 31.4°C 43.8% \n", - "London 16.5°C 22.7°C 74.6% \n", - "Cairo 33.5°C 39.7°C 34.4% \n", - "Moscow 20.1°C 26.3°C 69.3% \n", - "Tokyo 25.1°C 31.0°C 73.0% \n", - "Sydney 24.6°C 30.9°C 63.8% (S. summer)\n" - ] - } - ], "source": [ "# Compare summer stats across several cities\n", "compare_cities = [\"Madrid\", \"London\", \"Cairo\", \"Moscow\", \"Tokyo\", \"Sydney\"]\n", @@ -1166,68 +1185,85 @@ "print(f\"{'City':<12} {'Summer mean':>12} {'Summer max':>11} {'Summer humidity':>16}\")\n", "print(\"-\" * 58)\n", "for city in compare_cities:\n", - " v = climate.where(climate[\"city\"] == city)\n", + " v = climate.where(climate.city == city)\n", " # For Sydney (S. hemisphere) 'summer' is Jan-Mar, i.e. days 1-80 or 355-365\n", " if city == \"Sydney\":\n", - " s = v.where((v[\"day\"] <= 80) | (v[\"day\"] >= 355))\n", + " s = v.where((v.day <= 80) | (v.day >= 355))\n", " label = \"(S. summer)\"\n", " else:\n", - " s = v.where((v[\"day\"] >= SUMMER_START) & (v[\"day\"] <= SUMMER_END))\n", + " s = v.where((v.day >= SUMMER_START) & (v.day <= SUMMER_END))\n", " label = \"\"\n", " mean_t = s[\"temperature\"].mean()\n", " max_t = s[\"temperature\"].max()\n", " mean_h = s[\"humidity\"].mean()\n", " print(f\"{city:<12} {mean_t:>10.1f}°C {max_t:>9.1f}°C {mean_h:>14.1f}% {label}\")" - ] + ], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "City Summer mean Summer max Summer humidity\n", + "----------------------------------------------------------\n", + "Madrid 25.8°C 31.4°C 43.8% \n", + "London 16.5°C 22.7°C 74.6% \n", + "Cairo 33.5°C 39.7°C 34.4% \n", + "Moscow 20.1°C 26.3°C 69.3% \n", + "Tokyo 25.1°C 31.0°C 73.0% \n", + "Sydney 24.6°C 30.9°C 63.8% (S. summer)\n" + ] + } + ], + "execution_count": 20 }, { "cell_type": "code", - "execution_count": 55, "id": "4e2161ee", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:27.105416029Z", - "start_time": "2026-04-14T12:39:27.022601226Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:14.696603Z", "iopub.status.busy": "2026-04-07T12:06:14.695965Z", "iopub.status.idle": "2026-04-07T12:06:14.752771Z", "shell.execute_reply": "2026-04-07T12:06:14.751433Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:12.413861Z", + "start_time": "2026-05-01T05:57:12.294601Z" } }, + "source": [ + "# Top 10 hottest days in Madrid across the whole year\n", + "# Sort the full table, then filter — views cannot be sorted directly\n", + "hottest_all = climate.sort_by(\"temperature\", ascending=False)\n", + "madrid_sorted = hottest_all.where(hottest_all.city == \"Madrid\")\n", + "print(\"10 hottest days in Madrid:\")\n", + "print(madrid_sorted.select([\"city\", \"day\", \"temperature\", \"humidity\"]).head(10))" + ], "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "10 hottest days in Madrid:\n", - " city day temperature humidity \n", - " " - ] - }, - "jetTransient": { - "display_id": null - }, - "metadata": {}, - "output_type": "display_data" - } - ], "source": [ "plot_cities = {\n", " \"Madrid\": \"#e63946\",\n", @@ -1282,8 +1302,8 @@ "fig, ax = plt.subplots(figsize=(12, 5))\n", "\n", "for city, color in plot_cities.items():\n", - " v = climate.where(climate[\"city\"] == city)\n", - " d = v[\"day\"][:].astype(int)\n", + " v = climate.where(climate.city == city)\n", + " d = v.day[:].astype(int)\n", " t = v[\"temperature\"][:]\n", " order = np.argsort(d)\n", " ax.plot(d[order], t[order], label=city, color=color, linewidth=1.5, alpha=0.85)\n", @@ -1296,7 +1316,23 @@ "ax.grid(True, linestyle=\"--\", alpha=0.4)\n", "plt.tight_layout()\n", "plt.show()" - ] + ], + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 22 }, { "cell_type": "markdown", @@ -1310,42 +1346,26 @@ }, { "cell_type": "code", - "execution_count": 57, "id": "94e141a4", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:27.585476061Z", - "start_time": "2026-04-14T12:39:27.354159759Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:15.038502Z", "iopub.status.busy": "2026-04-07T12:06:15.038229Z", "iopub.status.idle": "2026-04-07T12:06:15.564804Z", "shell.execute_reply": "2026-04-07T12:06:15.563854Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:13.329232Z", + "start_time": "2026-05-01T05:57:13.134933Z" } }, - "outputs": [ - { - "data": { - "image/png": "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Ki4GoLqmhmeefoqjjjqS339f5Hx+QIA/8+fN56GHJwLwzvTpTJ/+NrfccjNvvvkWlVVVO11TakoKF19yGT///LPLe7vgwpEuX6zefmc6M2e8xw03XO/s5H373Xfcc8/dVFZWurXNzvJUxx133E5yUhKnDx1Gbm4e4MhUycYSbr75Jl599TWKizd4XF5kZCS33HwTP/z4I1deebVz+r333sMdt9+2zVoGDhxAUlIS199wI1999bVz+pY/KHzyyac89eQT5K9f3+l7X7MmnTG3bvu1NktLG8zQYWfy999/A/C//33OL7/8zN13jee662/YzrNd7Uhtm0VGRnLrrWOYP/9nLr3scuePCllZ2UyZMokLzj+fD2fPds7fp08st91+Bx9/7Ngz+f77H/DnH79z8cUXGf5l+auvvmbypMcZNmwon3zyKSeccDyRkZF8+tn/uGj0KLf5J095wiXjM2fOIi8vjwkT7iU+Ls75eXzggfsoKyvn3PPOp66uDoBFvy/igw9mUVDgvle2T58+DDnmOCorK53TPI0KubPL3dJ3331Pc3Mz55w9wqWTd/aI4dhsNr744gsAjj/+OPz8/LjsssuprKra5jJ31n77pTF61EhmzpzF3fc49gxOn/4uFeUV3HzzTQwZcjS//fa7c/7+/ftz7nkX8OeffwLw+RdfsPivP7ho9Cgee9zRKR0z5haioqI486wRLF++HIDZc+bw68JfXF67J+VWxCx0uKZID/DLggWMOPtcvv/+B/bbbz/GjLmF99+fydIlf3H6aaft1rJnzfrAedtms7FixUqsVivvv//P9NraWrKzs0nq29ft+R999JFLR3PpsmVYrVY++PBDl/mWLltOXFwcXl5eAJx55hlYrVa++OJLIiMinH9lpaXk5uZyzJCjXZ7f3NzMhx/OZncMH34WNTU1/PzLLy6v+ffKldTX1zNkq9fMyMhwdvA2vzeAhb/+6vxCCbBs0/Skvklur/n2O++43n97On5+fhx33HG7VFN+fr5bBw9w+fIbEhJCZEQEvy9aRHJykvOQxa7kqY7hw8/ijz/+pKa6xuW9LFi4EG9vb7fDGbd0/HGOL7dvvfWOy/TXX39ju7XU1jq+cJ94wgkEeDh0b0e9+96MHZ538eLFzg4eQFFxMd9//z0nnngCVg97gbrK5nZ6/Y03XPYaz5w1i9raWk459WSX+evr651flAHa2tpYvnyFx8/y3lZTU8P8+T9z7rnnAHDeueeyePFiioqKPM6/ZcYDAgKIjIjgr8VLsFqtHHDAAQD07t2bAw44gDlz5jg7YuBYh3a2h/Xrr79x6eB5sivL3VJ9fT3z5s1nxIjhLtPPPvtsli5d6lyfbD56YejQ03f4EOcddfLJjmy8+trrLtNfefU1AE495RSX6RkZGc4OHkBlZSXZOTn03SI7p5x8EouXLHF28DbP9+mnn7ksqyflVsQstCdPpIdYsWIF111/Az4+Puy3336cccYwrr/uOl577RVOO30YmZmZu7Tcrb9Q1dbV0dTU7PYrcm1tHREehjPfsrMDOL8AFW89vbYWLy8vQkNDqKqqJiUlBavVym+/LvBYV9tWeyg3biyhra1tx95UJ1JSUggLC2PV3ys8Ph4VFeVyv6ios/fmujdqcycjLNz1HLqOjg7y89e7TMvJyQEgMTFhl2pa38kegyMOP5y77hrHYYcdRmBgoMtjoSEhLl9Mu4KnOlJTUth/v/1YtWqlx+ds/V62lJAQD0Bubq7L9MrKSqqqqrdZS0FBAa+8+ho33XgD559/Hn/88Sfff/8DH3/yyU6974KC9dufaZOcreoEx/9tYGAgvXr1oqysbIeXtTM2t1N2do7L9La2NtavX09CvOseqA0b3PecVtfUkJY2eJuvEx4e7nHkyx1RXV29w5/VTz/7jP+88DzxcXEMGzaUSZOndDpvfFwcd919F6efdhoREeEuj4WEOn7I+CdHeW7Pz87O4cADD3Cb3tlnaku7stytff75F5xxxjAOP/wwFi9eQlJSEv/610HOPf2b57nk4ot47rlnuf/++1i48Fe+/uYbvvzyK5fO0a5ISIino6ODvDzX91BWVkZ1dTXxm97jZluv/wBqqmtc1nPx8fHOH7+2tPkw8i1f2zF9z+ZWZF+iTp5ID9PW1saKFStYsWIFOTk5PD9tKiOGn8XUac93+iVgW3sWbB5GDrTZPI8m6OmX5c5GHuzosHleBpZNNVmw2WxcetkVHl+voaHB5X5XjNxotVopKyvzeK4euA5iA9DRSTt4ajP4573tyZo8tUNSUhIffvg+2dnZPPLoYxQXF9PW1sbJJ5/MjTdc7/H8oq11lh2vTp7rqQ6LxcLPP//Cy//9r8fn5Gz1Ba8rPfbY48yePYehQ0/nhOOP5/HHH+XW28YwYsTZO3TOFEBzVw/b39nncdPe7L2h08/hdvYSvfH6a257kXfUBReOdDlseVu+//4HWltbef6Fafj6+vLF5194nM9qtfLBB7MIDw/n5ZdfJisrm8amRmJjY3nh+Wm7tfe0y//fO/H9Dz/Q2NjIiBHDWbx4CSNGDKejo4Mvv/xn0JXm5mbOO/9CjjlmCKeccgonnXgC55xzNgsWLuTiiy/FZvP8/7kzdrSz2Nn6b1fWcztrV3Mrsi9RJ0+kB1uxwrHHpHdMb8Bx+BPgNlqZp/NLjJafl4/VaqWgYD05Oe57RXZHZ19i8vPzOe64Y/nrr8V7Zbh/Ly8vkpL6ury/1NRUAAoKCrusptNOOxV/f3+uuuoalz2rQ4YMcZu3s7bZMjtbDnizM9nJz88nKCiQBQs8jyK4LYWFjj3KKSkprF//zx61yMhIt702nVm7di1r167lhRf+w+GHH8bn//uMyy+/nKeffgbY8S+3OyI1JcV9WmoqjY2Nzo55dU2N22cR/tmrsaUdrW1zO/Xrl+rSTj4+PiQmJrJg4c63vSePPvY44Vvtmd5Ra9ak7/C8zc3NfPvdd1x4wQXMnftTp+ehpaUNpl+/ftx+x50u1647ftNhz5v9k6Nkt2X065e6w3VtrSuW29TUxI8/zmX4WcN55JHHOOfsEfzxx5+UlJS4zGe321m48FcWLvyVRx+F2267lfsm3MsxxwzZpc/Wlu/By8uLlJQUsrKynNOjoqIIDw+nqNDzYbLbUlRURIqHz0K/fv3cXtsxfc/mVmRfonPyRHqAzn5RP+Xkk4B/DoGpr6+noqKCo7Y69+mqK6/YswXugq+/+Zb29nbGjR3r8fEd/WLvSWNjI+De2f38iy/x9vbmTg+XMXAcSur+hXx3XX3VVa73r76S1tZW55earqjJuWdxi1+5Q0JCGD3KffCKxqZGwsLcl5m3aTTHLbMTEBDAyJEXbvf1N/viiy85/PDDOeGEE9weCw0NdZ6P6ckvCxbQ2trKNddc5TL9+uuv2+7rBgcHuy07PX0tHR0d+G0xNHxjYyNhXfR/fPjhh3PgAf8cohcX14fTTz+dn3/+xbm3JT8vn7CwMJdDzHr37s0ZHobd39HaflmwgJaWFq695hqX6RdffBFhYWHM/fGnXX1LLv7++28WLFi4S3+bfzDYUa+88irPPTeV5194odN5Nu/Z2XpPzrXXubZDaWkpq1atYuTIkS7noh5/3HEMGjRop+raE8v93+ef06dPLJdccjH7778/n3/huucyPDzc7TmrV68G8HgJmp3x00+ObFx//bUu02+8wTGq5o9z5+70Muf+NI/DDzuMgw8+2DktMjLS7dIYeyu3IvsS7ckT6QEmPf44AQH+fPPtd2RlZeHr48vhhx/G2WePYP369S4Dksya9T633XYrzz7zNCtWruSoI4907j0yk/z8fJ5++hnuv/8+EhMT+Pbb76hvaKBvYiLDzhjGzBmzeOXVV3dp2Ss3DYjx+OOPMn/+z9g6bPzv889ZtGgR7773Hrffdiv777cfP//yC+1t7aSkJjP8rOE8PHGiy+iMu6upqZkTTzqR55+fyrJlyzn5pBM57dRTeeE//+cc6KEravr5l19oaWlh+jtvM2PGTIKCArnkkkuoqCgnNjbGZd6/V/7NFVdczh133E5ebh7lFeX8+utv/PzzLxQWFvLcc8/w3/++QofNxkWjR1FRUbnDe/P++99XOP3003h3+tvMnj2HlX//TWBgIIMHD2b4WWdy5JFHd7qnprKykldefY3bb7uVd999h5/mzuOAA/bnpJNOcjtkdWvHHnMMkyY/zpdffkVOTg7eXl5ccMEFdHR08NXX/7Tdyr//5rjjjuWGG66nZGMJ6wvWs2zZ8h16b1tLT1/LrFkzXC6hAPDsc/9cGuN/n/+PBx64jzffeIM333qLgIAArrzicnJycjjooINclrejtVVWVvLiiy8xfvw4Zs2cwfff/0C/fqlceeUVLFu2nI8/+cTtOWa3Zk36dvf+ZWVlkZubx8MPPUhsbCz1dXWcedaZhIe5722c8sRTvPfuO3z26Sd88OGHhIeHc83VV7F2bQZBQYEelr5jumK5P/00j7q6Oh5+6EHa29vdPttjx97BUUceyY9zf6KosJBeUVFceeUVFBcX8+eff213+SnJydxxh/uh36tWrWLu3J/4cPYcLr/sMsJCw/h90SIOPvhgRo8ayTfffOsysuaOevnl/3LhBeczc8Z7vPnmm85LKBQWFblc57Mn5lbEaOrkifQAjz0+iRHDz+KUk0/isksvwcfHh6LiYqZPf5fnX/iPy+F1055/gV69enHWWWcyYsRw5s2bz6WXXd7pwB5GevGll8nOyeGG669n3KYLtRcXF/PLz7/w/Q/f7/Jyv/76G9588y3OOedsLjj/fKxWK//7/HMAJky4n5Ur/+byyy7jvgn30t7eTkFBIZ988gl//bW4S97XZjZbB5deejlPPjGFhx58gPr6ep57bipTpz3vMt/u1pSdncMNN97EPffczUMPPUhZWSnvvvseFRWVTJv2nMu8U6c9T3xCPLfcfBMhISH89tvv/Pqr41Ic1157PVOemMzdd99FWVkZr7/xJjU1NTy/6cLc29PU3Mz5F4zk9ttvY/jws7jwwguor68nJyeHZ5+bSu12BkF56qmnaWlu5vLLL+eYIUNYumwZF19yKe+9+842n7d6zRp+nv8zp516KrGxsTQ1N7FmzRouu/wKli79Z1CIRx99jKefeop777mbgIAAPpw9Z5c7eYsWLWLxkiWMGzeW+Lg4MjMzuXPsONLT1zrnqaqq5tprr2fixId58IH7KSgoYMoTT5KakuLWyduZ2p6bOo2KikquvvpKHnnkYaqrq5kxcxZPPvnUbl9Sxaza29u58qqrmfT4o9x26xhaWlr45ptvefudd5j74w8u886fP58bbryJe++5m/sm3Et+fj5jx93F0KGnM+Too3a5hq5YbktLC99//wMXXHA+v/zyi9sPGN9//wOJCYlcNHo0kZERVFZWsWjRIp597rkdGkSof//+3HvP3W7TZ816n7lzf+Kuu+5mfX4+o0aNZNiwoZSVlfGf/3uRqVOn7VgjbKW0tJQLR45m0uOPMWbMGKqqq3jvvRmUbCxh6tRnXebdF3MrsidZ+sQldN1JCCIiskOmTZvK8LPOZMBAjQYnIiIiXUvn5ImIiIiIiPQg6uSJiIiIiIj0IOrkiYiIiIiI9CA6J09ERERERKQH0Z48ERERERGRHkSdPBERERERkR5kn7hOXkxMDA0NDUaXISIiIiIisluCgoIoKSnZ5jw9vpMXExPDsqVdewFjERERERERoxxy6OHb7Oj1+E7e5j14hxx6uKn25qWmppCTk2t0GSJulM3uweLtQ9pFdwOQ/sEz2NvbDK5oz1IujeFvsfB9wiAATi/MoNmusdq2pFyKGSmXPVtQUBDLli7ebr+mx3fyNmtoaKC+vt7oMpza2tpNVY/IZspm92Dx9qGxxdGxq6+v7/GdPOXSGO0WC/bGRsCRM3XyXCmXYkbKpYAGXjFMfX2d0SWIeKRsihkpl2JGyqWYkXIpoE6eYcrKyo0uQcQjZVPMSLkUM1IuxYyUSwF18gyTkpJidAkiHimbYkbKpZiRcilmpFwK7EPn5ImI9Ch2aK2rct4W2VOK21uNLkH2IQEB/kRERGK1WIwupduKjo6mrrbW6DJkF9jsdqqqKmlqat7tZamTZ5Di4mKjSxDxSNnsHuwdbaz76Hmjy9hrlEtjNNvtjCjKMroM01Iuu47FYmHkhRdw1FFHGV1Kt2e1WrHZbEaXIbth0aJFzPnoY+y7MdiVOnkG8fX1NboEEY+UTTEj5VLMSLnsOo4O3pF88eWX5OTk0tHebnRJ3ZaXtxcd7R1GlyG7wMvbm9TUFEYMPwuA2XM+2uVlqZNnkKioKMrKyowuQ8SNsilmpFyKGSmXXSMgIICjjjqKL778knnz5htdTrfn7+9Pc/PuH+4nxsjPzwdgxPDhfPHll7t86KahA6/ceusYvv7qS9ZlpLNyxTLeevMN+vVLdZnnozmzKS4qcPl78skpBlUsImIOFi9vUoffQOrwG7B46fc62TP8LBbejU3h3dgU/HSOlOwhERERALqAt8gmmz8LERGRu7wMQ78ZHH3UUbwzfTrLl6/A29uLCRPu5f1ZMznhxJNpampyzjdjxkyeefY55/0tH+uuMjIyjC5BxCNls5uwWAiMjnfe7umUS2NYgP39Apy3xZVy2TU2D7KiQzS7hvbidX+bPwu7MwCRoZ28Sy+73OX+nXeOY9XfKzjooIP4448/nNObmpt63OEQKSnJZGfnGF2GiBtlU8xIuRQzUi7FjPz8fGlp0ai4+zpTXScvNDQUgOrqapfp5593Hqv+XsFPc3/kvgn3EuDvb0B1XcvX18/oEkQ8UjbFjJRLMSPlUva0hIQEiosK2H///bY53/hxY/nh+28BsFg8f72fNm0qb735RpfXKOZkmhM5LBYLjz46kT///NPl8IdPP/uMwsIiSkpKSEsbzAMP3E+/fv247vobPC7H19fXZbSroKCgPV77rmhoaDC6BBGPlE0xI+VSzEi53POmRSfu1dcbW1awU/NPmzaV0aNG8u577zFhwv0uj02ZPImrrrqSD2fPYezYcV1Zppv/vvIqb739NoAunyCAiTp5U6ZMZvCgQZx73vku02fOnOW8vXbtWkpLS5kz+0OSkpKco89s6bZbxzB+vPsHadCgQTQ2NpKRkUFycjJ+fn40NjZSXFxM//79ASgp2YjFYqV3794AZGZmkpiQgH9AAM3NzRQUFDBgwAAAykpL6bDZiI2NBSA7O5vY2FiCgoJobW0hNzePQYMGAVBeXk5raytxcXEA5Obm4u3tRVpaGm1trWRlZZOWlgZAZWUlTU2NxMcnAJCfl0dEZCShoaHYOjrIWLeOtLTBgIXq6irq6upJTHSsANevX09YaChh4eHYbTbWZmQwaOBArF5e1NbWUFVVTVJSEgCFhYUEBQU6T+hMT09nwIABeHt7U1dXS3l5BSkpKYDjOkB+fn706tXL+f+QmpqCr68fDQ0NlJRsJDW1HwAbN27Ay8ub6OhoANatW0dSUl/8/PxpamqkqGjL9i4BICYmBoCsrCzi4+MICAikpaWZ/Pz1DBw40NHeZWV0dLQTG9sHgJycbGJi/mnvnJxcBg8eDEBFRQUtLS0u7R0V1YuQkFDa29vJzMx0tndVVSUNDY0kJGxq7/x8IiLCCQ0Nc7b34EGDsFit1FRXU1NbS9++fQEoKCggJCSY8PAIwE56+tot2ruWqspKkpKTASgqKiQgIJDIyH/au3//fvj4+FJfX0dZWblLe/v6+hIVFQU4zvlISUl2tvfGjRvp129ze2/Ey2olesvMJiY6RtZqaqKgsNCZ2dLSUux2GzExsc72jouLIzAwkJaWFvLyHJn18fGmqSmKtrZ2+vTZ3N459O4dTXBwCG2trWTn5Djbu7KigqbmZuLjHeeH5eXl0atX5Fbt7chsVVUVDfX1JGyZ2bAwwsLCnJl1tndNDTU1Nc72LiwoICg4eNMJ+o723jKzFRWVJDvbu4gAf38it8hsv9RUfHwd7V1aWkZqqmOQpw0bNuDj401UVLSzvc2wjoiOjnK0d2friETHZxkgLi6O0KDATtcRh519hSMvVfUE+fsSEuCL3W4nr7SGpOhQrFYr9U2t1DW10icy2PFeqxsI8PUmNNCxpyK3pJq+UaF4eVlpaG6lpqGFuF4hjvda04Cvtxct6353tndXryN8fLzZsMFH6wj27jqi76Y2Ase1t9I2tXd5eZnWEX5+tLW14ePjY851RDf6HuHlZcXHxwdfPz+8vLywWCx4e//zFdVqtTpOCrWDzW5z3AfsNsc1xCxWx3lLNpsNq2WLeW02rF47N6+/vz/t7e3Y7XZ8fHwAaGlpwdvbGy8vL+x2Oy0tLfhvOqrMarFQVFTEueecwxNPPEVdXR1eXl4EBARw3nnnUlhYiJfVsdyOjg46OjqcOyNaW1vx8rLitWnwrObmZvz8/LBYLI5529vx83Osgy0WK97e3s522XJecIxXYbPZnPW7z+uLl5cXVqsVi8XiXG5bWxsWwHvTe21ubsbX19d5rb3W1lbne21rawNwaRcfHx+P8+5MG7a3t2Oz2ZztsjPzOtrQCy8vL7DbaW5pwd/PDza34VbtbbVaPbZhR0cH7Vu0987M29bW5pLZrduwra3NZd4t27Cz9nZ8FqyEhoWStulIx83riM3bz+2x9IlL2PWr7HWRyZMeZ+jQ0znv/AspKNj2LygBAQFkZ63j4ksu4+eff3Z73NOevGVLFzNwUBr19fVdXvuuSktLIz093egyRNwom92DxduH/S9/EIDV703C3t7W6bx9T7lkr9S0fu6s7c+0i5RLY/hbLPza19F5OGZ9Os27cWHenki57BoJ8fGMGzeWqVOnUVhU5PJYd9iTFxYaSlJSEi++9BKffvoZAOedey63jLmZgvUF1NTWMnbsOE488UTuvOM2Bg0aRIfNxpIlS3j44UdcdlocfPDBPP3UE/Tv35+MjHW88J//4603X+e004eyevUajj76KD7+aA6XXnY5995zN4MHD+biSy5lyNFHM2zYUE47fRj+/v60trby0EMPctHoUXTYbHzw/gdERUcRGhLKNdde15VNJnvAtj4TwcHBrMtI327fxvBz8iZPepxhw4YxctTo7XbwAA7Yf38ASktLPD7e2tpKfX2980+HUohIT9Xe3EB7s9ZxsmdVdbRT1aFRD0W25YMPP+Si0aOc9y+6aBQffjjbZZ7AwABefe11zjhzOKNHX4TdZufNN1537o0LDAzk3elvs25dJsPOOIvnpk7l4Yce9Ph6999/H1OmPMkJJ55Mevpat8dvuvEGRo0cybjxd3HuuecTHh7OGcOGdeE7FrMz9HDNKVMmc96553D1NddRX9/g3P1YV1dHc3MzSUlJnHfeucyd+xNVVVXsl5bGI49M5PffF3kMdHeyceNGo0sQ8UjZ7B7s7W2sff9po8vYa5RLYzTb7ZxauM7oMkxLuZTNPv74E+6bcK/zsOTDDz+Cm28ew5Cjj3bO8/XX37g8Z9y48axatZKBAweSkZHBeeedi9VqZfxdd9PS0sK6devo06cPTz35hNvrPfvMc/yyYIHHWtra2rjuuut48cUX+eYbx2As9064jxNPPKGr3q50A4Z28q660nGeyCcfz3GZfufYccyePYe2tlaOO/ZYrrvuWgIDAijesIGvv/6a51/4jxHldikvq+E7UUU8UjbFjJRLMSPlUjarrKxk7tyfGD1qJBaLhbk/zaWyqsplnpSUZO6+6y4OOeRgIiMjnecWxsfHkZGRwYABA1iTnk5LS4vzOUuWLPH4eitWruy0ltCQEGJjY1i6bLlzWkdHBytWrHTuNZSez9BOXlz8to+zLi7ewAUXjtxL1exd0b17U15RYXQZIm6UTTEj5VLMSLmULX3w4YdMnvQ4APc/4H6Y5fR33qawsIi777mXjRtLsFqtzJ83F18fX7d5t6exsbHTx7YctEb2XfoJSkSkG7J4eZMy7CpShl2FxUsbdNkz/CwWXo1J4tWYJPy0B0Bkm+bNm4+Pjy/ePj7Mn+86OGBERDj9+/fn+Rf+w8KFv5KVlUV4WJjLPJmZmeyXluYciRHg0EMP3ek66urr2bixhEMPOdg5zcvLi4MOOnCnlyXdl74ZGCQzM9PoEkQ8Uja7CYuFoD4pzts9nXJpDAtwuH+Q87a4Ui5lSzabjRNOPMl5e0vV1TVUVlZy2WWXUFpaSnx8HPffd5/LPJ9++hkT7r2HZ555iv/7v5dITEzgpptu3Ok6mpubefPNNxlz6xhyc/PIysrihhuuJ3TTUPyyb9CePINsviaNiNkom2JGyqWYkXIpW9s8uvvW7HY7N98yhoMOPJCf5v7AI49M5PFJk13maWxs5MqrriZt8GC+/+4bJtx7D5MnT9npGnx9fXnl1df4+OOPef75qXz++WfUNzTwzbff7vL7ku5He/IMsvmijiJmo2yKGSmXYkbK5Z63s9et29vGjh23zce3vCbdggULOfGkU1we33p8iqVLl3Ha6cM6nef33xd5HNPiuanTeG7qNMBx8fjW1lYmTnyUiRMf3bE3Ij2O9uQZpLmpyegSRDxSNsWMlEsxI+VSzGjrQ0Vl36ROnkEKCguNLkHEI2VTzEi5FDNSLsWMWltbjS5BTECdPIMMGDDA6BJEPFI2xYyUSzEj5VLMSIcRC+icPBGRbsvWpl9rZc9r0qFfIiLdjjp5BiktLTW6BBGPlM3uwd7expoZk7c/Yw+hXBqj2W7n2IK1RpdhWsqlmFFbW5vRJYgJ6HBNg9jt+mVUzEnZFDNSLsWMlEsRMSt18gwSExNrdAkiHimbYkbKpZiRcilm5OPjY3QJYgI6XFNEpBuyeHnT96TRAKyf9yH2jnaDK5KeyBcLz0QnAHB3WSGt2A2uSEREdoQ6eQbJysoyugQRj5TNbsJiISRxoPN2T6dcGsNqgWMDQ5y31cdzpVyKGbW0tBhdgpiADtc0SFxcnNEliHikbIoZKZdiRsqlGKG4qIBhQ4d2+rgO1xTQnjzDBAYGGl2CiEfKppiRcilmpFzueX1PuWSvvt76ubN2av5p06YSFhrKNddet4cq2nlWq/bhiPbkGUa70sWslE0xI+VSzEi5FDOy6dqWgjp5hsnLyzO6BBGPlE0xI+VSzEi5lG056qij+OrLL8jNyWLZ0sXcf98EvLy8nI9/NGc2jz/2KA8+cD+rV/3N8mVLGD9urMsyUlKS+eTjj8jJzmT+vLkcf9xxbq8zePBgZs/+gOysTFatWsmkxx9z2cs8bdpU3nrzDW668UaWLV3MqlUrmTJ5Et7eOqCvJ1MnzyCDBg0yugQRj5RNMSPlUsxIuZTOxMbGMuO96axYsYLTThvKffc9wMUXX8Sdd9zuMt/IkRfS2NjI8BEjmDR5CmPH3unsyFksFt54/XXa2loZPuJs7p1wPw88cJ/L8wMCApg1cwY11TWcedZwbrzxJo4//ngmT57kMt+QIUeTlJzEyJGjufPOsYwaNZJRo0bu2UYQQ6mTJyIiIiLSha688gqKi4u5/4EHycrO5tvvvuPZ56Zy4403YNliROT09LVMnfY8ubl5fPTRx6xYsZJjjz0GgOOPO47+/ftx+x1jWbMmnT/++IMnnnza5XXOO+9c/Pz8uP2OO8nIyODXX3/jkUcf5cILzicqKso5X01NDQ9squXHH+fy49y5HHfssXunMcQQ2k9rkPLyMqNLEPFI2ewe7O1trHp7otFl7DXKpTGa7XYOy19jdBmmpVxKZwb078+SJUtdpv31118EBwcT16cPRcXFAKSnp7vMU1pa6uyc9R/Qn+LiYkpKSpyPL1myxPV1BgxgTfoampqanNP++ONPvLy86NevH+Xl5QBkrFvncq5eaUkpg9MGd8E7FbPSnjyDtLXpwsViTsqmmJFyKWakXMruamtvc7lvt9ux7ObomHa7+wUt27fKqh07Vou6AT2Z/ncN0qdPH6NLEPFI2RQzUi7FjJRL6UxmVhaHHXaoy7QjjjiCuro6ijds2KFlZGVmERcXR+/evZ3TDj3UdZmZmZnsl7YfAQEBzmlHHXUkHR0dZGdn78Y7kO5OnTwRkW7I4uVN4omjSDxxFBYvHXkve4YvFp6KSuCpqAR8sWz/CSL7oJDQEPbffz+XvxkzZhIXF8fkSY/Tv18/hp5+OneNH8drr73ucU+bJ78sWEBOTg4vPD+N/fZL49///jcT7r3HZZ5PP/mUlpYWXnhhGoMGDWLIkKOZ+PDDfPTxJ85DNWXfpG8GBsnJyTG6BBGPlM1uwmIhLGV/AAoXfmpwMXuecmkMqwVODQoFYGJFEezYd9N9hnIpAMcMGcIP33/nMm3WrPe57PIreejBB/jhh++orq7m/fc/4PkX/rPDy7Xb7Vx73fU89+yzfPXlFxQWFvLgQxN5f9YM5zxNzc1ccullPPbYI3z91Zc0NTfx9dff8Mgjj3bRu5PuSp08g/TuHU1BQaHRZYi4UTbFjJRLMSPlcs9bP3eW0SVs09ix4xg7dlynj581fESnj104cpTbtGuuvc7lfk5OLuedf4HLtLj4RJf7a9euZdSoi5z3fXx8aGv751w/T/VNnKhOYE+nwzUNEhwcYnQJIh4pm2JGyqWYkXIpZrTlBddl36VOnkHaWluNLkHEI2VTzEi5FDNSLsWMdvScP+nZ1MkzSLaO4xeTUjbFjJRLMSPlUsyopaXF6BLEBNTJM8jgwboApZiTsilmpFyKGSmXYkb+/v5GlyAmoIFXREREpEtMi07c/kxdYGxZwV55HRGR7kqdPINUVlQYXYKIR8pm92Bvb2P1e5Oct3s65dIYzXY7x6xPd94WV8qlmFF7e7vRJYgJqJNnkKbmZqNLEPFI2ew+9oXO3WbKpXHUueuccilmZLPZjC5BTEDn5BkkPj7e6BJEPFI2xYyUSzEj5VLMyNfX1+gSxAS0J09EpBuyWL2IG+K4yG7xb19gt3UYXJH0RD5YeKBXHwAmV2ygDe3VExHpDrQnzyB5eXlGlyDikbLZTVitRAw4hIgBh4C156/KlUtjeFlgRHA4I4LD8bIYXY35KJeyNyQkJFBcVMD++++3zfnGjxvLD99/u81LKEybNpW33nyjq0sUE+r53wxMqlevSKNLEPFI2RQzUi7FjJRLmTZtKsVFBTz55BS3x6ZMnkRxUQHTpk3dK7X895VXGTX6Iry9950D9S655GI+/eRj1qz+mzWr/+bDD2Zx8MEHu8yz+f9oy7+ZM97b5nKtVit3330Xi37/leysTH77dSF33nmHyzzR0dHMeO9dli5ZzORJj2OxuP4SlpyczLSpz7F48Z/k5mSx6PdfefmlFznooIO65L1vjzp5BgkJCTW6BBGPlE0xI+VSzEi5FICioiLOOftsl+vT+fn5ce6551BYWLhXavDy8qKxsZGqqmq8vLz2ymuawZCjj+az//2PkaNGc/bZ51JcvIH3Z80gNjbWZb6ffprHvw4+1Pl3y5hbt7ncMWNu4corLueBBx/ihBNPYvKUKdxy801ce83VznnuufsuVqxcyWWXX07fvn0595xznI8ddNBBfPvNV6SmpnDvvRM48aRTuPa668nKymLiww91bSN0Qp08g2h4WzErZVPMSLkUM1IuBeDvv1dRXLyBM84Y5px25hlnUFRczKpVq13mPfHEE/ns049JX7OKVatWMn362yQlJbnMc/DBB/P9d9+Qk53JN19/xQEHHODy+NFHH0VxUQEnnXQi337zFXm52fz730c4D9e0bxoR12q1MnHiw87XevCB+7Fs57DrUaNGkr5mFaeeegoLfplPdtY6XnvtFQL8/Rk58kL+WPQba1b/zeOPPYp1i1MFfH19efihB1my+C+yMjP48ovPOfroo5yPR0SE8/JLL7Jk8V9kZ61j7o8/uHSKAD6aM5vHH3uUBx+4n9Wr/mb5siWMHzd2m/XeetvtTJ/+LqtXryErO5vxd92N1Wrl2GOPcZmvtbWVsrIy519NTc02l3v44Yfx3XffM3fuTxQWFvLVV1/z88+/uOwlDAsPY+3ataSnr2X9+vWEhv3zo8/z06aSm5vHueddwNy5P5Gfn8/q1WuYOu15rr7m2m2+dldRJ88gmZmZRpcg4pGyKWakXIoZKZd7nr/F0umfL5YdntfPsmPz7qoPPvyQi0aPct6/6KJRfPjhbLf5AgMDePW11znjzOGMHn0RdpudN9943XmoX2BgIO9Of5t16zIZdsZZPDd1Kg8/9KDH17z//vuYMuVJTjjxZNLT1zqnbz4n76Ybb2DUyJGMG38X5557PuHh4ZwxbJjHZW0pICCAa6+5hptvHsMll17OkKOP5s033+CUk0/mssuv5PY77uSyyy5l+PCznM+ZPOlxDjvsUG6+ZQynnHo6X375FTNnvEdKSjIAfn7+rFz5N1dceSUnnXwqM2fO5D//ed7t0MqRIy+ksbGR4SNGMGnyFMaOvZPjjztuuzVvWbu3tw/V1dUu048++ihWrljGgl/m88QTU4iICN/mchYvXsKxxx5DamoKAPvtl8a//30EP82b55znxRdfZtLjj5GXm82BBx7AnDkfAXDA/vszePAgXn31NWeHe0u1tbU7/H52x75z0K7JpKUNdvlAipiFsilmpFyKGSmXe96vfdM6fWxhYx13lBU47/+YMIiATgaiWtzcwI0l+c77X8YPIMLL/WvwYflrdqnOjz/+hPsm3Ou8rMbhhx/BzTePYcjRR7vM9/XX37jcHzduPKtWrWTgwIFkZGRw3nnnYrVaGX/X3bS0tLBu3Tr69OnDU08+4faazz7zHL8sWOA23d/fn+bmZq677jpefPFFvvnmWwDunXAfJ554wnbfi6+vLxPuu5/8fEd7ffnVV1x4wQUc9K9DaGxsJDMzk99++50hQ47m88+/ID4ujtGjR3HEv4+ipKQEgFdefZWTTjqB0aNH8+STT7Fx40ZeefVV52u89fY7nHDiCZw9YjjLly93Tk9PX8vUac8DkJubx9VXXcWxxx7j8X168sAD91NSUsKCBQud0+bPm883X3/D+oICkpOSmDDhHma89x4jzj6n02sKvvjiS4QEB/PLz/Pp6OjAy8uLJ596mk8//cw5z8qVKzn0sCOIjIykrKzMOT1lU8cwKytrh2reU9TJM4yGKROzUjb3lr6nXLLrT97iF+fEE0dBj79gtXIpZqRcikNlZSVz5/7E6FEjsVgszP1pLpVVVW7zpaQkc/ddd3HIIQcTGRnpPOQxPj6OjIwMBgwYwJr0dJcRMpcsWeLxNVesXNlpPSEhIcTGxrB02XLntI6ODlasWOk2QMjWGhsbnR08gPKycgoKCmhsbHROKysvI6pXFACD0wbj7e3NwgU/uyzH19eXqqpqwHHo6O2338aI4cOJjY3F19cHX19fmpqaXJ6Tnp7ucr+0tJSoqKht1rvZrWNu4Zyzz+bCkSNd2u9/n3/uvL127VrWpKez6PdfGTLkaBYu/NXjss4eMYLzzz+PMWNuI2PdOvbffz8effQRSkpKnHvswNGmW3bwgO22796iTp5Bqjx88EXMQNnsJux26gqznLd7OuXSGM12O6cUZDhviyvlcs87Zn16p4/ZtorkqYUZnc67dXqHF3X9obYffPghkyc9DsD9D3g+xHL6O29TWFjE3ffcy8aNJVitVubPm4uvz85fwHzLTteW2tvb8fHx2enlbdbW1uZy3263u51/arfj7KAGBQXR3t7OsDPOpKPDdc9YQ0MDALfcfBPXXXsND098hLVr19LY2MSjj050e99t7e6vbdmBywTddOONjBlzC6MvumS7e9fXr19PRUUFycnJnXbyHnroAV588WVnB3Ht2rUkJCRw261jXDp5nuRk5wDQv39/Vq1evc159ySdk2eQhvp6o0sQ8UjZ7D7sto595iLoyqVxqm0dVO8jOdtZyuWe12y3d/rXulXXbVvztth3bN7dMW/efHx8fPH28WH+/J/dHo+ICKd///48/8J/WLjwV7KysggPC3OZJzMzk/3S0vDz83NOO/TQQ3eqDpvNRl1dHRs3lnDoIQc7p3t5eXHQQQfu3JvaAatWrcLb25tevaLIy8tz+du8l+uIIw7nu+++55NPPmXNmnTy8/NJTU3tkte/5eabuPPO27n0sstZuY29m5v16RNLREQEpSWlnc7jHxCAze7aYe3o6NihDueq1avJyMjgxhtv8LhXLzR074zKq06eQRISE40uQcQjZVPMSLkUM1IuZUs2m40TTjyJE0882eO5XtXVNVRWVnLZZZeQnJzMMccMYeLEh13m+fTTz7Db7TzzzFMMGDCAk08+iZtuunGn6vD1dewde/PNNxlz6xiGDR1K/379eGLK5D3SwcjJyeXjjz/hPy9M44wzhpGYmMjBBx/MrbeO4ZRTTnbMk5vH8ccfx+GHH0b//v15+qknid7BwzC3ZcwtN3P33XcxbvxdFBQUEh0dTXR0NIGBgYBjIJuHHnyAQw89hISEBI499hjefutNcvPymP/zPx3xDz98n6uvutJ5/4cffuT222/jlFNOJiEhgWHDhnHjDdfz7abzG7dn7Li7SE1N4bNPP+bkk0+ib9++pKUN5vbbb+Ptt97c7fe9I3S4pohIt2TBPyIagOaqMtwPRhLZfT5YGBcZA8DUyhLalDORbarfxt5du93OzbeM4fHHHuWnuT+QnZPDQw9N5JOP5zjnaWxs5MqrruapJ5/g++++ITMzk8mTp/DmG6/vdC2vvPoavWN68/zzU7HZbHzw4Wy++fZbQvfA9R3HjhvPnXfczsSHHyI2NpbKyiqWLl3Kjz/OBeCFF/5DUt++zJo5g6amJmbMnMW3332327VcccXl+Pn58cbrr7lMf+65qTw3dRo2m420tDRGjryQ0NBQSkpK+PnnX3j6mWdpbW11zp+clERkZKTz/oMPPsQ999zFE1Mm06tXFCUlJbw3YybTNg0Ksz3Lly/njDPP4vbbb+OZp58mMjKC0tJSFi9ewsSJj+zWe95Rlj5xCT16jR0cHMy6jHQGDkrb5gdvbwsKCnIepyxiJsrm3rO7A6+EJg4EoLZgnSnOy1s/d9YeW7ZyaQx/i8U5uuEx69O3ezjbtOi9s2dr7BYjKhpJuewaCfHxjBs3lqlTp1FYVGR0Od2e1WrtdNRI6R629ZnY0b6NDtc0SNhWx2CLmIWyKWakXIoZKZdiRl5eXkaXICZgaCfv1lvH8PVXX7IuI52VK5bx1ptv0K+f60mYfn5+TJk8iVWrVpK5bi2vv/bqDg+lambaMIhZKZtiRsqlmJFyKWakTp6AwZ28o486inemT2f4iHO46OJL8Pbx5v1ZMwkICHDO88gjEznttFO58cabOP+CkcTExvDmG69tY6ndg1270cWklE0xI+VSzEi5FFMyweH7YjxDB1659LLLXe7feec4Vv29goMOOog//viDkJAQLr5oNGNuvY1ff/0NgHFjx/PLL/M59NBDWLp0mRFld4m1GZ1fy0XESMqmmJFyKWakXIoZNW9xIXDZd5nqnLzNw7pWV1cDcNBBB+Lr68uCBQud82RlZ1NYWMhhhx1mRIldZvCgQUaXIOKRsilmpFyKGSmXYkb+W1xjT/ZdprmEgsVi4dFHJ/Lnn3+SsemXsd7RvWlpaaG2ttZl3rKycnpHR3tcjq+vr/P6IOAY+cqMduRiiiJGUDbFjJRLMSPlUkzJwwW4Zd9jmk7elCmTGTxoEOeed/5uLee2W8cwfvw4t+mDBg2isbGRjIwMkpOT8fPzo7GxkeLiYvr37w9ASclGLBYrvXv3BiAzM5PEhAT8AwJobm6moKCAAQMGAFBWWkqHzUZsbCwA2dnZxMbGEhQURGtrC7m5eQza9AtfeXk5ra2txMXFAZCbm0tAgD9paWm0tbWSlZVNWppjiOrKykqamhqJj08AID8vj4jISEJDQ7F1dJCxbh1paYMBC9XVVdTV1ZO46WKs69evJyw0lLDwcOw2G2szMhg0cCBWLy9qa2uoqqomKSkJgMLCQoKCAomIcFwTJD09nQEDBuDt7U1dXS3l5RWkpKQAUFxcjJ+fH7169QJg7dq1pKam4OvrR0NDAyUlG0lN7QfAxo0b8PLyJnpTJ3zdunUkJfXFz8+fpqZGioq2bO8SAGJiHNdgysrKIj4+joCAQFpamsnPX8/AgY4h4svKyujoaCc2tg8AOTnZxMT80945ObkMHjwYgIqKClpaWlzaOyqqFyEhobS3t5OZmels76qqShoaGklI2NTe+flERIQTGhrmbO/BgwZhsVqpqa6mpraWvn37AlBQUEBISDDh4RGAnfT0tVu0dy1VlZUkJScDUFRUSEBAoPMaLOnp6fTv3w8fH1/q6+soKyt3aW9fX1/nAEMZGRmkpCQ723vjxo3067e5vTfiZbUSvWVmExPx9/enuamJgsJCZ2ZLS0ux223ExMQ62zsuLo7AwEBaWlrIy3NktlevSKKjo2hra6dPn83tnUPv3tEEB4fQ1tpKdk6Os70rKypoam4mPj4egLy8PHr1ityqvR2ZraqqoqG+3nkB4fXr1xMWFkZYWJgzs872rqmhpqbG2d6FBQUEBQcTEfFPe2+Z2YqKSpKd7V1EgL8/kVtktl9qKj6+jvYuLS0jNdUxyNOGDRvw8fEmKira2d57ax2REhMOQHVDM23tNqLDHBdvLaqoIyLYn0A/H9o7bBSU1zrnrWlsoaW1nejwIGwtZZTWNBAdGkCwvy82m438slpSeoeBxUJdYwsNLW3ERgQ78lJVT5C/LyEBvtjtdvJKa0iKDsVqtVLf1EpdUyt9Ih3zllQ3EODrTWig4xfh3JJq+kaF4uVlpaG5lZqGFuJ6hTjea00Dvt5eBG36XO2JdUSvXpH4+PhoHbGX1xGxcXHc6W+ntbWVNouFtE1tWF5e5nEdYY1Pwd7Whr2gEGuqo157dQ32lhasMY4abEXFWMLDsAQFQUc7trwCrP2SAQv22lrsjU1YYx3bBVvxBiwhwVhCQsBuw5aTjzU1ibSoYFOsI4KCAvHx8THke0R0dJRjndwDvkd4eVnx8fHB188PLy8vLBYL3t6Or6jNzc34+vo6LwvQ1taG36Y9VW1tbQD4+PgA0NLSgo+Pj3Pe1tZW/P39AWhvb8dut3uc12630dKy7Xm9vb3x8vLCbrfT0tLiMq/NZnPuYGhtbcXLy8t1Xj8/sFjo6Oigo6Njq3mteHn98179/PywbJ63vR3fTe+1tbUVq9Xq0i5bztve3u5sF7vdjre391bz+mKxeG5DC+C96b1u3d5btuHutveOtuGutjd2O83bae8dbcOdmbetrW2XM9tZezs+C1ZCw0JJ23Sk4+Z1RHQnO7q2Zorr5E2e9DhDh57OeedfSEHBP9e+OeaYIcyZ/SGD0/Z32Zv35x+/8/obb/L662+4LcvTnrxlSxfrOnkiO0jZ3Ht26zp5JqTr5Imukye7QtfJ61q6Tl731yOukzd50uMMGzaMkaNGu3TwAFau/JvW1laOPfYY57R+/VJJSEhgyZIlHpfX2tpKfX2988+sK9/NvzyKmI2yKWakXIoZKZdiRlvu7JB9l6GdvClTJnP++ecx5tbbqK9vIDo6mujoaOcu2bq6Ot7/4EMemfgwQ4YczYEHHsi0qc+xePHibj2ypohIV/ALj8YvfMcO2xDZFd7AHeG9uSO8t3nO7xARiosKGDZ0qNFliIkZ2sm76sorCAsL45OP57Bi+VLn39lnj3DO88gjj/Ljj3N5/bXX+PSTjygtLePa624wsOquUVhgjkNNRLambHYTFgt+oZH4hUbuEyfZK5fG8LZYuCIsiivCovDeB3K2s5RLmTZtKm+96X76kJFaW1uNLsEQRx55JNPfeYulSxZvsxPcv39/3nn7LdamryYrM4Ovv/qS+E3nu3oyatRIiosKXP5ysjNd5omOjmbGe++ydMliJk96HMtW68vk5ORNO6r+JDcni0W//8rLL73IQQcdtPtvvBOG/jAXF7/9Y/dbWlq4/4EHuf+BB/dCRXtPUHAwdSY6R1BkM2VTzEi5FDNSLsWM9tVz8gIDA1i9Jp33P5jNW2++7nGepKQkPvvsEz54/wOeffY56urrGTRw4HavLVhbW8txx5/ovG/f6oLz99x9FytWrmTKE09w34QJnHvOOXz62WcAHHTQQcz+8H0yMjK4994JZGVlExwcxNDTT2fiww9xwYUjd+t9d8bwc/L2VY7Rv0TMR9kUM1IuxYyUS9meo446iq++/ILcnCyWLV3M/fdNcIwEuclHc2bz+GOP8uAD97N61d8sX7aE8ePGuiwjJSWZTz7+iJzsTObPm8vxxx3n9jqDBw9m9uwPyM7KZMXypTz91JMEBgY6H9+8x/GmG29k2dLFrFq1kimTJzlHhPRk/Lix/PD9t1w0ejR//bmIzHVrmTJlMlarlVtuvonly5awcsUybr/9NpfnhYaG8uwzT/P3yuVkrF3D7NkfsN9+ac7Hk5KSePutN1mxfCmZ69by9Vdfctxxx7os449Fv3Hbbbcy9blnWZeRzl9/LuLSS7c9WNm8efN5+uln+PbbbzudZ8K99/DTTz8xafIUVq1eTX5+Pt//8AMVFRXbXLbdbqesrMz5V15e7vJ4WHgYa9euJT19LevXryc0LNT52PPTppKbm8e5513A3Lk/kZ+fz+rVa5g67Xmuvubabb7u7lAnzzCGD2oq0gllU8xIuRQzUi73NIu3T+d/Xt5dPm9Xio2NZcZ701mxYgWnnTaU++57gIsvvog777jdZb6RIy+ksbGR4SNGMGnyFMaOvdPZkbNYLLzx+uu0tbUyfMTZ3Dvhfh544D6X5wcEBDBr5gxqqms486zh3Hrb7Rx33LFMnjzJZb4hQ44mKTmJkSNHc+edYxk1aiSjRm17L1JSUhInnXwil1x6ObeMuZWLLxrNe+9Op0+fPlxw4UgmT36CCffewyGHHOx8zmuv/peoqCguvewKhp1xJqv+XsXsDz8gPDwcgKCgQOb+9BOjRl/M6UOHMW/+fN55+223QyZvvPEGVqxcyelDz2D69Hd58okp9OuXujP/BS4sFgunnHIyOTm5zJo5g5UrlvHlF5/v0LmNQUFB/PnH7yz+6w/efutN5+V7NnvxxZeZ9Phj5OVmc+CBBzBnzkcAHLD//gwePIhXX33Nbe8f4HYt8K6k86gNkp6+1ugSRDxSNsWMlEsxI+Vyz9v/8s5P16krWEf+jzOd99Muugerj+eRJRs25JL77TvO+4NGjsXbP8htvlVvT9z1Yrdy5ZVXUFxc7DzlKCs7m5jYGB64/z6mTnve+aU/PX0tU6c9D0Bubh5XX3UVxx57DL8sWMDxxx1H//79uOTSy5zXDn3iyaeZNfM95+ucd965+Pn5cfsdd9LU1ERGRgYPPPgQ0995m8mTpzj3OtXU1PDAAw9is9nIys7mx7lzOe7YY5k16/1O34PVamXcuLtoaGggMzOT3377nX79Urns8iuw2+1kZ+cwZszNHDNkCMuWLeffRxzBwQcfzEH/OsR5buBjj09i6NChnHXWmcycOYs1a9JZsybd+RrPPPMsZwwbxumnn8bb70x3Tv/pp5+YPv1dAF586WWuv/46hgwZQnZ2zi79f0RFRREcHMytY27hqaefYfKUKZx04om88cZrXDhyNIsWLfL4vOzsbMaNv4v09HRCQkK5+aYb+Px/n3LSyaewYcNGAFauXMmhhx1BZGQkZWVlzuembLpeaFZW1i7VvDvUyTPIgAEDyMzM3P6MInuZsilmpFyKGSmXsi0D+vdnyZKlLtP++usvgoODievTh6LiYsBxIfktlZaWEhUVBUD/Af0pLi52dvAAt8uIDRgwgDXpa2hqagLAz8+Pv/5ajJeXF/369XN28jLWrXM5V6+0pJTBaYO3+R4KCgpcLkdWVl5Gh63DZa9UWVk5vTbVu99++xEUFMTqVStdluPv709yUhIAgYGB3DV+HKeccjK9e/fG29sbf39/4uPjXZ6TvmardikrI6pXr23Wuy1Wq+MAxu+++955re3Vq9dw+OGHc8Xll3XayVuyZKnL/+PixYv5ef48LrvsMp555lnn9I6ODpcOHuA2AMvepE6eQbZ1DLSIkZRNMSPlUsxIudzzVr83qfMHtzr8Lf2Dp3d43ow503anrC7V1t7mct9ut2Ox7voZVZ11LNrb2l1fBztWy7Zfp719q+fYPSzHbsdqdbxmUFAgJaWlXHjhKLdl1dbUAPDwww9y/HHH89jjk8jLy6O5uZnXX3sFH1/Xw2Xbtnpt7HZnR21XVFZW0tbWxrqtfpjJzMzk3/8+YoeX097ezqrVq0hJTt7uvDmb9jr279+fVatX71S9u0trJ4PU1e25Y3BFdoey2U3Y7dRvyHXe7umUS2O02O2MLM523hZXyuWeZ9+qA2TEvLsqMyuLs848w2XaEUccQV1dHcUbNuzQMrIys4iLi6N3796UlpYCcOihh7q+TmYmo0aOJCAggKamJjo6Ohgy5Gg6OjrIzs7umjezg/7+exW9o6Npb2+nsLDQ4zxHHH4Es+fMcQ6QEhgYSEJCwh6vra2tjRUrVrid15eamkphYdEOL8dqtZI2eDBzf5q33XlXrV5NRkYGN954A//7/HO38/JCQ0P32Hl5GnjFIBUVlUaXIOKRstl92NpasbXtG9dDUi6NYQdy2lrIaWvRECMeKJcCEBIawv777+fyFxfXh+nT3yUuLo7Jkx6nf79+DD39dO4aP47XXnvd4yAcnvyyYAE5OTm88Pw09tsvjX//+99MuPcel3k+/eRTWlpaeOGFaQwaNIh///sIJj3+OB99/InbKJB72i8LFrBkyVLefusNTjj+eBISEjj88MO49957nNeEy83N5cwzhrH//vux335pvPzSi7u1h26zwMBAZ/sDJPZNZP/993MZ0OXl/77K2SNGcMklF5OcnMzVV13Jaaed6jz3D+CFF6Zx34R7nffH3nkHJxx/PH379uXAAw7gxf/7D/HxCds8l3FLY8fdRWpqCp99+jEnn3wSffv2JS1tMLfffhtvv/Xmbr/vzmhPnkGSk5PdjsEWMQNlU8xIuRQzUi4F4JghQ/jh++9cps2a9T533X0Pl11+JQ89+AA//PAd1dXVvP/+Bzz/wn92eNl2u51rr7ue5559lq++/ILCwkIefGgi78+a4ZynqbmZSy69jMcee4Svv/qS5uYmvvrqax559LEueoc757LLr2DCvfcwdepz9OrlGIhk0aI/KC93nK/2yKOPMXXqs3z+v8+orKzkpZf+S3Bw8G6/7r/+dRAffzTHef/RRxyD6Hw4ew5jx44D4Ntvv2XChPu59bYxPP7YY+TkZHP99Tfy519/OZ8XHxePzfZPJzwsPJxnnnmK6OhoampqWPn335xzzrk7fD7u8uXLOePMs7j99tt45umniYyMoLS0lMWLlzBx4iO7/b47Y+kTl9Cjf5wLDg5mXUY6AwelUW+iC5ampaVpwyCmpGzuPX1P2fY1f7bHL8xxAnpLzbav77O3rJ87a48tW7k0hjdwTVg0AG/VlNG+7dmZFp24x2sCGFtWsFdeZ3uUy66REB/PuHFjmTp1GoVFO37YnHjm7+9Pc3Oz0WXIbtjWZ2JH+zbak2eQIq3ExKSUzW7CYsEvzDGaWUttZY8/L0+5NIa3xcKN4Y5O3ru15bT38JztLOVSzGjzpQtk36Zz8gwS4O9vdAkiHimbYkbKpZiRcilm1BXnt0n3pxQYJHI3rvMhsicpm2JGyqWYkXIpZqRLewiokyciIiIiItKjqJNnkLVr1xpdgohHyqaYkXIpZqRcdg3bpnM9vbQHqkto0JXub/NnwbYb50Grk2eQfqmp259JxADKppiRcilmpFx2jaqqKgBSU1MMrqRn8PPzM7oE2U2bPwtVVbt+LU79ZGIQH19fo0sQ8UjZFDNSLsWMlMuu0dTUxKJFixgx/CwAcnJy6Wjf3gU7pDO+fn60trQYXYbsAi9vb1JTUxgx/CwWLVpEU9Ou75VVJ88g9fV1Rpcg4pGy2U3Y7dRvzHfe7umUS2O02u1cviHHeVtcKZddZ85HHwMwYvhwgyvp/ry9vWhv7zC6DNkNixYtcn4mdpU6eQYpLS0zugQRj5RNh929UPneYGvdd867UC6NYQPW7EM521nKZdex2+3MnvMRX3z5JRERkVgtFqNL6rZ8fH1p07XyuiWb3U5VVeVu7cHbTJ08g6SmppKenm50GSJulE0xI+VSzEi57HpNTc00NRUbXUa3lpaWRm5urtFliMHUyRMR6aZ8QyIAaK2rMrgS6am8gYtDHdeCe7+2Ap0lJSLSPaiTZ5ANGzYYXYKIR8pmN2Gx4B/RG4DW+uoef16ecmkMb4uFOyNiAJhTV0l7D8/ZzlIuxYyUSwFdQsEwPj7qX4s5KZtiRsqlmJFyKWakXAqok2eYqKhoo0sQ8UjZFDNSLsWMlEsxI+VSQJ08ERERERGRHkWdPINkZGQYXYKIR8qmmJFyKWakXIoZKZcC6uQZJjk52egSRDxSNsWMlEsxI+VSzEi5FFAnzzB+fn5GlyDikbIpZqRcihkpl2JGyqWALqFgmMbGRqNLEPFI2ewm7HYaStY7b/d0yqUxWu12btiY57wtrpRLMSPlUkCdPMMUFxcbXYKIR8pm99HR0mR0CXuNcmkMG7CkRV8YO6NcihkplwI6XNMw/fv3N7oEEY+UTTEj5VLMSLkUM1IuBbQnT0Sk2/IJDgegrb7a0Dqk5/IGzguOAODT+irajS1HRER2kDp5Bikp2Wh0CSIeKZvdhMVCQGQMAG0NNT3+vDzl0hjeFgsTevUB4IuGatp7eM52lnIpZqRcCuhwTcNYLGp6MSdlU8xIuRQzUi7FjJRLAXXyDNO7d2+jSxDxSNkUM1IuxYyUSzEj5VJAnTwREREREZEeRZ08g2RmZhpdgohHyqaYkXIpZqRcihkplwLq5BkmMSHB6BJEPFI2xYyUSzEj5VLMSLkUUCfPMP4BAUaXIOKRsilmpFyKGSmXYkbKpYAuoWCY5uZmo0sQ8UjZ7CbsdhpLC523ezrl0hhtdjt3lK533hZXyqWYkXIpoE6eYQoKCowuQcQjZbP7aG9uMLqEvUa5NEYHsLCp3ugyTEu5FDNSLgV0uKZhBgwYYHQJIh4pm2JGyqWYkXIpZqRcCmhPnohIt+UTFApAW0OtwZVIT+UNnBEUBsA3DTW0G1uOiIjsIHXyDFJWWmp0CSIeKZvdhMVCQK8+ALQ11vX48/KUS2N4Wyw8EhUPwA+NtbT38JztLOVSzEi5FNDhmobpsNmMLkHEI2VTzEi5FDNSLsWMlEsBdfIMExsba3QJIh4pm2JGyqWYkXIpZqRcCqiTJyIiIiIi0qOok2eQ7Oxso0sQ8UjZFDNSLsWMlEsxI+VSwOBO3pFHHsn0d95i6ZLFFBcVMGzoUJfHp02bSnFRgcvfzBnvGVRt19KudDErZVPMSLkUM1IuxYyUSwGDR9cMDAxg9Zp03v9gNm+9+brHeX76aR5jx4133m9tbd1b5e1RQUFBRpcg4pGyKWakXIoZKZdiRsqlgMGdvHnz5jNv3vxtztPa2kpZWdneKWgvam1tMboEEY+UzW7CbqexrMh5u6dTLo3RZrdzb1mB87a4Ui7FjJRLgW5wnbyjjz6KlSuWUVNTw8Jff+Ppp5+mqqra6LJ2W25untEliHikbHYf7U31Rpew1yiXxugAfmysM7oM01IuxYyUSwGTD7wyf9587rhjLKNGX8zkyU9w9FFHMuO997BaOy/b19eX4OBg559Zd1kPGjTI6BJEPFI2xYyUSzEj5VLMSLkUMPmevP99/rnz9tq1a1mTns6i339lyJCjWbjwV4/Pue3WMYwfP85t+qBBg2hsbCQjI4Pk5GT8/PxobGykuLiY/v37A1BSshGLxUrv3r0ByMzMJDEhAf+AAJqbmykoKGDAgAEAlJWW0mGzOU9uzc7OJjY2lqCgIFpbW8jNzXN+yMrLy2ltbSUuLg6A3NxcoqOjgDTa2lrJysomLS0NgMrKSpqaGomPTwAgPy+PiMhIQkNDsXV0kLFuHWlpgwEL1dVV1NXVk5iYCMD69esJCw0lLDwcu83G2owMBg0ciNXLi9raGqqqqklKSgKgsLCQoKBAIiIiAUhPT2fAgAF4e3tTV1dLeXkFKSkpABQXF+Pn50evXr2c/xepqSn4+vrR0NBASclGUlP7AbBx4wa8vLyJjo4GYN26dSQl9cXPz5+mpkaKirZs7xIAYmJiAMjKyiI+Po6AgEBaWprJz1/PwIEDHe1dVkZHRzuxsX0AyMnJJibmn/bOycll8ODBAFRUVNDS0uLS3lFRvQgJCaW9vZ3MzExne1dVVdLQ0EhCwqb2zs8nIiKc0NAwZ3sPHjQIi9VKTXU1NbW19O3bF4CCggJCQoIJD48A7KSnr92ivWupqqwkKTkZgKKiQgICAomM/Ke9+/fvh4+PL/X1dZSVlbu0t6+vL1FRUQBkZGSQkpLsbO+NGzfSr9/m9t6Il9VK9JaZTUzE39+f5qYmCgoLnZktLS3FbrcRExPrbO+4uDgCAwNpaWkhL8+R2b59EykvL6OtrZ0+fTa3dw69e0cTHBxCW2sr2Tk5zvaurKigqbmZ+Ph4APLy8ujVK3Kr9nZktqqqiob6ehK2zGxYGGFhYc7MOtu7poaamhpnexcWFBAUHExExD/tvWVmKyoqSXa2dxEB/v5EbpHZfqmp+Pg62ru0tIzU1FQANmzYgI+PN1FR0c72Tk5OJjEmnObWdspqG0mMCnVkq64JCxAZEuCov6yGmPAg/Hy8aW1rZ2N1A32jwxzZqm/CZrPTKzTQUX95Lb1CAgjw86GtvYOiyjqSe4cDUN3QTFu7jegwx7xFFXVEBPsT6OdDe4eNgvJaUmIc89Y0ttDS2k50eBCtVj+q6psItrYT7O+LzWYjv6yWlN5hYLFQ19hCQ0sbsRHBjrxU1RPk70tIgC92u5280hqSokOxWq3UN7VS19RKn0jHvCXVDQT4ehMa6Of4HJVU0zcqFC8vKw3NrdQ0tBDXK8Tx+axpwNfbi6BNn6s9sY7o2zdR6wgD1hEJcXEc7xtIW3sbc/Jz6O/crnleR1jjU7C3tWEvKMSa6qjXXl2DvaUFa4yjBltRMZbwMCxBQdDRji2vAGu/ZMCCvbYWe2MT1ljHdsFWvAFLSDCWkBCw27Dl5GNNTSItKtjwdYSfnx8xMb3Jysoy7HtEcHCIvkeg7xFbryOCgoKIjo427HuEI7P6HrGn+hqbPxvbY+kTl2CKg+yLiwq45prr+Pa777Y5398rl/PU088wY8ZMj4/7+vri6+vrvB8UFMSypYsZOCiN+nrzHNoUHR3dI881lO5P2XToe8olRpewbRYLoYmOLy+1BetMcV7e+rmz9tiylUtj+Fss/NrX8WX2mPXpNG8nZ9OiE/dGWYzddJ6g0ZRLMSPlsmcLDg5mXUb6dvs2pt6Tt7U+fWKJiIigtKS003laW1u7xQic3aFG2Tcpm2JGyqWYkXIpZqRcChh8Tl5gYCD7778f+++/HwCJfRPZf//9iN+06/ehBx/g0EMPISEhgWOPPYa333qT3Lw85v/8s5Fld4nNhwCImI2yKWakXIoZKZdiRsqlgMF78v71r4P4+KM5zvuPPjIRgA9nz+G+++4nLS2NkSMvJDQ0lJKSEn7++ReefuZZ/UIhIiIiIiLSCUM7eb//voi4+M6P37/k0sv2YjV7V25urtEliHikbIoZKZdiRsqlmJFyKWDySyj0ZI7RNUXMR9kUM1IuxYyUSzEj5VKgCzt5oaGhXbWofUJwcIjRJYh4pGyKGSmXYkbKpZiRcimwi528MbfczNlnj3Def+WVl1m9aiVLFv/FfvuldVlxPVlbm84rFHNSNrsJu52mig00VWwwxeUT9jTl0hjtdjuPlBfxSHkR7ftAznaWcilmpFwK7GIn7/LLL6O4uBiA4487juOPO47LLruCefPm8dCDD3ZpgT1VVla20SWIeKRsdh9tDbW0NdQaXcZeoVwaox34oqGGLxpqaDe6GBNSLsWMlEuBXezkRUf3dnbyTj31FL748kt+/uUXXv7vf/nXvw7q0gJ7qrQ07fEUc1I2xYyUSzEj5VLMSLkU2MXRNWtqaoiLi6O4eAMnnXQiTz39DAAWiwUvL68uLE9ERDrj7R8EQHtzg8GViNlNi+58JOvtibQ6tuuVto6uKkdERPawXerkffPNN7z04v+Rm5tLREQEP/00D4AD9j+AvLy8rqyvx6qsrDS6BBGPlM1uwmIhsHcCALUF63r8eXnKpTGswAH+gQAsbKzDZmw5pqNcihkplwK72Mmb+MijFBQUEhfXh0mTptDY2AhA75jeTJ/+bpcW2FM1NTUaXYKIR8qmmJFyKWakXIoZKZcCu9jJ8/Hx4ZVXX3Wb/vrrb+x2QfuK+PgEamvTjS5DxI2yKWakXIoZKZdiRsqlwC4OvLJyxTKmPvcs/z7iiK6uR0RERERERHbDLnXybrvtDsLDw5k9+wMWLPiZW8fcQkxMTFfX1qPl69xFMSllU8xIuRQzUi7FjJRLgV3s5H373Xdcc+11HHrYEbz33gzOPfdc/vzjd6ZPf5szzhimETZ3QERkpNEliHikbIoZKZdiRsqlmJFyKbCLnbzNKisree211zn1tNN59NHHOO7YY3n9tVdZtnQxd981ngB//66qs8cJDQ01ugQRj5RNMSPlUsxIuRQzUi4FdnHglc2ioqIYNfJCRo0aSUJCAl999TXvf/ABffr0Ycwtt3DooYdy8SWXdlWtPYqtQ9cbEnNSNrsJu52myhLn7Z5OuTSGHchsbXbeFlfKpZiRcimwi528M84YxkWjR3HCCSeQmZnJ9Onv8vEnn1JbW+ucZ/HiJfw8/6cuK7SnyVi3zugSRDxSNruPtvpqo0vYa5RLY9iBDe1tRpdhWsqlmJFyKbCLh2tOm/ocG0tKOOfc8znt9GG8/c50lw4eQElJCf/5z/91SZE9UVraYKNLEPFI2RQzUi7FjJRLMSPlUmAX9+QdcshhNDU3b3Oe5uZmpk57flcWv4+wGF2ASCeUze7Cyy8AgI6WJoMr2RuUS6OEWR2DqdXYdAiYO+VSzEi5lF3s5G3ZwfPz88PHx8fl8fr6+t2rah9QXV1ldAkiHimb3YTFQlBMXwBqC9b1+PPylEtjWIF/+QcCsLCxDpux5ThNi07cK68ztqxgm48rl2JGyqXALnbyAgICePCB+xkxYjgRERFujyf2Td7dunq8ujp1hMWclE0xI+VSzEi5FDNSLgV28Zy8hx58gGOOGcKE++6ntbWVu+66h2efm0pJSQm333FnF5fYMyUm7p1fIUV2lrIpZqRcihkpl2JGyqXALu7JO+20U7n9jjv5/fdFTJv6HH/8+Sd5eXkUFhZy/nnn8emnn3VxmSIiIiIiIrIjdmlPXnh4OOvz1wNQV19PeHg4AH/++RdHHXVklxXXk61fv97oEkQ8UjbFjJRLMSPlUsxIuRTYxU5efv56Evs6TvjPzsri7BHDATj9tFOp2epSCuJZWGio0SWIeKRsihkpl2JGyqWYkXIpsIudvA9nz2b//dIAePGll7nyyivJyc7kkUcm8t//vtKlBfZUYZv2foqYjbIpZqRcihkpl2JGyqXALp6T9/rrbzhvL1iwkONPOJGDDjqQvLw80tPXdllxPZndZpaBqEVcKZvdhN1Oc1Wp83ZPp1waww7ktDY7b4sr5VLMSLkU2IVOnsViYfSoUZxx5jASExKx2+0UFBTw5VdfqYO3E9ZmZBhdgohHymb30Vq371wLSbk0hh0obG8zugzTUi7FjJRLgV04XPOdd97i2Wefpk9sLGvXrmXdunXEJ8Tz/LSpvPXmG9tfgAAwaOBAo0sQ8UjZFDNSLsWMlEsxI+VSYCf35I0eNYqjjjySUaMv4rfffnd57JhjhvDWm29w4YUX8NFHH3dpkT2R1cvL6BJEPFI2uw+rrz8Atk2H0/VkyqVxgq2O34PrdQiYG+VSzEi5FNjJPXnnnnsO//d/L7p18AB+/fU3XnzpZc4/77wuK64nq62tMboEEY+UzW7CYiE4Nong2CSwWIyuZo9TLo1hBQ71D+JQ/6BdG6mth1MuxYyUS4Gd7OSlpQ1m3vz5nT4+76d57Ldp1E3ZtqqqaqNLEPFI2RQzUi7FjJRLMSPlUmAnO3nh4eGUlZV3+nhZeTlhYWG7XdS+ICkpyegSRDxSNsWMlEsxI+VSzEi5FNjJTp6Xlxft7e2dPt7R0YG39y5dlUFERERERES6wE71yCwWC88/P5XWllaPj/v6+XZJUfuCwsJCo0sQ8UjZFDNSLsWMlEsxI+VSYCc7eXPmfLTtGerQyJo7KCgokLq6OqPLEHGjbIoZKZdiRsqlmJFyKbCTnbyx48bvqTr2ORERkWzcWGJ0GSJulE0xI+VSzEi5FDNSLgV2spMnIiImYbfTUlPuvC2yJ9iB/LYW520REeke1MkzSHp6utEliHikbHYfLTUVRpew1yiXxnB08jyfhy/KpZiTcimwk6NrStcZMGCA0SWIeKRsihkpl2JGyqWYkXIpoE6eYXSpCTErZbP7sPr4YvXZN0Y1Vi6NE2ixEmjR1wVPlEsxI+VSQJ08w9TV1RpdgohHymY3YbEQ3CeF4D4pYLEYXc0ep1wawwocHhDE4QFB+sLggXIpZqRcCuicPMOUl+8759JI96Jsihltmctp0Yl75TXHlhXs8dfYW+9F9gytL8WMlEsB7ckzTEpKitEliHikbIoZKZdiRsqlmJFyKaBOnoiIiIiISI+iTp5BiouLjS5BxCNlU8xIuRQzUi7FjJRLAXXyDOPn52d0CSIeKZtiRsqlmJFyKWakXAqok2eYXr16GV2CiEfKppiRcilmpFyKGSmXAhpdU0Ske7LbaamtdN4W2RPsQEFbq/O2iIh0D4buyTvyyCOZ/s5bLF2ymOKiAoYNHeo2z913jWfZ0sVkZ2Xy4QezSElJ3vuF7gFr1641ugQRj5TN7qOluoyW6jKjy9grlEtj2IHcthZy21rUyfNAuRQzUi4FDO7kBQYGsHpNOvc/8KDHx8fccjPXXHM1Eybcz/ARI2hsbGLWzBk94ljj1FQNbyvmpGyKGSmXYkbKpZiRcilg8OGa8+bNZ968+Z0+ft111/LCC//Hd99/D8Dtd9zJiuVLGTZ0KP/7/PO9VOWe4evb/Tuq0jMpm92HxcuxCrd3tBtcyZ6nXBrHz2IBoEWHBbtRLsWMlEsBEw+80rdvX2JiYliwcIFzWl1dHcuWLeewww41sLKu0dDQYHQJIh4pm92ExUJIfD9C4vvBpi/hPZlyaQwrcGRAMEcGBJv3C4OBlEsxI+VSwMQDr/TuHQ1AWVm5y/Sy8jJ69+7d6fN8fX3x9fV13g8KCtozBe6mkpKNRpcg4pGyKWakXIoZKZdiRsqlgIk7ebvqtlvHMH78OLfpgwYNorGxkYyMDJKTk/Hz86OxsZHi4mL69+8POD4UFovV2YnMzMwkMSEB/4AAmpubKSgoYMCAAQCUlZbSYbMRGxsLQHZ2NrGxsQQFBdHa2kJubh6DBg0CoLy8nNbWVuLi4gDIzc3l3/8+grKyctraWsnKyiYtLQ2AyspKmpoaiY9PACA/L4+IyEhCQ0OxdXSQsW4daWmDAQvV1VXU1dWTmJgIwPr16wkLDSUsPBy7zcbajAwGDRyI1cuL2toaqqqqSUpKAqCwsJCgoEAiIiIBSE9PZ8CAAXh7e1NXV0t5eQUpKY5juouLi/Hz83MOybt27VpSU1Pw9fWjoaGBkpKNpKb2A2Djxg14eXkTHe3opK9bt46kpL74+fnT1NRIUdGW7V0CQExMDABZWVnEx8cREBBIS0sz+fnrGThwoKO9y8ro6GgnNrYPADk52cTE/NPeOTm5DB48GICKigpaWlpc2jsqqhchIaG0t7eTmZnpbO+qqkoaGhpJSNjU3vn5RESEExoa5mzvwYMGYbFaqamupqa2lr59+wJQUFBASEgw4eERgJ309LVbtHctVZWVJCUnA1BUVEhAQCCRkf+0d//+/fDx8aW+vo6ysnKX9vb19SUqKgqAjIwMUlKSne29ceNG+vXb3N4b8bJaid4ys4mJ+Pv709zUREFhoTOzpaWl2O02YmJine0dFxdHYGAgLS0t5OU5Mtu3byJLly6lra2dPn02t3cOvXtHExwcQltrK9k5Oc72rqyooKm5mfj4eADy8vLo1Styq/Z2ZLaqqoqG+noStsxsWBhhYWHOzDrbu6aGmpoaZ3sXFhQQFBxMRMQ/7b1lZisqKkl2tncRAf7+RG6R2X6pqfj4Otq7tLSM1NRUADZs2ICPjzdRUdHO9k5OTiYxJpzm1nbKahtJjAp1ZKuuCQsQGRLgqL+shpjwIPx8vGlta2djdQN9o8Mc2apvwmaz0ys00FF/eS29QgII8POhrb2Doso6knuHA1Dd0Exbu43oMMe8RRV1RAT7E+jnQ3uHjYLyWlJiHPPWNLbQ0tpOdHgQm8bWJDo0kGB/H2w2G/lltaT0DgOLhbrGFhpa2oiNCHbkpaqeIH9fQgJ8sdvt5JXWkBQditVqpb6plbqmVvpEOuYtqW4gwNeb0EDHYT+5JdX0jQrFy8tKQ3MrNQ0txPUKcXw+axrw9fYiaNPnak+sI/r2TeSnn+YRHx+HNTYJe2sr9uINWJMd6zR7ZRX2jg6s0Y7Pja2gEEtULywBAdjbWrEXFGNNdeTDXl2NvbUN66YfE22FRVgiwrEEBUFHO7a8Aqz9UkiLCt7j6whrP8fn3lZSisXfD0uYIz+27FwsSQlYvH2wNzZir6zCmuD4jNlKy7D4+GCJcGTClpuHJSEOi48v9qYm7GUVWPs66rWVl2OxWrFsWvfY8tZj6RODxc8Pe0sL9o0lWJMc9dorKrHb7VijHJ8b2/oCLNFRUL1pr4DVijVlU3tXVWFv37K9i7D0isASGIi9rQ17QSHWTecF2atrsLe0YI1xrKdsRcVYwsO2au9kwIK9thZ7YxPWWMd2wVa8AUtIMJaQELDbsOXkY01NAosVe10d9rp6rHGO9ZRtYwmWwAAsoaGAHVt2HtbkRPDyxt7QgL26Bmt83D/t7eeHJXxTe+fkYklMwOKzqb0rqrAmxpMWFdzpOsLPz4+YmN78+utvhnyPiI6OcqyT9T1C3yO2+h4RFBREfn6+Yd8jHJkt2+e/R+ypvsbmz8b2WPrEJZjiIPviogKuueY6vv3uO8BxuOai33/ltNOHsnr1Gud8H380h9WrV/PwxEc8LsfTnrxlSxczcFAa9fX1e/Q97Iy0tDTS09ONLkPEjbLp0PeUS4wuYdssFkITHV9eagvWmeIyCuvnztpjy94yl9OiE/fY62xpbFnBHn+NvfVedpUVODbQ0Zlf2FiHzdhy9rrtZUDrSzEj5bJnCw4OZl1G+nb7NqY9xH79+vWUlJRw7LHHOqcFBwdzyCEHs2TJ0k6f19raSn19vfPPrMclb9y4wegSRDxSNsWMlEsxI+VSzEi5FDD4cM3AwECX694l9k1k//33o7qqmqLiYt54403uuP02cnNyWV9QwD1330VJSYlzb1935uXV446UlR5C2RQzUi7FjJRLMSPlUsDgTt6//nUQH380x3n/0UcmAvDh7DmMHTuOl17+L4GBgTz99JOEhoby119/celll9PS0mJUyV0mOjqa8vLy7c8ospcpm2JGyqWYkXIpZqRcChjcyfv990XExW/7fIRnnn2OZ559bi9VJCLSTdihta7KeVtkT7ADxW2tztsiItI9aH+uQdatW2d0CSIeKZvdhZ3mqlKji9hrlEtj2IGstu5/9MyeolyKGSmXAiYeeKWnS9o0ZLWI2SibYkbKpZiRcilmpFwKqJNnGD8/f6NLEPFI2ew+LFYvLFYvo8vYK5RL4/hgwQeL0WWYknIpZqRcCqiTZ5impkajSxDxSNnsJiwWQhL6E5LQHyw9/wu4cmkMK3B0YDBHBwbrC4MHyqWYkXIpoE6eYYqKio0uQcQjZVPMSLkUM1IuxYyUSwENvGKY/v37k56ebnQZIm7Mns2+p1xidAnSiT35f5MSE05uSbXjzsoFe+x1RHaG2deXsm9SLgW0J09ERERERKRHUSfPICUlJUaXIOKRsilmVFnXZHQJIm60vhQzUi4F1MkTERERERHpUdTJM0hMTIzRJYh4pGyKGUWGBBhdgogbrS/FjJRLAQ28IiLSPdmhtb7GeVtkT7ADG9vbnLdFRKR7UCfPIFlZWUaXIOKRstld2Gmu3Gh0EXtNQVmt0SXsk+zAutZmo8swLa0vxYyUSwEdrmmY+Pg4o0sQ8UjZFDPqHRZodAkibrS+FDNSLgXUyTNMQIC+sIg5KZvdiMXi+NsH+PnqwBOjWNGXhc5ofSlmpFwKaL1tmJYWHf4i5qRsdhMWC6GJAwlNHLhPdPRa2zqMLmGfZAWODQzh2MAQfWHwQOtLMSPlUkCdPMPk5683ugQRj5RNMaMNVfVGlyDiRutLMSPlUkCdPMMMHDjQ6BJEPFI2xYySeocZXYKIG60vxYyUSwGNrikiIiLi0bToxG0+bg2Lxha9e3uZx5YV7NbzRUQ80Z48g5SVlRldgohHyqaYUVV9k9EliLixV1YZXYKIG23HBdTJM0xHR7vRJYh4pGyKGXXYdCluMR97hwYEEvPRdlxAnTzDxMb2MboEEY+UTTGjqFANCS7mY42OMroEETfajgvonDwRke7JDm2Ndc7bInuCHShrb3PeFhGR7kGdPIPk5GQbXYKIR8pmd2GnqbzY6CL2msLyWqNL2CfZgfRWXXOrM7aCQqNLEHGj7biADtc0TExMrNEliHikbIoZ9QoJMLoEETeWqF5GlyDiRttxAXXyDBMUFGR0CSIeKZtiRgF+PkaXIOLGEqAfH8R8tB0X0OGahmltbTG6BBGPlM1uwmIhNNFxwdvagnVg79lnTLW1axRDI1iBYwNDAFjYWIfN2HJMx97WanQJIm60HRfQnjzD5OTkGl2CiEfKpphRUUWd0SWIuLEX7DvnxUr3oe24gDp5hhk8eLDRJYh4pGyKGSXHhBtdgogba2qy0SWIuNF2XECdPBERERERkR5F5+QZpKKiwugSRDxSNsWMahr2/jD+06IT9/prSvdir642ugQRN9qOC2hPnmFaWnRSrJiTsilm1KqBV8SE7K1tRpcg4kbbcQF18gwTFxdndAkiHimbYkbRYRoSXMzH2jva6BJE3Gg7LqDDNUVEuic7tDXVO2+L7Al2oKKj3XlbRES6B3XyDJKbq+FtxZyUze7CTlNZkdFF7DXFuoSCIezA6pYmo8swLVvhvvMZlO5D23EBHa5pmKioXkaXIOKRsilmFBbkZ3QJIm4sEeFGlyDiRttxAXXyDBMSEmp0CSIeKZtiRkH+vkaXIOLGEqRzRcV8tB0X0OGahmlvbze6BBGPlM1uwmIhJL4/AHVFWWDv2WdMdXTYjC5hn2QFjg4IBuD3pnr0v7CVDq0vxXy0HRdQJ88wmZmZRpcg4pGy2X1YrPvOwRjry2uNLmGf5WWxGF2CadnyCowuQcSNtuMCOlzTMGlpaUaXIOKRsilmlBITbnQJIm6s/VKMLkHEjbbjAurkiYiIiIiI9Cjq5BmkqqrS6BJEPFI2xYxqG1uMLkHEjb1GhxGL+Wg7LqBOnmEaGhqNLkHEI2VTzKipVQMJiPnYm3QNQTEfbccF1MkzTEJCgtEliHikbIoZxYRrqHoxH2tsjNEliLjRdlxAo2uKiHRb7c36tVb2vGpdJkBEpNtRJ88g+fn5Rpcg4pGy2U3Y7TSW7jvDt2+orDe6hH2SDVjZokMSO2Mr3mB0CSJutB0X0OGahomICDe6BBGPlE0xo5AAX6NLEHFjCQ0xugQRN9qOC6iTZ5jQ0DCjSxDxSNkUMwpWJ09MyBIcbHQJIm60HRcweSdv/LixFBcVuPz98vM8o8vqEraODqNLEPFI2ewmLBaC4/sRHN8PLBajq9njbDab0SXsk6zAUQFBHBUQZO4vDEaxaX0p5qPtuEA3OCdv7doMRl90sfN+R3vPOAE8Y906o0sQ8UjZ7D6sXqZfhXeZ/DJdj8wovhZ17zpjy11vdAkibrQdFzD5njyAjo52ysrKnH+VVVVGl9QlBg8aZHQJIh4pm2JGyb11+JGYjzU1yegSRNxoOy7QDTp5KSkpLF2ymN9/W8iL//cf4uPijC6pS1ispm962Ucpm2JGln3gkFTphrSXU0xI23EBkx+uuXTZMu4cO47s7Gx6945h/Lg7+fTTjznp5FNpaGjw+BxfX198ff85QT8oyJwX0K2prja6BBGPlE0xo7qmVqNLEHFjr6szugQRN9qOC5i8kzdv3nzn7fT0tSxbtow///ids0cM5/0PPvT4nNtuHcP48ePcpg8aNIjGxkYyMjJITk7Gz8+PxsZGiouL6d+/PwAlJRuxWKz07t0bgMzMTBITEvAPCKC5uZmCggIGDBgAQFlpKR02G7GxsQBkZ2cTGxtLUFAQra0t5ObmMWjT7vLy8nJaW1uJ27QXMjc3l+CQYNLC02hrayUrK5u0tDQAKisraWpqJD4+AYD8vDwiIiMJDQ3F1tFBxrp1pKUNBixUV1dRV1dPYmIiAOvXrycsNJSw8HDsNhtrMzIYNHAgVi8vamtrqKqqJinJcWhJYWEhQUGBREREbmrfdAYMGIC3tzd1dbWUl1eQkpICQHFxMX5+fvTq1QuAtWvXkpqagq+vHw0NDZSUbCQ1tR8AGzduwMvLm+joaADWrVtHUlJf/Pz8aWpqpKhoy/YuASAmJgaArKws4uPjCAgIpKWlmfz89QwcONDR3mVldHS0ExvbB4CcnGxiYv5p75ycXAYPHgxARUUFLS0tLu0dFdWLkJBQ2tvbyczMdLZ3VVUlDQ2NJCRsau/8fCIiwgkNDXO29+BBg7BYrdRUV1NTW0vfvn0BKCgoICQkmPDwCMBOevraLdq7lqrKSpKSkwEoKiokICCQyMh/2rt//374+PhSX19HWVm5S3v7+voSFRUFQEZGBikpyc723rhxI/36bW7vjXhZrURvmdnERPz9/WluaqKgsNCZ2dLSUux2GzExsc72jouLIzAwkJaWFvLyHJn19/enrb2NtrZ2+vTZ3N459O4dTXBwCG2trWTn5Djbu7KigqbmZuLj4wHIy8ujV6/Irdrbkdmqqioa6utJ2DKzYWGEhYU5M+ts75oaampqnO1dWFBAUHAwKTHhYLeTW1pD36hQvLysNDS3UtPYQlykYzjz0poG/Ly9CAvyd2SgpJqEXiH4eHvR2NJGVX0T8b1CHZ/P2ka8rVbCgx3z5pfW0CcyGF9vL5pb2ymrbSQxyjFvRV0TFiAyJMBRf1kNMeFB+Pl409rWzsbqBvpGOw4rrKpvwmaz0ys00FF/eS29QgII8POhrb2Doso6knuHA1Dd0Exbu43oMMe8RRV1RAT7E+jnQ3uHjYLyWsf7BmoaW2hpbSc6PIhKHKJDAwn298Fms5FfVktK7zCwWKhrbKGhpY3YCMcIgBur6gny9yUkwBe73U5eaQ1J0aFYrVbqm1qpa2qlT6Rj3pLqBgJ8vQkN9HO2oUt7N7QQ18vR3mU1Dfhu0d55JdXEb2rvppY2KuqaSIj6p729rBYiggP+ae+IYHx9vGhpbae0ppHEaMe8lXWO67NFhgTg5+1FdX0zvcMCWXbmBbS326iubiQqylFvQ0MLNpudkBBHDZWVDQQH++Hr601Hu43Kqgaiox31Nja20t7eQWioo4aqqgYCA/3w8/PGZrNTUVFPdHQIh2UsxV5Ti72pCWusYz1lK96AJTTEMaqirQNb7nrHIXsWK/a6Ouz1DVj7OD5jtg0bsQQFbRpm344tOw9rSl+wemGvr8deU4s13rGespWUYvH3wxLmyI8tOxdLUgIWbx/sjY3YK6uwJjg+Y7bSMiw+Plg2DZNuy83DkhCHxccXe1MT9rIKrH0d6zRbeTkWqxXLpnWPLW89lj4xWPz8sLe0YN9YgjXJ8RmzV1Rit9uxRjnW9bb1BViio6B604+qVivWFMc2xF5Vhb29A2u0Yz1lKyjC0isCS2Ag9rY27AWFWFMd6zR7dQ32lhasMY71lK2oGEt4GJagIOhox5ZXgLVfMmDBXluLvXGr9g4JxhISAnYbtpx81/auq8ca51hP2TaWYAkMwBIa+k97JyeClzf2hgbs1TWu7e3nhyV8U3vn5GJJTMDis6m9K6qwJm5q77JyLN5eWCIiNrV3Ppb4Plh8fbFjh6pqrH0TN7V3BRaLBUuvTe2dvx5L7BbtvaEEa/Km9q6sxG6zkRbl2B7t7PeI6OgoxzpZ3yP0PWKr7xHV1dVER0cb9j3Ckdky036PiIj4p723zGxFRSXJzvYuIsDfn8gtMtsvNRUfX0d7l5aWkZqaCsCGDRvw8fEmKira2d57sq+x+bOxPZY+cQn2HZrTJL7+6ksWLFjAE08+5fFxT3vyli1dzMBBadTXm+diumlpaaSnpxtdhogbs2ez7ymXGF2COVgshCY6vrzUFqwDe7dale+0lJhwckuqAdjfL2CvvObpf32/V17HzKzAsYGOzvHCxjo0xqkra78UbNm5u7WMsWUFXVSNiIPZt+Oye4KDg1mXkb7dvk23Omg3MDCQpKQkSktLO52ntbWV+vp6519nh3WKiHR3HS1NdLQ0GV2G9HB1HR3UaUh2EZFuxdSHaz780IN8/8OPFBYWEhsbw13jx2GzdfDpZ/8zurTdVlCgX+7EnJTNbsJup6Fk3xm+fWOVeY7E2JfYgGUtjUaXYVq2DRuNLkHEjbbjAibv5PXp04eXX3qRiIhwKior+evPvxg+4hwqKyu3/2STCwkJNtXhoyKbKZtiRkF+PjS19ozrpErPYQkKwt6oveliLtqOC5i8k3fzLWOMLmGPCQ+PYIN+ARQTUjbFjEIC/Siv05dpMRdLaAj2snKjyxBxoe24gMk7eT1bzx4kQbozZbNbsFgI7uMYSa1+Q26PH3ilx78/k7ICh/s7LkW0uLlBA6+4US7FjJRLUSfPMOnpa40uQcQjZbP7sHr7GF3CXpNbWmN0Cfssf11YuVO27DyjSxBxo+24QDcbXbMnGbTpui0iZqNsihklbbp2noiZWFP6Gl2CiBttxwXUyTOM1cvL6BJEPFI2xYys2pskZmTV+lLMR9txAXXyDFNbW2t0CSIeKZtiRg3NrUaXIOLGrhEMxYS0HRfQOXmGqeoBl4GQnmlXs9n3lEu6uBKRf9Q2thhdgogbe42+TIv56DumgPbkGSYpOdnoEkQ8UjbFjPpEhhhdgogba3yc0SWIuNF2XEB78kREuq2OVu3dkj2vwdZhdAkiIrKT1MkzSFFRodEliHikbHYTdjsNG/OMrmKvKa1uMLqEfZINWNLcaHQZpmUrKTW6BBE32o4L6HBNwwQEBBpdgohHyqaYkZ+vfpMU87H4+xldgogbbccF1MkzTGRkpNEliHikbIoZhQXqy7SYjyUszOgSRNxoOy6gwzVFRLoni4WgmCQAGkrywW43uCDpiazAIf6OvQLLmhuxGVuOiIjsIHXyDJKenm50CSIeKZvdh5fvvrN3K7ek2ugS9llBuuB3p2zZuUaXIOJG23EBHa5pmP79+xldgohHyqaYUWJUqNEliLixJCUYXYKIG23HBdTJM4yPj6/RJYh4pGyKGXl7aXMl5mPx9jG6BBE32o4LqJNnmPr6OqNLEPFI2RQzamxpM7oEETf2Rl1eQsxH23EBdfIMU1ZWbnQJIh4pm2JGVfXNRpcg4sZeWWV0CSJutB0X0MArhklJSdGJsWJKyqbsqv39AvbYsqOjQygr06/TYi7WhPjdHnxlWnRiF1WzbWPLCvbK64jxtB0XUCdPRKTbsrXrEEbZ85ptunCCiEh3o06eQYqLi40uQcQjZbObsNupL84xuoq9pq5Oh2sawQb82dxgdBmmZSstM7oEETfajgvonDzD+Ppq5CMxJ2VTzMhLo2uKCVl8NLqmmI+24wLq5BkmKirK6BJEPFI2xYwCA/WlRczHEhFudAkibrQdF9DhmiIi3ZPFQlBvx4ANDaUFYLcbXJD0RFbgX36BAKxoaURn54mIdA/q5BkkIyPD6BJEPFI2uw+vPTiapdmUl2tkTaOEeHkZXYJp2XLzjC5BxI224wI6XNMwKSnJRpcg4pGyKWYUER5kdAkibiwJcUaXIOJG23EBdfIM4+vrZ3QJIh4pm2JGXt7aXIn5WHx0rqiYj7bjAurkGaahQUNSizkpm2JGra3tRpcg4sbe1GR0CSJutB0XUCfPMBs3bjS6BBGPlE0xo/r6FqNLEHFjL6swugQRN9qOC6iTZ5h+/foZXYKIR8qmmFFkpM7JE/Ox9k0wugQRN9qOC2h0TRGRbsvWoUMY96Tvjzh9j7/G6X99v8dfY3e12nXhBBGR7kadPINoV7qYlbLZTdjt1BdlG13FXlNf12x0CfskG7CoSef3dMZWXm50CSJutB0X0OGahvGyqunFnJRNMSOL1WJ0CSJuLFpfiglpOy6gTp5honv3NroEEY+UTTGjoCANCS7mY4mMNLoEETfajgvocE0Rke7JYiEw2jHoQ2NZIdjtBhckPZEVOMAvAIBVLU3o7DwRke5BnTyDZGZmGl2CiEfKZvfh7R9odAl7TUVFvdEl7LPCvfRVoTO2vPVGlyDiRttxAR2uaZjExESjSxDxSNkUMwoLDTC6BBE3lj4xRpcg4kbbcQF18gzj7+9vdAkiHimbYkbePl5GlyDixuKnc0XFfLQdF1AnzzDNTU1GlyDikbIpZtTe1mF0CSJu7C0tRpcg4kbbcQF18gxTUFhodAkiHimbYkY1tfrSIuZj31hidAkibrQdF1AnzzADBgwwugQRj5RNMaNevYKNLkHEjTWpr9EliLjRdlxAo2uKiHRbdtuODWi/v58GLZFd16HLc4iIdDvq5BmktLTU6BJEPFI2uwm7nbrCfWeY7IZ6nftkBBvwa5MuX9EZe0Wl0SWIuNF2XECHaxrGbtclZcWclE0xIzvamyTmY9deTjEhbccF1MkzTExMrNEliHikbIoZBQdrSHAxH2tUL6NLEHGj7biADtcUEemmLARExwHQVFYM2tMle4AF2G/TOZ1rWpqUMhGRbkKdPINkZWUZXYKIR8pmN2EBnwDHiJNNFnp8H6+yQueFGcEC9PLydt7u4THbabb1BUaXIOJG23EBHa5pmLi4OKNLEPFI2RQzCgnVCKFiPpbe0UaXIOJG23GBbtLJu+rKK/lj0W/kZGfy5Refc/DBBxtd0m4LDAw0ugQRj5RNMSMfHy+jSxBxY/HXuaJiPtqOC3SDTt7ZZ49g4sSHmDr1eYYOO5M1a9Ywa+Z79OrVvU92bmnRcOBiTsqmmFFHu0aLE/Oxt7YaXYKIG23HBbpBJ++G669n1qz3+XD2bDIzM7l3wn00NTVz8UWjjS5tt+Tl5RldgohHyqaYUVV1g9EliLixF20wugQRN9qOC5i8k+fj48NBBx3IggULndPsdjsLFi7gsMMOM7Cy3Tdo0CCjSxDxSNkUM4qKCjG6BBE31pQko0sQcaPtuIDJR9eMjIzE29ubsvIyl+nlZeX079ff43N8fX3x9fV13g8KCnL51ywCAwMJDg42ugwRN7uazUB/vz1QjXTKYiHQzweAdn8/2MZFmf39uv//jZ+fL/49MGPeQeY+d8YKWAIcNXpbOtBBs66sAQHYTP5/uFlwk75z7Cv0HbNn29E+jak7ebvitlvHMH78OLfpy5YuNqAaERGRbbnP6AJ22HlGFyC75QKjCxCRLhUUFER9feeXFzJ1J6+yspL29naio1yHKI6KjqKsrMzjc/7vxZd49bXXXaZFRERQVVW1x+rcWUFBQSxbuphDDj2chgadZyLmoWyKGSmXYkbKpZiRcrlvCAoKoqSkZJvzmLqT19bWxsqVf3Psscfw7XffAWCxWDj22GN55+13PD6ntbWV1q1Gu9pWL9dIDQ0Npq1N9m3KppiRcilmpFyKGSmXPduO/N+aupMH8Nrrr/P8tKmsWLmSZcuWc/311xIYEMAHH842ujQRERERERHTMX0n7/PPv6BXZCR33zWe6OhoVq9ew6WXXU55ebnRpYmIiIiIiJiO6Tt5AG+/M52335ludBldprW1leeem+p2WKmI0ZRNMSPlUsxIuRQzUi5lM0ufuITOx90WERERERGRbsXUF0MXERERERGRnaNOnoiIiIiISA+iTp6IiIiIiEgPok6eAa668kr+WPQbOdmZfPnF5xx88MFGlyT7kCOPPJLp77zF0iWLKS4qYNjQoW7z3H3XeJYtXUx2ViYffjCLlJTkvV+o7FNuvXUMX3/1Jesy0lm5YhlvvfkG/fqluszj5+fHlMmTWLVqJZnr1vL6a68SFRVlUMWyL7jiisv58YfvyVi7hoy1a/j888846aQTnY8rk2IGt465heKiAh59dKJzmrIp6uTtZWefPYKJEx9i6tTnGTrsTNasWcOsme/Rq1cvo0uTfURgYACr16Rz/wMPenx8zC03c801VzNhwv0MHzGCxsYmZs2cgZ+f316uVPYlRx91FO9Mn87wEedw0cWX4O3jzfuzZhIQEOCc55FHJnLaaady4403cf4FI4mJjeHNN14zsGrp6TZs2MCUJ55g2BlncsaZZ/Hrr7/x9ltvMnDgQECZFOP961//4rLLLmX1mjUu05VN0eiae9mXX3zOihUreODBhwCwWCws/utP3n77bV586WWDq5N9TXFRAddccx3ffvedc9qypYt59dXXeeXVVwEICQlhxfKljB07nv99/rlRpco+JjIyklV/r+C88y/kjz/+ICQkhL9XLmfMrbfx1VdfA9C/Xz9++WU+w0eczdKlywyuWPYVq1f9zaRJk/jyq6+VSTFUYGAg3333Dfff/wB33H47q9esZuLER7W+FEB78vYqHx8fDjroQBYsWOicZrfbWbBwAYcddpiBlYk49O3bl5iYGBYsXOCcVldXx7JlyznssEMNrEz2NaGhoQBUV1cDcNBBB+Lr6+uy/szKzqawsFDrT9krrFYr55x9NoGBASxeslSZFMNNmTKJuXN/cskgaH0pDt3iYug9RWRkJN7e3pSVl7lMLy8rp3+//gZVJfKP3r2jASgrK3eZXlZeRu/evY0o6f/bu/eoKMs8DuBfLiMwIJoynMRhQVNywbRs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", - "text/plain": [ - "
" - ] - }, - "jetTransient": { - "display_id": null - }, - "metadata": {}, - "output_type": "display_data" - } - ], "source": [ "madrid_s = climate.where(\n", - " (climate[\"city\"] == \"Madrid\") & (climate[\"day\"] >= SUMMER_START) & (climate[\"day\"] <= SUMMER_END)\n", + " (climate.city == \"Madrid\") & (climate.day >= SUMMER_START) & (climate.day <= SUMMER_END)\n", ")[\"temperature\"][:]\n", "\n", "london_s = climate.where(\n", - " (climate[\"city\"] == \"London\") & (climate[\"day\"] >= SUMMER_START) & (climate[\"day\"] <= SUMMER_END)\n", + " (climate.city == \"London\") & (climate.day >= SUMMER_START) & (climate.day <= SUMMER_END)\n", ")[\"temperature\"][:]\n", "\n", "fig, ax = plt.subplots(figsize=(9, 4))\n", @@ -1373,7 +1393,23 @@ "ax.grid(True, linestyle=\"--\", alpha=0.4)\n", "plt.tight_layout()\n", "plt.show()" - ] + ], + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA3kAAAGGCAYAAADGq0gwAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQAAi6RJREFUeJzt3Qd4U1UbB/B/0r13Cx2Usod7D9wDVEBQAUUUB05AZagsBRRQmernAkRE2ago7sFQUFHZq1C6oAO6926T7zknJDZdFGx7T9L/73nyNOM29+TkzU3ee5aubWi4EURERERERGQX9FoXgIiIiIiIiJoOkzwiIiIiIiI7wiSPiIiIiIjIjjDJIyIiIiIisiNM8oiIiIiIiOwIkzwiIiIiIiI7wiSPiIiIiIjIjjDJIyIiIiIisiNM8oiIiIiIiOwIkzwiIrJpf23/AwsWzLfcvvLKK5CakiT/NrdxY8fIfVUnbs+c8SpawuDBg+T+wsPDW2R/9kzEkIilxhD1Lepd1H9TPm9rwbglan5M8ojsRLdu3bBo0Qf4+68/ER93FDt3/IPVq1bgkYcf0rpoyrnxxhvkj3OqbfjwBxv1w9UejR49Cn1694aKVC5bUxPJk7jMnTO7zsdffPEFyzb+fn4tXj5bJRLNZcuWal0MImohTPKI7MAll1yM77/7Bj169MCKlaswZcpLWLVqFQwGIx599FGti6ecm268EePGjdW6GEoa/uCDGDzItpO87dv/QlSHTvLvmXhm9Cj07nNmidSbb70t99Xc6ivbZ599LvefnJwMe1JSUorbb78NTk5OtR4bcGd/+biWRH2Lehf1T0SkIketC0BE/90zz4xGQUEBbr+9L/Lz860eCwgIaJVV7ObqipJSbX8IqsDV1RWlCtRDS5bDaDSirKysWffh5uaGkpISVFVVyYtWDAZDs79WLWzZsgW33noLbrzhBvz4009WJ7QiIyPxzbffou8dd7R4uRwcHKDX61FRUWGX9U5E9oMteUR2oH1kJI7ExNRK8ISsrKxGjSMR91fvwmgea9ShQxT+9/ZbOBx9EPv37cHzz4+Xj4eGtsXSj5bgyOFD2LN7J5544nGr5zOPi+rXry/GjnlOdh+NORItu5R6eXnB2dkZ06dPxb69u3E05jAWzJ8n76vprrsG4ofvv0Vc7FEcPLAf77/3rtx3dZ+tW4tNG3/Bueeeiy8+/wxxsTGYMPHFesfHPHyqC6u5y1f1MVU6nQ4jRjyKzZt+kd1e9+7ZhTfeeA0+Pj51dn0Sr/P770zl2/jLz5ZxYLfd1kfeFs8hyn9Oz561yiFed7t27bByxXLEHj2CXTt3YMxzz9Yq85mW6brrrpNlEtsOG3a/fGzI4MFYu3a1rO+E+Fhs2bwRDz74QK3/79atK6666kpLvYi6rR4PjRlb01A5vL295fu+45+/ZDl+37YVI59+Sr7Gxnj22WewY8ff8j1et24NunTpUmubusbkRUW1x+JFC2WsivKI5xCxJGJRENt7eHhgyKnXIy7mcX7m1965c2e8+87/cOjgfnz15RcN1oswcOAAbP1tiyUGLr/88kaN1ar5nA2Vrb6xTaLbrYgXUccirmbNnCHrvq7PjXhdoi5FnYrP6dNPPQmtnTx5Etv/+kvWYXV3DRyIQ4eiceTwkVr/c9lll2Hhwvfxz9/b5esWMTZt2lR5gqEm0fVVvHbx3oi/ffr0qbWN+Xj55BNPyM/fH79vQ2JCHLp06VzvsbQxz1sX8Xn5849tdT62YcOX8nNkdu011+DL9Z8j+tABeQwRMTZhQt3Hu7NJYp977ln5WkUdivgUz13z2Gz+jF926aX49puv5esV5b/nnrtrPaf4jIpjjzhGis+d+AzrdXX//LT1uCVSCVvyiOxAcnIKLr74InTt2hVHjtT+8fNffPD+ezh6NBazXnsdN910o0xCcnNz8cCw+7Ht9z8wc9ZruGvgAEx9+SXs2bMXf/1l3UVu9KiRsgXn3XffRfv27fHIIw+jsqJStkCIJGXe/AW46KILMWTIYBw/fhwL3nzLqoXyhefH4+uvv8HKVasR4O8v/18kcrf2vs0qqfXz88WK5Z/gq6824PMvvkBmRmadr2f58uVoExKC6667FqNGP1Pr8dlvvC5/uK1ZsxZLPlqKdhERMik8p+c5uHPAQFRWVlq2jWrfHu++8458TrHPJ598Ass+XooXJ0zExAkvYtmyT+R2o0aNxAcL38c111wnW5nM9HoHrFjxKXbt2oUZM2bhhhuuk0m0o6Mj5sydd1Zl6tixI95711SmFStXIi4uTt4vErqYmBj89NPPqKqsxC233ILXX5slf2x9vGyZ3Gbq1OmYMeMVFBUV4a23/yfvq68eT6eucojW1c8/X4e2bdrg0+UrkJKSIltmJk6cgOCQYLn/hoi6EfH3y8aN2LRxM8499xysWrkCzs61u/RVJ7r8iUTa2dkFHy39GBnp6WjTpg1uvvlm+QNStIKLWBBjwEQML1+xQv7fsWPHrJ5n0cL3kZCQiNffmH3apPSKK65A//795PtVXlYmf7yuXPEpbr+j3xl/RhtTtppJouiO/Ntvv+GTTz6V74V4/88///xa8SI+g6Jc333/vfyc3XHH7ZgyZTKiDx/G5s1boKX167/Eq69Mh7u7O4qLi2UC0rfvHVi0aDFcXFxqbd+v7x2yhXXZJ58iJycHF15wgRyT3LZtGzzxxFOW7a679losXrwQMTFH8drrb8DPzw8L5s/FiRMn6yzHkCGD4OLiihUrVqCsvBy5ObnQ6WsnKWf6vNVt2PC1PJkm3qO9e/da7g8LC8MlF1+MV16ZYUmYRHIVHX0Yc+fOk+URx6FLL7kETWHu3DnyZMLX33yDhYsW4cILL5BdhTt36oRHRzxmta3Yrzhpt2r1Gqxb9xnuvXcw3lwwH/v27ZfHGiEoKAifrVsDBwdH+R1QXFyC+4cNrbNV317ilkgVTPKI7MAHHyzE8uWf4OeffsCePXvw119/Y9u23/H7H39YfTGejd179uDFFyfK68uXr5ATu4iE7rXXXse7770v7//yy6+we9cO3HvvkFpJnvhyv+vuQZZyiO6jd97ZX34RP/DgcHmfSIbEDwbx/+YkT/y4GT9uLN6YPQf/+987luf77vsf8NOP38sfzdXvDwkJwQsvTpBlbMjOnbsQHx8vk7wvvlhv9Zg4K33//UMxcuRorP/yS8v9v//xJ1atXI5+ffta3d+pUyf063+nfE7haMxRrFq1Qv4gv/ba65GSmirvz83Lw5zZb+CKKy7Hn39ut/y/m5srtmzegpdenipvi2RL/IB7+umnsGTJR8jOyTnjMnWIisJ9Q4fh119/tXptd98zyOqH1dKPl2HF8k/x+OOPWZK8H378ES+88Dyys7Nr1c2Zqqsc4gy+aHW+tXcfmSwJ4v1KO5mGp556EgsXLkJq6ok6n8/f31+eqf/5l18wfPjDVpNwPPvM6AbLIlpeRBe/xx5/At9++53l/uonFMTrfeP113Ds+PF6X7toQRo5quF9mXXv3g29+9yO/fv3y9vi5MNvv/2K58ePw4jHrFu9T6cxZateT+KkwpYtv+L+YQ9YTirExsZh1qwZuPuuu7Bmral1VhAJ0OhnnsXnn5taJletWi0/4/fdd6/mP5bFeyVmKe3Tp7d83eIzK17f+i+/wr1DBtfaXpxwqh7jK1asRGJiomyJCgsNtXweJ0+eiIyMTAwYeJdM8IXtf27H6tUrkZRUu1W2bdu2uOrqa+Tnwqyu2UzP9Hmr+/HHn2TZ7+zfzyrJ69+vrzwh9vXXX8vb1157jUxwhw17QB4fmlKPHt1lgifq7fkXXrQcm7Mys+TnU7Tw//HHn1bHvwED78bff/8tb2/4+mvZeirem1deNSWlI0c+jcDAQHlyQ3w3CWvXrcPv236z27glUgW7axLZgd+2bkW//gNkK42YfEV8sYpkY9fOf3DrLbf8p+deuXK15br4sbF37z45JkV8qZqJFjXRUhPZrl2t///ss8+sEs1du3fL/1+9Zo3Vdrt270FoaKg8Wy+ISRfEduIsrZhBz3wRrTAJCQm4+qorrf5f/EASLV3/hWglyMvLw6+//Wa1z/379qGwsFD+yKlOtMiYEzzzaxO2/f675QelsPvU/ZHtImvtc+nHH1vfXrpM/oi75pprzqpMooWnZoInVP/xK7ooiuf4c/t2tG8faemy2JTqKod4LeIERF5untVr2bptm2y9rNmdsTrRRU3Uy0cfWdfX4sUfnrYs+fmmH9zXX3edbE08W598urzR2+7YscOS4AkiHn766Sdcf/11Mq6bi7meFn/4oVWrsWhNFZ/Tm26+0Wp7EUPmH8qCGGsmWgzr+iy3NBH34kf/gAF3ytsDBwyQ9SpagOtSPcZFi56IrX927JT1fc4558j7g4OD5fV169ZZEjHzMbS+FtbvvvveKsGry9k8b833QSQnont7df3795ct/ebjibn3Qu/etza6i3Nj3XijKTYWLlpsdf8HCxfJvzffdJPV/eJ1mRM8QdRRXHy87IJudtONN2DHzp2WBM+8nWiltde4JVIFW/KI7IQ4+ytaCETXNJHoiTFhj40YIbvT3HJrHxw9evSsnrfmD6r8ggI5s13Ns8jih7TonlTr/6slO4L5B1Bqzfvz82WC5+3thZycXERFRckfZ3/8vrXOclXUaKE8eTJNftH/F2KfohvQgf3/nkmvTpyRri4lpb7XdqLOJMPH13oMnZiw49ix41b3iVZGISIi/KzKdLyeFgPRnWv8+LG4+OKLZfe36ry9vKx+mDaFusohWvd69uiBAwf2Neq1VBceHib/igS/OvGDUcRLQ0Qrivih+uQTj8sxniLRFCdERBfbM3ndSUnW71VD4muUU94XHy/rXrRmZ2RkoDmY6ykuzhRHZuKzIbpDh4dZt0CdOFG75VS0PIuWyIb4+vrWOfNlY4ju3o39rIpW6rffelO2xIkWvRkzZ9W7rdhm/PPj5Ykt0X27Oi9vrxpxZGpJrk7UmegCXFN9n6nqzuZ56+qyKY7bogvzjh07Zevz+eefZ2npN28z9L57MW/eXEyaNFH22BBdFr/55lur5OhsiNcgjkmi9bM6EaviPQs79RrrO/4J4gRO9eOc6JFhPvlVnbkbefV9t0TcErUmTPKI7Iz4UhQJn7iIH5VijIQYqzJ/wZv1/ghoqGXBUMfMgQZD3bMJ1nVmub6ZB6uqDHU/B0zPodfrZMvh/cMerHN/YtxYdU0xc6OoB/GDpq6xejUnsRGq6qmHuuqs+mtrzjLVVQ/ix+KaNavkD6tp01+RCbaIE3Hm/onHH6tzfFFN9cWOQz3/W1c5RHz8+utveO99UzffmuJr/MBrSq+88irWrl0nW0DE2KlXX52OUaNHol+//o0aMyWUNvW0/fV9Hk+1ZreEej+Hp2kl+nDxolqtyI0lug5X77bcEJGMl5eX4823FsjJP77eYOq2WJPsHbB6pUw+33vvPdnNr7ikWI69fOvNBf+p9bTJ3/d6/PTzz3LsoWjNE0me+CuOnyKBs5SltBQD77oHV199FW666SbccP11svu7aA2/77775THzv2pssljf8e9sjnMtFbdErQmTPCI7JrpWCmJSC3P3J6HmbGV1jS/R2rHEY/KHmWg9iY+v3SrSHD9iRBfDa67phX/+2dEi0/2LlsvIyHZWr69Dhw7yb1JScpOV6ZZbbpYzDD700CNWLatXXXVVo+umeuxUn/DmTGJHvBYPD3ds3Vr3LIKnm1zI3LIpzuxXH8tTs9WmPocPH5aXt956W7aWbPjqSzzwwAOYPXuOfPy/toTUbLWsdV+HDvJHvDkxFy0PNT+L1Vs1qmts2cz11LFjB6t6Eq1uERERMhloCtNfeRW+NVqmG0uMbWwsEfNirOg9d9+NjRs31TsOTbTgiIk6nnn2Oau160Q3wLrjqH2t5xB1draa4nnFkhy//LIRfe/oi2nTXpHj80Src1paWq1YEC144jJ9OjB69Cg50ZNI/M7ms1X9NYhjkviMxcbGWrWwi+Q55dRrPNOeIOL5ahLvVc19t0TcErUmHJNHZAfqO6MuxkNU7wIjxjGIH5hX1Bj79NDwB6EaMcGKGMs3dsy/yzpU19gf9nURP7SFmj+wN3z9jRwbJqYQr8nUlbT2D/L/6uGHHrK+/fBw2XJh/lHTFGWytCxWO8stxuGJZRVqEq0fPj61nzPx1GyO1WNHjHsaNOgeNJYYX3nJJZfIpRVqEq/DPB6zLmJsk6iXRx6xrq/HHhtx2v16enrWem4xO6FoJXGpNjW8iAufJnqPxes899Q4MEEs+3HrrbfKlkxza4s4kSG64lbvYibGdt1Wx7T7jS2bqCexftujjzxidb+YkELsa+Mvm9AUxHhDkVCczcV8wuBMJpaaN28+3nzr34ly6mvZqdmS8+gI63pIT0/HgQMHMGjQIKuxqCIZFLMTn62met6vNmyQk4oMHXofevbsKSczqU4kWzUdPHhQ/q1rCZozsWmTKTYee+xRq/tFa78gZrU9Uxs3bZazg15wwQVWJ2ZqLo3RUnFL1JqwJY/IDsx49VU5U+P3P/woz8A6OznLlgoxhbs4K1p9QpKVK1fJM79iBsi9+/bJH+3m1iOViFYf0cIixp2I8Wk//PAjCouK5PIBfW7rgxXLV+KDhQvP6rn3nZoQQ3TZExM7GKoM8sfV9u3b8cmnn8opw8XYMTHZiVjuIapDe3l2/eWpU61mZ/yvxNjG62+4Hm++OR+7d+/BjTdcj1tuvlkuX2Ce6KEpyiT+R/yAEss7iNksRWva0KFDkZWViTZtQqy23b9vv5y2XMyEmZiQiMysTPz++x8yOUlOTsa8eXPw/vsfoMpgkLPoZWVlN7o1T/yfWOD6k2VLZddJ8T6IMWrdunVD3ztux+WXX1lvS42oDzGuTtTDJ598LJdQOOecnrjhhhtqdVmtqdfVV2PGzFdltzfRhdnRwQF33323TPK+/e7fuhPlEa2mYsZRMePn8aTj8n05GyKJXLlyudUSCsLcef8ujfHVhq/kjIxLPvwQSz76SCbNwx98QJbxvPPOs3q+xpZN1NM777wrp6IXy0aI7o6idUTsX2wvxiHaGtHyd7rWP3HcE+PhXn5piuyiWVhQgNvvuB2+NdaSFGa99gY+/eRjfLn+CzkBlEicxFILhw8fkZ+Ns9UUz7tp02Y5TlS8DnGSq+Zne8yYZ+Ux+5eNm5CSnIyAwED53oou2H///c9pn1/MYiw+2zWJBFW0lK5Zuw4PDBsGH28fOTGTSM7EjJvff/+D1cyajfXee+/jnrvvkjP5LlmyxLKEQnJKitU6n/YYt0RaY5JHZAfEdNVi3J1ouRt2/1DZxUV0yxPTX7/51ttW3evEtPFi4gexrpAY8yFmdBNTVtc3sYeW3nn3PTlb2+OPPYaxpxZqFz9mfvv1N/z0809n/bxitjyxRIEYyyKm5hbdQkWSJ0yYMEmu8yR+6IguUOKHlug6+cUXX8guk01JjDW8//4H5Hp1L02ZLFtaRYuFGD9Z3X8tk2jJffyJJ+XyCC+9NAUZGelyHSqRoC1Y8G/SIYh9iwkWxHIFokVC/LATSZ7Y56OPPoZZr82U69WJcYKLP1wiW2XEuM/GKCktlctpiPUPxUybYuFk8ZpFUjN33nw5qU9D3nhjNspKS2UXy6uvukpO6HDf0PvlD+uGHDx0CL9u+VUm0CIBKCktwaFDhzDsgQexa9e/k0JMn/4KZr/xBl584XmZcIkfvGeb5InkXMwqKOJWTAgiJj56bsxYmfyZiQljRJ1OnfoypkyeJCeIEetRiq6eNZO8MymbWHtSvLeiVXjatJflpBnLV6zE66+/8Z+XVFGVeF3DH3oYM16dLtfmFCc1RGIiZq/d+MvPVttu2bJFfh5EXYrPkzihNGbseDle86orrzjrMjTF84pyiwTn7rvvkuvF1TyBIR6LCI/AvUOGwN/fD9nZOTLWxMmDxkwiJJY9EOWrSZz8E0ne+PHP4/ixY3JdTjHRjficv/2/dzB//gKcbQvnPYOGYMarr2DkyJHIyc3Bp58ulycq5s+fi9Yet0TNSdc2NLzpBiEQEVGjLFgwX7Zede7C2eCIiIioaXFMHhERERERkR1hkkdERERERGRHmOQRERERERHZEY7JIyIiIiIisiNsySMiIiIiIrIjTPKIiIiIiIjsSKtYJy8kJARFRUVaF4OIiIiIiOg/8fDwQFpaWutO8kSCt3tX0y5gTEREREREpJULL7qkwUTP7pM8cwueqAiVWvM6dIhCfHyC1sUgqoWxaRt0jk7ofu/z8nr06jkwVlbAnjEuteGq0+Gn8K7y+q3JR1BqNGpUEjUxLklFjEv7JlrxRAPW6fIau0/yzERFFBYWQhUVFZVKlYfIjLFpO0lecZkpsRPHEntP8hiX2qjU6WAsLrbEGZM8a4xLUhHjkgROvKKRwsICRiApibFJKmJckooYl6QixiUJTPI0kpGRyQgkJTE2SUWMS1IR45JUxLgkgUmeRqKiohiBpCTGJqmIcUkqYlySihiX1KrG5BER2RUjUF6QY7lO1FxSK8tZudRi3Nxc4efnD71Ox1o/S0FBQSjIz2f92SCD0YicnGyUlJT+5+dikqeR1NRUrXZN1CDGpm0wVlUg5rM30VowLrUhJlrplxKr0d7Vx7hsOjqdDoPuuRtXXHFFEz5r66TX62EwGLQuBv0H27dvx7rPPofxP8xozCRPI87OzlrtmqhBjE1SEeOSVMS4bDqmBO9yfP3NN3KJqarKyiZ89tbFwdEBVZVVWheDzoKDo6NcAqNf3zvk7bXrPsPZYpKnkcDAQGRkZGi1e6J6MTZJRYxLUhHjsmm4ubnJFjyR4G3evKWJnrX1cnV1RWnpf+/uR9o4duyY/Nuvb1/5mTjbrpuaTrwyatRIfPftN4g5Eo19e3fjoyUfomPHDlbbfLZuLVJTkqwur78+S7MyExGpQOfgiA59H5cXcZ2oObjodPikTZS8iOtEzcHPz0/+FS14RATLZ0GMTz1bmv4yuPKKK/DxsmXYs2cvHB0dMGHCi1i1cgWuu/5GlJSUWLZbvnwF5sydZ7ld/TFbdeTIEa2LQFQnxqaN0OngHhRmuW7vGJfaEJHV08XNcp2sMS6bhnmSFXbRbBpsxbN95s/Cf5mASNMk7/5hD1jdfu65sTiwfy/OO+88/PXXX5b7S0pL7K5rY1RUe8TFxWtdDKJaGJukIsYlqYhxSSpycXFGWRlnxW3tlFonz9vbW/7Nzc21uv+ugQNl8rdp4y+YOOFFuLm6wtY5O7toXQSiOjE2SUWMS1IR45KaW3h4uByq1LNnjwa3Gzd2DH7+6Qd5Xaer++f9ggXz5dAoah0cVZo6d/r0qfj777+tuj+s//JLJCenIC0tDd27d8PkyZPQsWNHjHjs8Xpnuqo+25WHhwdUVFRUpHURiOrE2CQVMS5JRYzL5rcgKAItaUxG0hltLxKnIYMH4ZNPP8WECZOsHps1cwYeemg41qxdhzFjxqI5vf/BQny0dKm8zuUTSKkkb9asmejWtSsGDLzL6v4VK1Zarh8+fBjp6elYt3YNIiMjLbPPVDd61EiMG1f7g9S1a1cUFxfLBLJ9+/ZwcXGRt8UaN506dZLbpKWdlGc/goOD5e2jR48iIjwcrm5usn9zUlISOnfuLB/LSE9HlcGANm3ayNtxcXHyukgqy8vLkJCQKPcpZGZmory8HKGhofJ2QkKCHIPYvXt3VFSUIzY2Tl4XsrPFAojFCAsLl7ePJSbCz99ftnIaqqpwJCZGJrtidERubg4KCgoREWE6AB4/fhw+3t7w8fWF0WDA4SNH0LVLF+gdHJCfn4ecnFxZb0JycjI8PNwtAzqjo6Pla3N0dERBQT4yM7MQFRUlHxN1JOorICDA8j6I6V3FGUzxBSfqrUOHjvKxkydPwMHBUS7EKcTExCAysh1cXFzl60pJqV7fafJvSEiI/BsbG4uwsFC4ubmjrKwUx44dR5cuXUz1nZGBqqpKtGnTVt6Oj49DSMi/9S0GqHbrJuoFyMrKQllZmVV9BwYGwMvLG5WVlfJ9Nde3WHCyqKhYnimT9X3sGPz8fOHt7WOpbxGXOr0eebm5yMvPR7t27eS2Ih68vDzh6ysGjBsRHX24Wn3nIyc7G5Ht28ttU1KS5evy9/+3vjt16ggnJ2cUFhYgIyPTqr7FiQoxa5sgYlZ0CTLX98mTJ+WJDlN9n4SDXo+g6jEbEWGaWaukBEnJyZaYFZ8do9Eg681c36KO3N1FfZchMdEUs05OjigpCURFRSXatjXXdzyCg4Pg6emFivJyxMXHW+o7OysLJaWlCAszjQ8TzxMQ4F+jvk0xm5OTg6LCQoRXj1kfH3kxx6ylvvPy5MVc38lJSfDw9Dw1QN9U39VjNisrW362TfWdIlv8/avFbMcOHeDkbKrv9PQMdOhgmuTpxIkT8jUHBppiVpVjRFBQoKm+6ztGRJg+y4L4P28P93qPERf3f9AULzmF8HB1hpebs1x7JzE9D5FB3nJNpcKSchSUlKOtv6fpteYWwc3ZEd7upl4HCWm5aBfoDQcHPYpKy5FXVIbQAC/Ta80rgrOjA8pi/my2Y4R4j06ccOIxooWPEe1OxaQg4qT7qWNyZmYGjxEuLqioqICTk5Oaxwgb+h0hjiuiHp1dXODg4CBP/IvnqR57clCoUSwWbTDdFjcNpjXEdHqdJbnR66ptK247nNm24rMhvrvEMVKUSRDfkaI8omzifnFbbCfLptPJ75wBd96J1157AwUFBXI7MWPowIEDZD2Jz6DYvqqqSl7MjRHiPRWvXRwTBREjop7E65fbVlbK27LcOr0sg7leqm9rnq9CvAZz+Wtv6yzLJepO/I/5eUUMi2dwPPVaxbaifOa19kQZza9VbCtUrxdxva5tz6QOxbbi/831cibbmurQQV5gNKJUbCtem05XZ32LstZVh2I78dzmejmTbWUdVovZmnUoHq++bfU6rK++TZ8FPbx9vNH9VE9H8zHC/P15Orq2oeFnv8peE5k541X07n0rBt51jzwANkR8aOJiY3Df0GH49ddfG9WSt3vXDnTp2h2FhYVQhTgYiwMikWoYm7ZB5+iEng9MkdcPfjoDxkrTF0dd2t00tEXKdHzjvyflmhrjUhuuOh1+b2dKHq4+Hi0XRyfGZVMLDwvD2LFjMH/+AiSnpNhcS55IjEXy+86772L9+i/l/QMHDMDTI59C0vEkeYJYtORdf/31eO7Z0TJ5Fwn+zp078fLL06waLS644ALMfuM1eeLgyJEYvPX2//DRksW45dbeOHjwEK688gp8/tk6Oa/Fiy88L0+43jf0flx15ZXo06c3brm1j0wSRLLw0ktTcO+QwXJfq1etRmBQILy9vPHIoyOavN6o5T4Tnp6ecmWC0+U2ehUSvD59+mDQ4CGnTfCEc3r2lH/T001neGsSQS1esPnCrhREZK8qS4vkhag55VRVygsR1W/1mjUyoTK7997BWLNmrdU27u5uWLhoMW67vS+GDLlXti4u+XCxpTVO9Kz5ZNlSxMQcRZ/b7sC8+fPx8kumk3k1TZo0EbNmvS5npBc9W2p68onHMXjQIIwdNx4DBtwFX19f3NanD9/CVsRR6y6aAwfciYcfGYHCwiJL86No6hbNl+KsiGjq3rhxk+zm1aN7d0ybNhV//rm9zoC2JaILDZGKGJu2QbTcHV41G60F41IbouXu5uQYjfauPsYlmX3++RdyckDz0IVLLrkUTz01UrawmX333fdWFTZ27DgcOLBPDk0R3a7Fb17RbW/c+OdlF0XRnV0Mm3jj9ddqVfTcOfPw29atdb4BokvgiBEj8M477+D7702Tsbw4YSKuv/46vmGtiKZJ3kPDTeNEvvh8ndX9z40Zi7Vr18l+5tf06oURIx6Fu5sbUk+cwHfffYc333obtk70zyZSEWOTVMS4JBUxLslMjIUUjRJiEhbRMrdx00Zk5+RYVZAYO/v8+PG48MIL5Bh989hCMR+BSPLEmMZD0dEywTMTXTrrsnffvnor39vLC23ahGDX7j2W+8Q4sr1791laDcn+aZrkhYY13M86NfUE7r5nEOyRGASfmZWldTGIamFskooYl6QixiXV7LIphiEJkybX7ma57OOlcsb45194ESdPpskkb8vmjXB2+ncuicYSE4PVp/qkNdR6sTmJiMgG6RwcEdXnIXkR14mag4tOh4UhkfIirhNR/TZv3iJnzRYzVW7ZYj05oJi5W0ymInqjbdv2u5y91tfHx2obMRurGJpknolRuOiii864ygsKC2USedGFF1juE7NPnnfeuXz7WhH+MtCI+CATqYixaSN0Oni0jbJct3eMS22IyLrE1bTerP1H2ZljXFJ1Ygr8666/wXK9utzcPNmlc9iwoXK5EtFFc9LEiVbbiJk5J7z4AubMeQP/+9+7iIgIx5NPPnHGlSzmtViyZAlGjhopl+IQCeXjjz8ml9Gg1oMteRoxr0lDpBrGJqmIcUkqYlxSTebZ3WsS67099fRInHfuudi08Wc5keCrM2bW6oI5/KGH0b1bN/z04/cy4Zs5c9YZV7JYSuyDhYvw+eef480352PDhi9RWFSE738wTcJCrQNb8jRiXtSRSDWMTVIR45JUxLhUb926libWv2tI9TXptm7dhutvuKnB+Sl27dot17qrbxsxw3xdc1rMm79AXgQx1k8sKTZ16nR5odaJLXkaKS0p0WrXRA1ibJKKGJekIsYlqahmV1FqnZjkaSQpOVmrXRM1iLFJKmJckooYl6Qi0YpHxCRPI2ItFCIVMTZJRYxLUhHjklTEbsQkcEweEZGNMlTwbC01vxJ2/SIisjlM8jQips8lUhFj0zYYKytwaLn1zGz2jHGpjVKjEb2SDmu0d/UxLklFFRUVWheBFMDumhoxGjkoltTE2CQVMS5JRYxLIlIVkzyNhIS00WrXRA1ibJKKGJekIsYlqcjJyUnrIpAC2F2TiMgG6Rwc0e6GIfL68c1rYKyq1LpIZIecocOcoHB5/fmMZJTDqHWRiIioEZjkaSQ2NlarXRM1iLFpI3Q6eEV0sVy3d4xLbeh1QC93L8t15njWGJekorKyMq2LQApgd02NhIaGarVrogYxNklFjEtSEeOStJCakoQ+vXvX+zi7a5LAljyNuLu7MwJJSYxNUhHjklTEuGx+7W4aipZ0fOPKM9p+wYL58PH2xiOPjoAq9Hq24RBb8jTDpnRSFWOTVMS4JBUxLklFBq5tSUzytJOYmMgAJCUxNklFjEtSEeOSGnLFFVfg22++RkJ8LHbv2oFJEyfAwcHB8vhn69bi1VemY8rkSTh4YD/27N6JcWPHWD1HVFR7fPH5Z4iPO4otmzfi2muuqbWfbt26Ye3a1YiLPYoDB/ZhxquvWLUyi9bGj5Z8iCefeEKWQ2wza+YMODqyQ589Y3uuRrp27arVrokaxNgkFTEuSUWMS6pPmzZtsPzTZdi7dy9uuaU3Jk6cjPvuuxfPPfuM1XaDBt2D4uJi9O3XDzNmzsKYMc9ZEjmdTocPFy9GRUU5+vbrjxcnTMLkyROt/t/NzQ0rVyxHXm4ebr+jL5544klce+21mDlzhtV2V111JSLbR2LQoCF47rkxGDx4kLyQ/WKSR0RERETUhIYPfxCpqamYNHkKYuPi8MOPP2LuvPl44onHZfJmFh19GPMXvImEhER89tnn2Lt3H3r1ulo+JpK9Tp064plnx+DQoWj89ddfeO312Vb7GThwAFxcXPDMs8/hyJEj+P33PzBt+nTcc/ddCAwMtGyXl5eHyafK8ssvG/HLxo24plcvvud2jO20GsnMzNBq10QNYmzaBmNlBQ4snYrWgnGpjVKjERcfO6TR3tXHuKT6dO7UCTt37rK6759//oGnpydC27ZFSmqqvC86Otpqm/T0dEty1qlzJ5kopqWlWR7fuXOn9X46d8ah6EMoKSmx3PfXX3/LbqEdO3ZEZmamvO9ITIzVWL30tHR0696Nb6AdY0ueRioquHAxqYmxSSpiXJKKGJf0n2OossLqttFohO4/zo4pnqOmyhq/O40wQq9jGmDP+O5qpG3btlrtmqhBjE1SEeOSVMS4pPocjY3FxRdfZHXfpZdeioKCAqSeONGoios9GivXYgwODrbcd9FF1s959OhR9OjeQ47NM7viistRVVWFuLg4vkGtGJM8IiIbpHNwRMT1g+VFXCdqDs7Q4Y3AcHkR14moNi9vL/Ts2cPqsnz5CpmgzZzxKjp17Ijet96K8ePGYtGixXW2tNXlt61bER8fj7feXIAePbrjsssuw4QXX7DaZv0X6+VSHm+9tUBOBCQmWJn68sv47PMvLF01qXXiLwONiA8tkYoYmzZCp4NPVE95NXnbetg7xqU29DrgZg9veX1qVoro40XVMC5JuPqqq/DzTz9aVcbKlasw7IHheGnKZPz884/Izc3FqlWr8eZbbze60kQy+OiIxzBv7ly5FENycjKmvDQVq1Yut2xTUlqKofcPwyuvTMN3336DktISfPfd95g2bTrfnFaOSZ5GgoODkJSUrNXuierF2CQVMS5JRYzL5nd840qobMyYsfJSnzv69qv3sXsGDa513yOPjrC6HR+fgIF33W11X2hYhNXtw4cPY/Dgey23nZycUFHx71i/uso3dSqTQHvH7poa8fT00mrXRA1ibJKKGJekIsYlqaj6guvUejHJ00hFeblWuyZqEGOTVMS4JBUxLklFjR3zR/aNSZ5G4jgmjxTF2CQVMS5JRYxLUpGYiIWISZ5GunXjApSkJsYmqYhxSSpiXJKKXF1dtS4CKYATrxAREVGTWBBkPSFEcxmTkdQi+yEislVM8jSSnZWl1a6JGsTYtA3Gygoc/HSG5bq9Y1xqo9RoxNXHoy3XyRrjklRUWVmpdRFIAUzyNCLWNSFSEWPTdrSG5M6McakdJnf1Y1ySigwGg9ZFIAVwTJ5GwsLCtNo1UYMYm6QixiWpiHFJKnJ2dta6CKQAtuQREdkgnd4BoVeZFtlN/eNrGA1VWheJ7JATdJgc0FZen5l1AhVgl00iIlvAljyNJCYmarVrogYxNm2EXg+/zhfKi7hu7xiX2nDQAf08feVFXCdrjEtqCeHh4UhNSULPnj0a3G7c2DH4+acfGlxCYcGC+fhoyYfNUEpSjf3/MlBUQIC/1kUgqhNjk1TEuCQVMS5JJE0iAXv99Vm1KmPWzBnyMbFNS3j/g4UYPOReODq2no56Q4feh/VffI5DB/fLy5rVK3HBBRfU+R5Vv6xY/mmDz6vX6/H88+Ox/c/fERd7FH/8vg3PPfes1TZBQUFY/ukn2LVzB2bOeBU6nfWZsPbt22PB/HnYseNvJMTHyud67913cN555zVhDTTwGlpkL1SLl5c3a4WUxNgkFTEuSUWMSxJSUlJwZ//+VuvTubi4YMCAO5GcnNwileTg4IDi4mLk5OTK663FVVdeiS+/+gqDBg9B//4DkJp6AqtWLkebNm2sttu0aTPOv+Aiy+XpkaMafN6RI5/G8AcfwOQpL+G662/AzFmz8PRTT+LRRx62bPPC8+Oxd98+DHvgAbRr1w4D7rzT8phI5H74/lt06BCFF1+cgOtvuAmPjngMsbGxmPryS2gJTPI0wultSVWMTVIR45JUxLgkYf/+AzK5uO22PpYKuf2225CSmooDBw5aVdL111+PL9d/juhDB3DgwD4sW7YUkZGRVtuIlqiffvwe8XFH8f133+Kcc86xevzKK6+QrVE33HC9TCQSE+Jw2WWXWrprGk8tdyJao6ZOfdmyrymTJ6FGY1MtgwcPktvffPNN2PrbFsTFxmDRog/g5uqKQYPuwV/b/5AtZq++Ml0+f/XJXl5+aQp27vgHsUeP4JuvN8hymvn5+cpWLPG4eM6Nv/xslRQJn61bK59XlPPggf3Ys3unfE0NGTX6GSxb9gkOHjyE2Lg4jBv/vCxXr15XW21XXl6OjIwMyyUvL6/B573kkovx448/YePGTTJR//bb7/Drr79ZtRL6+Prg8OHDiI4+jOPHj8Pb598GnDcXzEdCQiIGDLxbPsexY8dkGecveBMPP/IoWgKTPI0cPXpUq10TNYixSSpiXJKKGJfNz1Wnq/fiDF2jt3Wpkd3Ut93ZWr1mDe4dMthy+957B2PNmrW1tnN3d8PCRYtx2+19MWTIvTAajFjy4WJLVz93d3d8smwpYmKOos9td2De/PkyearLpEkTMWvW67ju+htlomFmHpP35BOPY/CgQRg7bjwGDLgLvr6+uK3Pv4lofdzc3PDoI4/gqadGYuj9D8jWsiVLPsRNN96IYQ8MxzPPPodhw+5H3753WP5HdFe8+OKL8NTTI3HTzbfim2++lV0io6Lay8ddXFyxb99+PDh8OG648WasWLECb7/9Zq2ulSKRFC2Sffv1w4yZszBmzHO49pprGvEO/Ft2R0cn5ObmWt0vEs59e3fLxPW112bJpLMhO3bslImiaIkTevToLhPpTZs3W7Z55533MOPVV2SSfe6552Ddus/k/ef07Ilu3bpi4cJFloS7uvz8fLSE1tNpVzHdu3ez+kASqYKxSSpiXJKKGJfN7/d23et9bFtxAZ7NSLLc/iW8K9zqmYhqR2kRnkg7Zrn9TVhn+DnU/hl88bFDZ1XOzz//AhMnvGhZVuOSSy6VSZJIkKr77rvvrW6PHTtOtrJ16dIFR44cwcCBA2RLlGiREslaTEwM2rZtizdef63WPufOmYfftm6tdb/oNlpaWooRI0bgnXfewfff/yDvf3HCRFx//XWnfS2iVW7CxEmy9Un45ttvcc/dd+O88y+UCZg4ufHHH3/iqquuxIYNXyMsNBRDhgzGpZddgbS0NPk/HyxciBtuuA5DhgzB66+/gZMnT8r7zD5a+jGuu/469O/XF3v27LHcL34bi9YuQbSEPfzQQzLZqut11mXy5EmyDFu3brPct2XzFnz/3fc4npSE9pGRmDDhBSz/9FP0639nvWsKvvPOu/Dy9MRvv25BVVWV7AL7+huzsX79l5Zt9u3bh4suvhT+/v6yddAs6lRiKLpmaolJnmY4TRmpirHZUtrdNPTs/7naGeeI6wcDdZwttC+MS1IR45JMsrOzZbe8IYMHyVa5jZs2Ijsnp1b1iJat58ePx4UXXiCTA3OXx7CwUJnkde7cGYeio61myNy5c2ed1SzGg9XHy8sLbdqEYNfufxMokazs3buv1gQhNYlEzpzgCZkZmUhKSpL3m2VkZiAwIFBe79a9m5zsZdvWX2sli2KMoCBe5zPPjEa/vn3leDlnZyf5eElJidX/REdHW91OT09HYKBpP6czauTTcmzkPYMGWdXfVxs2WK6L7pWifsUkKCJJ3bbt9zqfq3+/frjrroEYOXI0jsTEyJlNp0+fJhNIc4uduU6rJ3jC6eq3pTDJ00hOHR98IhUwNm2E0YiC5FNnCe0+wWNcaqXUaMRNSUcs18kaj5fN7+rj1j/6qzPUCMmbk02xWpea0ds3pemHzYgum6LbojBpct1dLJd9vBTJySl4/oUXcfJkmkx+tmzeCGenM1/AvHrSVXOsqJOTE85WRUWF1W3R5bDm+FNxODAnqB4eHvLxPrfdjqoq65axoqIi+VdMWjLi0Ufw8tRpMtEqLi7B9OlTa73uisra+9Y1YpmgJ594Qk6WMuTeoaftKXf8+HFkZWXJ2S/rS/Jeemmy7I5pThBFmcVSFqNHjbRK8uoSHxcv/3bq1AkHDlqPyWxJHJOnkaLCQq12TdQgxqbtEAugt5ZF0BmX2sk1VMkL1ca4bH7i5EJ9l/IaqVtD25bVOElR33b/xebNW+Dk5AxHJyds2WLdqiWIcWDih/+bb70tkwvRnc/Xx8dqG9EVskf37nJ2TrOLLrrojMohuiAWFBTIJPKiC/8d8ya6HJ533rloagcOHJAteQEBgXLtyOoXcyvXpZdeIicy+eKL9Th0KFq2FHbo0KFJ9i8SyOeeewb3D3tAdqE8nbZt28DPzw/paen1buPq5gaD0TphFa12jUk4RWInWmWfeOLxOlv1vL1bZoZ9JnkaCY+I0GrXRA1ibJKKGJekIsYl1UyuxHT7119/Y51jvXJz82S3zmHDhspWpKuvvkrOflmdGPMlWq/mzHlDdt288cYb8OSTT5xRRYtukMKSJUswctRI9OndG506dsRrs2Y2S4IRH58gxyS+/dYCOcNoRESEnFBl1KiRuOmmG03bJCTi2muvkbNWikR39huvI6iR3TAbMvLpp+R6dmJymaSkZLl2nbi4u7vLx8Xfl6ZMxkUXXShb4sT4vqUfLUFCYiK2/PpvIr5mzSo8/NBwy+2ff/5Fdi8V5Rf/16dPHzzx+GP44dT4xtMZM3a8nLRFzKQq3kOxxIIYwyueU+y/JbC7JhGRTdLB1S9IXivNEWdK2ZWOmp4TdBjrHyKvz89OQwXjjKhBhQ301BLJm5h9UiwTsGnjz4iLj8dLL03FF5+vs+qCOfyhh+VEK2IZBdGyN3PmLDkD55n6YOEiBIcE480358ukc/Watfj+hx/g3QxrNY8ZOw7PPfuMXANOjLnLzs7Brl278MsvG+Xjb731NiLbtcPKFcvlOLzlK1bihx9//M9lefDBB2Sr54eLF1ndP2/efMybv0C+7u7du8tZO0WCK8bUiaUQZs+ZK5dVMBMTsogxkmZTpryEF14YLxNj0UIp/u/T5Suw4NSkMKcjJpO57fY7ZFI3Z/Zs+Pv7yfGFYtbOqVOnoSXo2oaG2/UvA09PT8QciUaXrt0b/OC1NNF/2dxPmUgljE3bmXjFO6KLvJqfFKPEuLzjG1c223MzLrUhppQ3z24oxkadrjvbgqCW6aUyptqMilpiXDaN8LAwjB07BvPnL0BySkoTPWvrJcbK1TdrJNn+Z6KxuQ27a2rEp0YfbCJVMDZJRYxLUhHjklQkxt4RaZrkib663337jcxGxQKFHy35EB07Wg/CFE2ws2bOkGuIHI05jMWLFjZ6KlWV8YuBVMXYJBUxLklFjEtSEZM80jzJu/KKK/DxsmXo2+9O3HvfUDg6OWLVyhVytXqzadOm4pZbbsYTTzyJu+4ehJA2IVjyoXW/W1tkZDM6KYqxSSpiXJKKGJekJAW675P2NJ14RUx1Wt1zz43Fgf17cd555+Gvv/6SCzned+8QjBw1Gr///ofcZuyYcfjtty1ylpxdu3bDVh0+Uv9aLkRaYmySihiXpCLGJamotNpC4NR6KTUmzzyta25urvwr1vIQ08Bu3brNsk1sXBySk5Nx8cUXw5Z169pV6yIQ1YmxSSpiXJKKGJekItdqa+xR66XMEgpisUCx8v3ff/8tFxAUgoOCUVZWhvz8fKttMzIyERxkmjq8JpEUmtcHMc98paLGLKZIpAXGJqmIcUkqYlySkupYgJtaH2WSvFmzZsozYgMG3vWfnmf0qJEYN25srfu7du0q1x4RCaRYgFJM6CJup6amykUZhbS0k9Dp9AgODpa3xdokEeHhctX70tJSJCUlyYUphYz0dFQZDHItECEuLk5eF0lleXkZEhIS5T6FzMxMuRZHaGiovJ2QkAA3N1e5bkdFRTliY+PkdUEskllSUoywsHB5+1hiIvz8/WUrp6GqCkdiYuRiimKNrNzcHBQUFMpFJ4Xjx4/Dx9sbPr6+cpyA6EbStUsX6B0ckJ+fh5ycXERGRsptRWuoh4c7/PxMa4JER0fL1+bo6IiCgnxkZmYhKipKPibqSNRXQECAvH348GG5wKOzs4tcBkLUW4cOHeVjJ0+egIODo1yIUoiJiUFkZDu4uLjK15WSUr2+0+TfkBDTGkyxsbEICwuFm5s7yspKcezYcXTpYpoiPiMjA1VVlWjTpq28HR8fh5CQf+tbLMTZrZuoFyArK0ueHKhe34GBAfDy8kZlZaV8X831nZOTjaKiYrnQpazvY8fg5+cLb28fS32LuBRf5Hm5ucjLz5cLWgoiHry8POHr6yfXKIuOPlytvvORk52NyPbt5bYpKcnydZnXYBH13alTRzg5OaOwsECeuKhe3+JEhXmCIRGzUVHtLfV98uRJdOxoru+TcNDrEVQ9ZiMi4OrqitKSEiQlJ1tiVqzPYjQaZL2Z61vUkVgoVNRXYqIpZgMC/BEUFIiKikq0bWuu73gEBwfB09MLFeXlcm0fc31nZ2WhpLQUYWFh8rZ4HvEc1vVtitmcnBwUFRZaFhCWMevjIy/mmLXUd16evJjrOzkpCR6envDz+7e+q8dsVla2/Gyb6jsFbq6u8K8Wsx07dICTs6m+09Mz0KGDaZKnEydOwMnJEYGBpphtyWNEVIivfCy3qBQVlQYE+ZgWb03JKoCfpyvcXZxQWWVAUma+Zdu84jKUlVciyNcDhrIMpOcVIcjbDZ6uznLK7GMZ+YgK9pFf8gXFZSgqq0AbP09TvOQUwsPVGV5uznK9psT0PEQGecvptgtLylFQUo62/qZt03KL4ObsCG930xnhhLRctAv0hoODHkWl5cgrKkNogJfpteYVwdnRAR6nPlfNcYwQMeXk5MRjRAsfI9qEhuI5V6P8DqvQ6dD91Oc+MzOjzmOEPiwKxooKGJOSoe9gOqYZc/NgLCuDPsRUBkNKKnS+PtCJk7BVlTAkJkHfUXx2dTDm58NYXAJ9G9P3giH1BHRentB5eYkBcDDEH4O+QyS6B3oqcYwQ36MiLrX4HSGO0/KYbAe/I8RxRdSjs4uLnDREnPgXzyOIehPxbl4WoKKiQj6XIK4L4n8F8V0mrpu3FXUmYl0Q30fiuFfXtiLuy8oa3laUR5RN3C9uV99W7MvcwCD2Kbaz2laUV6dDVVWVvFhvq5fHRPNrFa9NZ962slLWiXlbUdbq9VJ9W1EOc72I/YrtrLd1lrFZVx2KlNDx1GutWd/V6/C/1ndj6/Bs61uMRSw9TX3rG1mHZ7KtrMOzjNn66tv0WdDD28cb3U/1dDQfI8zfnzaxTt7MGa+id+9bMfCue+QB0Ozqq6/CurVr0K17T6vWvL//+hOLP1yCxYs/bFRL3u5dO7hOHlEjcd0nG1knT0FcJ4+4Th6dDa6T17S4Tp7ts4t18kSC16dPHwwaPMQqwRP27dsvM9peva623CeWWBCtLjt37qzz+cT24gWbL6ouOG4+80ikGsYmqYhxSSpiXJKKqjd2UOul17qL5l13DZSzZxYWFsnmR3ExN8kWFBRg1eo1mDb1ZVx11ZU499xzsWD+POzYscOmZ9YkImoKLr5B8kLUXETno2d9g+VFmfEdRITUlCT06d2bNUFqJnkPDX9QjsX54vN12Ltnl+XSv38/yzbTpk3HL79sxOJFi7D+i89kP/lHRzwOWyfGDhCpiLFpI3Q6uHj7y0trGGTPuNSGo06HB30C5UVcJ2uMS1qwYD4+WlJ7+JCWRK+21ujyyy/Hso8/wq6dOxpMgsUY2o+XfoTD0QcRe/QIvvv2G4SdGu9al8GDB8nnq36JjztqtY1opFr+6Sdy36KXohijV50Yx2tqqPobCfGx2P7n73jv3XfksnHNRdMTc6FhpoG+DRGDLSdNniIv9kQMDi9ooB8tkVYYm6QixiWpiHFJKmqtY/Lc3d1w8FA0Vq1ei4+WLK5zGzFx0JdffoHVq1Zj7tx58re4mFzodGsLirlBrrn2esttMSFMdS88Px579+3DrNdew8QJEzDgzjux/ssv5WMikVu7ZpWctOnFFyfIiZI8PT3Q+9ZbMfXll3D3PYPQHDQfk9damWb/IlIPY5NUxLgkFTEu6XSuuOIKfPvN17L1RkwEOGniBNNMkKd8tm4tXn1lOqZMnoSDB/Zjz+6dGDd2jNVziNlzv/j8M9l6tGXzRlx7zTW19iNmvF67djXiYo/KXnGz33hdzp5ds8XxySeekOU4cGAfZs2cYZkRsi6iHD//9APuHTIE//y9HUdjDsuhViKJfPqpJ2VZ9+3djWeeGW31f2Im17lzZmP/vj04cviQLFePHqbZX82J1tKPlshyiucULWnXXNPL6jn+2v4HRo8ehfnz5spJRsT+77+/4cnKNm/egtmz5+CHH36od5sJL76ATZs2YcbMWThw8KCcVf2nn3+WM7M3RCR1YqZ380XMeFudj6+PnDVWzOorZqkVs2Kavblgvpwtd8DAu7Fx4ya5z4MHD2H+gjfx8COPorkwydOM5pOaEtWDsUkqYlySihiXzU3n6FT/5dTSA025bVMS090v/3QZ9u7di1tu6Y2JEyfjvvvuxXPPPmO13aBB98gle/r26yeTjzFjnrMkcqLb34eLF8ulMvr2648XJ0zC5MkTrf7fzc0NK1csR15uHm6/oy9GjX5GJk0zZ86w2k7MbxHZPhKDBg3Bc8+Nkd0QxaUhIiG74cbrMfT+B/D0yFG4794h+PSTZXL5FNECNXPmazJxuvDCCyz/s2jh+3KJl/uHPYg+t92OA/sPYO2a1fD1NS0FJJbe2LhpEwYPuQ+39u6DzVu24OOlS2t1mXziicdl69itvW/DsmWf4PXXZskJGM+WTqfDTTfdKJfdEvUlEtRvvt7QqLGNYuZxMbv/jn/+kgmqeYkvs3feeQ8zXn0FiQlxOPfcc7Bu3Wfy/nN69kS3bl2xcOGiWq1/Qs21wJsSx1FrRGT6RCpibJKKGJekIsZl8+v5QP3DdQqSYnDslxWW293vfQF6p7pnliw6kYCEHz623O46aAwcXT1qbXdg6VQ0leHDH5RrBJqHHMXGxSGkTQgmT5ooW3HMP/pFHInbgmjxefihh+TM8r9t3SqTPbGu7tD7h1nWDn3t9dlYueJTy34GDhwg12F75tnnUFJSIrsFTp7yEpZ9vBQzZ86ytDqJdSUnT54iu3KKsvyycSOu6dULK1euqvc1iFa7sWPHy9nqxbqPf/zxp0y0hj3woCx/XFw8Ro58CldfdRV2796Dyy69FBdccAHOO/9Cy9jAV16dgd69e+OOO27HihUrcehQtLyYzZkzF7f16YNbb70FSz9eZrlftLiJ5E5459338NhjI3DVVVfJfZ6NwECxtqQnRo18Gm/MnoOZs2bhhuuvx4cfLsI9g4Zg+/btdf6fWJ9u7Ljxci1IsQbwU08+jg1frccNN96EEydOym327duHiy6+VK6HLFr6zKJOrRcq1h1taUzyNCIWDBUfFiLVMDZJRYxLUhHjkhqMj06dsHPnLqv7/vnnH5lohLZti5TUVHmfSB6qS09PlwmJ0KlzJ5komhM8oeYyYiIOD0UfkgmeIBK+f/7ZIbuFduzY0ZLkHYmJsRqrl56Wjm7duzX4GsTyZtWXI8vIzECVocqqVSojIxMBp8rbo0cP2ep18MA+q+cRM+e3j4yU10U30vHjxspWteDgYNllVDweFhZm9T/R1RJBWd6MDAQGBOBs6fWmDow//viTZa1t0W3ykksuwYMPDKs3yRPvYfX3Uczy/+uWzRg2bJhMUM3EIunVEzyh5gQsLYlJnlYV30AfaCItMTZJRYxLUhHjsvkd/NS6y6GVGt3folfPbvS2R9YtgCoqKiusbosESncqITkb9SUWlRWV1vuBEXpdw/uprKzxP8Y6nsdohF6vs3TFTEtPxz33DK71XPl5efLvyy9PwbXXXCtb+BITE1FaWorFiz6Ak7N1d9mKGvsWOzcnamcjOzsbFRUViKnRyCIaXS677NJGP4+okwMHDyCqffvTbht/qtVRzOgpxgC2JGYaGikoaL4+uET/BWPTRhiNKDyRYLlu7xiX2igzGjEoNc5ynawxLpufsUYCpMW2Z+tobCzuuP02q/suvfRSuQ506okTjXqO2KOxCA0NlS1eooVPuOiii6z3c/QoBg8aJMfmidY80aIkxt+Jv6KrYUvav/8AgoOCZCKUnJxc5zaXXnIp1q5bZ5kgRbTshYeHN3vZKioq5PjImuP6OnTogOTklEY/j0g0u3frho2bNp92W5HYie6zYnzhVxs21BqXJyapaa5xeZx4RSNZWdla7ZqoQYxN22GoKJeX1oBxqQ3xcyS+okxemOLVxrgkwcvbCz179rC6hIa2lePJRIIm1k3r1LGjnDJfdFNctGhxnZNw1EWMy4uPj8dbby6QM1RedtllcqKT6tZ/sV4uOfbWWwvQtWtX2So149VX8dnnX9SaBbK5ifKKro1LP/oQ1117rUzeLrnkYrz44guWNeESEhJw+219ZD2J1yTWi/svLXRmIlk0178Q0S5CXq8+oct77y9E/379MHTofXLtuocfGo5bbrnZMvZPEPU4ccKLlttjnntWvpZ27drh3HPOwTv/exthYeENjmWsbszY8ejQIQpfrv8cN954g3ye7t27yVlJxSQuzYUteRoRgVWzDzaRChibpCLGJamIcUmCmHTk559+tKoMkQCMf/4FDHtgOF6aMhk///wjcnNzsWrVarz51tuNrjiRDD464jHMmztXLsUgWsemvDQVq1Yut2xTUloqJ2Z55ZVpcjmC0tISfPvtd5g2/RVN3iAxKYtIROfPn4eAANNEJNu3/4XMTNN4NVGu+fPnYsNXX8oulO+++74cp/hfnX/+efj8s3WW29OnmSbRWbN2HcaMGSuvi9bDCRMmYdTokXj1lVcQHx+Hxx57An//84/l/8JCw2Aw/JuE+/j6Ys6cN+SC52Lymn379+POOwc0em6NPXv24Lbb75BJ3ZzZs+Hv7ydbZXfs2ImpU6ehuejahobb9ck5ETRifY0uXbujUKHFx7t3784kj5TE2Gw57W5qeM2f03HxMQ1AL8treH2flnJ848pme27GpTbEmeBHfILk9Y/yMlBjhEwtC4IiWqRcYzKSoALGZdMIDwvD2LFjMH/+AiSnNL7bHNVNTGIixrmRfX4mGpvbsCVPIyk8iJGiGJs2QqeDi49pNrOy/Gy7H5fHuNSGo06HJ3xNSd4n+ZmotPM4O1OMS1KReekCat04Jk8jbq6uWu2aqEGMTVIR45JUxLgkFTXF+DayfYwCjfj/h3U+iJoTY5NUxLgkFTEuSUVc2oMEJnlERERERER2hEmeRg4fPqzVrokaxNgkFTEuSUWMy6ZhODXW08GRU0U0BU66YvvMnwXzZ+NsMMnTSMcO1gsxEqmCsUkqYlySihiXTSMnJ0f+FWuJ0X/n4uLCarRx5s9CTs7Zr6vNUyYacXJ21mrXRA1ibJKKGJekIsZl0ygpKcH27dvRr+8d8nZ8fAKqKk+3YAfVx9nFBeVlZawgG23BEwme+CyIz0RJydkvhcEkTyOFhQVa7ZqoQYxNG2E0ovDkMct1e8e41Ea50YgHTsRbrpM1xmXTWffZ5/Jvv759GWb/kaOjAyorq1iPNkwkeObPxNlikqeR9PQMrXZN1CDGZtMsVN4SDOWtZ7FbxqU2DAAOtaI4O1OMy6ZjNBqxdt1n+Pqbb+Dn5w+9TteEz976WpgruFaeTRJj8EQXzf/SgmfGJE8jHTp0QHR0tFa7J6oXY5NUxLgkFTEum574cVtSktoMz9x6dO/eHQkJCVoXgzTGJI+IyEY5e/nJv+UFpkkLiJrjR8J93qZ1XVflZ4GjpIiIbAOTPI2cOHFCq10TNYixaSN0Orj6Bcur5YW5dj8uj3GpDUedDs/5hcjr6wqyUWnncXamGJekIsYlCVxCQSNOTsyvSU2MTVIR45JUxLgkFTEuSWCSp5HAwCBGICmJsUkqYlySihiXpCLGJQlM8oiIiIiIiOwIkzyNHDlyRKtdEzWIsUkqYlySihiXpCLGJQlM8jTSvn17RiApibFJKmJckooYl6QixiUJTPI04uLiwggkJTE2SUWMS1IR45JUxLgkgVM8aqS4uJgRSEpibNoIoxFFacct1+0d41Ib5UYjHj+ZaLlO1hiXpCLGJQlM8jSSmprKCCQlMTZtR1VZCVoLxqU2DAB2lvGkZH0Yl6QixiUJ7K6pkU6dOjECSUmMTVIR45JUxLgkFTEuSWBLHhGRjXLy9JV/KwpztS4K2fGPhIGefvL6+sIcVGpdICIiahQmeRpJSzup1a6JGsTYtBE6Hdz8Q+TViqI8ux+Xx7jUhqNOhwkBbeX1r4tyUWnncXamGJekIsYlCeyuqRGdjlVPamJskooYl6QixiWpiHFJAjMNjQQHBzMCSUmMTVIR45JUxLgkFTEuSWCSR0REREREZEeY5Gnk6NGjWu2aqEGMTVIR45JUxLgkFTEuSWCSp5GI8HBGICmJsUkqYlySihiXpCLGJQlM8jTi6ubGCCQlMTZJRYxLUhHjklTEuCSBSyhopLS0lBFISmJs2gijEcXpyZbr9o5xqY0KoxHPph+3XCdrjEtSEeOSBCZ5GklKSmIEkpIYm7ajsrQIrQXjUhtVALaVFGq0d/UxLklFjEsS2F1TI507d2YEkpIYm6QixiWpiHFJKmJcksCWPCIiG+Xk4S3/VhTla10UsuMfCbd5+Mjr3xfloVLrAhERUaMwydNIRnq6VrsmahBj00bodHALaCuvVhQX2P24PMalNhx1OkwLDJPXfy7OR6Wdx9mZYlySihiXJLC7pkaqDAZGICmJsUkqYlySihiXpCLGJQlM8jTSpk0bRiApibFJKmJckooYl6QixiUJTPKIiIiIiIjsCJM8jcTFxWm1a6IGMTZJRYxLUhHjklTEuCTNk7zLL78cyz7+CLt27kBqShL69O5t9fiCBfPl/dUvK5Z/CnvApnRSFWOTVMS4JBUxLklFjEvSfHZNd3c3HDwUjVWr1+KjJYvr3GbTps0YM3ac5XZ5eTnsgYeHh9ZFIKoTY5NUxLgkFTEuSUWMS9I8ydu8eYu8NEQkdRkZGbA35eVlWheBqE6MTRthNKI4I8Vy3d4xLrVRYTTixYwky3WyxrgkFTEuySbWybvyyiuwb+9u5OXlYdvvf2D27NnIycmFrUtISNS6CER1YmzajsqSQrQWjEttVAH4RazDSHViXJKKGJek/MQrWzZvwbPPjsHgIfdh5szXcOUVl2P5p59Cr6+/2M7OzvD09LRcVG2y7tq1q9ZFIKoTY5NUxLgkFTEuSUWMS1K+Je+rDRss1w8fPoxD0dHY/ufvuOqqK7Ft2+91/s/oUSMxbtzYOgO+uLgYR44cQfv27eHi4iJvp6amolOnTnKbtLST0On0CA4OlrePHj2KiPBwuLq5obS0FElJSejcubN8LCM9XS42aR7cKmYyEtdFUimaycVZFPOHLDMzU3Y7DQ0NlbcTEhIQFBQIoDsqKsoRGxuH7t27y8eys7NRUlKMsLBweftYYiL8/P3h7e0NQ1UVjsTEoHv3bgB0yM3NQUFBISIiIuS2x48fh4+3N3x8fWE0GHD4yBF07dIFegcH5OfnyRbQyMhIuW1ycjI8PNzh5+cvb0dHR8vX5ujoiIKCfGRmZiEqKko+JupI1FdAQIDlvejQIQrOzi4oKiqS9dahQ0f52MmTJ+Dg4IigoCB5OyYmBpGR7eDi4ipfV0pK9fpOk39DQkLk39jYWISFhcLNzR1lZaU4duw4unTpYqrvjAxUVVWiTZu28nZ8fBxCQv6t7/j4BHTrJuoFyMrKQllZmVV9BwYGwMvLG5WVlfJ9Ndd3Tk42ioqKER5+qr6PHYOfny+8vX0s9d2ta1fo9Hrk5eYiLz8f7dq1k9uKePDy8oSvr5/oL4fo6MPV6jsfOdnZiGzfXm6bkpIsX5e//7/13alTRzg5OaOwsAAZGZlW9S1OVgQGihiBjNmoqPaW+j558iQ6djTX90k46PUIqh6zERFwdXVFaUkJkpKTLTGbnp4Oo9Eg681c36KO3N1FfZchMdEUs+3aRSAzMwMVFZVo29Zc3/EIDg6Cp6cXKsrLERcfb6nv7KwslJSWIiwsTN4WzxMQ4F+jvk0xm5OTg6LCQoRXj1kfH3kxx6ylvvPy5MVc38lJSfDw9ISf37/1XT1ms7Ky5WfbVN8pcHN1hX+1mO3YoQOcnE31nZ6egQ4dOsjHTpw4AScnRwQGmmLWfIyICPFFaXklMvKLERHobYqtghLoAPh7uZnKn5GHEF8PuDg5oryiEidzi9AuyMcUW4UlMBiMCPB2N5U/Mx8BXm5wc3FCRWUVUrIL0D7YVz6WW1SKikoDgnxM26ZkFcDP0xXuLk6orDIgKTMfUSGmbfOKy1BWXokgXw+U613kfjz1lfB0dYbBYMCxjHxEBfsAOh0KistQVFaBNn6epnjJKYSHqzO83JxhNBqRmJ6HyCBveeKssKQcBSXlaOtv2jYttwhuzo7wdncxfY7SctEu0BsODnoUlZYjr6gMoQFeps9nXhGcHR3gcepz1RzHCBGXPEa0/DEiPDQU1zq7o6KyAuuOxaOT5Xut7mOEPiwKxooKGJOSoe9gKq8xNw/GsjLoQ0xlMKSkQufrA504CVtVCUNiEvQdxWdXB2N+PozFJdC3MX0vGFJPQOflCZ2XF2A0wBB/DPoOkege6Kn5MUJ8L4aEBMt60up3hDwm83cEf0fU+B0hYkgcY7X6HdHQMaI1/Y5waaZcw/z9eTq6tqHhSnSyFzNnPvLICPzw448Nbrd/3x68MXsOli9fUefj4otPXMxEoO/etQNdunZHYaE6XZvEG2SPYw3J9jE2TdrdNBRK0+ngHWE6CZKfFKPEuLzjG1c223MzLrXhqtPh93am5P3q49EoPU2cLQgy/fBqbmNOjRPUGuOSVMS4tG+ip2LMkejT5jZKt+TV1LZtG5l9p6el17uNONNlCzNw2kIZqXVibJKKGJekIsYlqYhxSZqPyRPNuz179pAXIaJdhLwedqrp96Upk3HRRRfKrnS9el2NpR8tQUJiIrb8+qvNv3vmLhdEqmFskooYl6QixiWpiHFJmrfknX/+efj8s3WW29OnTZV/16xdh4kTJ8lxU4MG3SPHo4mxGb/++htmz5nLMxREREREREQqJnl//rkdoWH1998fev8w2CsxaJpIRYxNUhHjklTEuCQVMS5J+SUU7Jlpdk0i9TA2SUWMS1IR45JUxLikJk3yRJdKajwxhSyRihibpCLGJamIcUkqYlzSWSd5I59+Cv3797Pc/uCD93DwwD7s3PEPevQwTbVMDRPr2hCpiLFpI4xGlGSdkBcVlk9oboxLbVQajZiWmSIv4jpZY1ySihiXdNZJ3gMPDJML+wnXXnONvAwb9iA2b96Ml6ZMYc02glgAnUhFjE3bUVGULy+tAeNSG5UAvi7KkxdxnawxLklFjEs66yQvKCjYkuTdfPNN+Pqbb/Drb7/hvffflzNm0umJmUOJVMTYJBUxLklFjEtSEeOSznp2zby8PLkGR2rqCdxww/V4Y/Yceb9Op4ODgwNrloioBTi6esi/laVFrG9q0IKg+meyPh1/vel7PdtQxVomIrIRZ5Xkff/993j3nf/JKVr9/PywadNmef85Pc9BYmJiU5fRLmVnZ2tdBKI6MTZthE4H9+BweTU/Kcbux+UxLrXr7nOOq7u8vq24AAaNyqEqxiWpiHFJZ53kTZ02HUlJyQgNbYsZM2ahuLhY3h8cEoxlyz5hzTZCSYmpzohUw9gkFTEuSUWMS1IR45LOOslzcnLCBwsX1rp/8eIPWauNFBYWjvz8aNYXKYexSSpiXJKKGJekIsYlnfXEK/v27sb8eXNx2aWXshaJiIiIiIhsPckbPfpZ+Pr6Yu3a1di69VeMGvk0QkJCmr50duwYxy6SohibpCLGJamIcUkqYlzSWSd5P/z4Ix55dAQuuvhSfPrpcgwYMAB///Unli1bittu68MZNhvBz9+fEUhKYmySihiXpCLGJamIcUlnneRVn71n0aLFuPmWWzF9+iu4plcvLF60ELt37cDz48fBzdWVtVwPb29v1g0pibFJKmJckooYl6QixiWd9cQrZoGBgRg86B4MHjwI4eHh+Pbb77Bq9Wq0bdsWI59+GhdddBHuG3o/a7oOhiquN0RqYmzaCKMRJdlpluv2jnGpDRFZR8tLLdfJGuOSVMS4pLNO8kSXzHuHDMZ1112Ho0ePymUTPv9iPfLz8y3b7NixE79u2cRarseRmBjWDSmJsWk7Kgpz0VowLrUhErsTlRUa7V19jEtSEeOSzrq75oL583AyLQ13DrgLt9zaB0s/XmaV4AlpaWl4++3/sZbr0b17N9YNKYmxSSpiXJKKGJekIsYlnXVL3oUXXoySUlP3jfqUlpZi/oI3Wcv10rFuSFGMTVvh4OIm/1aVlcD+MS614qN3kH/zDBxmUBvjklTEuKSzTPKqJ3guLi5ycfTqCgsLWbenkZubwzoiJTE2bYROB4+QdvJqflKM3Y/LY1xq193nfFd3eX1bcQEMUMOCoIgW2c+YjKQGH2dckooYl3TWSZ6bmxumTJ6Efv36ws/Pr9bjEe3as3ZPo6CAiTCpibFJKmJckooYl6QixiWd9Zi8l6ZMxtVXX4UJEyehvLwc48e/gLnz5stxeM88+xxrthEiIlrmLCTRmWJskooYl6QixiWpiHFJZ92Sd8stN8tk7s8/t8tJWP76+28kJiYiOTkZdw0ciPXrv2TtEhERERER2UpLnq+vL44fOy6vFxQWytvC33//gyuuuLxpS2injh831R+RahibpCLGJamIcUkqYlzSWSd5x44dR0Q704D/uNhY9O/XV16/9ZabkVdjKQWqm4+3N6uGlMTYJBUxLklFjEtSEeOSzjrJW7N2LXr26C6vv/Puexg+fDji445i2rSpeP/9D1izjfkAnmr9JFINY5NUxLgkFTEuSUWMSzrrMXmLF39oub516zZce931OO+8c+W4vOjow6zZRjAaVJmImsgaY9NGGI0ozUm3XLd3jEuN6h1AfLlp2ST7j7Izx7gkFTEu6aySPJ1OhyGDB+O22/sgIjwCRqMRSUlJ+Obbb5ngnYHDR44wAklJjE3bUV7QetbbZFxqQyR2yZUVGu1dfYxLUhHjks6qu+bHH3+EuXNno22bNjh8+DBiYmIQFh6GNxfMx0dL/m3ho4Z17dKFVURKYmySihiXpCLGJamIcUln3JInWvCuuPxyDB5yL/7440+rx8S6eSLJu+eeu/HZZ5+zdk9D7+DAOiIlMTZth97ZVf41nOpOZ88Yl9rx1JvOBxdymEEtjEtSEeOSZBycSTUMGHAn/ve/d2oleMLvv/8hJ2ER6+TR6eXn57GaSEmMTRuh08GzTaS8iOv2jnGp3Y+Ei1w95OWsZmqzc4xLUhHjkoQzOmZ3794Nm7dsqffxzZs2o8epWTepYTk5uawiUhJjk1TEuCQVMS5JRYxLOuMkTyx6npGRWe/jGZmZ8PHxYc02QmRkJOuJlMTYJBUxLklFjEtSEeOSzjjJc3BwQGVlZb2PV1VVwdHxrFZlICIiIiIioibgeKbLJ7z55nyUl5XX+bizi3NTlKlVSE5O1roIRHVibJKKGJekIsYlqYhxSWec5K1b91nDGxSAM2s2koeHOwoKChiFpBzGJqmIcUkqYlySihiXdMZJ3pix41hrTcTPzx8nT6axPkk5jE1SEeOSVMS4JBUxLkngADoiIltkNKIs79REWEaj1qUhOyUi61hFmeU6ERHZBiZ5GomOjtZq10QNYmzajrK8LLQWjEstk7y6x+ET45LUxOMlCVzbVCOdO3dmBJKSGJukIsYlqYhxSSpiXJLAJE8jXGqCVMXYtB16J2d5aQ0Yl9px1+nlhWpjXJKKGJck8KitkYKCfEYgKYmxaSN0Oni2jZIXcd3eMS61+5FwiZuHvPAHQ22MS1IR45IEjsnTSGZm6xlLQ7aFsUmqx+WCoIgW2eeYjKRm30dLvRZqHjxekooYlyTwxJxGoqKiGIGkJMYmqYhxSSpiXJKKGJckMMkjIiIiIiKyI0zyNJKamqrVrokaxNgkFTEuSUWMS1IR45IEJnkacXFxYQSSkhibpCLGJamIcUkqYlySwCRPIwEBAYxAUhJjk1TEuCQVMS5JRYxLEji7JhGRLTIaUZafbblO1CxhBiCpotxynYiIbIOmLXmXX345ln38EXbt3IHUlCT06d271jbPjx+H3bt2IC72KNasXomoqPawB4cPH9a6CER1YmzajrLcDHlpDRiX2hCJXUJFmbwwyauNcUkqYlyS5kmeu7sbDh6KxqTJU+p8fOTTT+GRRx7GhAmT0LdfPxQXl2DliuV20de4QwcuoUBqYmySihiXpCLGJamIcUmad9fcvHmLvNRnxIhH8dZb/8OPP/0kbz/z7HPYu2eXbPH7asMG2DJnZ9tPVMk+MTZth87BdAg3VlXC3jEuteOi08m/ZewWXAvjklTEuCSlJ15p164dQkJCsHXbVst9BQUF2L17Dy6++CLYuqKiIq2LQFQnxqaN0OngFdZRXsR1e8e41O5HwuVunvKi7A8GDTEuSUWMS1J64pXg4CD5NyMj0+r+jMwMBAcH1/t/zs7O8mLm4eEBFaWlndS6CER1YmySihiXpCLGJamIcUlKJ3lna/SokRg3bmyt+7t27Yri4mIcOXIE7du3l+P6xG2xYGSnTp0sHwqdTm9JIo8ePYqI8HC4urmhtLQUSUlJ6Ny5s3wsIz0dVQYD2rRpI2/HxcXJ6yKpLC8vQ0JCotynkJmZifLycoSGhsrbCQkJuOyyS2UCW1FRjtjYOHTv3l0+lp2djZKSYoSFhcvbxxIT4efvD29vbxiqqnAkJgbdu3cTp/GRm5uDgoJCREREyG2PHz8OH29v+Pj6wmgw4PCRI+japQv0Dg7Iz89DTk4uIiMj5bbJycnw8HCHn5+/vB0dHS1fm6OjIwoK8pGZmYWoKNO4QVFHor7MU/KKAb2iv7foDiDOFol669Cho3zs5MkTcHBwRFCQKUmPiYlBZGQ7uLi4yteVklK9vtPkX9FiK8TGxiIsLBRubu4oKyvFsWPH0aVLF1N9Z2SgqqoSbdq0lbfj4+MQEvJvfcfHJ6BbN1EvQFZWFsrKyqzqOzAwAF5e3qisrJTvq7m+c3KyUVRUjPDwU/V97Bj8/Hzh7e1jqe9uXbtCp9cjLzcXefn5spVZEPHg5eUJX18/OT1BdPThavWdj5zsbES2N00UlJKSLF+Xv/+/9d2pU0c4OTmjsLBAxkL1+hYnKgIDA+VtEbNiwiFzfZ88eRIdO5rr+yQc9HoEVY/ZiAi4urqitKQEScnJlphNT0+H0WiQ9Waub1FH7u6ivsuQmGiK2XbtIrBr1y5UVFSibVtzfcfLEy+enl6oKC9HXHy8pb6zs7JQUlqKsLAweVs8T0CAf436NsVsTk4OigoLEV49Zn185MUcs5b6zsuTF3N9JyclwcPTE35+/9Z39ZjNysqWn21TfafAzdUV/tVitmOHDnByNtV3enoGOnToIB87ceIEnJwcERhoilnzMSIixBel5ZXIyC9GRKC3KbYKSiDazPy93Ezlz8hDiK8HXJwcUV5RiZO5RWgX5GOKrcISGAxGBHi7m8qfmY8ALze4uTihorIKKdkFaB/sKx/LLSpFRaUBQT6mbVOyCuDn6Qp3FydUVhmQlJmPqBDTtnnFZSgrr0SQrwdOza2JIG93eLo6wWAw4FhGPqKCfWTrXkFxGYrKKtDGz9MULzmF8HB1hpebM4xGIxLT8xAZ5A29Xo/CknIUlJSjrb9p27TcIrg5O8Lb3dS1PCEtF+0CveHgoEdRaTnyisoQGuBl+nzmFcHZ0QEepz5XzXGMEHG5adNmeYzQt4mEsbwcxtQT0Lc3HdOM2TkwVlVBH2T63BiSkqELDIDOzQ3GinIYk1Kh72CKD2NuLozlFdCfOploSE6Bzs8XOnFSsKoShsQk6DtGoXugZ7MfI8R+ZBnS0qFzdYHOxxQ/hrgE6CLDoXN0grG4WL4+fbjpM2ZIz4DOyUmWWd5OSIQuPBQ6J2cYS0pgzMiCvp2pvIbMTFk23aljjyHxOHRtQ6BzcYGxrAzGk2nQR5rKa8zKlnGhDzR9bgzHk6AT9Zl7queJXg991Kn6zsmBsbJ6fadAF+AHnbs7jBUVMCYlQ39q7LkxN0/uSx9iOk4ZUlKh8/WpUd/ivdHBmJ8PY3EJ9G1M3wuG1BPQeXlC5+UFGA0wxB+DvkMkoNPDWFAAY0Eh9KGm45ThZBp07m7QeYvPqxGGuETo20cADo4wFhXJcujDQv+tbxcXWQ55Oz4BuohwWa+yvrNyoI8IkzFQ3zFCfC+GhATj99//0OR3RFBQoOmYzN8R/B1R43eEiCFxrNLqd4QpZjNa/e8Il2bKNczfn6ejaxsarsSEWWJ2zUceGYEffvxR3hZfktv//B233NobBw8esmz3+WfrcPDgQbw8dVqjW/LE7JxdunZHYWEhVCGSDPFDn0g1jE2TdjcNhdJ0OnhHmE6C5CfFKLGMwvGNK1skLhcEmU4SNLcxGUnNvo+Wei1nS3TR7OVuSua3FRfAgNbldDHA4yWpiHFp3zw9PRFzJPq0uY2yXezFGX5xFrdXr15WL+rCCy/Azp276v0/caZLvGDzRdV+yeJsNpGKGJukIsYlqYhxSSpiXJLm3TVF8271de8i2kWgZ88eyM3JRUpqKj78cAmefWY0EuITcDwpCS88P14mfubWPlsmuisRqYixSSpiXJKKGJekIsYlCZpmGueff57sfmk2fdpU+XfN2nUYM2Ys3n3vfZkIzp79uhyT9s8//+D+YQ/Ifr+2TvSnFX3siVTD2CQVMS5JRYxLUhHjkjRP8v78cztCwxoejzBn7jx5ISKiaoxAeUGO5TpRcxChlVpRzjAjIrIx7DOoETGjHJGKGJu2wojSnHS0FoxL7ZK82Arb7z3TXBiXpCLGJSk98Yq9E1OGE6mIsUkqYlySihiXpCLGJQlM8jQi1oQiUhFj03bo9A7y0howLrXjBJ28UG2MS1IR45IEJnkaEYv+EqmIsWkjdDp4hXeSF3Hd3jEutfuRcKW7p7zwB0NtjEtSEeOSBB6zNZKSksoIJCUxNklFjEtSEeOSVMS4JIETr2ikU6dOiI6OZhSSclSPzXY3DdW6CKTBexMV4ouEtFzTjX1b+R6QElQ/XlLrxLgkgS15REREREREdoRJnkbS0tK02jVRgxibpKLsghKti0BUC4+XpCLGJQlM8oiIiIiIiOwIkzyNhISEaLVrogYxNklF/l5uWheBqBYeL0lFjEsSOPEKEZEtMgLlhXmW60TNFGY4WVnBMCMisjFM8jQSGxur1a6JGsTYtBVGlGafRGuRlJGvdRFabZIXU16qdTGUxeMlqYhxSQK7a2okLCyUEUhKYmySioJ93LUuAlEtPF6SihiXJDDJ04ibG3+wkJoYmzZEpzNdWgEXZ3Y80fKHAn8s1I3HS1IR45IEHrc1UlbG7i+kJsamjdDp4B3RRV5aQ6JXXlGldRFa7Y+EXu5e8sIfDLXxeEkqYlySwGO2Ro4dO84IJCUxNklFJ3IKtS4CUS08XpKKGJckMMnTSJcuXRiBpCTGJqkoMthH6yIQ1cLjJamIcUkCBzkQERER1WFBUESD9aL3CYIh6L+1Mo/JSGLdE1GTY0ueRjIyMrTaNVGDGJukopzCEq2LQFSLMTuHtULK4fc4CUzyNFJVVckIJCUxNklFVQau+E7qMVZxQiBSD7/HSWCSp5E2bdoyAklJjE1SUaA3l50h9eiDArUuAlEt/B4ngWPyiIhskRGoKC6wXCdqpjBDRmUFw4yIyMYwydNIfHycVrsmahBj01YYUZKZitYiOTNf6yK02iQvupzrutbHkJTcou8HUWPwe5wEdtfUSEhIG0YgKYmxSSoK8HLTughEtegCA1grpBx+j5PAJE8jHh4ejEBSEmOTVOTm4qR1EYhq0bnx5AOph9/jJLC7pkbKy8sYgaQkxqaN0OngHdFFXs1PigGM9j0wr6KSsxhqdSa4l7uXvL6tuAAGTUqhLmNFudZFIKqF3+MksCVPI/HxCYxAUhJjk1SUknVqkhkihRiTWs+4WLId/B4ngUmeRrp168YIJCUxNklF7UN8tS4CUS36Du1ZK6Qcfo+TwCSPiIiIiIjIjnBMnkaysrK02jVRgxibpKK8opafxn9BUESL75NsizE3V+siENXC73ES2JKnkbIyTrxCamJskorKOfEKKchYbloonkgl/B4ngUmeRkJDQxmBpCTGJqkoyIfLzpB69MFBWheBqBZ+j5PA7ppERLbICFSUFFquEzVTmCGrqpJhRkRkY5jkaSQhgUsokJoYm7bCiJKMFLQWqVxCQbMk72BZiTY7twGG5NbzGSTbwe9xEthdUyOBgQGMQFISY5NU5OPhonURiGrR+XFpD1IPv8dJYJKnES8vb0YgKYmxSSrycHXWughEteg8OFaU1MPvcRLYXVMjlZWmMQ5EqmFs2gidDl5hneTVgpRYwGjfA/OqqgxaF6HVngm+0s1TXv+zpBB8F2o4NV6RSCX8HieBSZ5Gjh49yggkJTE2bYdO33o6YxzPzNe6CK2Wg06ndRGUZUhM0roIRLXwe5yE1vMLQTHdu3fXughEdWJskoqiQjj2idSj7xildRGIauH3OAlM8oiIiIiIiOwIkzyN5ORka7VrogYxNklF+cVlWheBqBZjHrsRk3r4PU4CkzyNFBUVMwJJSYxNUlFJOSe4IPUYS7iGIKmH3+MkMMnTSHh4OCOQlMTYJBWF+HKqelKPvk2I1kUgqoXf4yRwdk0iIhtVWcoeAdT8crlMABGRzWGSp5Fjx45ptWuiBjE2bYTRiOL01jN9+4nsQq2L0CqJdfH2lbFLYr31k3qiRd8Posbg9zgJ7K6pET8/TgdOamJskoq83Jy1LgJRLTpvL9YKKYff4yQwydOIt7cPI5CUxNgkFXkyySMF6Tw9tS4CUS38Hiflk7xxY8cgNSXJ6vLbr5thDwxVVVoXgahOjE0bodPBM6yjvIjr9s5gEB0HSYsfCVe4eciL0j8YtGLgdzmph9/jZBNj8g4fPoIh995nuV1VaR/TaB+JidG6CER1YmzaDr2D8ofwJnMsg+uRacVZx/SuPoaE4y36XhA1Br/HSVD+yF1VVYmMjAzLJTsnB/agW9euWheBqE6MTVJR+2B2cSf16DtEal0Eolr4PU42keRFRUVh184d+POPbXjnf28jLDQU9kCnV77qqZVibJKKdK2gSyrZILZykoL4PU6C0n19du3ejefGjEVcXByCg0MwbuxzWL/+c9xw480oKiqq83+cnZ3lxczDQ80FdPNyc7UuAlGdGJukooKScq2LQFSLsaCAtULK4fc4KZ/kbd68xXI9Ovowdu/ejb//+hP9+/XFqtVr6vyf0aNGYty4sbXu79q1K4qLi3HkyBG0b98eLi4u8nZqaio6deokt0lLOwmdTo/g4GB5++jRo4gID4ermxtKS0uRlJSEzp07y8cy0tNRZTCgTZs28rZIRMV1kVSWl5chISFR7lPIzMxEeXk5Qk+1QiYkJMDTyxPdfbujoqIcsbFx6N69u3wsOzsbJSXFCAsLl7ePJSbCz98f3t7eciCt6GfdvXs3cZ4Gubk5KCgoREREhNz2+PHj8PH2ho+vL4wGAw4fOYKuXbpA7+CA/Pw85OTkIjLS1LUkOTkZHh7u8PPzP1W/0fK1OTo6oqAgH5mZWbIVVRB1JOorICBA3j58+DA6dIiCs7OLTLZFvXXo0FE+dvLkCTg4OCIoKEjejomJQWRkO7i4uMrXlZJSvb7T5N+QkBD5NzY2FmFhoXBzc0dZWSmOHTuOLl26mOo7I0N23W3Tpq28HR8fh5CQf+s7Pj4B3bqJegGysrJQVlZmVd+BgQHw8vJGZWWlfF/N9Z2Tk42iomKEh5+q72PH5NTDYmYqc32Lbg/irJg4aObl56Ndu3ZyWxEPXl6e8PX1E1/1Mkb/re985GRnI7J9e7ltSkqyfF3+/v/Wd6dOHeHk5IzCwgJkZGRa1bc4UREYGChvi5iNimpvqe+TJ0+iY0dzfZ+Eg16PoOoxGxEBV1dXlJaUICk52RKz6enpMBoNst7M9S3qyN1d1HcZEhNNMSv+t6KyAhUVlWjb1lzf8QgODoKnpxcqyssRFx9vqe/srCyUlJYiLCxM3hbPExDgX6O+TTGbk5ODosJChFePWR8feTHHrKW+8/LkxVzfyUlJ8PD0RFSIr1wjLiE9D+0CveHgoEdRaTnyissQ6m+azjw9rwgujg7w8XA1xUBaLsIDvODk6IDisgrkFJYgLMDb9PnML4ajXg9fT9O2x9Lz0NbfE86ODigtr0RGfjEiAk3bZhWUQLQn+Xu5mcqfkYcQXw+4ODmivKISJ3OL0C7I1K1Q7MNgMCLA291U/sx8BHi5wc3FCRWVVUjJLkD7YNNSKrlFpaioNCDIx7RtSlYB/Dxd4e7ihMoqA5Iy802vW3x5F5ehrLwSQb4eyD51fAvydoenq5OcnESMXYsSXRt1OhQUl6GorAJt/EwzAJ7MKYSHq7NcjsBoNCIxPQ+RQd7Q6/UoLCmXiZR47fLzmVsEN2dHeLu7WOrQqr6LyhAaYKrvjLwiWV/m+k5My0XYqfouKauQ9RYe+G99O+h18PN0+7e+/Tzh7OQgX1d6XjEigkzbZheY1mcT9S3ez9zCUgT7uGP37XejstKA3NxiBAaayltUVCbr28vLVIbs7CJ4errA2dkRVZUGZOcUISjIVN7i4nJUVlbB29tUhpycIri7u8DFxVE+R1ZWodz24iO7YMzLh7GkBPo2IZZ10cS0+XJWRUOVHJMlu+zp9PIHv7GwCPq2ps+Y4cRJ6Dw8Tk2zb4QhLhH6qHZiMCWMhYXyufVhpuOUIS0dOlcX6HxM8WOIS4AuMhw6RycYi4thzM6BPtz0GTOkZ0Dn5ATdqaV4DAmJ0IWHQufkLMtqzMiCvp3pmGbIzJSfJ92pY48h8Th0bUOgc3GBsawMxpNp0EeaPmPGrGwZF/pA07HecDwJuqBAEaCmQNProY8yfYcYc3JgrKyCXjwutk1KgS7ADzp3dxgrKmBMSoa+g+mYZszNk/vSh5iOU4aUVOh8fWTdoKoShsQk6DuKY6UOxvx8GItr1LeXJ3ReXoDRAEP8Mev6LiiEPtR0nDKcTIPO3Q06b+9/67t9BODgCGNRkSyHVX27uMhyyNvxCdBFhMt6lfWdlQN9xKn6zsiEztEBOj+/U/V9DLqwttA5O8MII5CTC3070zHNkJklW511Aafq+9hx6NpUq+8TadC3P1Xf2dnyuNc9sPtZ/Y4ICgo0HZP5O4K/I2r8jsjNzZW/w7T6HWGK2Qxlf0f4+f37u636b9+srGyZI8jv4ZQUuLm6wr/ab9+OHTrAydn0uy09PQMdOnSQj504cQJOTo4IDDT99m3uXMP8G/t0dG1Dw42wId99+w22bt2K115/o9Etebt37UCXrt1RWKjOYroiyRA/9IlUo3pstrtpqNZFUINOB+8I00mQ/KQYmfjaM5HkikRT6OliSs6a263//ITWTgws6OVuSo63FRfIxdGpWv10jJIJ+X8xJiOJVUqt6nuc/htPT0/EHIk+bW5jUwPDxJkC0RIlziTUR5zpEi/YfKmvWycRka2rKiuRF6LmVFBVJS9ERGQ7lO6u+fJLU/DTz7/IroVt2oRg/LixMBiqsP7Lr2DrRHMskYoYmzbCaERRWuuZvl10NaWWJ1rudpcVs+rrq58TJ1k3pBx+j5PySZ7ox/veu+/IMVJZ2dn45+9/0LffnXLcmq0TY7lU6j5KZMbYJBV5uDihpNw+1kkl+yHGFYoxhEQq4fc4KZ/kPfX0SNgrMVnHCZ4BJAUxNklFXu4uyDw1EQuRKsSkOsaMTK2LQWSF3+OkfJJn3+x7kgSyZYxNm6DTwbOtaSa1whMJdj/xit2/PkWJgfuXuJqWItpRWsSJV2phXJKKGJfEJE8zYtpWIhUxNm2H3tEJrYVYMoO04aq3qTnaWpRYpoFINfweJ4FHbo2I9dSIVMTYJBWJ9fyIVCPXPSRSDL/HSWCSpxGxYDaRihibpCKxYDuRcvT8Lif18HucZBywGrSRn5/PqiclMTZJRUWl5VoXgagWI2fJJgXxe5wETryikRw7WAaC7NPZxma7m4Y2eVmIzPKLy1gZpBxjHk/Yknr4G5MEtuRpJLJ9e0YgKYmxSSpq6++ldRGIatGHhbJWSDn8HieBLXlERDaqqpytW9T8igxVrGYiIhvDJE8jKSnJWu2aqEGMTRthNKLoZOuZvj09t0jrIrRKBgA7S4u1LoayDGnpWheBqBZ+j5PA7poacXNzZwSSkhibpCIXZ56TJPXoXF20LgJRLfweJ4FJnkb8/f0ZgaQkxiapyMedP6ZJPTofH62LQFQLv8dJ4KlRIiJbpNPBIyRSXi1KOya7bxI1x5ngC11NPU92lxbL7ptERKQ+JnkaiY6O1mrXRA1ibNoOB+fW07qVkJardRFaLQ8u+F0vQ1xCS74VRI3C73ES2F1TI506dWQEkpIYm6SiiEBvrYtAVIsuMpy1Qsrh9zgJTPI04uTkzAgkJTE2SUWODvy6IvXoHJ20LgJRLfweJ4HfmhopLCxgBJKSGJukouKyCq2LQFSLsZjLS5B6+D1OApM8jWRkZDICSUmMTVJRTmGp1kUgqsWYncNaIeXwe5wETryikaioKA6MJSUxNuls9XRxa7bKCwryQkYGe0CQWvThYf958pUFQRFoCWMyklpkP6Q9fo+TwCSPiMhGGSrZhZGaX6mBCycQEdkaJnkaSU1N1WrXRA1ibNoIoxGFqfFoLQoK2F1TCyK9+7u0SJN92wJDeobWRSCqhd/jJHBMnkacnTm7JqmJsUkqcuDsmqQgnRNn1yT18HucBCZ5GgkMDGQEkpIYm6Qid3eeGCP16Px8tS4CUS38HieB3TWJiGyRTgePYNOEDUXpSbL7JlFznAk+38VdXt9bViy7bxIRkfqY5GnkyJEjWu2aqEGMTdvh0IyzWaomM5Mza2rFy8FBs32rzpCQqHURiGrh9zgJ7K6pkaio9oxAUhJjk1Tk5+uhdRGIatGFh7JWSDn8HieBSZ5GnJ1dGIGkJMYmqcjBkV9XpB6dE8eKknr4PU4CvzU1UlTEKalJTYxNUlF5eaXWRSCqxVhSwloh5fB7nAQmeRo5efIkI5CUxNgkFRUWlmldBKJajBlZrBVSDr/HSWCSp5GOHTsyAklJjE1Skb8/x+SRevTtwrUuAlEt/B4ngbNrEhHZKEMVuzA2p58uvRXN7dZ/foLqyo1cOIGIyNYwydMIm9JJVYxNG2E0ojAlDq1FYUGp1kVolUR6t72EY8jrrZ/MzBZ9P4gag9/jJLC7pkYc9Kx6UhNjk1Sk0+u0LgJRLTp+l5OC+D1OAjMNjQQFBzMCSUmMTVKRhweXnSH16Pz9tS4CUS38HieB3TWJiGyRTgf3INOkD8UZybL7JlFznAk+x8VNXj9QViK7bxIRkfqY5Gnk6NGjWu2aqEGMTdvh6OqO1iIrq1DrIrRavg78qVAfQ+LxFn0viBqD3+MksLumRiIiIhiBpCTGJqnIx9vUmkSkEl3bEK2LQFQLv8dJYJKnEVdXV0YgKYmxSSpydHLQughEtehcOFaU1MPvcRKY5GmktKSEEUhKYmySiiorqrQuAlEtxrIy1goph9/jJDDJ00hScjIjkJTE2CQV5eXzxBipx3gyTesiENXC73ESmORppHPnzoxAUhJjk1QUEOCpdRGIatFHtmOtkHL4PU4Cp8wiIrJRRkPjJrTveWoKfKKzUcXlOYiIbA6TPI2kp6drtWuiBjE2bYTRiILk1rMUS1Ehxz5pQZxG+L2Ey1fUx5iV3aLvB1Fj8HucBHbX1IjRyCVlSU2MTVKREVzsndRjZCsnKYjf4yQwydNISEgbRiApibFJKvL05LIzpB59YIDWRSCqhd/jJLC7JhGRTdLBLShUXivJSJVtXURNH2VAj1NjOg+VlTDKiIhsBJM8jcTGxmq1a6IGMTZthA5wcjPNOFmis/8cLzuL48K0IEIrwMH0U6EVhNkZMxxP0roIRLXwe5wEdtfUSGio6Qw8kWoYm6QiL2/OEErq0QUHaV0Eolr4PU42k+Q9NHw4/tr+B+LjjuKbrzfgggsugK1zd3fXughEdWJskoqcnBy0LgJRLTpXjhUl9fB7nGwiyevfvx+mTn0J8+e/id59bsehQ4ewcsWnCAiw7cHOZWWcDpzUxNgkFVVVckZiUo+xvFzrIhDVwu9xsokk7/HHHsPKlauwZu1aHD16FC9OmIiSklLcd+8Q2LLExESti0BUJ8YmqSgnt0jrIhDVYkw5wVoh5fB7nJRP8pycnHDeeedi69ZtVmvSbN22FRdffDFsWdeuXbUuAlGdGJukosBAL62LQFSLPiqStULK4fc4KT+7pr+/PxwdHZGRmWF1f2ZGJjp17FTn/zg7O8uLmYeHh9VflfpLe3qaZsYjsofYdHd1aZbyUD10Ori7OMmrlaLuG1iU2dXF9t8bFxdnuNphjDl6uCt/JljnZiqjo64K7DRbo37c3GBQ/D008yzhb47Wgr8x7Vtjcxqlk7yzMXrUSIwbN7bW/bt37dCkPERERPWbaDOVM1DrAtB/cjfrj8jukr3CwkLbTPKys7NRWVmJoEDrKYoDgwKRkWHdumf2v3fexcJFi63u8/PzQ05ODlR6U0TSeeFFl6CoiONMSB2MTVIR45JUxLgkFTEuW8/7nJaW1uA2Sid5FRUV2LdvP3r1uho//PijvE+n06FXr174eOnHdf5PeXm5vFTXUJarJZHgqVo2at0Ym6QixiWpiHFJKmJc2rfG5A9KJ3nCosWL8eaC+di7bx92796Dxx57FO5ubli9Zq3WRSMiIiIiIlKO8knehg1fI8DfH8+PH4egoCAcPHgI9w97AJmZmVoXjYiIiIiISDnKJ3nC0o+XyYu9EN1J582bX6tbKZHWGJukIsYlqYhxSSpiXJKZrm1oeP3zbhMREREREZFNUXoxdCIiIiIiIjozTPKIiIiIiIjsCJM8IiIiIiIiO8IkTwMPDR+Ov7b/gfi4o/jm6w244IILtCgGtVKXX345ln38EXbt3IHUlCT06d271jZiNtvdu3YgLvYo1qxeiaio9pqUlVqPUaNG4rtvv0HMkWjs27sbHy35EB07drDaxsXFBbNmzsCBA/twNOYwFi9aiMDAQM3KTPbvwQcfwC8//4Qjhw/Jy4YNX+KGG663PM6YJBWMGvm0/D6fPn2q5T7GJjHJa2H9+/fD1KkvYf78N9G7z+04dOgQVq74FAEBAYxGahHu7m44eCgakyZPqfPxkU8/hUceeRgTJkxC3379UFxcgpUrlssvDKLmcuUVV+DjZcvQt9+duPe+oXB0csSqlSvg5uZm2WbatKm45Zab8cQTT+KuuwchpE0Ilny4iG8KNZsTJ05g1muvoc9tt+O22+/A77//gaUfLUGXLl0Yk6SE888/H8OG3Y+Dhw5Z3c/jJXF2zRYmWu727t2LyVNekrd1Oh12/PM3li5dinfefY8RSS1KnPl75JER+OHHHy33iRa8hQsX44OFC+VtLy8v7N2zC2PGjMNXGzbwHaIW4e/vjwP792LgXffgr7/+knG4f98ejBw1Gt9++53cplPHjvjtty3o268/du3azXeGWsTBA/sxY8YMfPPtd4xJ0pS7uzt+/PF7TJo0Gc8+8wwOHjqIqVOn83hJElvyWpCTkxPOO+9cbN26zXKf0WjE1m1bcfHFFzMkSXPt2rVDSEiIjEmzgoIC7N69BxdffJGmZaPWxdvbW/7Nzc2Vf8Wx09nZ2er4GRsXh+TkZB4/qUXo9Xrc2b+/7A2xY+cuxiRpbtasGdi4cZPVcVHg8ZJsZjF0ezoz7ejoiIzMDKv7MzMy0aljJ83KRWQWHBwk/2ZkZFpViojZ4OBgVhS1CNHDQYwt+fvvv3HkyBFTbAYFo6ysDPn5+daxmZGJ4CBT3BI1h27duuHrDV/KLutFRUV4dMRjOHr0KM7p2ZMxSZoRJxzOPedc3H5H31qP8XhJApM8IiJSyqxZM9Gta1cMGHiX1kUhQlxcHG65tY/sAtf3jtvx1psL5JhQIq2EhrbFK69Mk+OXxckvorqwu2YLys7ORmVlJYICrc86BwYFIiPDunWPSAvp6aY4DAqynrFQxGx6ejrfFGp2M2e8iltuvgn3DBqCEydO/hubGemyJcXcjdMSm0GBSOfxk5pRRUUFEhMTsX//frz2+htywrQRIx5hTJJmzjv3PAQFBeHHH77H8WMJ8nLVVVfi0UcekddF7xseL4lJXgt/Uezbtx+9el1t1S2pV69e2LlzJ6ORNHf8+HGkpaXJmDTz9PTEhRdegJ07d2laNmodCV6fPn0waPAQJCUlWT0mjp3l5eVWx0+xxEJ4eDiPn9SidHo9nJ1dGJOkma3btuGGG2+WLczmy549e/HF+vXy+t69+3i8JHbXbGmLFi/GmwvmY+++fXIyi8ceexTubm5YvWYtw5FabDau6uveRbSLQM+ePZCbk4uU1FR8+OESPPvMaCTEJ+B4UhJeeH68TPyqz8BJ1BxdNAcOuBMPPzIChYVF8iy1eeKf0tJS+XfV6jWYNvVlORlLQUEhZs54BTt27ODMmtRsJk54EZs2b0FKSoo84SVi9Korr8TQocMYk6QZMTbUPF7ZrLi4GDk5OZb7ebwkjslrYRs2fI0Af3+52LT4EXPw4CHcP+wBZGZaT3RB1FzOP/88fP7ZOsvt6dNMi6euWbsOY8aMxbvvvS8TwdmzX5dd4/755x8Zo+z3T83poeEPyr9ffP5vbArPjRmLtWtN902bNh1GgwGLFy2Ci4sztmz5FRMnTeYbQ80mMDAQb7+1QE48JU40REdHywTvt62mGYgZk6QqxiZxnTwiIiIiIiI7wjF5REREREREdoRJHhERERERkR1hkkdERERERGRHmOQRERERERHZESZ5REREREREdoRJHhERERERkR1hkkdERERERGRHmOQRERERERHZESZ5REREdub558dj9huvN9nzOTk54a/tf+C8885rsuckIqLmwySPiIiaRWpKUoOXcWPH2F3Ni0RoxIhHNS1DUFAQRjz6CN56+3+W+9zc3PD+e+9i964deO/dd+Dm6lrrf2a8+gr+/GMbEuJjseOfv7Ds44/Qq9fV8vGKigp88MFCTJ48scVfDxERnTkmeURE1CzOv+Aiy+Wll6ciPz/f6r73P1hoMzXv4ODQovsTLWdna+jQ+7Bjx06kpKRY7nvssREoKirCfUOHobS0FCMeG2F5LDw8HD98/x2uvvoqvDpjJm66+RYMvf8B/P7Hn5g1c4Zluy/Wf4nLLr0UXbp0+Q+vjIiIWgKTPCIiahYZGRmWS0FBAYxGo9V9A+7sj1+3bEJ83FH89utmDB/+oFXiIVr7+vXri/VffI642KP47ttv0KFDFM4//3x8/923OBpzGMs//QT+/v6W/1uwYD4+WvIhxo55Dvv37cGRw4fw+uuzrJImnU6HUaNGYvufv8vn/fnnH3HHHbdbHr/yyivkvm+44Xr88P23SEyIw2WXXYrIyEgs/WgJ9u7ZJfctynPNNb0s//fZurWIiIjAK9OnWVorBdFi+fNPP1jVjWjtE61+Ncv9zDOjsWvnDmz9bYu8PzS0LT744D1EHzqAgwf2y/2LumnInf374+eff7G6z9fHB/Hx8Th8+DBiY2Ph4+1teey1WTNhhBG339EP3333PeLjExATE4NFixajb787Ldvl5eXhnx07cOed/U/73hMRkbYcNd4/ERG1QgMHDsD48eMxecoUHDhwEOec0xNz5sxGcXEx1q37zLLd+HFj8fLU6bJVav78uXj3nXdQWFSIl1+eipKSEnyw8H05/mzixEmW/xFdDMvKynD3PYMRERGOBfPnIScnF2+8MVs+Pnr0KNx910C8OGESEhIScMUVl+N/b7+FrKxsbN++3fI8kyZNxKuvzMCx48dlgiMSro2bNuH1N2ajvLwM99xzDz5euhTXXnsdUlJTMeKxx/HLzz9i+YqVWLFi5RnXiSh3QWEB7r1vqLzt6OiIlSuWY+fOXRh41z2orKzEc88+g5UrPsVNN98qu1DW5Ovriy5dOmPvvr1W93+09GOsXbMaL774AhITEzHk3qGW7UUyK16TqM+aROtrdXt278Hll112xq+NiIhaFpM8IiJqcePHjcMrr7yK7783tXAlJSXJboAPDLvfKskT48B+/fVXeX3Jhx/h/fffxaDBQ2SLkrB61WoMHjzI6rnLKyowduw4lJSWyhapOXPn4aUpkzF79hzZovfM6FEYcu99MnkSjh8/Lrshin1XT/LmzpmH37ZutdzOzc3FoUPRlttz5szFbX364NZbb8HSj5fJx6uqqlBYWChbKs+USHDHj3/BkrzddddA6PV6jBv/vGWbMWPH4XD0QVx15ZX49bffaj1HWFio/J+0tDSr+5OTk3F1r2sQGBhoVbb27dvL7WNj4xpVRvG84eFhZ/zaiIioZTHJIyKiFiUmAYmKao958+Zgzpw3rMa9iW6d1R2KPmy5npFpSk6iq9+XkYmAgEDr/zl0SCZ4Zjt37oSnpydCQ0Ph4eEBd3d3rF5l3dImkj/Roljd3n37rG6L/xMtizfddCOCg4NlS5urqyvCwpom6RFdKau3zvXs0UMmYaJraHUuLi6IbB8J1M7xZHmE0tKyWo+Zu8tWp9OdWRlFvYr3j4iI1MYkj4iIWpRItITxz7+A3bv3WD0mWsKqq6yssEpSTPdV/nsfjNDrG5+peHi4y78PPPgQTp48afWY6IJZs2WtupdfnoJrr7kWr7w6Q3Z5FBOYLF70AZycG54kxWAw1MqmnBxrf/0WF1t3l3T38MC+ffsxavQztbbNysqqc1/Z2dnyr6+vj+V6QxISEmX5OnXqiMbw8/WV3VqJiEhtTPKIiKhFZWZm4sSJk3Iik/Xrv2zy5+/Ro4ds0RJJmHDRRRfJLpSpqamyS6W4X3RrrN41szEuveRSrF23Dj/88IOlZa/mJCiiq2jNmTizsrMRHBRkdV/Pnj1Pu7/9+/ejf79+sr5E+RsjMfGYHEfXpXMXOYHK6Yj62LLlVzz00HAsWfJRrXF53t7eVuPyunbrigMHDzSqLEREpB3OrklERC1u3rx5GD1qJB595GE5Y2a3bt0wZPBgPP74Y//5uZ2dnDBv7hx07twZN954g+xiuXTpx7IlUCwj8MHCRZg+bSoGDbpHJprnnnMOHnn4IXm7IWKSlttv64OePXugR4/ucr05MZ6tuuSkZFxx+eVo06YN/P385H1//PEnAgICMPLpp+T+Hho+HDfccMNpX8f6L9YjOycbS5cuwWWXXSZn7hQzf776ynS0bdumzv8Rr3Hr1m1yNtDGmjR5Chz0enz37de4/fbbZFfaTp06yffm6w3WSbiYdOXXX+voJ0pEREphkkdERC1u5arVcpKRIUMGY+MvP+Pzz9bJCVSOHzctO/BfbNv2u0zI1n/xGT54/z389NPPmDd/geVxMQHLgjffkkmmWMJhhZit8qabTrvvadNfQW5eHjZ89SWWfbxUtoDt32/dqjVn7lyER4Tjj9+34sAB05g+sWTBxEmTZWuZmH3zggvPxwcLFzZq/Ntdd90jZxZd8uEiWdZ5c+fKMXkFBfW37K1ctQr97+wvl4poDDHxTO8+t8tkdOrLL2HTxl+wevVK9OrVCxOqzVp68cUXwcvLC99++12jnpeIiLSjaxsabhrkQEREZOPEenNiDbhHHv13se/W6NtvvsbixR/iy6++arLnFAnzwUOH8L//vdNkz0lERM2DLXlERER25oUXX4SDo/XYwP9CzD4affiwTByJiEh9nHiFiIjIzhw8eEhemopY2uGtt95usucjIqLmxe6aREREREREdoTdNYmIiIiIiOwIkzwiIiIiIiI7wiSPiIiIiIjIjjDJIyIiIiIisiNM8oiIiIiIiOwIkzwiIiIiIiI7wiSPiIiIiIjIjjDJIyIiIiIisiNM8oiIiIiIiGA//g9EjdT6PhlXbwAAAABJRU5ErkJggg==" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 23 }, { "cell_type": "markdown", @@ -1385,43 +1421,27 @@ }, { "cell_type": "code", - "execution_count": 58, "id": "334a833a", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:27.853545427Z", - "start_time": "2026-04-14T12:39:27.605095719Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:15.573465Z", "iopub.status.busy": "2026-04-07T12:06:15.573114Z", "iopub.status.idle": "2026-04-07T12:06:16.558429Z", "shell.execute_reply": "2026-04-07T12:06:16.555709Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:13.471867Z", + "start_time": "2026-05-01T05:57:13.330446Z" } }, - "outputs": [ - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "jetTransient": { - "display_id": null - }, - "metadata": {}, - "output_type": "display_data" - } - ], "source": [ "city_summer_means = {}\n", "for city in CITY_PROFILES:\n", - " v = climate.where(climate[\"city\"] == city)\n", + " v = climate.where(climate.city == city)\n", " if city == \"Sydney\" or city == \"Sao Paulo\":\n", - " s = v.where((v[\"day\"] <= 80) | (v[\"day\"] >= 355))\n", + " s = v.where((v.day <= 80) | (v.day >= 355))\n", " else:\n", - " s = v.where((v[\"day\"] >= SUMMER_START) & (v[\"day\"] <= SUMMER_END))\n", + " s = v.where((v.day >= SUMMER_START) & (v.day <= SUMMER_END))\n", " city_summer_means[city] = s[\"temperature\"].mean()\n", "\n", "sorted_cities = sorted(city_summer_means.items(), key=lambda x: x[1], reverse=True)\n", @@ -1438,7 +1458,23 @@ "ax.grid(True, axis=\"x\", linestyle=\"--\", alpha=0.4)\n", "plt.tight_layout()\n", "plt.show()" - ] + ], + "outputs": [ + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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" + }, + "metadata": {}, + "output_type": "display_data", + "jetTransient": { + "display_id": null + } + } + ], + "execution_count": 24 }, { "cell_type": "markdown", @@ -1453,46 +1489,24 @@ }, { "cell_type": "code", - "execution_count": 59, "id": "2c3a977b", "metadata": { - "ExecuteTime": { - "end_time": "2026-04-14T12:39:27.972265822Z", - "start_time": "2026-04-14T12:39:27.857019185Z" - }, "execution": { "iopub.execute_input": "2026-04-07T12:06:16.566822Z", "iopub.status.busy": "2026-04-07T12:06:16.563241Z", "iopub.status.idle": "2026-04-07T12:06:16.715871Z", "shell.execute_reply": "2026-04-07T12:06:16.712493Z" + }, + "ExecuteTime": { + "end_time": "2026-05-01T05:57:13.526988Z", + "start_time": "2026-05-01T05:57:13.473475Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Table with feels_like column:\n", - " city day temperature humidity wind_speed pressure feels_like \n", - " 100.0", lambda ct: ct.where(ct.value > 100.0)), + ("value > 120.0", lambda ct: ct.where(ct.value > 120.0)), + ("value between 0 and 10", lambda ct: ct.where((ct.value >= 0.0) & (ct.value <= 10.0))), + ("timestamp > 450_000", lambda ct: ct.where(ct.timestamp > 4_500_000)), + ("timestamp > 4_999_000", lambda ct: ct.where(ct.timestamp > 4_999_000)), +] + + +def bench(fn, reps=5): + times = [0.0] * reps + for i in range(reps): + t = time.perf_counter() + result = fn() + times[i] = (time.perf_counter() - t) * 1000 + return min(times), result + + +print("\n--- Full scan (no index) ---") +baseline = {} +ct = blosc2.CTable.open(B2Z, mode="r") +assert not ct.indexes, "expected no indexes yet" +for label, fn in QUERIES: + ms, view = bench(lambda fn=fn: fn(ct)) + baseline[label] = (ms, len(view)) + print(f" {label:<38} {ms:7.1f} ms {len(view):>8,} rows") +ct.close() + +# --------------------------------------------------------------------------- +# 4. Build indexes (append mode → rezip on close) +# --------------------------------------------------------------------------- + +print("\nBuilding indexes (mode='a') ...") +ct = blosc2.CTable.open(B2Z, mode="a") + +t0 = time.perf_counter() +ct.create_index("value", kind=blosc2.IndexKind.FULL) +print(f" value FULL index {(time.perf_counter() - t0) * 1000:.0f} ms") + +t0 = time.perf_counter() +ct.create_index("timestamp", kind=blosc2.IndexKind.FULL) +print(f" timestamp FULL index {(time.perf_counter() - t0) * 1000:.0f} ms") + +t0 = time.perf_counter() +ct.close() +print( + f" closed + rezipped {(time.perf_counter() - t0) * 1000:.0f} ms " + f"({os.path.getsize(B2Z) / 1e6:.1f} MB)" +) + +# --------------------------------------------------------------------------- +# 5. Read-only: verify indexes survived, benchmark +# --------------------------------------------------------------------------- + +print("\nReopening .b2z read-only ...") +ct = blosc2.CTable.open(B2Z, mode="r") +found = [idx.col_name for idx in ct.indexes] +print(f" indexes present: {found}") +assert "value" in found, "index for 'value' missing after round-trip!" +assert "timestamp" in found, "index for 'timestamp' missing after round-trip!" + +print() +print(f"{'query':<38} {'no index':>9} {'indexed':>9} {'speedup':>8} {'rows':>8}") +print("-" * 78) + +for label, fn in QUERIES: + i_ms, view = bench(lambda fn=fn: fn(ct)) + b_ms, b_n = baseline[label] + sp = b_ms / i_ms if i_ms > 0 else float("inf") + assert len(view) == b_n, f"row count mismatch for {label!r}" + print(f" {label:<38} {b_ms:8.1f}ms {i_ms:8.1f}ms {sp:7.1f}x {len(view):>8,}") + +ct.close() + +# --------------------------------------------------------------------------- +# 6. Cleanup +# --------------------------------------------------------------------------- + +os.remove(B2Z) +print("\nDone.") diff --git a/examples/ctable/indexing.py b/examples/ctable/indexing.py index 961371ded..f67378403 100644 --- a/examples/ctable/indexing.py +++ b/examples/ctable/indexing.py @@ -54,7 +54,7 @@ def load_rows(table: blosc2.CTable, nrows: int = 240) -> None: print("active stale?", idx_active.stale) # Queries can combine indexed and non-indexed predicates. - recent_active = pt.where((pt["sensor_id"] >= 180) & pt["active"] & (pt["region"] == "north")) + recent_active = pt.where((pt.sensor_id >= 180) & pt.active & (pt.region == "north")) print("\nLive rows with sensor_id >= 180, active=True, region='north':", len(recent_active)) print("sensor_ids:", recent_active["sensor_id"]) print("statuses:", recent_active["status"][:]) @@ -79,7 +79,7 @@ def load_rows(table: blosc2.CTable, nrows: int = 240) -> None: print("Indexes after reopen from .b2z:", packed.indexes) # Query directly against the .b2z bundle; no unpack step is needed. - warm_active = packed.where(packed["active"] & (packed["status"] == "warm") & (packed["sensor_id"] > 100)) + warm_active = packed.where(packed.active & (packed.status == "warm") & (packed.sensor_id > 100)) print("\nRows from .b2z with active=True, status='warm', sensor_id > 100:", len(warm_active)) print("sensor_ids:", warm_active["sensor_id"]) print("regions:", warm_active["region"][:]) diff --git a/examples/ctable/nullable.py b/examples/ctable/nullable.py index 8c9974f9c..79503108e 100644 --- a/examples/ctable/nullable.py +++ b/examples/ctable/nullable.py @@ -5,17 +5,18 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### -# Nullable columns: null_value sentinels, null-aware aggregates, -# is_null / notnull, sort nulls-last, Arrow null masking, CSV empty cells. +# Nullable columns: null_value sentinels, nullable=True, NullPolicy, +# null-aware aggregates, is_null / notnull, sort nulls-last, Arrow null masking, +# and CSV empty cells. # # CTable does not have a built-in "missing" bit per row like pandas does. -# Instead it uses a *sentinel value* approach: you choose a specific value -# that represents "null" for a column, and the library treats it -# transparently in aggregates, sorting, unique(), value_counts(), and -# Arrow export. +# Instead it uses a *sentinel value* approach: each nullable column stores a +# specific value that represents "null". The library treats that value +# transparently in aggregates, sorting, unique(), value_counts(), and Arrow +# export. # -# This is especially useful for integer and string columns that have no -# natural null (unlike float, which can use NaN). +# You can either choose sentinels explicitly with null_value=, or ask CTable to +# choose them from the active NullPolicy with nullable=True. import os import tempfile @@ -24,24 +25,57 @@ import blosc2 # --------------------------------------------------------------------------- -# Schema with nullable columns +# Schema with explicit null_value sentinels # --------------------------------------------------------------------------- -# Use null_value= on any spec to declare the sentinel. -# The sentinel bypasses validation constraints (ge/le etc.) so you can -# store it even when it would otherwise violate them. +# Use null_value= on any spec to declare the sentinel. The sentinel bypasses +# validation constraints (ge/le etc.) so you can store it even when it would +# otherwise violate them. @dataclass class Reading: sensor_id: int = blosc2.field(blosc2.int32(ge=0)) # -999 is "no reading" for temperature (normally ge=-50, le=60) - temperature: float = blosc2.field(blosc2.float64(ge=-50.0, le=60.0, null_value=-999.0), default=-999.0) + temperature: float = blosc2.field(blosc2.float64(ge=-50.0, le=60.0, null_value=-999.0)) # "" is "unknown" for location (string) - location: str = blosc2.field(blosc2.string(max_length=16, null_value=""), default="") + location: str = blosc2.field(blosc2.string(max_length=16, null_value="")) # -1 is "not measured" for signal strength (normally ge=0, le=100) - signal: int = blosc2.field(blosc2.int8(ge=0, le=100, null_value=-1), default=-1) + signal: int = blosc2.field(blosc2.int8(ge=0, le=100, null_value=-1)) +# --------------------------------------------------------------------------- +# Schema using nullable=True and NullPolicy +# --------------------------------------------------------------------------- +# nullable=True means "make this column nullable and choose the sentinel from +# the current NullPolicy". column_null_values overrides the type-wide policy for +# specific columns. + + +@dataclass +class AutoReading: + sensor_id: int = blosc2.field(blosc2.int32(ge=0)) + temperature: float = blosc2.field(blosc2.float64(ge=-50.0, le=60.0, nullable=True)) + location: str = blosc2.field(blosc2.string(max_length=16, nullable=True)) + signal: int = blosc2.field(blosc2.int8(ge=0, le=100, nullable=True)) + + +policy = blosc2.NullPolicy( + float_value=-999.0, + string_value="", + column_null_values={"signal": -1}, +) +with blosc2.null_policy(policy): + auto = blosc2.CTable(AutoReading) + +print("NullPolicy + nullable=True selected these sentinels:") +print(f"temperature: {auto['temperature'].null_value!r}") +print(f"location : {auto['location'].null_value!r}") +print(f"signal : {auto['signal'].null_value!r}") + +# --------------------------------------------------------------------------- +# Work with nullable columns +# --------------------------------------------------------------------------- + data = [ (0, 22.3, "roof", 87), (1, -999.0, "cellar", 41), # temperature unknown @@ -52,7 +86,7 @@ class Reading: ] t = blosc2.CTable(Reading, new_data=data) -print("Table with nullable columns:") +print("\nTable with nullable columns:") print(t) # --------------------------------------------------------------------------- @@ -74,7 +108,7 @@ class Reading: # Null-aware aggregates # --------------------------------------------------------------------------- print("\n--- Aggregates skip null sentinels ---") -print(f"temperature.mean() = {t['temperature'].mean():.2f} (only 3 non-null readings)") +print(f"temperature.mean() = {t['temperature'].mean():.2f} (only 4 non-null readings)") print(f"temperature.min() = {t['temperature'].min():.2f}") print(f"temperature.max() = {t['temperature'].max():.2f}") print(f"signal.sum() = {t['signal'].sum()} (non-null: 87+41+62+95 = 285)") diff --git a/examples/ctable/querying.py b/examples/ctable/querying.py index 433c51109..e41d2ddd2 100644 --- a/examples/ctable/querying.py +++ b/examples/ctable/querying.py @@ -5,7 +5,7 @@ # SPDX-License-Identifier: BSD-3-Clause ####################################################################### -# Querying: where() filters, select() column projection, and chaining. +# Querying: expression indexing, where() filters, projection, and chaining. from dataclasses import dataclass @@ -36,25 +36,25 @@ class Sale: t = blosc2.CTable(Sale, new_data=data) # -- where(): row filter ---------------------------------------------------- -high_value = t.where(t["amount"] > 200) +high_value = t.where(t.amount > 200) print(f"Sales > $200: {len(high_value)} rows") print(high_value) -not_returned = t.where(not t["returned"]) +not_returned = t["not returned"] print(f"Not returned: {len(not_returned)} rows") # -- chained filters (views are composable) --------------------------------- -north = t.where(t["region"] == "North") -north_big = north.where(north["amount"] > 100) +north = t.where(t.region == "North") +north_big = north.where(north.amount > 100) print(f"North region + amount > 100: {len(north_big)} rows") print(north_big) -# -- select(): column projection (no data copy) ----------------------------- -slim = t.select(["id", "amount"]) +# -- column projection via [] (no data copy) -------------------------------- +slim = t[["id", "amount"]] print("id + amount only:") print(slim) -# -- combined: select columns, then filter rows ----------------------------- -result = t.select(["region", "amount"]).where(not t["returned"]) +# -- combined filter + projection ------------------------------------------- +result = t.where("not returned", columns=["region", "amount"]) print("Region + amount for non-returned sales:") print(result) diff --git a/examples/ctable/real_world.py b/examples/ctable/real_world.py index 927f05bd9..a94b66281 100644 --- a/examples/ctable/real_world.py +++ b/examples/ctable/real_world.py @@ -71,15 +71,15 @@ class WeatherReading: t.info() # -- Filter to station 3 ---------------------------------------------------- -station3 = t.where(t["station_id"] == 3) +station3 = t.where(t.station_id == 3) print(f"Station 3: {len(station3)} readings") -print(f" mean temperature : {station3['temperature'].mean():.1f} °C") -print(f" mean humidity : {station3['humidity'].mean():.1f} %") -print(f" mean wind speed : {station3['wind_speed'].mean():.1f} km/h\n") +print(f" mean temperature : {station3.temperature.mean():.1f} °C") +print(f" mean humidity : {station3.humidity.mean():.1f} %") +print(f" mean wind speed : {station3.wind_speed.mean():.1f} km/h\n") # -- 5 hottest days at station 3 (sort full table, then filter) ------------ sorted_by_temp = t.sort_by("temperature", ascending=False) -hottest_s3 = sorted_by_temp.where(sorted_by_temp["station_id"] == 3) +hottest_s3 = sorted_by_temp.where(sorted_by_temp.station_id == 3) print("5 hottest days at station 3:") print(hottest_s3.head(5)) @@ -98,7 +98,8 @@ class WeatherReading: path = f"{tmpdir}/station3" try: # Views cannot be sorted or saved directly — materialise via Arrow first - s3_copy = blosc2.CTable.from_arrow(station3.to_arrow()) + arrow = station3.to_arrow() + s3_copy = blosc2.CTable.from_arrow(arrow.schema, arrow.to_batches()) s3_copy.sort_by("day_of_year", inplace=True) sorted_s3 = s3_copy sorted_s3.save(path, overwrite=True) diff --git a/examples/ctable/varlen_columns.py b/examples/ctable/varlen_columns.py new file mode 100644 index 000000000..1a6661332 --- /dev/null +++ b/examples/ctable/varlen_columns.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import blosc2 as b2 + + +@dataclass +class Product: + code: str = b2.field(b2.string(max_length=8)) + ingredients: list[str] = b2.field( # noqa: RUF009 + b2.list(b2.string(max_length=16), nullable=True, batch_rows=2) + ) + + +products = b2.CTable(Product) +products.append(("A1", ["salt", "water"])) +products.append(("B2", [])) +products.append(("C3", None)) +products.extend( + [ + ("D4", ["flour", "oil"]), + ("E5", ["cocoa"]), + ] +) + +print("ingredients:", products.ingredients[:]) +print("tail:", products.tail(2).ingredients[:]) + +# Whole-cell replacement is explicit. +ing = products.ingredients[0] +ing.append("pepper") +products.ingredients[0] = ing +print("updated first row:", products.ingredients[0]) + +standalone = b2.ListArray(item_spec=b2.string(max_length=16), nullable=True) +standalone.extend([["a", "b"], [], None]) +print("standalone list array:", standalone[:]) diff --git a/examples/vlarray.py b/examples/objectarray.py similarity index 54% rename from examples/vlarray.py rename to examples/objectarray.py index 0e988c83b..ec1b091eb 100644 --- a/examples/vlarray.py +++ b/examples/objectarray.py @@ -12,55 +12,55 @@ def show(label, value): print(f"{label}: {value}") -urlpath = "example_vlarray.b2frame" -copy_path = "example_vlarray_copy.b2frame" +urlpath = "example_objectarray.b2frame" +copy_path = "example_objectarray_copy.b2frame" blosc2.remove_urlpath(urlpath) blosc2.remove_urlpath(copy_path) -# Create a persistent VLArray and store heterogeneous Python values. -vla = blosc2.VLArray(urlpath=urlpath, mode="w", contiguous=True) -vla.append({"name": "alpha", "count": 1}) -vla.extend([b"bytes", ("a", 2), ["x", "y"], 42, None]) -vla.insert(1, "between") +# Create a persistent ObjectArray and store heterogeneous Python values. +oarr = blosc2.ObjectArray(urlpath=urlpath, mode="w", contiguous=True) +oarr.append({"name": "alpha", "count": 1}) +oarr.extend([b"bytes", ("a", 2), ["x", "y"], 42, None]) +oarr.insert(1, "between") -show("Initial entries", list(vla)) -show("Negative index", vla[-1]) -show("Slice [1:6:2]", vla[1:6:2]) +show("Initial entries", list(oarr)) +show("Negative index", oarr[-1]) +show("Slice [1:6:2]", oarr[1:6:2]) # Slice assignment with step == 1 can resize the container. -vla[2:5] = ["replaced", {"nested": True}] -show("After slice replacement", list(vla)) +oarr[2:5] = ["replaced", {"nested": True}] +show("After slice replacement", list(oarr)) # Extended slices require matching lengths. -vla[::2] = ["even-0", "even-1", "even-2"] -show("After extended-slice update", list(vla)) +oarr[::2] = ["even-0", "even-1", "even-2"] +show("After extended-slice update", list(oarr)) # Delete by index, by slice, or with pop(). -del vla[1::3] -show("After slice deletion", list(vla)) -removed = vla.pop() +del oarr[1::3] +show("After slice deletion", list(oarr)) +removed = oarr.pop() show("Popped entry", removed) -show("After pop", list(vla)) +show("After pop", list(oarr)) # Copy into a different backing store and with different compression parameters. -vla_copy = vla.copy(urlpath=copy_path, contiguous=False, cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) -show("Copied entries", list(vla_copy)) -show("Copy storage is contiguous", vla_copy.schunk.contiguous) -show("Copy codec", vla_copy.cparams.codec) +oarr_copy = oarr.copy(urlpath=copy_path, contiguous=False, cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) +show("Copied entries", list(oarr_copy)) +show("Copy storage is contiguous", oarr_copy.schunk.contiguous) +show("Copy codec", oarr_copy.cparams.codec) # Round-trip through a cframe buffer. -cframe = vla.to_cframe() +cframe = oarr.to_cframe() restored = blosc2.from_cframe(cframe) show("from_cframe type", type(restored).__name__) show("from_cframe entries", list(restored)) -# Reopen from disk; tagged stores come back as VLArray. +# Reopen from disk; tagged stores come back as ObjectArray. reopened = blosc2.open(urlpath, mode="r", mmap_mode="r") show("Reopened type", type(reopened).__name__) show("Reopened entries", list(reopened)) # Clear and reuse an in-memory copy. -scratch = vla.copy() +scratch = oarr.copy() scratch.clear() scratch.extend(["fresh", 123, {"done": True}]) show("After clear + extend on in-memory copy", list(scratch)) diff --git a/examples/ref-object.py b/examples/ref-object.py new file mode 100644 index 000000000..ae754b281 --- /dev/null +++ b/examples/ref-object.py @@ -0,0 +1,68 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +import numpy as np + +import blosc2 + + +def show(label, value): + print(f"{label}: {value}") + + +array_path = "example_ref_array.b2nd" +store_path = "example_ref_store.b2z" +store_src_path = "example_ref_store_src.b2nd" +refs_path = "example_ref_values.b2frame" + +for path in (array_path, store_path, store_src_path, refs_path): + blosc2.remove_urlpath(path) + +# Create a persistent array and derive a Ref from the live object. +array = blosc2.asarray(np.arange(5, dtype=np.int64), urlpath=array_path, mode="w") +array_ref = blosc2.Ref.from_object(array) +show("Array Ref", array_ref) +show("Array Ref payload", array_ref.to_dict()) + +# Rebuild the Ref from its plain dictionary form and reopen it later. +array_ref_restored = blosc2.Ref.from_dict(array_ref.to_dict()) +show("Array Ref restored", array_ref_restored) +show("Array Ref values", array_ref_restored.open()[:]) + +# DictStore members get a different kind of Ref because the store path alone +# is not enough to identify the external member inside the container. +store_src = blosc2.asarray(np.arange(5, dtype=np.int64) * 10, urlpath=store_src_path, mode="w") +with blosc2.DictStore(store_path, mode="w", threshold=None) as dstore: + dstore["/node"] = store_src + +with blosc2.DictStore(store_path, mode="r") as dstore: + member = dstore["/node"] + member_ref = blosc2.Ref.from_object(member) + show("DictStore member Ref", member_ref) + show("DictStore member payload", member_ref.to_dict()) + show("DictStore member values", member_ref.open()[:]) + +# Refs are also msgpack-serializable through ObjectArray / BatchArray. +refs = blosc2.ObjectArray(urlpath=refs_path, mode="w", contiguous=True) +refs.extend([array_ref, member_ref]) + +reopened_refs = blosc2.open(refs_path, mode="r") +ref0 = reopened_refs[0] +ref1 = reopened_refs[1] +show("ObjectArray round-trip types", [type(ref0).__name__, type(ref1).__name__]) +show("ObjectArray round-trip values", [ref0.open()[:], ref1.open()[:]]) + +# Refs can also be stored in vlmeta and recovered both individually and in bulk. +meta_holder = blosc2.SChunk() +meta_holder.vlmeta["array_ref"] = array_ref +show("vlmeta single-key Ref", meta_holder.vlmeta["array_ref"]) +show("vlmeta single-key values", meta_holder.vlmeta["array_ref"].open()[:]) +show("vlmeta bulk Ref", meta_holder.vlmeta[:]["array_ref"]) +show("vlmeta bulk values", meta_holder.vlmeta[:]["array_ref"].open()[:]) + +for path in (array_path, store_path, store_src_path, refs_path): + blosc2.remove_urlpath(path) diff --git a/examples/vlstore-lazyudf.py b/examples/vlstore-lazyudf.py index cf5325883..2c627876a 100644 --- a/examples/vlstore-lazyudf.py +++ b/examples/vlstore-lazyudf.py @@ -19,9 +19,9 @@ def kernel_add_twice(x, y): return x + y * 2 -urlpath = "example_vlstore_lazyudf.b2frame" -a_path = "example_vlstore_lazyudf_a.b2nd" -b_path = "example_vlstore_lazyudf_b.b2nd" +urlpath = "example_objstore_lazyudf.b2frame" +a_path = "example_objstore_lazyudf_a.b2nd" +b_path = "example_objstore_lazyudf_b.b2nd" blosc2.remove_urlpath(urlpath) blosc2.remove_urlpath(a_path) blosc2.remove_urlpath(b_path) @@ -30,11 +30,11 @@ def kernel_add_twice(x, y): b = blosc2.asarray(np.arange(5, dtype=np.float32) * 2, urlpath=b_path, mode="w") lazy_udf = blosc2.lazyudf(kernel_add_twice, (a, b), dtype=a.dtype, shape=a.shape) -vla = blosc2.VLArray(urlpath=urlpath, mode="w", contiguous=True) -vla.append({"kind": "lazyudf", "value": lazy_udf}) +oarr = blosc2.ObjectArray(urlpath=urlpath, mode="w", contiguous=True) +oarr.append({"kind": "lazyudf", "value": lazy_udf}) -restored = vla[0]["value"] -show("Stored type", type(vla[0]["value"]).__name__) +restored = oarr[0]["value"] +show("Stored type", type(oarr[0]["value"]).__name__) show("Computed values", restored[:]) reopened = blosc2.open(urlpath, mode="r") diff --git a/plans/ctable-getitem-row.md b/plans/ctable-getitem-row.md new file mode 100644 index 000000000..dea60ab79 --- /dev/null +++ b/plans/ctable-getitem-row.md @@ -0,0 +1,593 @@ +# Enhance `CTable` indexing and access semantics + +## Motivation + +Current `CTable.__getitem__` only supports column-name lookup: + +```python +t["col"] +``` + +This leaves several surprising or missing behaviors: + +- `t[1]` does not mean “row 1” +- `t[1:3]` does not mean a row slice +- `t[[1, 3]]` / `t[mask]` do not provide natural row selection through `[]` +- `t[["a", "b"]]` does not provide natural column projection +- `t["f1 > f2"]` is not available as shorthand for row filtering +- `t.row[...]` exists as a separate row API, which becomes redundant once `t[...]` gains proper row semantics + +This gives `CTable` a much more natural and powerful access model while preserving efficient columnar behavior. + +--- + +## Design goals + +1. Make `CTable` indexing intuitive by key type. +2. Keep column access ergonomic. +3. Make scalar row access feel scalar-like. +4. Keep slices/masks/projections as table/view operations when possible. +5. Add convenient expression-based filtering. +6. Preserve efficient columnar behavior instead of materializing rows eagerly by default. +7. Provide explicit NumPy materialization hooks when desired. + +--- + +## Proposed semantics + +## 1. String key + +### Column lookup first + +If the string matches a column name: + +```python +t["ingredients"] # -> Column +``` + +### Otherwise: boolean expression filter + +If the string is not a column name, interpret it as a boolean expression: + +```python +t["f1 > f2"] +``` + +This should behave like: + +```python +t.where("f1 > f2") +``` + +and return a filtered `CTable` view. + +### Error behavior + +If the string is neither: + +- a known column name +- nor a valid boolean expression + +raise a clear exception. + +Recommended: + +- parsing/evaluation failure -> propagate a meaningful expression error +- non-boolean expression result -> `TypeError` or `ValueError` + +Example: + +```python +t["f1 + f2"] # error: expression does not evaluate to bool +``` + +--- + +## 2. Integer key + +Integer indexing should return a single row object: + +```python +t[1] +``` + +Recommended return type: + +- a schema-derived **namedtuple row** + +Example: + +```python +row = t[1] +row.ingredients +row.id +``` + +### Why namedtuple + +- scalar-like semantics for scalar indexing +- lightweight and familiar +- readable repr/printing +- works across mixed column types +- no need to force a NumPy structured scalar as the primary API + +### Negative indexing + +Support: + +```python +t[-1] +``` + +with normal Python sequence semantics. + +### Out-of-bounds + +Raise `IndexError`. + +--- + +## 3. Slice key + +Slice indexing should return a row-selected `CTable` view: + +```python +t[1:3] +``` + +This preserves columnar/view behavior and avoids eager row materialization. + +### Important + +Slices should **not** default to NumPy structured arrays. The view is more natural for Blosc2 because: + +- it preserves table operations +- it avoids eager materialization +- it composes better with later filtering/projection + +--- + +## 4. Integer-list / integer-array key + +These should perform row gather and return a `CTable` view: + +```python +t[[1, 3, 5]] +t[np.array([1, 3, 5])] +``` + +Return: + +- row-selected `CTable` view + +--- + +## 5. Boolean mask key + +Boolean mask selection should return a filtered `CTable` view: + +```python +t[mask] +``` + +Return: + +- row-selected `CTable` view + +Behavior should align with current `where(...)` / row-view behavior. + +--- + +## 6. List of strings key + +Column projection should be supported directly: + +```python +t[["col1", "col2"]] +``` + +Return: + +- a projected `CTable` view/subtable with only those columns + +This is important and should be distinguished from row-gather lists by inspecting element types. + +### Rules + +- list of `str` -> column projection +- list/array of `int` -> row gather +- boolean array -> row filter + +### Validation + +Unknown column names should raise `KeyError`. + +--- + +## 7. Tuple / multidimensional indexing + +Do not add full multidimensional indexing in this change. + +For now: + +- unsupported tuple keys should raise `TypeError` + +Possible future extension: + +```python +t[rows, cols] +``` + +but that should not be introduced in the first pass. + +--- + +## 8. Remove `t.row[...]` + +Once `t[...]` gains proper row semantics, `t.row[...]` no longer adds much value. + +Recommendation: + +- deprecate `t.row[...]` +- then remove it in a follow-up cleanup + +Rationale: + +- `t[1]` is the natural spelling for row access +- keeping both `t[1]` and `t.row[1]` is redundant +- one obvious way is better here + +### Transitional plan + +Phase 1: + +- keep `t.row[...]` working +- mark it as deprecated +- update docs/examples to prefer `t[...]` + +Phase 2: + +- remove `t.row[...]` + +--- + +## 9. `where(..., columns=[...])` + +The natural explicit filtered-projection API should be: + +```python +t.where("x > 3", columns=["a", "b"]) +``` + +not `query(...)`. + +This should return a filtered `CTable` view projected to the selected columns. + +### Relationship with shorthand `t["expr"]` + +These should align: + +```python +t["x > 3"] +``` + +should behave like: + +```python +t.where("x > 3") +``` + +while: + +```python +t.where("x > 3", columns=["a", "b"]) +``` + +provides the explicit projected variant. + +--- + +## 10. `__array__()` for `CTable` and views + +It is useful to support explicit NumPy materialization via: + +```python +np.asarray(t) +np.asarray(t[1:10]) +``` + +This should produce a NumPy structured array. + +### dtype mapping policy + +When computing the structured dtype: + +- fixed-width scalar columns -> native NumPy dtypes +- `vlstring` / `vlbytes` -> `object` +- list columns -> `object` +- nested/object-backed values -> `object` + +Example structured dtype: + +```python +[ + ("id", np.int64), + ("score", np.float64), + ("name", object), + ("ingredients", object), +] +``` + +### Why this is useful + +- interop with NumPy APIs +- explicit materialization for debugging/export +- compatibility with code expecting record arrays / structured arrays + +### Why not use this as default `__getitem__` behavior + +Because: + +- slices/views should stay lazy-ish and table-oriented +- eager row-major materialization is expensive +- Blosc2 is fundamentally columnar + +So `__array__()` should be an explicit materialization hook, not the default indexing return type for slices/masks. + +--- + +## Summary of target API + +Recommended access behavior: + +```python +t["col"] # -> Column +t["f1 > f2"] # -> filtered CTable view +t[1] # -> namedtuple row +t[-1] # -> namedtuple row +t[1:3] # -> row-sliced CTable view +t[[1, 3, 5]] # -> gathered-row CTable view +t[mask] # -> filtered CTable view +t[["a", "b"]] # -> projected CTable view +np.asarray(t[1:3]) # -> structured ndarray +``` + +And explicit filtering/projection: + +```python +t.where("x > 3") +t.where("x > 3", columns=["a", "b"]) +``` + +--- + +## Current implementation areas to update + +## File: `src/blosc2/ctable.py` + +### `CTable.__getitem__` + +This is the main dispatch point. + +It should dispatch by key type and contents: + +1. `str` + - if exact column name -> `Column` + - else -> expression filter +2. `int` + - return namedtuple row +3. `slice` + - return row-selected view +4. `list` / `np.ndarray` + - if strings -> projected view + - if bool mask -> filtered view + - if ints -> gathered-row view +5. otherwise + - raise `TypeError` + +### Row object creation + +Add a cached namedtuple type per schema. + +Suggested internal helper: + +- `_row_namedtuple_type()` +- `_materialize_row(index)` + +The row object should be built from logical/live row semantics, not raw physical slots. + +### Projection helpers + +Add a helper for column subset views, e.g.: + +- `_project_columns(names)` + +This should preserve: + +- schema subset +- computed column handling if applicable +- shared column storage when safe + +### Expression dispatch helper + +Add a helper for string-expression indexing, e.g.: + +- `_getitem_expression(expr)` + +This should likely reuse: + +- existing `where(...)` +- existing expression parsing/evaluation + +with the rule that expressions must evaluate to booleans. + +### `where()` enhancement + +Extend `where()` to accept: + +```python +columns = [...] +``` + +so it can combine filtering and projection. + +--- + +## Other files that may need updates + +## Tests + +Likely in: + +- `tests/ctable/test_row_logic.py` +- `tests/ctable/test_ctable_dataclass_schema.py` +- new dedicated indexing tests if cleaner + +## Documentation/examples + +Any examples using: + +- `t.row[...]` +- column-only `t[...]` + +should be updated to reflect the new design. + +--- + +## Testing plan + +## New tests for `__getitem__` + +### Column access + +```python +assert isinstance(t["id"], Column) +``` + +### Row access by int + +```python +row = t[1] +assert row.id == ... +assert row.text == ... +``` + +### Negative int + +```python +row = t[-1] +``` + +### Slice access + +```python +sub = t[1:4] +assert isinstance(sub, CTable) +assert len(sub) == 3 +``` + +### Integer gather + +```python +sub = t[[1, 3, 5]] +``` + +### Boolean mask + +```python +sub = t[mask] +``` + +### Column projection + +```python +sub = t[["a", "b"]] +assert sub.col_names == ["a", "b"] +``` + +### Expression string + +```python +sub = t["a > b"] +``` + +### String precedence + +If a column is literally named something expression-like, exact column-name match should win. + +Example: + +```python +t["a > b"] +``` + +returns the column if that exact column exists; otherwise it is treated as an expression. + +### Error tests + +- unknown projected column -> `KeyError` +- unsupported key type -> `TypeError` +- non-boolean expression -> error +- out-of-bounds integer -> `IndexError` + +--- + +## Backward-compatibility notes + +This is a meaningful behavior change. + +### Old behavior + +- `t["col"]` -> column +- `t[1]` -> not meaningful / confusing +- `t.row[1]` -> row selection path + +### New behavior + +- `t["col"]` -> column +- `t["expr"]` -> filter if not a column name +- `t[1]` -> namedtuple row +- `t[1:3]` -> ctable view +- `t[["a", "b"]]` -> projected view +- `t.row[...]` -> deprecated + +This should be documented clearly. + +--- + +## Implementation phases + +## Phase 1: typed `__getitem__` dispatch + +1. Update `CTable.__getitem__` +2. Add int/slice/list/mask/string-expression routing +3. Add clear exceptions for unsupported keys + +## Phase 2: namedtuple rows + +1. Add schema-cached row namedtuple type +2. Add logical-row materialization helper +3. Make `t[i]` return namedtuple rows + +## Phase 3: projection support + +1. Add `t[["a", "b"]]` +2. Add internal projected-view helper +3. Ensure schema/column metadata stays consistent + +## Phase 4: filtering shorthand and `where(columns=...)` + +1. Add `t["expr"]` +2. Add `t.where(..., columns=[...])` +3. Ensure behavior matches between both forms + +## Phase 5: NumPy materialization hooks + +1. Add `__array__()` for tables/views +2. Use structured dtype with `object` fallback for varlen/nested columns +3. Optionally add explicit helpers like `to_numpy()` / `to_records()` + +## Phase 6: remove `t.row[...]` + +1. Remove `t.row[...]` +2. Update tests/docs/examples diff --git a/plans/ctable-nulls.md b/plans/ctable-nulls.md new file mode 100644 index 000000000..6847a3b3d --- /dev/null +++ b/plans/ctable-nulls.md @@ -0,0 +1,894 @@ +# CTable Nullable Scalar / Parquet Fidelity Plan + +## Summary + +Improve CTable scalar null handling so Parquet nullable scalar columns can round-trip with high fidelity: + +```text +Parquet null -> CTable null sentinel -> Parquet null +``` + +This plan focuses on the remaining fidelity gaps after batch-wise Parquet import/export and Arrow schema metadata support: + +- nullable numeric scalar columns; +- nullable string/bytes scalar columns; +- nullable bool scalar columns. + +The guiding approach is to continue using CTable's existing in-band `null_value` sentinel model for scalar columns, while adding sensible default sentinel choices for Parquet imports and a special physical representation for nullable bools. + +--- + +## Decisions + +### 1. Automatic sentinel inference defaults to enabled for Parquet imports + +`CTable.from_parquet()` should default to automatic scalar null sentinels: + +```python +CTable.from_parquet(path, auto_null_sentinels=True) +``` + +That is, the public default should become effectively `True` for Parquet imports because preserving Parquet nulls is the expected behavior for interchange. + +For lower-level Arrow batch imports, default can be considered separately, but the recommended behavior is also to make it available and likely default-on when importing Arrow schemas from Parquet. + +### 2. String/bytes sentinels are configurable public options + +Default string/bytes sentinels: + +```python +string_null_value = "__BLOSC2_NULL__" +bytes_null_value = b"__BLOSC2_NULL__" +``` + +Expose them on import APIs: + +```python +CTable.from_parquet( + path, + auto_null_sentinels=True, + string_null_value="__BLOSC2_NULL__", + bytes_null_value=b"__BLOSC2_NULL__", +) +``` + +and similarly for `from_arrow_batches()` where appropriate. + +No full collision scan is performed by default. This avoids whole-column scans for large datasets. Users who know their data can override the sentinel values. + +### 3. Nullable bool API supports both explicit sentinel and convenience nullable flag + +Support both: + +```python +blosc2.bool(null_value=255) +blosc2.bool(nullable=True) +``` + +`nullable=True` is shorthand for: + +```python +null_value = 255 +``` + +Physical representation: + +```text +False = 0 +True = 1 +Null = 255 +``` + +A nullable bool column is logically a bool column but physically stored as `uint8` so the sentinel is preserved. + +### 4. Nullable bool expression rewrites use a conservative scope + +Initial expression support should be: + +- equality/inequality rewrites everywhere CTable column expressions are built; +- bare `flag` and `~flag` rewrites only in filter contexts. + +For nullable bool encoded as `0/1/255`: + +```text +flag == True -> raw == 1 +flag == False -> raw == 0 +flag != True -> raw == 0 +flag != False -> raw == 1 +``` + +In filter contexts: + +```text +flag -> raw == 1 +~flag -> raw == 0 +``` + +This excludes nulls from both true and false selections, matching common tabular filtering expectations. + +--- + +## Goals + +1. Preserve nullable numeric, string, bytes, and bool Parquet columns through CTable round-trips. +2. Avoid whole-column pre-scans for sentinel selection. +3. Keep using the current scalar sentinel null model for consistency with existing nullable columns. +4. Add general nullable bool column support to CTable, not just a Parquet-specific workaround. +5. Preserve batch-wise import/export behavior. +6. Maintain clear, predictable raw storage semantics. +7. Keep nullable bool expression/filter behavior useful without implementing full three-valued logic in V1. + +--- + +## Non-goals + +- Add separate validity bitmap storage for scalar CTable columns. +- Implement full SQL/Arrow/Pandas Kleene three-valued boolean algebra in V1. +- Guarantee no sentinel collision for string/bytes columns without user-provided sentinels. +- Hide all sentinels from raw column reads. +- Automatically infer collision-free string/bytes sentinels by scanning entire columns. + +--- + +## Current behavior and gap + +CTable already supports scalar nulls via `null_value` sentinels, e.g.: + +```python +blosc2.int64(null_value=-1) +blosc2.float64(null_value=float("nan")) +blosc2.string(max_length=16, null_value="") +``` + +Arrow/Parquet export already maps sentinel values back to Arrow nulls in the scalar export path: + +```text +sentinel in CTable -> Arrow null bitmap -> Parquet null +``` + +The missing pieces are: + +1. During Parquet import, infer and attach appropriate sentinels automatically. +2. During batch writes, replace Arrow nulls with those sentinels before writing to CTable storage. +3. For bool columns, introduce a physical representation that can actually preserve a sentinel. +4. For strings/bytes, choose practical default sentinels without full scans. +5. Avoid lossy importer workarounds such as filling nulls with `0`, `NaN`, or `""` without recording them as actual `null_value` sentinels. + +--- + +## Sentinel policy + +### Numeric columns + +For nullable Arrow/Parquet numeric fields, if `auto_null_sentinels=True`: + +```text +int8 -> np.iinfo(np.int8).min +int16 -> np.iinfo(np.int16).min +int32 -> np.iinfo(np.int32).min +int64 -> np.iinfo(np.int64).min +uint8 -> np.iinfo(np.uint8).max +uint16 -> np.iinfo(np.uint16).max +uint32 -> np.iinfo(np.uint32).max +uint64 -> np.iinfo(np.uint64).max +float32 -> NaN +float64 -> NaN +``` + +These become the column spec's `null_value`. + +Example: + +```python +pa.field("score", pa.int32(), nullable=True) +``` + +maps to: + +```python +blosc2.int32(null_value=np.iinfo(np.int32).min) +``` + +### String columns + +For nullable Arrow string/large_string fields: + +```python +blosc2.string(max_length=..., null_value="__BLOSC2_NULL__") +``` + +The selected `max_length` must be large enough to store both actual values and the sentinel. Therefore: + +```python +max_length = max(inferred_or_configured_max_length, len(string_null_value)) +``` + +No collision scan is performed by default. + +### Bytes columns + +For nullable Arrow binary/large_binary fields: + +```python +blosc2.bytes(max_length=..., null_value=b"__BLOSC2_NULL__") +``` + +The selected `max_length` must be at least: + +```python +len(bytes_null_value) +``` + +No collision scan is performed by default. + +### Bool columns + +For nullable Arrow bool fields: + +```python +blosc2.bool(nullable=True) +``` + +or equivalently: + +```python +blosc2.bool(null_value=255) +``` + +Physical storage dtype: + +```python +np.uint8 +``` + +Encoding: + +```text +0 false +1 true +255 null +``` + +Non-null Arrow bool values are converted as: + +```text +False -> 0 +True -> 1 +``` + +Arrow nulls are converted as: + +```text +Null -> 255 +``` + +--- + +## Schema changes + +### `blosc2.schema.bool` + +Current `bool` spec has fixed dtype: + +```python +dtype = np.dtype(np.bool_) +``` + +Change it to support nullable bools: + +```python +class bool(SchemaSpec): + python_type = builtins.bool + + def __init__(self, *, nullable: bool = False, null_value=None): + if nullable and null_value is None: + null_value = 255 + if null_value is not None and null_value != 255: + raise ValueError("Nullable bool null_value must be 255") + self.null_value = null_value + self.nullable = null_value is not None + self.dtype = np.dtype(np.uint8) if self.nullable else np.dtype(np.bool_) +``` + +Metadata: + +```json +{"kind": "bool"} +``` + +or: + +```json +{"kind": "bool", "nullable": true, "null_value": 255} +``` + +### Display/type labels + +A nullable bool column should still display as a logical bool column, possibly with a nullable marker: + +```text +bool? +``` + +or: + +```text +bool(nullable) +``` + +Internally, physical dtype is `uint8`. + +--- + +## Arrow/Parquet import changes + +### API additions + +Add/import parameters: + +```python +@classmethod +def from_parquet( + cls, + path, + *, + columns=None, + batch_size=65_536, + urlpath=None, + mode="w", + cparams=None, + dparams=None, + validate=False, + auto_null_sentinels=True, + string_null_value="__BLOSC2_NULL__", + bytes_null_value=b"__BLOSC2_NULL__", + **kwargs, +): ... +``` + +For `from_arrow_batches()`: + +```python +def from_arrow_batches( + cls, + schema, + batches, + *, + urlpath=None, + mode="w", + auto_null_sentinels=True, + string_null_value="__BLOSC2_NULL__", + bytes_null_value=b"__BLOSC2_NULL__", +): ... +``` + +If there is concern about changing `from_arrow_batches()` defaults, keep that default as `False` initially but make `from_parquet()` pass `True`. + +### Type mapping + +Add helper: + +```python +def _auto_null_sentinel(pa, pa_type, *, string_null_value, bytes_null_value): ... +``` + +Return appropriate sentinel for supported nullable scalar types. + +When building specs from Arrow fields: + +```python +if auto_null_sentinels and field.nullable: + null_value = _auto_null_sentinel(...) +else: + null_value = None +``` + +Then pass `null_value` into the corresponding SchemaSpec constructor. + +For list and struct fields, do not use scalar sentinels; their nested nulls are handled in the ListArray/Python-object layer. + +--- + +## Batch write behavior + +When writing an Arrow column into CTable storage: + +### List columns + +Unchanged: + +```python +list_col.extend(arrow_col.to_pylist()) +``` + +### String/bytes columns + +If Arrow nulls exist and the CTable spec has a `null_value`, replace `None` with the sentinel before building the NumPy array: + +```python +values = arrow_col.to_pylist() +if null_value is not None: + values = [null_value if v is None else v for v in values] +arr = np.array(values, dtype=col.dtype) +``` + +If Arrow nulls exist and no sentinel is configured, raise a clear error. + +### Numeric columns + +Use Arrow null mask: + +```python +arr = arrow_col.to_numpy(zero_copy_only=False).astype(col.dtype) +if arrow_col.null_count: + arr[np.asarray(arrow_col.is_null())] = null_value +``` + +For floats, Arrow may produce `NaN` for nulls already, but still explicitly applying the sentinel is clearer and consistent. + +### Nullable bool columns + +If physical dtype is `uint8`: + +```python +values = arrow_col.to_numpy(zero_copy_only=False) +arr = values.astype(np.uint8) # False -> 0, True -> 1 +if arrow_col.null_count: + arr[np.asarray(arrow_col.is_null())] = 255 +``` + +Need care because Arrow `to_numpy()` on nullable bool may produce object arrays or fail in some versions. Fallback: + +```python +py_values = arrow_col.to_pylist() +arr = np.array([255 if v is None else int(v) for v in py_values], dtype=np.uint8) +``` + +This fallback is acceptable for bool columns. + +--- + +## Arrow/Parquet export behavior + +The existing sentinel-to-Arrow-null logic should be extended to nullable bool physical `uint8` columns. + +For scalar export: + +```python +arr = col[:] +nv = col.null_value +null_mask = col._null_mask_for(arr) if nv is not None else None +``` + +For nullable bool: + +```python +values = arr == 1 +pa.array(values, mask=null_mask, type=pa.bool_()) +``` + +Important: do not export nullable bool physical `uint8` as Arrow uint8. The logical Arrow type should be bool. + +For non-nullable bool, keep current behavior: + +```python +pa.array(arr) # Arrow bool +``` + +--- + +## Nullable bool raw access semantics + +Raw reads expose the physical sentinel representation: + +```text +False -> 0 +True -> 1 +Null -> 255 +``` + +Example: + +```python +t["flag"][:] # np.array([1, 0, 255], dtype=uint8) +``` + +This is consistent with CTable's existing nullable scalar model, where raw reads expose sentinel values. + +`Column.is_null()`, `Column.notnull()`, and `Column.null_count()` should work normally. + +--- + +## Nullable bool expression/filter semantics + +### Equality/inequality rewrites + +For nullable bool columns, rewrite these comparisons before they reach the generic LazyExpr mechanism: + +```text +flag == True -> raw == 1 +flag == False -> raw == 0 +flag != True -> raw == 0 +flag != False -> raw == 1 +``` + +This ensures nulls do not match either true or false predicates. + +These rewrites can apply everywhere CTable column expressions are built. + +### Bare bool in filter context + +In filter contexts: + +```python +ct.where(ct.flag) +``` + +rewrite as: + +```python +ct.where(ct.flag == 1) +``` + +### Negation in filter context + +In filter contexts: + +```python +ct.where(~ct.flag) +``` + +rewrite as: + +```python +ct.where(ct.flag == 0) +``` + +This prevents nulls from being included by `~(raw == 1)` semantics. + +### Temporary logical bool arrays + +If needed for expression support, create temporary in-memory bool NDArrays scoped to the operation: + +```python +flag_true = raw == 1 +flag_false = raw == 0 +``` + +These temporaries: + +- are not persisted; +- are not added to `ct._cols`; +- live only during operations like `ct.where(...)`; +- can be compressed if materialized as Blosc2 NDArrays; +- should be avoided when a simple comparison rewrite is enough. + +### Explicitly out of scope for V1 + +Full nullable boolean algebra, e.g. preserving nulls in value-producing expressions: + +```text +~flag -> [False, True, Null] +flag & other_nullable_flag +flag | other_nullable_flag +``` + +can be deferred. V1 focuses on practical filtering semantics. + +--- + +## Sorting and index interaction + +Nullable scalar sentinels must not leak into user-visible sort/filter semantics. + +### Sorting behavior + +Nulls should participate in sorting but always sort last, regardless of sort direction: + +```python +ct.sort_by("score", ascending=True) # non-null ascending, nulls last +ct.sort_by("score", ascending=False) # non-null descending, nulls last +``` + +The current non-indexed sort path already uses a null-indicator key: + +```text +0 = non-null +1 = null +``` + +as a more significant lexsort key, which gives nulls-last behavior. + +However, FULL-index sort fast paths can be wrong for nullable columns because they sort raw sentinels: + +```text +signed int sentinel = dtype min -> sorts first ascending +uint/bool sentinel = dtype max/255 -> order depends on direction +string sentinel = lexicographic -> order depends on value +``` + +V1 rule: + +> If the sort key column has `null_value`, do not use the FULL-index sort fast path unless the index is explicitly marked null-aware and nulls-last. Fall back to the null-aware lexsort path. + +Future index metadata can include: + +```json +{ + "null_aware": true, + "null_order": "last" +} +``` + +so sorted index paths can safely support nullable columns. + +### Indexed `where()` behavior + +Normal comparisons should not match nulls: + +```python +ct.where(ct.score > 10) # null score rows excluded +ct.where(ct.score < 10) # null score rows excluded +ct.where(ct.score == 10) # null score rows excluded +ct.where(ct.score != 10) # null score rows excluded +``` + +Explicit null selection should use: + +```python +ct.where(ct.score.is_null()) +``` + +For index-accelerated `where()`, raw sentinel values can otherwise produce incorrect matches. Examples: + +```text +uint null sentinel = max_uint -> may match score > 10 +int null sentinel = min_int -> may match score < 0 +string sentinel -> may match lexicographic ranges +``` + +V1 rule: + +> If an indexed query references nullable columns, post-filter index-produced candidate positions with the relevant `notnull` physical masks before returning them. + +For simple comparisons and AND-only expressions: + +```python +ct.where((ct.score > 10) & (ct.age < 20)) +``` + +index result positions should be filtered as: + +```python +positions = positions[score_notnull[positions] & age_notnull[positions]] +``` + +This is simple, safe, and avoids null sentinel false positives. + +### OR expressions + +Global null-mask post-filtering is not correct for OR expressions. Example: + +```python +ct.where((ct.score > 10) | (ct.category == 3)) +``` + +A row with `score == null` and `category == 3` should match. A global mask: + +```python +score.notnull() & category.notnull() +``` + +would wrongly drop it. + +V1 rule: + +> For indexed expressions containing OR over nullable columns, fall back to full scan unless the expression has been rewritten with branch-local null checks. + +Future branch-local AST rewrite: + +```python +(score > 10) | (category == 3) +``` + +becomes: + +```python +((score > 10) & score.notnull()) | ((category == 3) & category.notnull()) +``` + +This lets the existing planner see a semantically correct predicate. The planner may still need post-filtering or index support for the temporary null-mask operands, but correctness no longer depends on a global mask. + +### Does this remove the need for a nullable-aware planner? + +Partly. Injecting/post-filtering null masks handles many cases without changing the planner: + +- simple comparisons; +- range predicates; +- AND-only combinations. + +A planner is still useful for: + +- deciding when an expression is AND-only vs contains OR; +- identifying referenced nullable columns; +- combining indexed predicates efficiently; +- supporting future branch-local null rewrites. + +So the V1 approach is not a full nullable-aware planner rewrite. It is a correctness layer around existing index results. + +--- + +## Interaction with schema metadata + +The CTable schema already stores each column's `null_value` in the column spec metadata. + +For diagnostics and Parquet fidelity metadata, Arrow metadata can optionally record import-time sentinel decisions: + +```json +{ + "metadata": { + "arrow": { + "fields": { + "score": { + "original_arrow_type": "int32", + "null_sentinel": -2147483648 + }, + "label": { + "original_arrow_type": "string", + "null_sentinel": "__BLOSC2_NULL__" + }, + "flag": { + "original_arrow_type": "bool", + "null_sentinel": 255, + "physical_dtype": "uint8" + } + } + } + } +} +``` + +This is optional because the CTable schema itself is sufficient to export sentinels as nulls. + +--- + +## Impact on `off/import-to-b2z-gpt.py` + +Once nullable scalar support is implemented, the OFF importer should stop manually filling nullable scalar nulls. + +Current workaround: + +```text +nullable numeric/string -> fill with 0/NaN/"" +nullable bool -> wrap as list +``` + +Future behavior: + +```text +nullable numeric -> scalar with auto sentinel +nullable string -> scalar with "__BLOSC2_NULL__" sentinel +nullable bytes -> scalar with b"__BLOSC2_NULL__" sentinel +nullable bool -> scalar nullable bool, physical uint8 sentinel 255 +``` + +Long strings may still be wrapped as `list` for variable-length storage, but that becomes a storage choice, not a null-preservation workaround. + +Expected OFF roundtrip improvement: + +- null-count differences for numeric/string/bool scalar columns should drop to zero; +- value differences caused by filled nulls should disappear; +- remaining differences should be due to intentional schema/storage transformations, if any. + +--- + +## Tests + +### Numeric nulls + +1. Nullable int Parquet column round-trips null counts and values. +2. Nullable uint Parquet column round-trips null counts and values. +3. Nullable float Parquet column round-trips null counts and values using NaN sentinel. +4. Exported Parquet contains real nulls, not sentinel values. + +### String/bytes nulls + +1. Nullable string Parquet column imports with `null_value="__BLOSC2_NULL__"`. +2. Export maps sentinel back to Parquet nulls. +3. `max_length` is at least `len("__BLOSC2_NULL__")`. +4. Nullable bytes column behaves similarly with `b"__BLOSC2_NULL__"`. +5. User-provided `string_null_value` / `bytes_null_value` are respected. + +### Bool nulls + +1. `blosc2.bool(nullable=True)` uses physical `uint8` dtype. +2. `blosc2.bool(null_value=255)` is equivalent. +3. Nullable bool Parquet imports as `0/1/255` raw values. +4. Export maps `255` back to Parquet nulls and emits Arrow bool type. +5. `is_null()`, `notnull()`, `null_count()` work. + +### Bool filtering + +For raw values: + +```text +[1, 0, 255] +``` + +Verify: + +```python +ct.where(ct.flag == True) # true row only +ct.where(ct.flag == False) # false row only +ct.where(ct.flag != True) # false row only +ct.where(ct.flag != False) # true row only +ct.where(ct.flag) # true row only +ct.where(~ct.flag) # false row only +``` + +### OFF roundtrip + +Run: + +```bash +python off/import-to-b2z-gpt.py --roundtrip --overwrite +``` + +Expected: + +- same row count; +- same column count; +- same Arrow types for imported/exported columns; +- zero null-count differences for scalar columns using auto sentinels; +- significantly fewer value differences than current baseline. + +--- + +## Implementation milestones + +### Milestone 1: Port numeric auto sentinel support + +- Add `_auto_null_sentinel()` helper. +- Add `auto_null_sentinels` to `from_arrow_batches()` and `from_parquet()`. +- Replace Arrow nulls with numeric sentinels during batch writes. +- Add numeric nullable Parquet tests. + +### Milestone 2: String/bytes sentinels + +- Add `string_null_value` and `bytes_null_value` parameters. +- Include sentinel length in max length calculation. +- Replace Arrow nulls with configured sentinel during batch writes. +- Export sentinels as Arrow nulls. +- Add string/bytes nullable tests. + +### Milestone 3: Nullable bool schema/storage + +- Extend `blosc2.bool()` with `nullable=True` and `null_value=255`. +- Use `np.uint8` physical dtype for nullable bool. +- Adjust display/type helpers where needed. +- Add import/export conversion for nullable bool. +- Add nullable bool tests. + +### Milestone 4: Nullable bool filter rewrites + +- Detect nullable bool columns in expression-building paths. +- Rewrite equality/inequality comparisons. +- Rewrite bare nullable bool and `~nullable_bool` in filter contexts. +- Add filter tests. + +### Milestone 5: Update OFF importer and assess + +- Remove scalar null filling workaround. +- Stop wrapping nullable bools as `list`. +- Keep long string wrapping only when desired for storage efficiency. +- Run roundtrip assessment and document remaining differences. + +--- + +## Open questions + +1. Should `from_arrow_batches()` default `auto_null_sentinels=True`, or only `from_parquet()`? +2. Should `blosc2.bool(null_value=255)` allow any other sentinel in the future, or always enforce `255`? +3. Should raw nullable bool display show `True`/`False`/`NULL` even though raw reads expose `0/1/255`? +4. Should string/bytes sentinel collision checking be offered as an optional slow mode? +5. Should singleton-list long strings eventually be replaced by true variable-length scalar string storage? diff --git a/plans/ctable-object-array.md b/plans/ctable-object-array.md new file mode 100644 index 000000000..0ef8ab861 --- /dev/null +++ b/plans/ctable-object-array.md @@ -0,0 +1,638 @@ +# CTable container/schema naming cleanup and variable-length scalar string support + +## Motivation + +The current CTable-related container naming mixes two different concerns: + +1. **Physical storage layout** + - `VLArray` + - `BatchArray` +2. **Logical row semantics** + - `ListArray` + +This has made the design harder to reason about as requirements have become clearer. + +In particular, the recent Parquet/OFF import work exposed an important missing concept: + +- we need to store **long scalar strings/bytes efficiently** +- we want to avoid fixed-width `NDArray` string dtypes for wildly variable-length payloads +- we do **not** want to change the logical type from scalar string to `list[string]` + +The current workaround promotes long scalar strings to `list` and stores them in a `ListArray`, wrapping each scalar value as a singleton list: + +```python +before = "...json string..." +after = ["...json string..."] +``` + +This preserves bytes but changes logical shape, which is a departure from the source Parquet schema. + +The root issue is that we are missing a clear separation between: + +- **storage primitives** (how bytes/objects are packed) +- **logical column kinds** (what one row value means) + +Since these APIs are not yet publicly released, this is a good time to simplify the model. + +--- + +## Design principle + +Adopt a strict split between: + +### 1. Physical containers +Public low-level containers should describe **how objects are physically packed/stored**. + +### 2. Logical schema kinds +CTable schema specs should describe **what one row value is**. + +### 3. Internal adapters +Any row-wise wrapper needed to adapt a physical container to CTable column semantics should remain internal. + +This prevents proliferation of public container types whose only purpose is to bridge CTable behavior. + +--- + +## Proposed naming model + +## Physical layer: public containers + +### `ObjectArray` +Rename current `VLArray` to `ObjectArray`. + +Semantics: +- one logical object per entry +- one serialized object per chunk +- row/item-oriented access + +Why rename: +- `VLArray` emphasizes variable-length encoding but not the real abstraction +- the real concept is: an array of Python/Blosc2 objects +- `ObjectArray` pairs naturally with `BatchArray` + +### `BatchArray` +Keep `BatchArray`. + +Semantics: +- one entry is one batch +- each batch contains many objects/items +- optimized for packing many items per chunk + +This gives a clear public pair: + +- `ObjectArray`: unbatched object storage +- `BatchArray`: batched object storage + +--- + +## Logical/schema layer: public CTable specs + +### `list(...)` +Represents a list-valued row cell. + +### `vlstring(...)` +Represents a scalar variable-length string column. + +### `vlbytes(...)` +Represents a scalar variable-length bytes column. + +These are **logical column kinds**, not low-level container types. + +This is the right place to expose: +- nullability +- string/bytes validation constraints +- batching hints for storage +- serializer choices if needed + +--- + +## Internal implementation layer + +`ListArray` should no longer be treated as a fundamental public abstraction. +It is better understood as an **internal adapter** implementing row-wise list-column semantics on top of a physical storage primitive. + +Similarly, the new scalar variable-length string/bytes support should be implemented via an internal row-wise adapter over `BatchArray`, rather than introducing additional public container classes like: + +- `VLStringArray` +- `VLBytesArray` +- `VLScalarArray` + +Those names add public API surface without adding a new true storage primitive. + +### Recommended internal direction + +- keep or rename `ListArray` internally if desired +- add an internal scalar adapter for `vlstring` / `vlbytes` +- both can be backed by `BatchArray` + +This keeps the public container list minimal while preserving a clean implementation. + +--- + +## Why this is better + +## 1. Cleaner mental model + +Users and maintainers can reason as follows: + +### Low-level storage choice +- one object per slot/chunk -> `ObjectArray` +- many objects per chunk -> `BatchArray` + +### CTable schema choice +- row is a list -> `list(...)` +- row is a long scalar string -> `vlstring(...)` +- row is a long scalar bytes value -> `vlbytes(...)` + +This is much clearer than mixing storage and row semantics in container class names. + +## 2. Avoids singleton-list hacks + +Long scalar strings will no longer need to be represented as `list` just to get efficient batched storage. + +Instead: +- logical type remains scalar string +- physical storage uses batched object packing internally + +## 3. Minimal public surface + +Public containers remain few and conceptually crisp: +- `ObjectArray` +- `BatchArray` + +No need to add more public wrapper classes just for CTable internals. + +## 4. Better future extensibility + +If later we need other logical variable-length scalar kinds, they can be added at the schema layer without inventing new public containers. + +--- + +## Scope decision + +This proposal focuses on supporting: + +- `vlstring` +- `vlbytes` + +for CTable scalar columns. + +It does **not** aim to make a fully generic nullable object column type public right now. +That broader design space can be revisited later if needed. + +--- + +## High-level implementation strategy + +## A. Rename physical container `VLArray` -> `ObjectArray` + +### Goals +- make public physical container names consistent +- preserve the existing storage behavior of current `VLArray` + +### Notes +- because this machinery is not publicly released, we do not need to optimize for compatibility +- internal tags/metadata may remain versioned in a way that allows reopening existing local test artifacts if useful, but compatibility is not the primary constraint + +### Tasks +- rename `src/blosc2/vlarray.py` to `src/blosc2/objectarray.py` and update the class name to `ObjectArray` +- update all imports/references across the repo +- keep on-disk metadata tag as `vlarray` for now; rename later if desired + +#### Recommendation +- public class name: `ObjectArray` +- rename module file to `objectarray.py` in Phase 1 alongside the class rename — keeping the file named `vlarray.py` while the class is `ObjectArray` creates a persistent split-brain that adds noise during all subsequent phases; the cost is low since the APIs are not yet public +- metadata tag can remain `vlarray` initially to minimize internal breakage, then be renamed later if desired + +--- + +## B. Reframe `ListArray` as internal adapter + +### Goals +- stop treating `ListArray` as a fundamental public storage primitive +- make it clear it is a row-wise list-column adapter + +### Tasks +- keep implementation in place initially +- reduce public-facing emphasis on `ListArray` +- optionally rename internally later (not required for phase 1) + +This can be done incrementally; there is no need to rename the implementation immediately if that creates churn. + +--- + +## C. Add new schema specs: `vlstring`, `vlbytes` + +## File +- `src/blosc2/schema.py` + +### `vlstring` +Properties should likely include: +- `nullable: bool = False` +- `serializer: str = "msgpack"` +- `batch_rows: int | None = 2048` +- `items_per_block: int | None = None` + +### `vlbytes` +Properties should likely include: +- `nullable: bool = False` +- `serializer: str = "msgpack"` +- `batch_rows: int | None = 2048` +- `items_per_block: int | None = None` + +### Dropped fields: `min_length`, `max_length`, `pattern`, `storage` +- `min_length` / `max_length` / `pattern` are application-layer validation concerns, not storage schema concerns. They do not affect how data is physically stored and would make `vlstring`/`vlbytes` the only place in the blosc2 schema system that enforces runtime business-logic constraints. They can be added later if a concrete use case emerges. +- `storage` is premature: there is only one storage backend (`batch`) and no second option is defined. Adding the field now would require CTable code to guard on it from day one while ignoring it. Add it when a real alternative exists. +- Note: `max_len` is intentionally kept on `string()` where it has a clear, unambiguous meaning — it sizes the fixed-width NDArray dtype (e.g. `dtype=' string()` and `bytes -> bytes()` inference unchanged +- require `vlstring`/`vlbytes` to be requested explicitly via `blosc2.field(...)` + +### Reasoning +Automatic inference should continue to produce the simpler fixed-width scalar types. The variable-length scalar types are a deliberate storage choice. + +--- + +## E. Internal scalar adapter over `BatchArray` + +## Goal +Provide row-wise scalar column semantics on top of `BatchArray`. + +This adapter should remain **internal**, not a new public container concept. + +### Behavior required by CTable +- `append(value)` appends one scalar row +- `extend(values)` appends many scalar rows +- `flush()` writes pending values as batches +- `__len__()` returns number of rows +- `__getitem__(int)` returns one scalar value +- `__getitem__(slice/list/array)` returns row values +- `__setitem__(int, value)` updates one persisted/pending row +- nullable support via native `None` + +### Storage backend +Use `BatchArray` physically. + +#### Physical representation +A persisted chunk should contain many scalar items, e.g.: + +```python +["a", "bbbb", None, "ccc"] +``` + +not singleton lists. + +### Suggested implementation shape +- small internal adapter class in a new module, e.g. `src/blosc2/_scalar_array.py` +- one implementation parameterized by `py_type` (`str` or `bytes`) +- avoid creating separate public classes unless later justified + +### Pending-buffer strategy +The adapter maintains a `_pending: list` that accumulates rows before they are flushed to a `BatchArray` chunk: +- on `append(value)`: append to `_pending`; if `len(_pending) >= batch_rows`, flush immediately +- on `extend(values)`: extend `_pending` in segments of `batch_rows`, flushing each full segment +- on `flush()`: serialize and write the remaining `_pending` entries as one chunk, then clear `_pending` +- on `__setitem__(i, value)`: if row `i` is still in `_pending` (index >= flushed row count), update it in-place there; otherwise re-read, repack, and rewrite the persisted chunk that contains row `i` + +### Validation/coercion +For string mode: +- allow `str` +- optionally coerce to `str` +- allow `None` only if nullable + +For bytes mode: +- allow `bytes`, `bytearray`, `memoryview`, optionally `str` -> encode if desired +- allow `None` only if nullable + +### Null handling +Use native `None` in persisted batch payloads. +Do not use scalar null sentinels for `vlstring` / `vlbytes`. + +### msgpack and None serialization +The `BatchArray`'s msgpack codec must be configured with `raw=False` so that msgpack strings round-trip as `str` (not `bytes`). Verify that `None` passes through the codec unchanged, since it is the native null representation for nullable columns. This is a common footgun with msgpack defaults. + +--- + +## F. CTable storage backend support + +## File +- `src/blosc2/ctable_storage.py` + +### Current state +Storage knows how to create/open: +- NDArray scalar columns +- list columns + +### Needed changes +Add support for scalar varlen string/bytes columns, e.g.: +- `create_varlen_scalar_column(...)` +- `open_varlen_scalar_column(...)` + +The exact public/internal function names can be decided during implementation. + +### Persistent layout +We can reuse the same file style as current list columns (`.b2b`). + +Example: +- `/_cols/ingredients.b2b` + +### Metadata tag +Tag the underlying stored object so reopen logic knows this `BatchArray` is serving a scalar varlen CTable column role. + +Recommended additional metadata key: + +```python +meta["vlscalar"] = { + "version": 1, + "py_type": "str", # or "bytes" + "nullable": True, + "batch_rows": 2048, +} +``` + +Even if the public name is not `VLScalarArray`, `vlscalar` is an acceptable **internal metadata role name**. Alternatively, we may prefer a more direct tag like `vltext` or `ctable_varlen_scalar`. This can be finalized during implementation. + +### Recommendation +Use a neutral internal role tag, for example: + +```python +meta["ctable_varlen_scalar"] = {...} +``` + +This avoids tying metadata too strongly to a class name we do not want to expose publicly. + +--- + +## G. CTable core integration + +## File +- `src/blosc2/ctable.py` + +This is the largest integration area. + +### New spec/category checks +Today the code distinguishes mainly between: +- scalar NDArray-backed columns +- `ListSpec` columns + +We need explicit branching for: +- fixed-width scalar columns +- list columns +- variable-length scalar string/bytes columns + +Suggested helpers: +- `_is_list_column(col)` +- `_is_varlen_scalar_column(col)` +- `_is_varlen_column(col)` (optional shared helper) + +### Query/expression guard +`vlstring` and `vlbytes` columns are **not queryable in phase 1**. Any code path that feeds a column into a lazy expression or index sidecar must raise `NotImplementedError` with a clear message when the column spec is `vlstring` or `vlbytes`. Without this explicit guard, partial code paths will silently produce wrong results (e.g. treating the column as a fixed-width NDArray). + +### Areas to update +At minimum: +- nullable spec resolution +- column initialization +- grow logic +- flush logic +- open/load/save logic +- append / extend +- row coercion +- copy / compact / sort paths +- `Column.__getitem__` +- `Column.__setitem__` +- `Column.__iter__` +- Arrow schema export +- Arrow batch export +- Arrow import / Parquet import + +### Null policy interaction +`vlstring` and `vlbytes` should bypass scalar sentinel logic. +Nullability is represented natively with `None`. + +### Row coercion +Current scalar coercion path does: + +```python +np.array(val, dtype=col.dtype).item() +``` + +That must not be used for `vlstring` / `vlbytes` because `dtype=None` and native nulls are expected. + +### Grow behavior +Varlen scalar columns should behave like list columns: +- no NDArray resize needed +- only `_valid_rows` grows + +### Flush behavior +A common flush helper should flush: +- list-backed columns +- varlen scalar columns + +--- + +## H. Arrow / Parquet support + +## Goal +Long scalar strings/bytes should round-trip as scalar Arrow/Parquet columns, not singleton lists. + +## Export +When exporting: +- `vlstring` -> Arrow `string` +- `vlbytes` -> Arrow `large_binary` +- preserve `None` as Arrow nulls + +## Import +Update Arrow/Parquet type inference so scalar string/bytes columns can map to: +- fixed-width `string` / `bytes` for short values +- `vlstring` / `vlbytes` for long values + +### Important consequence +The OFF importer (`off/parquet-to-blosc2.py`) should stop promoting long scalar strings to `list`. + +Instead: +- keep the column logically scalar +- import it as `vlstring` +- store it physically through the new batched scalar mechanism + +This fixes the current problem where: + +```python +before = "json-string" +after = ["json-string"] +``` + +--- + +## I. Query/expression/indexing support + +## Recommendation for phase 1 +Support first: +- storage +- append/extend +- row access +- copy/load/save +- Arrow/Parquet import/export + +Do **not** aim initially for full support in: +- lazy expressions over `vlstring`/`vlbytes` +- CTable indexing sidecars on these columns +- advanced vectorized operations + +### Why +These features largely assume NDArray-like scalar columns and can be added later if needed. + +### Sorting +Sorting may be implemented later either by: +- materializing values to Python lists / object arrays +- or adding targeted support + +It does not need to block the initial storage redesign. + +--- + +## J. Tests to add/update + +## New tests +- schema round-trip for `vlstring` / `vlbytes` +- append/get/set/extend/flush behavior +- null handling with native `None` +- save/load persistent CTable containing `vlstring` / `vlbytes` +- Arrow export produces scalar string/binary columns +- Parquet import/export round-trips long scalar strings without singleton-list wrapping +- OFF-style regression test for `ingredients` + +## Existing tests to revise +- any tests assuming long imported strings become `list` +- list-column tests that may now share helper paths with varlen scalar columns + +--- + +## K. Suggested implementation phases + +## Phase 1: naming cleanup foundation +1. Rename public `VLArray` -> `ObjectArray` +2. Rename module file `vlarray.py` -> `objectarray.py` +3. Update all imports and references across the repo +4. Leave `BatchArray` unchanged +5. Keep `ListArray` working, but treat it as internal implementation machinery + +## Phase 2: schema and storage primitives +1. Add `vlstring` / `vlbytes` specs +2. Add schema compiler support +3. Add internal scalar adapter over `BatchArray` +4. Add storage backend support for opening/creating these columns + +## Phase 3: CTable integration +1. Add varlen scalar branching throughout `ctable.py` +2. Support append/extend/load/save/copy/basic access +3. Add flush handling and null handling + +## Phase 4: Arrow/Parquet integration +1. Export `vlstring`/`vlbytes` as scalar Arrow columns +2. Import long scalar strings/bytes as `vlstring`/`vlbytes` +3. Update `off/parquet-to-blosc2.py` to stop wrapping long strings as singleton lists + +## Phase 5: cleanup and polish +1. Update docs/examples/tests +2. Review whether any internal adapter renames are worthwhile +3. Reassess whether advanced query/index support is needed + +--- + +## Open questions + +### 1. Public name of the renamed `VLArray` +Recommendation: `ObjectArray` + +Alternative possibilities: +- `ItemArray` +- `PackedObjectArray` + +`ObjectArray` is the clearest. + +### 2. File/module renaming +Rename `vlarray.py` -> `objectarray.py` in Phase 1 alongside the class rename. +Keeping the filename as `vlarray.py` while the exported class is `ObjectArray` creates a persistent split-brain that adds cognitive noise throughout all subsequent phases. The cost is low since the APIs are not yet public. + +### 3. Internal metadata role name for varlen scalar column wrappers +Needs a final decision. + +Recommendation: +- use a neutral internal key like `ctable_varlen_scalar` + +### 4. Whether `vlbytes` should decode/encode `str` automatically +This is a policy choice. + +Recommendation: +- keep behavior conservative initially +- accept bytes-like inputs explicitly +- only coerce from `str` if there is a strong existing precedent + +### 5. Whether `serializer="arrow"` should be supported initially +Recommendation: +- start with `msgpack` +- add Arrow serializer support only if there is a demonstrated benefit + +### 6. `min_length`, `max_length`, `pattern` on `vlstring`/`vlbytes` +Dropped from phase 1. These are application-layer validation concerns, not storage schema concerns. They can be revisited if a concrete use case is identified. + +### 7. `storage` field on `vlstring`/`vlbytes` +Dropped from phase 1. There is only one storage backend (`batch`) and no second option is defined. Add when a real alternative exists. + +--- + +## Final recommendation + +Adopt the following conceptual model: + +### Public physical containers +- `ObjectArray` (renamed from `VLArray`) +- `BatchArray` + +### Public logical CTable specs +- `list(...)` +- `vlstring(...)` +- `vlbytes(...)` + +### Internal adapters +- list-column adapter over physical containers +- varlen scalar string/bytes adapter over `BatchArray` + +This gives a much cleaner and more scalable design than the current mix of storage and row-semantics names, and it directly solves the long-string import problem without distorting scalar columns into singleton lists. diff --git a/plans/ctable-parquet-metadata.md b/plans/ctable-parquet-metadata.md new file mode 100644 index 000000000..84ec9fe56 --- /dev/null +++ b/plans/ctable-parquet-metadata.md @@ -0,0 +1,404 @@ +# CTable Parquet/Arrow Metadata Plan + +## Summary + +Add an optional, internal metadata section to the persistent `CTable` schema dict so Arrow/Parquet import paths can preserve enough original Arrow schema information for better future round-tripping. + +The immediate motivation is supporting nested Parquet columns such as: + +```text +struct +list> +``` + +The target design is full internal `StructSpec` support, including `list>`, with metadata preserving the original Arrow schema so `parquet -> CTable -> parquet` can round-trip supported nested columns without schema loss. + +This is intended as an implementation detail, not a public `t.metadata` API. + +--- + +## Goals + +1. Preserve original Arrow/Parquet schema information when importing into CTable. +2. Keep the metadata optional and backward-compatible. +3. Avoid changing the physical data layout for existing columns. +4. Avoid exposing a public metadata API for now. +5. Enable Parquet round-tripping for nested columns, including `struct` and `list` columns. +6. Implement full internal `StructSpec` support for Arrow/Parquet nested schemas. +7. Preserve enough Arrow schema fidelity so supported nested columns can round-trip through `parquet -> CTable -> parquet` without schema loss. +8. Ensure old tables without metadata continue loading unchanged. +9. Ensure readers that do not understand the metadata can ignore it safely. + +--- + +## Non-goals + +- Expose user-facing metadata management methods. +- Guarantee exact Arrow schema reconstruction for all possible Arrow schemas outside the supported nested V1 scope. +- Store large amounts of per-row or per-column statistics in schema metadata. + +--- + +## Proposed schema dict extension + +Current persistent CTable schema serialization stores column definitions in a JSON-like dict. Extend this dict with an optional top-level `metadata` field: + +```json +{ + "columns": [ + {"name": "code", "spec": {"kind": "string", "max_length": 32}}, + {"name": "generic_name", "spec": {"kind": "list", "item": {"kind": "struct", "fields": [...]}}} + ], + "metadata": { + "arrow": { + "schema_ipc_base64": "...", + "fields": { + "generic_name": { + "original_arrow_type": "list>", + "ctable_storage": "list>", + "conversion": "native_struct" + } + } + } + } +} +``` + +The exact top-level schema dict may contain additional existing keys; this plan only adds an optional sibling key named `metadata`. + +### Compatibility rules + +- If `metadata` is absent, treat it as `{}`. +- If `metadata` is present but contains unknown keys, preserve them when possible. +- If `metadata.arrow` is absent, Arrow/Parquet code falls back to ordinary inference. +- Existing table loading should not require or validate Arrow metadata. + +--- + +## Internal representation + +Prefer storing metadata on `CompiledSchema`: + +```python +@dataclass +class CompiledSchema: + row_cls: type | None + columns: list[CompiledColumn] + columns_by_name: dict[str, CompiledColumn] + metadata: dict[str, Any] = field(default_factory=dict) +``` + +No public property is required. CTable internals can access: + +```python +self._schema.metadata +``` + +or helper methods if needed: + +```python +def _schema_metadata(self) -> dict[str, Any]: + return self._schema.metadata +``` + +Do not expose `t.metadata` initially. + +--- + +## Serialization changes + +### `schema_to_dict()` + +Emit metadata only when non-empty: + +```python +def schema_to_dict(schema: CompiledSchema) -> dict: + d = {...} + if schema.metadata: + d["metadata"] = schema.metadata + return d +``` + +### `schema_from_dict()` + +Read metadata defensively: + +```python +def schema_from_dict(d: dict) -> CompiledSchema: + return CompiledSchema( + row_cls=None, + columns=columns, + columns_by_name=columns_by_name, + metadata=dict(d.get("metadata", {})), + ) +``` + +### Existing code paths + +Update any manual `CompiledSchema(...)` construction to pass no metadata or explicitly preserve it. Because `metadata` defaults to `{}`, most call sites should continue to work. + +Important places to check: + +- dataclass schema compilation +- Pydantic schema compilation +- `CTable.open()` +- `CTable.load()` +- `CTable.save()` +- `CTable.select()` / views +- `CTable.from_arrow()` +- `CTable.from_arrow_batches()` +- `CTable.from_parquet()` +- schema mutation methods + +For views/selections, metadata should probably be filtered to selected columns where practical, but this can be deferred if metadata remains internal. + +--- + +## Arrow schema encoding + +Do not rely solely on `schema.to_string()` for round-trip fidelity. Store a serialized Arrow schema when possible. + +Potential encoding: + +```python +import base64 +import pyarrow as pa + +sink = pa.BufferOutputStream() +with pa.ipc.new_stream(sink, schema): + pass +schema_ipc = sink.getvalue().to_pybytes() +schema_ipc_base64 = base64.b64encode(schema_ipc).decode("ascii") +``` + +Store under: + +```json +{ + "metadata": { + "arrow": { + "schema_ipc_base64": "...", + "schema_string": "... optional debug string ..." + } + } +} +``` + +Open question: verify the best PyArrow API for schema-only IPC serialization. If `pa.ipc` exposes a direct schema serialization API, prefer that. + +--- + +## Column-level Arrow metadata + +For fields that are transformed during import, store explicit conversion notes: + +```json +{ + "metadata": { + "arrow": { + "fields": { + "generic_name": { + "original_arrow_type": "list>", + "ctable_storage": "list>", + "conversion": "native_struct" + }, + "no_nutrition_data": { + "original_arrow_type": "bool", + "ctable_storage": "list", + "conversion": "nullable_scalar_wrapped_as_singleton_list" + }, + "ecoscore_data": { + "original_arrow_type": "string", + "ctable_storage": "list", + "conversion": "long_nullable_scalar_wrapped_as_singleton_list" + } + } + } + } +} +``` + +This lets exporters distinguish native CTable schemas from import-time adaptations. + +--- + +## StructSpec and nested list support + +Add a first-class internal `StructSpec` so CTable can represent Arrow structs directly: + +```python +blosc2.struct( + { + "lang": blosc2.string(), + "text": blosc2.string(), + } +) +blosc2.list(blosc2.struct({"lang": blosc2.string(), "text": blosc2.string()})) +``` + +Then import can map: + +```text +struct<...> -> blosc2.struct(...) +list> -> blosc2.list(blosc2.struct(...), nullable=True) +``` + +For `ListArray`, list cells can continue to be stored row-wise as Python values such as `list[dict]`, but the `ListSpec.item_spec` should be a typed `StructSpec`, not an opaque object spec. Coercion validates each dict-like item against the struct fields, and Arrow export uses the saved/original Arrow struct schema where available. + +Metadata records the original Arrow field and the native struct conversion: + +```json +{ + "original_arrow_type": "list>", + "ctable_storage": "list>", + "conversion": "native_struct" +} +``` + +Opaque object support can remain a fallback for Arrow types outside the supported nested V1 scope, but the primary goal is native `StructSpec` support and full Parquet round-tripping for supported nested columns. + +--- + +## Import behavior proposal + +When `CTable.from_parquet()` or `CTable.from_arrow_batches()` receives an Arrow schema: + +1. Preserve the original Arrow schema IPC bytes in schema metadata. +2. For columns imported without transformation, no field-level entry is required. +3. For transformed columns, add a field-level entry describing the conversion. +4. For unsupported columns that are skipped by a custom importer, optionally record them under `metadata.arrow.skipped_fields` if the importer chooses to. + +Potential internal helper: + +```python +def _arrow_metadata_from_schema( + schema, *, field_conversions=None, skipped_fields=None +) -> dict: + return { + "arrow": { + "schema_ipc_base64": encode_arrow_schema(schema), + "schema_string": schema.to_string(), + "fields": field_conversions or {}, + "skipped_fields": skipped_fields or {}, + } + } +``` + +--- + +## Export behavior proposal + +`CTable.to_parquet()` should check: + +```python +arrow_meta = self._schema.metadata.get("arrow", {}) +``` + +For ordinary columns: + +- Continue using normal CTable → Arrow conversion. + +For columns with known conversions: + +- `struct` and `list` columns: + - Convert Python dict/list-of-dict values to `pa.array(..., type=original_arrow_type)`. +- singleton-list wrapped nullable scalars: + - Optionally unwrap back to nullable scalar Arrow columns. +- long string wrapped as `list`: + - Optionally unwrap back to original nullable scalar string column if metadata says so. + +If metadata is missing, malformed, or incompatible with current data, fall back to safe behavior or raise a clear error depending on export mode. + +Possible control flag: + +```python +t.to_parquet(path, use_original_arrow_schema=True) +``` + +For supported nested columns, the implementation goal is to use the original Arrow schema by default when metadata is available and compatible. If metadata is unavailable or incompatible, fail clearly or fall back according to the selected export mode. + +--- + +## Tests + +### Schema metadata persistence + +1. Creating a CTable without metadata produces a schema dict without `metadata` or with an empty metadata field omitted. +2. Loading old schema dicts without `metadata` works. +3. Saving and opening a CTable with metadata preserves the metadata exactly. +4. `CTable.save()` and `CTable.load()` preserve metadata. + +### Arrow metadata + +1. `from_parquet()` stores original Arrow schema metadata when enabled. +2. `from_arrow_batches()` stores original Arrow schema metadata when given a schema. +3. Metadata survives close/open for `.b2z` and directory-backed stores. +4. Unknown metadata keys survive round-trip. + +### Struct/nested round-trip tests + +1. Import a top-level Arrow `struct` column as `StructSpec`. +2. Import a `list` column as `list(StructSpec)`. +3. Stored values are Python dicts or lists of dicts with validated fields. +4. Metadata records the original Arrow type. +5. Export reconstructs the original `struct` / `list` Arrow type. +6. Parquet → CTable → Parquet preserves schema and values for supported nested fields. + +--- + +## Migration/backward compatibility + +This change should be backward-compatible because: + +- `metadata` is optional. +- Existing schema dicts remain valid. +- Existing code paths can ignore unknown metadata. +- No physical storage format changes are required. +- Public APIs do not change. + +Potential compatibility concern: + +- If older versions of python-blosc2 strictly reject unknown top-level schema keys, tables written with metadata may not load in old versions. Check existing `schema_from_dict()` behavior. If necessary, store metadata in a nested location old readers already ignore, or accept that forward compatibility to older versions is limited. + +--- + +## Open questions + +1. What exact PyArrow API should be used for schema-only serialization? +2. Should metadata be preserved exactly or normalized/sanitized on save? +3. Should selected views filter metadata to selected columns? +4. Should `from_parquet()` always store Arrow metadata, or only when nested/transformed columns are present? +5. Should metadata be compressed if the Arrow schema is large? +6. Should there eventually be a public metadata API, or should this remain strictly internal? +7. For singleton-list wrapped scalars, should `to_parquet()` unwrap by default when original Arrow metadata is present? + +--- + +## Suggested milestones + +### Milestone 1: Generic schema metadata plumbing + +- Add optional `metadata` to `CompiledSchema`. +- Update `schema_to_dict()` and `schema_from_dict()`. +- Ensure save/open/load preserve metadata. +- Add tests for backward compatibility and metadata preservation. + +### Milestone 2: Arrow schema metadata helpers + +- Add private helpers to encode/decode Arrow schema metadata. +- Store Arrow schema metadata in `from_arrow_batches()` / `from_parquet()`. +- Add tests using simple Arrow schemas. + +### Milestone 3: StructSpec and ListArray nested support + +- Add internal `StructSpec` with field specs, metadata serialization, and validation/coercion. +- Allow `ListSpec(StructSpec)` for msgpack-backed ListArray. +- Map Arrow `struct` and `list` to `StructSpec` / `list(StructSpec)` in import. +- Store field conversion metadata. + +### Milestone 4: Metadata-aware Parquet export + +- Teach `to_parquet()` to consult original Arrow metadata. +- Reconstruct `struct` and `list` arrays from stored dict/list-of-dict values. +- Optionally unwrap singleton-list transformed scalar columns. +- Add Parquet → CTable → Parquet round-trip tests for selected nested fields. diff --git a/plans/ctable-varlen-cols.md b/plans/ctable-varlen-cols.md new file mode 100644 index 000000000..451c48859 --- /dev/null +++ b/plans/ctable-varlen-cols.md @@ -0,0 +1,1080 @@ +# CTable Variable-Length Columns Implementation Plan + +## Summary + +Add support for variable-length list columns to `CTable` via a new logical list type: + +- public schema API: `b2.list(...)` +- physical row-oriented container: `blosc2.ListArray` +- internal storage backends: + - `VLArray` for row-oriented point updates + - `BatchArray` for append/read efficiency + +The design goal is to let users declare typed list columns in a `CTable` schema without exposing backend details unless they want to tune them. + +`ListArray` should also be treated as a first-class public container for row-oriented list-valued data, not merely as an internal `CTable` helper. At the same time, it should not be positioned as a replacement for `VLArray` or `BatchArray`; those remain the lower-level variable-length building blocks for more generic or explicitly batch-oriented workloads. + +Example target API: + +```python +from dataclasses import dataclass +import blosc2 as b2 + + +@dataclass +class Product: + code: str = b2.field(b2.string(max_length=32)) + ingredients: list[str] = b2.field(b2.list(b2.string(), nullable=True)) + allergens: list[str] = b2.field( + b2.list(b2.string(), storage="batch", serializer="msgpack") + ) +``` + +--- + +## Final decisions already made + +### Public API + +- Use `b2.list(...)`, not `b2.list_(...)`. +- Use `cell` only as a documentation/design term when helpful, not as a formal API term. +- `ListArray` is the public row-oriented container abstraction for variable-length list columns. +- `ListArray` should be documented as a first-class container in its own right, useful both inside and outside `CTable`. +- `ListArray` should not be presented as replacing `VLArray` or `BatchArray`; instead, it should be positioned as the natural high-level container for row-oriented list data, while `VLArray` and `BatchArray` remain the lower-level building blocks. + +### Defaults + +- default `storage="batch"` +- default `serializer="msgpack"` +- default `nullable=False` +- `serializer="arrow"` remains optional and must not introduce a hard `pyarrow` dependency +- `serializer="arrow"` is only allowed with `storage="batch"` + +### Null semantics + +For V1, distinguish: + +- `None` → null list cell +- `[]` → empty list cell + +Do not support nullable items inside the list by default. + +So V1 supports: + +- `nullable=True|False` for the whole list cell +- no `item_nullable=True` behavior yet + +### Update semantics + +Support **explicit whole-cell replacement only**: + +```python +t.ingredients[5] = ["salt", "sugar"] +``` + +Do not support implicit write-through mutation of returned Python objects: + +```python +x = t.ingredients[5] +x.append("salt") # local only +# user must reassign +``` + +### Batch layout policy + +- default `batch_rows` should follow the column chunk size +- batch-backed list columns should use an internal append buffer in `ListArray` +- buffering lives in `ListArray`, not in `BatchArray` +- flushes occur: + - when buffer reaches `batch_rows` + - on explicit `flush()` + - on persistence boundaries such as `save()` / `close()` + - before exports that must observe all rows (e.g. `to_arrow()`) + +### V1 scope + +Support in V1: + +- schema declaration via `b2.list(...)` +- append / extend +- row reads +- whole-cell replacement +- persistence (`save`, `open`, `load`) +- standalone `ListArray` reopen through `blosc2.open()` / `blosc2.from_cframe()` +- `head`, `tail`, `select`, and scalar-driven `where()`/view operations +- `compact()` +- `to_arrow()` / `from_arrow()` +- display / info support + +Explicitly out of scope for V1: + +- indexes on list columns +- computed columns over list columns +- sorting by list columns +- list-aware predicates such as contains / overlaps +- nullable items inside a list +- nested list-of-list / struct / map types +- standalone insert/delete API on `ListArray` + +--- + +## Main design principle + +Do **not** force list columns into the current scalar `np.dtype` model. + +Today the schema/compiler/storage path assumes that every column: + +- has a scalar `np.dtype` +- is physically stored as an `NDArray` +- can be coerced with scalar NumPy conversion rules + +That is not true for list columns. + +Instead, the refactor should distinguish: + +- logical scalar columns +- logical list columns + +and separately distinguish their physical storage: + +- scalar column → `NDArray` +- list column → `ListArray` + +This keeps the scalar path fast and clean while adding a first-class path for variable-length lists. + +--- + +## High-level architecture + +### Logical layer + +Add a new schema descriptor: + +- `ListSpec` + +Keep existing scalar specs, but conceptually move toward: + +- `ColumnSpec` + - `ScalarSpec` + - `ListSpec` + +This can be implemented either by introducing explicit base classes or by broadening the meaning of the current spec system. The important part is that `CompiledColumn` and `CTable` must stop assuming every spec has a scalar `dtype`. + +### Physical layer + +Add a new container: + +- `blosc2.ListArray` + +`ListArray` is cell-oriented: + +- `arr[i]` returns one list cell or `None` +- `arr[i:j]` returns a Python `list` of cells +- `arr[i] = value` replaces one cell +- `append(value)` appends one cell +- `extend(values)` appends many cells + +Internally it wraps one of: + +- `VLArray` +- `BatchArray` + +### CTable layer + +Teach `CTable` to manage two families of physical columns: + +- scalar columns backed by `NDArray` +- list columns backed by `ListArray` + +`CTable` should understand list columns at the schema and storage levels, but should not need backend-specific logic beyond creation/open/flush/update hooks. + +--- + +## Phase 1: Schema system changes + +## 1.1 Add `b2.list(...)` + +Add a new public builder in `src/blosc2/schema.py`: + +```python +def list( + item_spec, + *, + nullable=False, + storage="batch", + serializer="msgpack", + batch_rows=None, + items_per_block=None, +): ... +``` + +Initial accepted parameters: + +- `item_spec` + - typically a scalar spec such as `b2.string()` or `b2.int32()` +- `nullable` + - whether the whole list cell may be `None` +- `storage` + - `"batch"`, `"vl"` +- `serializer` + - `"msgpack"`, `"arrow"` +- `batch_rows` + - optional row count per persisted batch for batch backend +- `items_per_block` + - forwarded to `BatchArray` when backend is batch + +Validation rules for V1: + +- `storage` must be `"batch"` or `"vl"` +- `serializer` must be `"msgpack"` or `"arrow"` +- if `storage == "vl"`, serializer must be `"msgpack"` +- if `serializer == "arrow"`, storage must be `"batch"` +- `item_spec` should initially be restricted to scalar specs + +## 1.2 Introduce `ListSpec` + +Add a new schema descriptor class with at least: + +- `python_type = list` +- `item_spec` +- `nullable` +- `storage` +- `serializer` +- `batch_rows` +- `items_per_block` + +Methods analogous to existing specs: + +- `to_metadata_dict()` +- optional `display_label()` helper or equivalent + +Suggested serialized form: + +```json +{ + "kind": "list", + "item": {"kind": "string", "max_length": 64}, + "nullable": true, + "storage": "batch", + "serializer": "msgpack", + "batch_rows": 65536, + "items_per_block": 256 +} +``` + +## 1.3 Broaden `field()` acceptance + +`b2.field(...)` should accept any valid column spec, not just scalar specs. + +The implementation contract becomes: + +- scalar field spec allowed +- list field spec allowed + +No user-visible API change beyond this. + +--- + +## Phase 2: Schema compiler changes + +## 2.1 Relax `CompiledColumn` + +`CompiledColumn` currently assumes a scalar `dtype`. Refactor it so list columns are first-class. + +Target shape: + +- `name` +- `py_type` +- `spec` +- `default` +- `config` +- `display_width` +- optional scalar dtype information only when applicable + +There are two acceptable implementation styles: + +### Option A: minimal change + +Keep `dtype` on `CompiledColumn`, but allow it to be `None` for non-scalar columns. + +### Option B: cleaner long-term change + +Replace mandatory `dtype` with something like: + +- `storage_dtype: np.dtype | None` +- `logical_kind` +- convenience properties such as `is_scalar`, `is_list` + +Recommendation: choose the smallest refactor that avoids fake object dtypes. + +## 2.2 Update annotation validation + +Current annotation validation is scalar-oriented. Extend it to support: + +```python +ingredients: list[str] = b2.field(b2.list(b2.string())) +``` + +Compiler responsibilities: + +- inspect `typing.get_origin(annotation)` +- inspect `typing.get_args(annotation)` +- validate that `list[...]` annotations match `ListSpec` +- validate that the item annotation matches `item_spec` + +For V1, support: + +- built-in `list[T]` +- likely `typing.List[T]` as a compatibility path if desired + +Restrict V1 item annotations to scalar item types. + +## 2.3 Schema serialization/deserialization + +Extend `schema_to_dict()` / `schema_from_dict()` so list specs round-trip through stored schema metadata. + +This includes: + +- emitting `kind="list"` +- recursively serializing `item_spec` +- restoring `ListSpec` on reopen/load + +--- + +## Phase 3: Add `ListArray` + +## 3.1 Public role + +Create a new file: + +- `src/blosc2/list_array.py` + +And export it from: + +- `src/blosc2/__init__.py` + +`ListArray` should be the row-oriented facade used by `CTable` and also a standalone public container for users working with row-oriented list-valued data. + +It should not expose `BatchArray`'s native batch-oriented semantics. + +Documentation should encourage `ListArray` for typed, row-oriented list data, while still keeping `VLArray` and `BatchArray` visible as the lower-level containers for arbitrary object payloads and explicitly batch-oriented workflows. + +## 3.2 Core API + +Initial API: + +- constructor taking list spec / backend hints / storage kwargs +- `append(value)` +- `extend(values)` +- `flush()` +- `close()` +- `__enter__()` / `__exit__()` +- `__getitem__(index|slice)` +- `__setitem__(index, value)` +- `__len__()` +- `__iter__()` +- `copy(**kwargs)` +- `info` +- `to_arrow()` when possible +- `from_arrow()` if useful as a constructor helper + +V1 read behavior: + +- `arr[i]` → `list | None` +- `arr[i:j]` → `list[list | None]` + +No item-level API like `arr.items` is required for V1. + +## 3.3 Validation/coercion inside `ListArray` + +`ListArray` should validate cell values against the provided `ListSpec`. + +Rules for V1: + +- `None` allowed only if `nullable=True` +- otherwise value must be list-like +- strings / bytes are not accepted as list-like cells +- each item must satisfy `item_spec` +- `None` items rejected for V1 + +The goal is that `ListArray` can be safely used both inside and outside `CTable`. + +## 3.4 Backend selection + +Selection policy: + +- `storage="batch"` → batch backend +- `storage="vl"` → VL backend +- `serializer="arrow"` only valid with batch backend +- default backend = batch +- default serializer = msgpack + +Implementation note: backend choice should be explicit in metadata and persisted schema, not inferred later from heuristics. + +--- + +## Phase 4: `ListArray` backend implementation + +## 4.1 VL backend + +Map one logical cell to one VLArray entry. + +Properties: + +- simplest implementation +- natural row-level replacement +- no internal buffer needed + +Implementation behavior: + +- `append(cell)` → `VLArray.append(cell)` +- `extend(cells)` → `VLArray.extend(cells)` +- `__getitem__(i)` → `VLArray[i]` +- `__setitem__(i, cell)` → `VLArray[i] = cell` + +This backend is the easiest one and should be implemented first to stabilize list semantics. + +## 4.2 Batch backend + +Map many logical cells to one persisted batch in `BatchArray`. + +Persisted representation for V1 msgpack path: + +- one batch = list of cells +- each cell = `None` or Python list of scalar items + +Arrow-serializer path, implemented after the msgpack path but within the same overall design: + +- one batch = Arrow array where each slot corresponds to one list cell +- type = `list` + +## 4.3 Internal append buffer + +`ListArray(storage="batch")` maintains: + +- persisted batches in a `BatchArray` +- a pending in-memory Python list of cells not yet flushed + +Suggested internal state: + +- `_store`: `BatchArray` +- `_pending_cells: list` +- `_batch_rows: int` +- mapping helpers derived from persisted batch lengths + +Append flow: + +- validate cell +- append to `_pending_cells` +- if `len(_pending_cells) >= batch_rows`, flush full batches + +Extend flow: + +- validate each cell +- fill pending buffer +- flush full batches as needed + +Flush flow: + +- write full `batch_rows` groups to `BatchArray.append(...)` +- keep any tail cells pending unless caller requested a full final flush +- on explicit final flush / close / save, write tail as last batch + +## 4.4 Logical indexing in batch backend + +`ListArray` must expose row-level indexing across: + +- persisted cells in `BatchArray` +- pending cells in `_pending_cells` + +This requires row → batch lookup for persisted rows. + +Suggested approach: + +- rely on `BatchArray`'s stored batch lengths and prefix sums for persisted portion +- append pending tail logically after persisted rows + +Point update behavior: + +- if target row is in pending cells: replace in memory +- if target row is persisted: load batch, replace cell, rewrite entire batch + +This should be supported but documented as more expensive than VL-backed updates. + +## 4.5 Flush semantics + +`ListArray.flush()` should: + +- write all pending cells, including the tail +- leave `_pending_cells` empty + +Automatic flushes should occur on: + +- buffer full +- explicit `flush()` +- `close()` / context-manager exit +- `CTable.save()` +- `CTable.to_arrow()` +- any persistence-sensitive operation that must see all rows on disk + +--- + +## Phase 5: `CTable` storage abstraction changes + +## 5.1 Broaden `TableStorage` + +Current `TableStorage.create_column()` / `open_column()` assume `NDArray`. Extend storage abstraction to support list columns. + +Recommended shape: + +- `create_scalar_column(...)` +- `open_scalar_column(...)` +- `create_list_column(...)` +- `open_list_column(...)` + +Alternative minimal path: + +- keep `create_column(...)` but branch on compiled spec kind + +Recommendation: use explicit separate methods if the diff stays manageable; it makes the abstraction cleaner. + +## 5.2 File-backed layout + +Current scalar layout is under: + +- `/_cols/` + +Keep that logical namespace for list columns too. + +Possible physical forms: + +- `/_cols/` points directly to the underlying backend object (`VLArray` or `BatchArray`), tagged so it reopens logically as `ListArray` +- `/_cols/` plus optional side metadata stored in schema + +For V1, prefer storing backend configuration in the schema metadata and keeping the on-disk object itself as the concrete backend container. + +## 5.3 In-memory layout + +In-memory `CTable` should keep list columns as live `ListArray` objects. + +No persistence is needed there beyond normal `save()` behavior. + +--- + +## Phase 6: `CTable` core changes + +## 6.1 Column creation/opening + +During table creation/open/open-from-schema: + +- scalar compiled columns create/open `NDArray` +- list compiled columns create/open `ListArray` + +`self._cols` may continue to map names to physical column objects, but code using `self._cols[name]` must stop assuming every entry is an `NDArray`. + +### 6.1.1 Physical length vs capacity + +Scalar columns remain capacity-based and grow with `_valid_rows`. + +List columns should instead be treated as append-sized physical stores: + +- their physical length tracks the number of written physical rows +- they are not preallocated out to `len(_valid_rows)` +- `_grow()` should continue to resize scalar columns and `_valid_rows`, but should not pad list columns with placeholder cells + +This means `CTable` internals must stop assuming every stored column has physical length equal to `len(_valid_rows)`. + +Logical row resolution should always go through physical row positions that are actually written. + +## 6.2 Row coercion path + +Current `_coerce_row_to_storage()` is scalar-only. Refactor it to branch on column kind. + +For scalar columns: + +- keep current NumPy scalar coercion + +For list columns: + +- validate and normalize through list-spec logic +- store Python list / `None` as-is for handoff to `ListArray` + +## 6.3 Append path + +Current `append()` loops over columns and assigns directly into scalar arrays. Refactor: + +- scalar column: `ndarray[pos] = scalar` +- list column: `list_array.append(cell)` + +Important invariants: + +- all stored columns must stay logically aligned by row index +- list columns append one physical cell per newly written physical row position +- `_last_pos` remains the source of truth for the next physical row id + +This means `append()` must ensure each list column receives exactly one new cell per appended row. + +### Write coordination / partial-failure safety + +This deserves explicit care because mixed scalar/list writes are no longer a single homogeneous NDArray operation. + +V1 should guarantee at least: + +- validate and normalize the full incoming row or batch before mutating storage +- mark `_valid_rows` only after all column writes for the row(s) succeed +- avoid leaving logically visible partial rows after a failure + +For list columns in particular, the implementation should prefer staging writes in memory where possible, or otherwise provide best-effort rollback for per-row appends that fail after some columns were already updated. + +## 6.4 Extend path + +Current `extend()` materializes column arrays and writes slices into NDArrays. For list columns, this should become backend-aware. + +Suggested behavior: + +- scalar columns: keep vectorized path +- list columns: collect Python list of cells and call `ListArray.extend(cells)` + +This may be less vectorized than the scalar path, which is acceptable for V1. + +## 6.5 Delete behavior + +No special physical delete support is required for V1. + +`CTable.delete()` already uses `_valid_rows`, so list columns can simply remain append-only physically while logical row deletion is controlled by the validity mask. + +## 6.6 Row reads + +`_Row.__getitem__` and column reads must support list columns. + +Expected behavior: + +- row access on a list column returns the corresponding list cell or `None` +- sliced column access on a list column returns a Python `list` of cells + +## 6.7 Column wrapper behavior + +Current `Column` logic is NumPy/NDArray-centric. For V1, update it carefully for list columns. + +Key points: + +- `Column.dtype` is not meaningful for list columns and should return `None` rather than a fake NumPy dtype +- many scalar comparison/aggregate methods should reject list columns cleanly +- `Column.__getitem__` must work for logical row indexing on list columns +- `Column.__setitem__` should support whole-cell replacement for list columns + +Recommendation: + +- branch internally on compiled spec kind +- do not try to make list columns mimic NumPy arrays + +## 6.8 String representation and info + +Update display and info methods so list columns show a logical type label like: + +- `list[string]` +- `list[int32]` + +For previews, show Python repr-style cells with truncation. + +Statistics in `describe()` for list columns can be omitted in V1 or show a message like: + +- `(stats not available for list columns)` + +--- + +## Phase 7: Arrow interoperability + +## 7.1 Keep Arrow optional + +`pyarrow` must remain optional. + +Rules: + +- importing / using list columns with msgpack default must not require `pyarrow` +- `serializer="arrow"` should raise a clear `ImportError` when `pyarrow` is absent +- `CTable.to_arrow()` / `from_arrow()` may already be optional-import based and should stay that way + +## 7.2 `CTable.to_arrow()` + +Extend `to_arrow()` so list columns export to Arrow list arrays. + +For msgpack-backed list columns: + +- materialize Python cells +- build `pa.array(values)` with appropriate list type when possible + +For Arrow-backed batch list columns later: + +- a faster path may reuse Arrow-native data more directly if feasible + +V1 success criterion: + +- correct Arrow output, even if implemented through Python materialization + +## 7.3 `CTable.from_arrow()` + +Extend Arrow import to recognize Arrow list fields and create `ListSpec` columns. + +Initial supported Arrow list cases for V1: + +- `list` +- possibly `large_list` if trivial to normalize +- optionally numeric item types if easy to map consistently + +Import policy: + +- create `b2.list(item_spec, storage="batch", serializer="msgpack")` by default unless caller later gains a way to override +- append row cells into `ListArray` + +The important part for V1 is round-tripping list columns through `CTable`. + +--- + +## Phase 8: Persistence and reopen behavior + +## 8.1 Schema metadata + +Persist list-specific metadata in the table schema. + +Each list column should carry at least: + +- `kind="list"` +- serialized `item_spec` +- `nullable` +- `storage` +- `serializer` +- `batch_rows` if relevant +- `items_per_block` if relevant + +## 8.2 Container reopen behavior + +When a table is reopened: + +- compiled schema reconstructs `ListSpec` +- storage layer opens the concrete list column backend +- `CTable` re-wraps it as `ListArray` + +For standalone use, persistent `ListArray` containers should also reopen as `ListArray` through the generic dispatch path. + +### 8.2.1 Layered metadata tagging + +`ListArray` should have its own explicit container tag in fixed metadata, while preserving the underlying backend tag. + +Examples: + +- batch-backed `ListArray`: + - `meta["batcharray"] = {...}` + - `meta["listarray"] = {...}` +- VL-backed `ListArray`: + - `meta["vlarray"] = {...}` + - `meta["listarray"] = {...}` + +This keeps both identities available: + +- physical identity: the underlying storage container (`BatchArray` or `VLArray`) +- logical identity: the object should reopen as `ListArray` + +Suggested `meta["listarray"]` payload: + +```json +{ + "version": 1, + "backend": "batch", + "serializer": "msgpack", + "nullable": false, + "item_spec": {"kind": "string", "max_length": 64}, + "batch_rows": 65536, + "items_per_block": 256 +} +``` + +The exact payload can be trimmed, but it should at least record: + +- format version +- backend kind +- serializer +- nullability +- serialized item spec +- backend-specific layout hints when relevant + +Recommendation: store this in `schunk.meta`, not only in `vlmeta`, because it defines the container kind used for reopen dispatch. + +### 8.2.2 Generic reopen dispatch + +`blosc2.open()` and `blosc2.from_cframe()` should prefer `ListArray` when `meta["listarray"]` is present. + +Suggested dispatch priority: + +1. `listarray` +2. `batcharray` +3. `vlarray` +4. existing fallback behavior + +This makes the generic open path return the logical container type rather than exposing the raw backend by default. + +Advanced users can still reach the lower-level backend explicitly if needed. + +### 8.2.3 `ListArray` reopen constructor + +`ListArray` should support an internal reopen hook such as: + +```python +ListArray(_from_schunk=schunk) +``` + +This path should: + +- validate the `listarray` tag +- validate consistency with the backend tag (`batcharray` or `vlarray`) +- reconstruct the correct backend wrapper +- return a row-oriented `ListArray` object + +## 8.3 Save/load and flush coordination + +Ensure all list columns are flushed before: + +- saving to disk +- serializing table data for export if needed +- closing persistent stores + +Add a helper on `CTable` such as an internal `_flush_varlen_columns()` used by: + +- `save()` +- `to_arrow()` +- close/discard paths + +--- + +## Phase 9: Testing plan + +Add focused tests rather than trying to cover the entire matrix at once. + +## 9.1 Schema/compiler tests + +New tests for: + +- `b2.list(...)` construction +- invalid storage/serializer combinations +- annotation matching for `list[str]` +- schema serialization/deserialization of `ListSpec` + +## 9.2 `ListArray` tests + +Standalone tests covering both backends: + +- append / extend +- `None` vs `[]` +- reject nullable items in V1 +- row reads +- slice reads +- whole-cell replacement +- negative indexing +- flush behavior, including `close()` / context-manager flush-on-exit +- reopen behavior for persistent stores +- `blosc2.open()` / `blosc2.from_cframe()` dispatch returning `ListArray` +- read-only handling where applicable + +Batch backend specific tests: + +- pending-buffer reads before flush +- automatic flush on buffer full +- update in pending region +- update in persisted region +- correct length across persisted + pending regions + +VL backend specific tests: + +- direct row replacement + +## 9.3 `CTable` tests + +Add `CTable` tests for: + +- schema with one list column +- schema with scalar + list columns mixed +- append row with list column +- extend rows with list column +- read rows back +- `head`, `tail`, `select` +- scalar-driven `where()` / view operations with list columns carried through correctly +- `compact()` with list columns +- row deletion via `_valid_rows` +- reopen persistent table +- `save()` / `load()` round-trip +- `to_arrow()` / `from_arrow()` with list columns + +## 9.4 Non-goal tests for V1 + +Do not add tests for unsupported features such as: + +- list column indexes +- sorting by list column +- computed expressions over lists +- nullable items inside lists + +Instead, add clear failure-path tests where appropriate. + +--- + +## Phase 10: Documentation plan + +Update documentation incrementally. + +## 10.1 Reference docs + +Add docs for: + +- `b2.list(...)` +- `ListArray` +- `CTable` list column support + +## 10.2 Tutorial/examples + +Add at least one example such as: + +- products with `ingredients: list[str]` + +Show: + +- schema declaration +- append / extend +- distinction between `None` and `[]` +- whole-cell replacement +- save/reopen +- Arrow export if `pyarrow` is installed + +## 10.3 Design notes in docs + +Explain briefly: + +- `cell` as a descriptive concept only +- batch vs VL backend tradeoffs +- why `msgpack` is the default +- why returned Python lists must be reassigned after mutation + +--- + +## Recommended implementation order + +To reduce risk, implement in this order: + +1. **Schema groundwork** + - add `ListSpec` + - add `b2.list(...)` + - update schema serialization and compiler + +2. **Standalone `ListArray` with VL backend first** + - easiest path to stabilize list semantics + - validates `None` vs `[]`, whole-cell replacement, persistence + - includes layered tagging and standalone reopen through `blosc2.open()` / `from_cframe()` + +3. **`CTable` integration for VL-backed list columns** + - proves scalar/list coexistence in compiler and core table paths + +4. **Batch-backed `ListArray` with pending buffer** + - implement `batch_rows` buffering + - add flush semantics and persisted/pending indexing + +5. **`CTable` integration for batch-backed list columns** + - update save/open/load and flush coordination + +6. **Arrow import/export support for list columns** + - keep optional + - start with materialization-based path + +7. **Docs and broader test coverage** + +This staged rollout makes it easier to separate: + +- logical list semantics +- `CTable` schema/compiler changes +- batch buffering complexity + +--- + +## Practical code touch points + +Expected Python files to update or add: + +### New files + +- `src/blosc2/list_array.py` +- `tests/test_list_array.py` +- `tests/test_ctable_varlen.py` or equivalent +- `doc/reference/list_array.rst` or a combined varlen/list reference page +- example file under `examples/ctable/` + +### Existing files likely to change + +- `src/blosc2/__init__.py` +- `src/blosc2/schema.py` +- `src/blosc2/schema_compiler.py` +- `src/blosc2/ctable.py` +- `src/blosc2/ctable_storage.py` +- likely `src/blosc2/schema_validation.py` +- likely `src/blosc2/schema_vectorized.py` +- `src/blosc2/schunk.py` for standalone reopen dispatch +- `src/blosc2/core.py` for generic `open()` / `from_cframe()` dispatch for `ListArray` +- docs under `doc/reference/ctable.rst` and related tutorial pages + +--- + +## Open follow-up items after V1 + +These are intentionally postponed, not rejected: + +- `item_nullable=True` +- nested list-of-list +- struct / map item types +- list-aware query predicates +- indexing for membership tests +- sorting/grouping semantics for list columns +- optimized Arrow-native batch representation +- convenience APIs for explicitly opening the raw backend (`VLArray` / `BatchArray`) behind a `ListArray` when advanced users want to bypass the logical container + +--- + +## Short rationale for the chosen defaults + +### Why `b2.list(...)` + +It reads naturally next to `list[str]` annotations and fits the rest of the schema-builder API. + +### Why `ListArray` should be first-class + +It gives users a clear row-oriented abstraction for list-valued data that is useful both on its own and as the natural physical model for list columns inside `CTable`, while still preserving `VLArray` and `BatchArray` as lower-level containers. + +### Why `msgpack` default + +It avoids a hard `pyarrow` dependency and works uniformly with both `VLArray` and `BatchArray`. + +### Why `storage="batch"` default + +It better matches append/scan-oriented table workloads and Parquet-like usage, while `VLArray` remains available for update-heavy cases. + +### Why whole-cell replacement only + +It keeps behavior explicit and avoids surprising write-through mutation of returned Python lists. + +### Why `None` vs `[]` + +The distinction is valuable and common, while item-level nullability can be added later without breaking the model. + +--- + +## Outcome expected from V1 + +After this work: + +- `CTable` should be able to host list columns in a way that is: + - typed at the schema level + - persistent + - row-addressable + - backend-tunable + - install-light by default + - compatible with optional Arrow export/import + +- `ListArray` should stand as a public reusable container for row-oriented list-valued data. + +This should happen without compromising the current scalar-column fast path or displacing `VLArray` / `BatchArray` from their lower-level roles. diff --git a/pyproject.toml b/pyproject.toml index d5c1cc2a3..a5d674b97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,12 @@ blosc2 = "blosc2" homepage = "https://github.com/Blosc/python-blosc2" documentation = "https://www.blosc.org/python-blosc2/python-blosc2.html" +[project.optional-dependencies] +parquet = ["pyarrow"] + +[project.scripts] +parquet-to-blosc2 = "blosc2.cli.parquet_to_blosc2:main" + [dependency-groups] dev = [ "dask", @@ -129,6 +135,7 @@ ignore = [ "RUF015", "RUF059", "SIM108", + "SIM117", ] [tool.ruff.lint.extend-per-file-ignores] diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index 6e64f2fb9..374e430d2 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -567,7 +567,8 @@ def _raise(exc): from .dict_store import DictStore from .tree_store import TreeStore from .batch_array import Batch, BatchArray -from .vlarray import VLArray, vlarray_from_cframe +from .list_array import ListArray +from .objectarray import ObjectArray, objectarray_from_cframe from .ref import Ref from .b2objects import open_b2object @@ -632,7 +633,7 @@ def _raise(exc): # Delayed imports for avoiding overwriting of python builtins. # Note: bool, bytes, string shadow builtins in the blosc2 namespace by design — # they are schema spec constructors (b2.bool(), b2.bytes(), etc.). -from .ctable import Column, CTable +from .ctable import DEFAULT_NULL_POLICY, Column, CTable, NullPolicy, get_null_policy, null_policy from .ndarray import ( abs, acos, @@ -746,11 +747,15 @@ def _raise(exc): int16, int32, int64, + list, string, + struct, uint8, uint16, uint32, uint64, + vlbytes, + vlstring, ) __all__ = [ # noqa : RUF022 @@ -766,6 +771,7 @@ def _raise(exc): "DEFAULT_FLOAT", "DEFAULT_INDEX", "DEFAULT_INT", + "DEFAULT_NULL_POLICY", # Mathematical constants "e", "pi", @@ -784,11 +790,15 @@ def _raise(exc): "int16", "int32", "int64", + "list", "string", + "struct", "uint8", "uint16", "uint32", "uint64", + "vlbytes", + "vlstring", # Classes "C2Array", "CParams", @@ -806,6 +816,8 @@ def _raise(exc): "DSLSyntaxError", "LazyExpr", "LazyUDF", + "ListArray", + "NullPolicy", "NDArray", "NDField", "Operand", @@ -822,7 +834,7 @@ def _raise(exc): "TreeStore", "Tuner", "URLPath", - "VLArray", + "ObjectArray", # Version "__version__", # Utils @@ -1019,8 +1031,10 @@ def _raise(exc): "validate_expr", "var", "vecdot", - "vlarray_from_cframe", + "objectarray_from_cframe", "where", "zeros", "zeros_like", + "get_null_policy", + "null_policy", ] diff --git a/src/blosc2/batch_array.py b/src/blosc2/batch_array.py index 2fdb8cad8..19e93a235 100644 --- a/src/blosc2/batch_array.py +++ b/src/blosc2/batch_array.py @@ -169,7 +169,7 @@ class BatchArray: Serializer used for batch payloads. ``"msgpack"`` is the default and is the general-purpose choice for Python items, including nested Blosc2 containers such as :class:`blosc2.NDArray`, :class:`blosc2.SChunk`, - :class:`blosc2.VLArray`, :class:`blosc2.BatchArray`, and + :class:`blosc2.ObjectArray`, :class:`blosc2.BatchArray`, and :class:`blosc2.EmbedStore`, which are serialized transparently via :meth:`to_cframe` / :func:`blosc2.from_cframe`. Msgpack also supports structured Blosc2 reference objects, currently diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index d1e650376..bddb5a0a6 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -1963,6 +1963,13 @@ cdef class SChunk: raise RuntimeError("Could not update the desired chunk") return rc + def reorder_offsets(self, order): + cdef np.ndarray[np.int64_t, ndim=1] offsets_order = np.ascontiguousarray(order, dtype=np.int64) + rc = blosc2_schunk_reorder_offsets(self.schunk, offsets_order.data) + if rc < 0: + raise RuntimeError("Could not reorder the chunk offsets") + return None + def update_data(self, nchunk, data, copy): cdef Py_buffer buf PyObject_GetBuffer(data, &buf, PyBUF_SIMPLE) diff --git a/src/blosc2/cli/__init__.py b/src/blosc2/cli/__init__.py new file mode 100644 index 000000000..e74517cc8 --- /dev/null +++ b/src/blosc2/cli/__init__.py @@ -0,0 +1 @@ +"""Command-line utilities for blosc2.""" diff --git a/src/blosc2/cli/parquet_to_blosc2.py b/src/blosc2/cli/parquet_to_blosc2.py new file mode 100644 index 000000000..607787bd5 --- /dev/null +++ b/src/blosc2/cli/parquet_to_blosc2.py @@ -0,0 +1,676 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +"""Import/export Parquet datasets through a Blosc2 CTable store. + +The installed ``parquet-to-blosc2`` utility supports three modes: + +* default import: parquet -> ``.b2z`` / ``.b2d`` +* ``--export``: existing ``.b2z`` / ``.b2d`` -> parquet +* ``--roundtrip``: parquet -> ``.b2z`` / ``.b2d`` -> parquet and compare + +The output extension selects the storage layout: ``.b2z`` is compact/zip-backed, +while ``.b2d`` is sparse directory-backed. + +Scalar string columns are stored as ``vlstring`` (variable-length, no length +limit). Scalar binary columns are stored as ``vlbytes``. Nullable string/binary +columns are represented with native ``None`` — no sentinel value is needed. + +Struct-valued columns are wrapped as ``list`` (one-element lists) so +that they round-trip through the list-column machinery. True list columns pass +through unchanged. Unsupported types (nested lists, timestamps, etc.) are +skipped. +""" + +from __future__ import annotations + +import argparse +import base64 +import contextlib +import cProfile +import gc +import io +import os +import pstats +import shutil +import sys +import time +from pathlib import Path +from typing import Any + +import blosc2 +from blosc2.schema_compiler import schema_to_dict + +DEFAULT_BATCH_SIZE = 2048 + + +def require_pyarrow(): + try: + import pyarrow as pa + import pyarrow.parquet as pq + except ImportError as exc: + raise ImportError( + "parquet-to-blosc2 requires pyarrow; install it with: pip install 'blosc2[parquet]'" + ) from exc + return pa, pq + + +def _default_import_output(input_path: Path) -> Path: + return input_path.with_suffix(".b2z") + + +def _default_export_output(input_path: Path) -> Path: + return input_path.with_suffix(".parquet") + + +def _default_roundtrip_output(input_path: Path) -> Path: + return input_path.with_name(f"{input_path.stem}-roundtrip.parquet") + + +def _format_bytes(n: int | None) -> str: + if n is None: + return "n/a" + value = float(n) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if abs(value) < 1024 or unit == "TiB": + return f"{value:.1f} {unit}" + value /= 1024 + return f"{value:.1f} TiB" + + +def _peak_rss_bytes() -> int: + import resource + + peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + if sys.platform == "darwin": + return int(peak) + return int(peak) * 1024 + + +def _current_rss_bytes() -> int | None: + try: + import psutil + except ImportError: + return None + return int(psutil.Process(os.getpid()).memory_info().rss) + + +def memory_report(label: str, pa=None) -> None: + arrow_allocated = None + arrow_pool = None + if pa is not None: + try: + arrow_allocated = int(pa.total_allocated_bytes()) + arrow_pool = int(pa.default_memory_pool().bytes_allocated()) + except Exception: + pass + parts = [ + f"[mem] {label}", + f"rss={_format_bytes(_current_rss_bytes())}", + f"peak={_format_bytes(_peak_rss_bytes())}", + ] + if arrow_allocated is not None: + parts.append(f"arrow_total={_format_bytes(arrow_allocated)}") + if arrow_pool is not None: + parts.append(f"arrow_pool={_format_bytes(arrow_pool)}") + print(" ".join(parts), flush=True) + + +def maybe_memory_report(args, label: str, pa=None) -> None: + if args.mem_report: + memory_report(label, pa) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Import/export Parquet datasets via Blosc2 CTable (.b2z compact or .b2d sparse).", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--export", action="store_true", help="Export input .b2z/.b2d to output parquet.") + mode.add_argument( + "--roundtrip", action="store_true", help="Run parquet -> .b2z/.b2d -> parquet and compare." + ) + parser.add_argument( + "input_path", type=Path, help="Input parquet file or Blosc2 store, depending on mode." + ) + parser.add_argument( + "output_path", + nargs="?", + type=Path, + default=None, + help="Output path. Defaults depend on the mode and input path.", + ) + parser.add_argument("--parquet-batch-size", type=int, default=DEFAULT_BATCH_SIZE) + parser.add_argument( + "--max-rows", + type=int, + default=None, + help="Maximum number of rows to import from the source parquet file; imports all rows by default.", + ) + parser.add_argument( + "--batch-size", + dest="parquet_batch_size", + type=int, + help=argparse.SUPPRESS, + ) + parser.add_argument( + "--blosc2-batch-size", + type=int, + default=DEFAULT_BATCH_SIZE, + help="Rows grouped into each persisted BatchArray batch for imported Blosc2 varlen/list columns.", + ) + parser.add_argument("--codec", type=str, default="ZSTD", choices=[c.name for c in blosc2.Codec]) + parser.add_argument("--clevel", type=int, default=5) + parser.add_argument( + "--mem-report", + action="store_true", + help="Print process/Arrow memory diagnostics at import phases and during batch processing.", + ) + parser.add_argument( + "--mem-every", + type=int, + default=1, + help="With --mem-report, print batch memory diagnostics every N batches.", + ) + parser.add_argument( + "--batch-report-every", + type=int, + default=1, + help="Print progress every N batches; the final batch is always reported.", + ) + parser.add_argument( + "--profile", + action="store_true", + help="Run the selected operation under cProfile and print cumulative timing stats.", + ) + parser.add_argument("--overwrite", action="store_true") + return parser + + +def prepare_output(path: Path, overwrite: bool) -> None: + if not path.exists(): + return + if not overwrite: + raise FileExistsError(f"Output already exists: {path} (use --overwrite to replace)") + if path.is_dir(): + shutil.rmtree(path) + else: + path.unlink() + + +def encode_arrow_schema(schema) -> str: + return base64.b64encode(schema.serialize().to_pybytes()).decode("ascii") + + +def decode_arrow_schema(pa, encoded: str): + return pa.ipc.read_schema(pa.BufferReader(base64.b64decode(encoded))) + + +def _release_arrow_temporaries(pa) -> None: + gc.collect() + with contextlib.suppress(Exception): + pa.default_memory_pool().release_unused() + + +def classify_columns(pa, schema): + """Classify Parquet schema columns into importable categories.""" + fixed_cols: dict[str, object] = {} + struct_wrap_cols: dict[str, object] = {} + conversions: dict[str, dict[str, Any]] = {} + nullable_scalars: list[str] = [] + + for field in schema: + t = field.type + if pa.types.is_struct(t): + struct_wrap_cols[field.name] = pa.list_(t) + conversions[field.name] = {"conversion": "struct_wrapped_as_singleton_list"} + continue + if pa.types.is_list(t) or pa.types.is_large_list(t): + value_type = t.value_type + if pa.types.is_list(value_type) or pa.types.is_large_list(value_type): + conversions[field.name] = {"conversion": "skipped", "reason": f"nested list: {t}"} + else: + fixed_cols[field.name] = field + continue + if pa.types.is_boolean(t): + fixed_cols[field.name] = field + if field.nullable: + nullable_scalars.append(field.name) + conversions[field.name] = {"conversion": "nullable_scalar_sentinel"} + continue + if pa.types.is_integer(t) or pa.types.is_floating(t): + fixed_cols[field.name] = field + if field.nullable: + nullable_scalars.append(field.name) + conversions[field.name] = {"conversion": "nullable_scalar_sentinel"} + continue + if pa.types.is_string(t) or pa.types.is_large_string(t): + fixed_cols[field.name] = field + conversions[field.name] = {"conversion": "vlstring_nullable" if field.nullable else "vlstring"} + continue + if pa.types.is_binary(t) or pa.types.is_large_binary(t): + fixed_cols[field.name] = field + conversions[field.name] = {"conversion": "vlbytes_nullable" if field.nullable else "vlbytes"} + continue + conversions[field.name] = {"conversion": "skipped", "reason": f"unsupported: {t}"} + + return fixed_cols, struct_wrap_cols, conversions, nullable_scalars + + +def build_import_schema(pa, original_schema, fixed_cols: dict, struct_wrap_cols: dict): + """Build the Arrow schema passed to CTable.from_arrow().""" + fields = [] + for field in original_schema: + if field.name in struct_wrap_cols: + fields.append(pa.field(field.name, struct_wrap_cols[field.name], nullable=True)) + elif field.name in fixed_cols: + fields.append(field) + return pa.schema(fields) + + +def transform_batch(pa, batch, selected_cols: list[str], struct_wrap_cols: dict): + """Wrap struct-valued cells as singleton lists; pass everything else through.""" + if not struct_wrap_cols: + return batch + arrays = list(batch.columns) + for name, target_type in struct_wrap_cols.items(): + try: + idx = batch.schema.get_field_index(name) + except KeyError: + continue + if idx < 0: + continue + arr = batch.column(idx) + arrays[idx] = pa.array([[v] if v is not None else None for v in arr.to_pylist()], type=target_type) + return pa.record_batch(arrays, names=selected_cols) + + +def store_original_arrow_metadata(ct, original_schema, imported_schema, conversions: dict) -> None: + fields_meta = {} + for field in original_schema: + entry = conversions.get(field.name) + if entry is None: + continue + entry = dict(entry) + entry["original_arrow_type"] = str(field.type) + if field.name in imported_schema.names: + entry["ctable_arrow_type"] = str(imported_schema.field(field.name).type) + fields_meta[field.name] = entry + ct._schema.metadata = { + "arrow": { + "schema_ipc_base64": encode_arrow_schema(original_schema), + "schema_string": original_schema.to_string(), + "imported_schema_ipc_base64": encode_arrow_schema(imported_schema), + "fields": fields_meta, + } + } + ct._storage.save_schema(schema_to_dict(ct._schema)) + + +def ctable_store_kind(path: Path) -> str: + if path.suffix == ".b2d": + return "sparse directory (.b2d)" + if path.suffix == ".b2z": + return "compact zip (.b2z)" + return f"unknown ({path.suffix or 'no suffix'})" + + +def print_import_plan( + args, + input_path, + output_path, + pf, + parquet_schema, + fixed_cols, + struct_wrap_cols, + conversions, + nullable_scalars, +): + vlstring_cols = [ + n for n, e in conversions.items() if e.get("conversion") in {"vlstring", "vlstring_nullable"} + ] + vlbytes_cols = [ + n for n, e in conversions.items() if e.get("conversion") in {"vlbytes", "vlbytes_nullable"} + ] + wrapped_structs = list(struct_wrap_cols) + skipped = {n: e for n, e in conversions.items() if e.get("conversion") == "skipped"} + print(f"Input: {input_path} ({input_path.stat().st_size / 1e6:.1f} MB)") + print(f"Output: {output_path}") + print(f"CTable store: {ctable_store_kind(output_path)}") + print(f"Rows: {pf.metadata.num_rows:,}") + if args.max_rows is not None: + print(f"Rows to import: {min(args.max_rows, pf.metadata.num_rows):,}") + print(f"Parquet columns: {len(parquet_schema)}") + print(f"Imported columns: {len(fixed_cols) + len(struct_wrap_cols)}") + print(f" Fixed-width: {len(fixed_cols) - len(vlstring_cols) - len(vlbytes_cols)}") + print(f" vlstring: {len(vlstring_cols)}") + print(f" vlbytes: {len(vlbytes_cols)}") + print(f" Struct→list: {len(wrapped_structs)}") + print(f" Nullable scalars: {len(nullable_scalars)}") + print(f" Skipped unsupported: {len(skipped)}") + for name, entry in skipped.items(): + print(f" - {name}: {entry['reason']}") + print(f"Parquet batch size: {args.parquet_batch_size:,}") + print(f"Blosc2 batch size: {args.blosc2_batch_size:,}") + print(f"Codec / level: {args.codec} / {args.clevel}") + print() + + +def progress_batches(pa, pf, args, selected_cols, struct_wrap_cols): + rows_done = 0 + t0 = time.perf_counter() + total = pf.metadata.num_rows if args.max_rows is None else min(args.max_rows, pf.metadata.num_rows) + for batch_n, raw_batch in enumerate( + pf.iter_batches(batch_size=args.parquet_batch_size, columns=selected_cols), start=1 + ): + remaining = total - rows_done + if remaining <= 0: + break + if len(raw_batch) > remaining: + raw_batch = raw_batch.slice(0, remaining) + report_batch_mem = args.mem_report and batch_n % args.mem_every == 0 + if report_batch_mem: + memory_report(f"batch {batch_n} after parquet read", pa) + batch = transform_batch(pa, raw_batch, selected_cols, struct_wrap_cols) + if report_batch_mem: + memory_report(f"batch {batch_n} after transform", pa) + rows_done += len(batch) + elapsed = time.perf_counter() - t0 + rate = rows_done / elapsed if elapsed > 0 else 0.0 + eta = (total - rows_done) / rate if rate > 0 else 0.0 + if batch_n % args.batch_report_every == 0 or rows_done >= total: + print( + f" batch {batch_n:4d} {rows_done:>12,}/{total:,} " + f"{elapsed:7.1f}s {rate / 1e3:7.1f}k rows/s ETA {eta:6.0f}s", + flush=True, + ) + if report_batch_mem: + memory_report(f"batch {batch_n} before ctable write", pa) + yield batch + if report_batch_mem: + memory_report(f"batch {batch_n} after ctable write", pa) + + +def import_parquet_to_ctable(args, input_path: Path, output_path: Path): + if args.parquet_batch_size <= 0: + raise ValueError("--parquet-batch-size must be positive") + if args.blosc2_batch_size <= 0: + raise ValueError("--blosc2-batch-size must be positive") + if args.max_rows is not None and args.max_rows < 0: + raise ValueError("--max-rows must be non-negative") + if args.mem_every <= 0: + raise ValueError("--mem-every must be positive") + if args.batch_report_every <= 0: + raise ValueError("--batch-report-every must be positive") + if output_path.suffix not in {".b2z", ".b2d"}: + raise ValueError("output_path must use the .b2z (compact) or .b2d (sparse) extension") + if not input_path.exists(): + raise FileNotFoundError(f"Input file not found: {input_path}") + prepare_output(output_path, args.overwrite) + + pa, pq = require_pyarrow() + maybe_memory_report(args, "after pyarrow import", pa) + pf = pq.ParquetFile(input_path) + maybe_memory_report(args, "after ParquetFile open", pa) + parquet_schema = pf.schema_arrow + + fixed_cols, struct_wrap_cols, conversions, nullable_scalars = classify_columns(pa, parquet_schema) + maybe_memory_report(args, "after column classification", pa) + + selected_cols = [f.name for f in parquet_schema if f.name in fixed_cols or f.name in struct_wrap_cols] + import_schema = build_import_schema(pa, parquet_schema, fixed_cols, struct_wrap_cols) + maybe_memory_report(args, "after import schema build", pa) + + print_import_plan( + args, + input_path, + output_path, + pf, + parquet_schema, + fixed_cols, + struct_wrap_cols, + conversions, + nullable_scalars, + ) + + t0 = time.perf_counter() + maybe_memory_report(args, "before CTable import", pa) + + ct = blosc2.CTable.from_arrow( + import_schema, + progress_batches(pa, pf, args, selected_cols, struct_wrap_cols), + urlpath=str(output_path), + mode="w", + cparams=blosc2.CParams(codec=blosc2.Codec[args.codec], clevel=args.clevel), + capacity_hint=( + pf.metadata.num_rows if args.max_rows is None else min(args.max_rows, pf.metadata.num_rows) + ), + string_max_length=None, + auto_null_sentinels=True, + blosc2_batch_size=args.blosc2_batch_size, + ) + maybe_memory_report(args, "after CTable import", pa) + store_original_arrow_metadata(ct, parquet_schema, import_schema, conversions) + maybe_memory_report(args, "after metadata save", pa) + elapsed = time.perf_counter() - t0 + rows = len(ct) + cols = len(ct.col_names) + ct.close() + maybe_memory_report(args, "after CTable close", pa) + + output_size = ( + output_path.stat().st_size + if output_path.is_file() + else sum(f.stat().st_size for f in output_path.rglob("*") if f.is_file()) + ) + print(f"Done in {elapsed:.2f}s") + print(f"Rows imported: {rows:,}") + print(f"Columns imported: {cols}") + print(f"Output size: {output_size / 1e6:.1f} MB") + return selected_cols + + +def original_schema_from_ctable(pa, ct): + arrow_meta = ct._schema.metadata.get("arrow", {}) + encoded = arrow_meta.get("schema_ipc_base64") + if encoded: + return decode_arrow_schema(pa, encoded) + return None + + +def unwrap_singleton_list(pa, arr, arrow_type): + return pa.array( + [None if cell is None or len(cell) == 0 else cell[0] for cell in arr.to_pylist()], type=arrow_type + ) + + +def export_ctable_to_parquet(input_path: Path, output_path: Path, *, batch_size: int, overwrite: bool): + pa, pq = require_pyarrow() + if batch_size <= 0: + raise ValueError("--parquet-batch-size must be positive") + prepare_output(output_path, overwrite) + ct = blosc2.CTable.open(str(input_path)) + original_schema = original_schema_from_ctable(pa, ct) + fields_meta = ct._schema.metadata.get("arrow", {}).get("fields", {}) + export_names = [ + name + for name in (original_schema.names if original_schema is not None else ct.col_names) + if name in ct.col_names + ] + export_schema = ( + pa.schema([original_schema.field(name) for name in export_names]) + if original_schema is not None + else ct._arrow_schema_for_columns(export_names) + ) + + singleton_list_conversions = { + "struct_wrapped_as_singleton_list", + "nullable_scalar_wrapped_as_singleton_list", + "long_nullable_scalar_wrapped_as_singleton_list", + "scalar_string_promoted_after_overflow", + } + + t0 = time.perf_counter() + with pq.ParquetWriter(output_path, export_schema, compression="zstd") as writer: + for batch in ct.iter_arrow_batches(columns=export_names, batch_size=batch_size): + arrays = [] + for name in export_names: + arr = batch.column(name) + meta = fields_meta.get(name, {}) + field = export_schema.field(name) + conversion = meta.get("conversion", "") + if conversion in singleton_list_conversions: + arr = unwrap_singleton_list(pa, arr, field.type) + elif conversion in {"vlstring", "vlstring_nullable", "vlbytes", "vlbytes_nullable"}: + if str(arr.type) != str(field.type): + arr = arr.cast(field.type) + elif str(arr.type) != str(field.type): + arr = pa.array(arr.to_pylist(), type=field.type) + arrays.append(arr) + out_batch = pa.record_batch(arrays, schema=export_schema) + writer.write_table(pa.Table.from_batches([out_batch]), row_group_size=len(out_batch)) + elapsed = time.perf_counter() - t0 + rows = len(ct) + ct.close() + print(f"Exported {rows:,} rows and {len(export_names)} columns to {output_path} in {elapsed:.2f}s") + return export_names + + +def read_parquet_prefix(pa, pq, path: Path, columns: list[str], max_rows: int | None): + if max_rows is None: + return pq.read_table(path, columns=columns) + pf = pq.ParquetFile(path) + schema = pa.schema([pf.schema_arrow.field(name) for name in columns]) + batches = [] + rows_done = 0 + for batch in pf.iter_batches(batch_size=DEFAULT_BATCH_SIZE, columns=columns): + remaining = max_rows - rows_done + if remaining <= 0: + break + if len(batch) > remaining: + batch = batch.slice(0, remaining) + batches.append(batch) + rows_done += len(batch) + return pa.Table.from_batches(batches, schema=schema) + + +def assess_parquet_difference( + original_path: Path, roundtrip_path: Path, exported_cols: list[str], max_rows: int | None = None +): + pa, pq = require_pyarrow() + orig_pf = pq.ParquetFile(original_path) + rt_pf = pq.ParquetFile(roundtrip_path) + original_schema = orig_pf.schema_arrow + roundtrip_schema = rt_pf.schema_arrow + common = [ + name for name in exported_cols if name in original_schema.names and name in roundtrip_schema.names + ] + missing = [name for name in original_schema.names if name not in roundtrip_schema.names] + + orig = read_parquet_prefix(pa, pq, original_path, common, max_rows) + rt = pq.read_table(roundtrip_path, columns=common) + differing = [] + type_diffs = [] + null_diffs = [] + for name in common: + if str(original_schema.field(name).type) != str(roundtrip_schema.field(name).type): + type_diffs.append(name) + if orig[name].null_count != rt[name].null_count: + null_diffs.append((name, orig[name].null_count, rt[name].null_count)) + if not orig[name].equals(rt[name]): + differing.append(name) + + print("\nRoundtrip assessment") + print(f" Original rows: {orig_pf.metadata.num_rows:,}") + if max_rows is not None: + print(f" Original rows compared: {orig.num_rows:,}") + print(f" Roundtrip rows: {rt_pf.metadata.num_rows:,}") + print(f" Original columns: {len(original_schema)}") + print(f" Roundtrip columns: {len(roundtrip_schema)}") + print(f" Missing columns: {len(missing)}") + for name in missing: + print(f" - {name}: not imported/exported") + print(f" Type differences: {len(type_diffs)}") + for name in type_diffs: + print(f" - {name}: {original_schema.field(name).type} -> {roundtrip_schema.field(name).type}") + print(f" Null-count diffs: {len(null_diffs)}") + for name, a, b in null_diffs[:20]: + print(f" - {name}: {a} -> {b}") + if len(null_diffs) > 20: + print(f" ... {len(null_diffs) - 20} more") + print(f" Value differences: {len(differing)} of {len(common)} compared columns") + if differing: + print(" First value-different columns:") + for name in differing[:20]: + print(f" - {name}") + print(f" Original size: {original_path.stat().st_size / 1e6:.1f} MB") + print(f" Roundtrip size: {roundtrip_path.stat().st_size / 1e6:.1f} MB") + + +def _run_command(args) -> int: + if args.export: + input_path = args.input_path + output_path = args.output_path or _default_export_output(input_path) + export_ctable_to_parquet( + input_path, output_path, batch_size=args.parquet_batch_size, overwrite=args.overwrite + ) + return 0 + if args.roundtrip: + input_path = args.input_path + b2_path = args.output_path or _default_import_output(input_path) + roundtrip_path = _default_roundtrip_output(input_path) + selected = import_parquet_to_ctable(args, input_path, b2_path) + exported = export_ctable_to_parquet( + b2_path, roundtrip_path, batch_size=args.parquet_batch_size, overwrite=True + ) + assess_parquet_difference(input_path, roundtrip_path, exported or selected, max_rows=args.max_rows) + return 0 + + output_path = args.output_path or _default_import_output(args.input_path) + import_parquet_to_ctable(args, args.input_path, output_path) + return 0 + + +def _run_profiled(args) -> int: + profiler = cProfile.Profile() + profiler.enable() + try: + return _run_command(args) + finally: + profiler.disable() + stream = io.StringIO() + stats = pstats.Stats(profiler, stream=stream).sort_stats("cumulative") + stats.print_stats(50) + print("\n[cProfile] Top cumulative-time functions\n") + print(stream.getvalue().rstrip()) + + +def _option_present(argv: list[str], option: str) -> bool: + return any(arg == option or arg.startswith(option + "=") for arg in argv) + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else list(argv) + args = build_parser().parse_args(argv) + + parquet_specified = _option_present(argv, "--parquet-batch-size") or _option_present( + argv, "--batch-size" + ) + blosc2_specified = _option_present(argv, "--blosc2-batch-size") + if parquet_specified and not blosc2_specified: + args.blosc2_batch_size = args.parquet_batch_size + elif blosc2_specified and not parquet_specified: + args.parquet_batch_size = args.blosc2_batch_size + + if args.profile: + return _run_profiled(args) + return _run_command(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/blosc2/core.py b/src/blosc2/core.py index ceb78acde..4cb022c01 100644 --- a/src/blosc2/core.py +++ b/src/blosc2/core.py @@ -1922,10 +1922,16 @@ def ndarray_from_cframe(cframe: bytes | str, copy: bool = False) -> blosc2.NDArr def from_cframe( cframe: bytes | str, copy: bool = True ) -> ( - blosc2.EmbedStore | blosc2.NDArray | blosc2.SChunk | blosc2.BatchArray | blosc2.VLArray | blosc2.C2Array + blosc2.EmbedStore + | blosc2.NDArray + | blosc2.SChunk + | blosc2.ListArray + | blosc2.BatchArray + | blosc2.ObjectArray + | blosc2.C2Array ): """Create a :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk `, - :ref:`BatchArray ` or :ref:`VLArray ` instance + :ref:`BatchArray ` or :ref:`ObjectArray ` instance from a contiguous frame buffer. Parameters @@ -1943,7 +1949,7 @@ def from_cframe( Returns ------- out: :ref:`EmbedStore `, :ref:`NDArray `, :ref:`SChunk `, - :ref:`BatchArray ` or :ref:`VLArray ` + :ref:`BatchArray ` or :ref:`ObjectArray ` A new instance of the appropriate type containing the data passed. See Also @@ -1957,10 +1963,12 @@ def from_cframe( # Check the metalayer to determine the type if "b2embed" in schunk.meta: return blosc2.estore_from_cframe(cframe, copy=copy) + if "listarray" in schunk.meta: + return blosc2.ListArray(_from_schunk=schunk_from_cframe(cframe, copy=copy)) if "batcharray" in schunk.meta: return blosc2.BatchArray(_from_schunk=schunk_from_cframe(cframe, copy=copy)) if "vlarray" in schunk.meta: - return blosc2.vlarray_from_cframe(cframe, copy=copy) + return blosc2.objectarray_from_cframe(cframe, copy=copy) if "b2o" in schunk.meta: return blosc2.open_b2object(ndarray_from_cframe(cframe, copy=copy)) if "b2nd" in schunk.meta: diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 7f8bcd18c..c1a6e9f2f 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -12,40 +12,34 @@ import ast import contextlib +import contextvars import dataclasses import itertools import os import pprint import re import shutil -import weakref -from collections.abc import Iterable -from dataclasses import MISSING +from collections import namedtuple +from collections.abc import Iterable, Mapping +from dataclasses import MISSING, dataclass +from dataclasses import field as dataclass_field from textwrap import TextWrapper -from typing import Any, Generic, TypeVar +from typing import Any, Generic, Literal, TypeVar import numpy as np +import blosc2 from blosc2 import compute_chunks_blocks from blosc2.ctable_storage import FileTableStorage, InMemoryTableStorage, TableStorage -from blosc2.schema_compiler import schema_from_dict, schema_to_dict - -try: - from line_profiler import profile -except ImportError: - - def profile(func): - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - - wrapper.__name__ = func.__name__ - return wrapper - - -import blosc2 from blosc2.info import InfoReporter, format_nbytes_info +from blosc2.list_array import ListArray, coerce_list_cell +from blosc2.scalar_array import _ScalarVarLenArray from blosc2.schema import ( + ListSpec, SchemaSpec, + StructSpec, + VLBytesSpec, + VLStringSpec, complex64, complex128, float32, @@ -73,8 +67,110 @@ def wrapper(*args, **kwargs): _validate_column_name, compile_schema, compute_display_width, + schema_from_dict, + schema_to_dict, ) + +@dataclass(frozen=True) +class NullPolicy: + """Default sentinels for inferred CTable scalar nulls. + + CTable nullable scalar columns are represented with per-column sentinel + values. This policy is used when CTable has to infer those sentinels, such + as when importing nullable scalar Arrow or Parquet columns without an + explicit column-level null sentinel. The selected sentinel is stored in the + resulting CTable schema, so existing tables remain self-describing. + + Examples + -------- + Use :func:`blosc2.null_policy` to apply a policy while creating a CTable + from data with nullable scalar columns:: + + policy = blosc2.NullPolicy( + signed_int_strategy="max", + string_value="", + column_null_values={"user_id": -1, "country": "NA"}, + ) + + with blosc2.null_policy(policy): + table = blosc2.CTable.from_parquet("data.parquet") + + The same policy is used for explicit nullable schema specs:: + + @dataclass + class Row: + user_id: int = blosc2.field(blosc2.int64(nullable=True)) + country: str = blosc2.field(blosc2.string(nullable=True)) + + with blosc2.null_policy(policy): + table = blosc2.CTable(Row) + + ``column_null_values`` takes precedence over the type-wide defaults in the + policy. This is useful when a particular column needs a sentinel that is + known not to collide with its real values. + """ + + string_value: str = "__BLOSC2_NULL__" + bytes_value: bytes = b"__BLOSC2_NULL__" + float_value: float = float("nan") + bool_value: int = 255 + signed_int_strategy: Literal["min", "max"] = "min" + unsigned_int_strategy: Literal["min", "max"] = "max" + column_null_values: Mapping[str, Any] = dataclass_field(default_factory=dict) + + def sentinel_for_arrow_type(self, pa, pa_type): + """Return the default sentinel for *pa_type*, or ``None`` if unsupported.""" + signed_ints = [ + (pa.int8(), np.int8), + (pa.int16(), np.int16), + (pa.int32(), np.int32), + (pa.int64(), np.int64), + ] + unsigned_ints = [ + (pa.uint8(), np.uint8), + (pa.uint16(), np.uint16), + (pa.uint32(), np.uint32), + (pa.uint64(), np.uint64), + ] + for arrow_type, dtype in signed_ints: + if pa_type == arrow_type: + info = np.iinfo(dtype) + return info.min if self.signed_int_strategy == "min" else info.max + for arrow_type, dtype in unsigned_ints: + if pa_type == arrow_type: + info = np.iinfo(dtype) + return info.min if self.unsigned_int_strategy == "min" else info.max + if pa_type in (pa.float32(), pa.float64()): + return self.float_value + if pa_type == pa.bool_(): + return self.bool_value + if pa_type in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()): + return self.string_value + if pa.types.is_binary(pa_type) or pa.types.is_large_binary(pa_type): + return self.bytes_value + return None + + +DEFAULT_NULL_POLICY = NullPolicy() +_NULL_POLICY = contextvars.ContextVar("blosc2_null_policy", default=DEFAULT_NULL_POLICY) + + +def get_null_policy() -> NullPolicy: + """Return the current default null policy.""" + return _NULL_POLICY.get() + + +@contextlib.contextmanager +def null_policy(policy: NullPolicy): + """Temporarily set the default policy for CTable null sentinel inference.""" + token = _NULL_POLICY.set(policy) + try: + yield + finally: + _NULL_POLICY.reset(token) + + # --------------------------------------------------------------------------- # Index proxy and CTableIndex # --------------------------------------------------------------------------- @@ -523,34 +619,29 @@ def _find_physical_index(arr: blosc2.NDArray, logical_key: int) -> int: raise IndexError("Unexpected error finding physical index.") -class _RowIndexer: - def __init__(self, table): - self._table_ref = weakref.ref(table) - - def __getitem__(self, item): - table = self._table_ref() - if table is None: - raise ReferenceError("owning CTable has been released") - return table._run_row_logic(item) +def _make_namedtuple_row_type(col_names: tuple[str, ...]): + base = namedtuple("CTableRow", col_names, rename=True) + field_name_map = dict(zip(col_names, base._fields, strict=True)) + class CTableRow(base): + __slots__ = () + _field_name_map = field_name_map + _original_fields = col_names -class _Row: - def __init__(self, table: CTable, nrow: int): - self._table = table - self._nrow = nrow - self._real_pos = None + def __getitem__(self, key): + if isinstance(key, str): + try: + return getattr(self, self._field_name_map[key]) + except KeyError as exc: + raise KeyError( + f"No field named {key!r}. Available: {list(self._original_fields)}" + ) from exc + return tuple.__getitem__(self, key) - def _get_real_pos(self) -> int: - self._real_pos = _find_physical_index(self._table._valid_rows, self._nrow) - return self._real_pos + def as_dict(self) -> dict[str, Any]: + return {name: self[name] for name in self._original_fields} - def __getitem__(self, col_name: str): - if self._real_pos is None: - self._get_real_pos() - cc = self._table._computed_cols.get(col_name) - if cc is not None: - return cc["lazy"][self._real_pos] - return self._table._cols[col_name][self._real_pos] + return CTableRow # --------------------------------------------------------------------------- @@ -578,6 +669,17 @@ def is_computed(self) -> bool: """True if this column is a virtual computed column (read-only).""" return self._col_name in self._table._computed_cols + @property + def is_list(self) -> bool: + col = self._table._schema.columns_by_name.get(self._col_name) + return col is not None and isinstance(col.spec, ListSpec) + + @property + def is_varlen_scalar(self) -> bool: + """True if this column holds variable-length scalar strings or bytes.""" + col = self._table._schema.columns_by_name.get(self._col_name) + return col is not None and isinstance(col.spec, (VLStringSpec, VLBytesSpec)) + @property def _valid_rows(self): if self._mask is None: @@ -597,7 +699,7 @@ def __getitem__(self, key: int | slice | list | np.ndarray): """ return self._values_from_key(key) - def _values_from_key(self, key): + def _values_from_key(self, key): # noqa: C901 """Materialise values for a logical index key.""" if isinstance(key, int): n_rows = len(self) @@ -613,12 +715,14 @@ def _values_from_key(self, key): real_pos = blosc2.where(valid, _arange(len(valid))).compute() start, stop, step = key.indices(len(real_pos)) if start >= stop: - return np.array([], dtype=self.dtype) + return [] if (self.is_list or self.is_varlen_scalar) else np.array([], dtype=self.dtype) selected_pos = real_pos[start:stop:step] # physical row positions if self.is_computed: lo, hi = int(selected_pos.min()), int(selected_pos.max()) chunk = np.asarray(self._raw_col[lo : hi + 1]) return chunk[selected_pos - lo] + if self.is_list or self.is_varlen_scalar: + return self._raw_col[selected_pos] return np.asarray(self._raw_col[selected_pos]) elif isinstance(key, np.ndarray) and key.dtype == np.bool_: @@ -632,6 +736,8 @@ def _values_from_key(self, key): if self.is_computed: raw_np = np.asarray(self._raw_col[:]) return raw_np[phys_indices] + if self.is_list or self.is_varlen_scalar: + return self._raw_col[phys_indices] return self._raw_col[phys_indices] elif isinstance(key, (list, tuple, np.ndarray)): @@ -640,6 +746,8 @@ def _values_from_key(self, key): if self.is_computed: raw_np = np.asarray(self._raw_col[:]) return raw_np[phys_indices] + if self.is_list or self.is_varlen_scalar: + return self._raw_col[phys_indices] return self._raw_col[phys_indices] raise TypeError(f"Invalid index type: {type(key)}") @@ -708,7 +816,7 @@ def view(self) -> ColumnViewIndexer: """ return ColumnViewIndexer(self) - def __setitem__(self, key: int | slice | list | np.ndarray, value): + def __setitem__(self, key: int | slice | list | np.ndarray, value): # noqa: C901 if self._table._read_only: raise ValueError("Table is read-only (opened with mode='r').") if self.is_computed: @@ -723,7 +831,6 @@ def __setitem__(self, key: int | slice | list | np.ndarray, value): self._raw_col[int(pos_true)] = value elif isinstance(key, np.ndarray) and key.dtype == np.bool_: - # Boolean mask in logical space. n_live = len(self) if len(key) != n_live: raise IndexError( @@ -731,9 +838,15 @@ def __setitem__(self, key: int | slice | list | np.ndarray, value): ) all_pos = np.where(self._valid_rows[:])[0] phys_indices = all_pos[key] - if isinstance(value, (list, tuple)): - value = np.array(value, dtype=self._raw_col.dtype) - self._raw_col[phys_indices] = value + if self.is_list or self.is_varlen_scalar: + if len(value) != len(phys_indices): + raise ValueError("Length mismatch in list-column assignment") + for pos, cell in zip(phys_indices, value, strict=True): + self._raw_col[int(pos)] = cell + else: + if isinstance(value, (list, tuple)): + value = np.array(value, dtype=self._raw_col.dtype) + self._raw_col[phys_indices] = value elif isinstance(key, (slice, list, tuple, np.ndarray)): real_pos = blosc2.where(self._valid_rows, _arange(len(self._valid_rows))).compute() @@ -743,9 +856,15 @@ def __setitem__(self, key: int | slice | list | np.ndarray, value): else: phys_indices = np.array([real_pos[i] for i in key], dtype=np.int64) - if isinstance(value, (list, tuple)): - value = np.array(value, dtype=self._raw_col.dtype) - self._raw_col[phys_indices] = value + if self.is_list or self.is_varlen_scalar: + if len(value) != len(phys_indices): + raise ValueError("Length mismatch in list-column assignment") + for pos, cell in zip(phys_indices, value, strict=True): + self._raw_col[int(pos)] = cell + else: + if isinstance(value, (list, tuple)): + value = np.array(value, dtype=self._raw_col.dtype) + self._raw_col[phys_indices] = value else: raise TypeError(f"Invalid index type: {type(key)}") @@ -755,6 +874,9 @@ def __iter__(self): if self.is_computed: yield from self._iter_chunks_computed(size=None) return + if self.is_list or self.is_varlen_scalar: + yield from self._raw_col[np.where(self._valid_rows[:])[0]] + return arr = self._valid_rows chunk_size = arr.chunks[0] @@ -794,27 +916,173 @@ def __repr__(self) -> str: def __len__(self): return blosc2.count_nonzero(self._valid_rows) + @property + def shape(self) -> tuple[int]: + """Logical shape of the live column values.""" + return (len(self),) + + @property + def ndim(self) -> int: + """Number of logical dimensions.""" + return 1 + + @property + def size(self) -> int: + """Number of live values in the column.""" + return len(self) + + def _ensure_queryable(self) -> None: + if self.is_varlen_scalar: + raise NotImplementedError( + f"Column {self._col_name!r} is a vlstring/vlbytes column; " + "lazy expressions and vectorized comparisons are not supported yet." + ) + + @staticmethod + def _unwrap_operand(other): + if isinstance(other, Column): + other._ensure_queryable() + return other._raw_col + return other + + @property + def _is_nullable_bool(self) -> bool: + col = self._table._schema.columns_by_name.get(self._col_name) + return ( + col is not None + and col.spec.to_metadata_dict().get("kind") == "bool" + and getattr(col.spec, "null_value", None) is not None + ) + + def __neg__(self): + self._ensure_queryable() + return -self._raw_col + + def __pos__(self): + self._ensure_queryable() + return +self._raw_col + + def __abs__(self): + self._ensure_queryable() + return abs(self._raw_col) + + def __add__(self, other): + self._ensure_queryable() + return self._raw_col + self._unwrap_operand(other) + + def __radd__(self, other): + self._ensure_queryable() + return self._unwrap_operand(other) + self._raw_col + + def __sub__(self, other): + self._ensure_queryable() + return self._raw_col - self._unwrap_operand(other) + + def __rsub__(self, other): + self._ensure_queryable() + return self._unwrap_operand(other) - self._raw_col + + def __mul__(self, other): + self._ensure_queryable() + return self._raw_col * self._unwrap_operand(other) + + def __rmul__(self, other): + self._ensure_queryable() + return self._unwrap_operand(other) * self._raw_col + + def __truediv__(self, other): + self._ensure_queryable() + return self._raw_col / self._unwrap_operand(other) + + def __rtruediv__(self, other): + self._ensure_queryable() + return self._unwrap_operand(other) / self._raw_col + + def __floordiv__(self, other): + self._ensure_queryable() + return self._raw_col // self._unwrap_operand(other) + + def __rfloordiv__(self, other): + self._ensure_queryable() + return self._unwrap_operand(other) // self._raw_col + + def __mod__(self, other): + self._ensure_queryable() + return self._raw_col % self._unwrap_operand(other) + + def __rmod__(self, other): + self._ensure_queryable() + return self._unwrap_operand(other) % self._raw_col + + def __pow__(self, other): + self._ensure_queryable() + return self._raw_col ** self._unwrap_operand(other) + + def __rpow__(self, other): + self._ensure_queryable() + return self._unwrap_operand(other) ** self._raw_col + + def __and__(self, other): + self._ensure_queryable() + return self._raw_col & self._unwrap_operand(other) + + def __rand__(self, other): + self._ensure_queryable() + return self._unwrap_operand(other) & self._raw_col + + def __or__(self, other): + self._ensure_queryable() + return self._raw_col | self._unwrap_operand(other) + + def __ror__(self, other): + self._ensure_queryable() + return self._unwrap_operand(other) | self._raw_col + + def __xor__(self, other): + self._ensure_queryable() + return self._raw_col ^ self._unwrap_operand(other) + + def __rxor__(self, other): + self._ensure_queryable() + return self._unwrap_operand(other) ^ self._raw_col + + def __invert__(self): + self._ensure_queryable() + if self._is_nullable_bool: + return self._raw_col == 0 + return ~self._raw_col + def __lt__(self, other): - return self._raw_col < other + self._ensure_queryable() + return self._raw_col < self._unwrap_operand(other) def __le__(self, other): - return self._raw_col <= other + self._ensure_queryable() + return self._raw_col <= self._unwrap_operand(other) def __eq__(self, other): - return self._raw_col == other + self._ensure_queryable() + if self._is_nullable_bool and isinstance(other, (bool, np.bool_)): + return self._raw_col == int(other) + return self._raw_col == self._unwrap_operand(other) def __ne__(self, other): - return self._raw_col != other + self._ensure_queryable() + if self._is_nullable_bool and isinstance(other, (bool, np.bool_)): + return self._raw_col == int(not other) + return self._raw_col != self._unwrap_operand(other) def __gt__(self, other): - return self._raw_col > other + self._ensure_queryable() + return self._raw_col > self._unwrap_operand(other) def __ge__(self, other): - return self._raw_col >= other + self._ensure_queryable() + return self._raw_col >= self._unwrap_operand(other) @property def dtype(self): - return self._raw_col.dtype + return getattr(self._raw_col, "dtype", None) def iter_chunks(self, size: int = 65536): """Iterate over live column values in chunks of *size* rows. @@ -840,6 +1108,10 @@ def iter_chunks(self, size: int = 65536): if self.is_computed: yield from self._iter_chunks_computed(size=size) return + if self.is_list: + raise TypeError("Column.iter_chunks() is not supported for list columns in V1.") + if self.is_varlen_scalar: + raise TypeError("Column.iter_chunks() is not supported for varlen scalar columns.") valid = self._valid_rows raw = self._raw_col arr_len = len(valid) @@ -950,6 +1222,15 @@ def assign(self, data) -> None: raise ValueError("Table is read-only (opened with mode='r').") if self.is_computed: raise ValueError(f"Column {self._col_name!r} is a computed column and cannot be written to.") + if self.is_list: + values = list(data) + if len(values) != len(self): + raise ValueError(f"assign() requires {len(self)} values (live rows), got {len(values)}.") + live_pos = np.where(self._valid_rows[:])[0] + for pos, cell in zip(live_pos, values, strict=True): + self._raw_col[int(pos)] = cell + self._table._root_table._mark_all_indexes_stale() + return n_live = len(self) arr = np.asarray(data) if len(arr) != n_live: @@ -988,7 +1269,14 @@ def _null_mask_for(self, arr: np.ndarray) -> np.ndarray: return arr == nv def is_null(self) -> np.ndarray: - """Return a boolean array True where the live value is the null sentinel.""" + """Return a boolean array True where the live value is the null sentinel. + + For varlen scalar columns (vlstring/vlbytes) nullability is represented + as native ``None`` values, so this returns True wherever the value is + ``None``. + """ + if self.is_varlen_scalar: + return np.array([v is None for v in self], dtype=np.bool_) return self._null_mask_for(self[:]) def notnull(self) -> np.ndarray: @@ -998,8 +1286,11 @@ def notnull(self) -> np.ndarray: def null_count(self) -> int: """Return the number of live rows whose value equals the null sentinel. - Returns ``0`` in O(1) if no ``null_value`` is configured for this column. + Returns ``0`` in O(1) if no ``null_value`` is configured for this column + and the column is not a varlen scalar column. """ + if self.is_varlen_scalar: + return sum(1 for v in self if v is None) if self.null_value is None: return 0 return int(self.is_null().sum()) @@ -1082,28 +1373,41 @@ def _require_kind(self, kinds: str, op: str) -> None: # Aggregates # ------------------------------------------------------------------ - def sum(self): + def sum(self, dtype=None): """Sum of all live, non-null values. Supported dtypes: bool, int, uint, float, complex. Bool values are counted as 0 / 1. Null sentinel values are skipped. + + Parameters + ---------- + dtype: + Optional accumulator dtype. When omitted, float columns use + ``np.float64``, complex columns use ``np.complex128``, and integer + / bool columns use ``np.int64``. """ self._require_kind("biufc", "sum") self._require_nonempty("sum") # Use a wide accumulator to reduce overflow risk - acc_dtype = ( - np.float64 - if self.dtype.kind == "f" - else ( - np.complex128 if self.dtype.kind == "c" else np.int64 if self.dtype.kind in "biu" else None + acc_dtype = np.dtype(dtype).type if dtype is not None else None + if acc_dtype is None: + acc_dtype = ( + np.float64 + if self.dtype.kind == "f" + else ( + np.complex128 + if self.dtype.kind == "c" + else np.int64 + if self.dtype.kind in "biu" + else None + ) ) - ) result = acc_dtype(0) for chunk in self._nonnull_chunks(): result += chunk.sum(dtype=acc_dtype) - # Return in the column's natural dtype when it fits, else keep wide - if self.dtype.kind in "biu": + # Return in the column's natural dtype when it fits, else keep the requested/wide dtype + if dtype is None and self.dtype.kind in "biu": return int(result) return result @@ -1245,6 +1549,7 @@ def _fmt_bytes(n: int) -> str: _EXPECTED_SIZE_DEFAULT = 1_048_576 +_BATCH_SIZE_DEFAULT = 2048 # --------------------------------------------------------------------------- # Computed-column definition (virtual columns backed by a LazyExpr) @@ -1286,13 +1591,12 @@ def __init__( self._validate = validate self._table_cparams = cparams self._table_dparams = dparams - self._cols: dict[str, blosc2.NDArray] = {} + self._cols: dict[str, blosc2.NDArray | ListArray] = {} self._computed_cols: dict[str, dict] = {} # virtual/computed columns self._materialized_cols: dict[str, dict] = {} # stored columns auto-filled from expressions self._expr_index_arrays: dict[str, blosc2.NDArray] = {} self._col_widths: dict[str, int] = {} self.col_names: list[str] = [] - self.row = _RowIndexer(self) self.auto_compact = compact self.base = None @@ -1326,9 +1630,14 @@ def __init__( self.col_names = [c["name"] for c in schema_dict["columns"]] self._valid_rows = storage.open_valid_rows() for name in self.col_names: - col = storage.open_column(name) - self._cols[name] = col cc = self._schema.columns_by_name[name] + if self._is_list_column(cc): + col = storage.open_list_column(name) + elif self._is_varlen_scalar_column(cc): + col = storage.open_varlen_scalar_column(name, cc.spec) + else: + col = storage.open_column(name) + self._cols[name] = col self._col_widths[name] = max(len(name), cc.display_width) self._n_rows = int(blosc2.count_nonzero(self._valid_rows)) self._last_pos = None # resolve lazily on first write @@ -1348,6 +1657,7 @@ def __init__( self._schema = compile_schema(row_type) else: self._schema = _compile_pydantic_schema(row_type) + self._resolve_nullable_specs(self._schema) self._n_rows = 0 self._last_pos = 0 @@ -1367,6 +1677,13 @@ def __init__( def close(self) -> None: """Close any persistent backing store held by this table.""" storage = getattr(self, "_storage", None) + try: + self._flush_varlen_columns() + except Exception: + with contextlib.suppress(Exception): + if storage is not None and hasattr(storage, "close"): + storage.close() + raise if storage is not None and hasattr(storage, "close"): storage.close() @@ -1385,20 +1702,140 @@ def __del__(self): elif storage is not None and hasattr(storage, "close"): storage.close() + @staticmethod + def _is_list_column(col: CompiledColumn) -> bool: + return isinstance(col.spec, ListSpec) + + @staticmethod + def _is_varlen_scalar_column(col: CompiledColumn) -> bool: + return isinstance(col.spec, (VLStringSpec, VLBytesSpec)) + + @staticmethod + def _is_list_spec(spec: SchemaSpec) -> bool: + return isinstance(spec, ListSpec) + + @staticmethod + def _policy_null_value_for_spec(spec: SchemaSpec, policy: NullPolicy): + if isinstance(spec, (int8, int16, int32, int64)): + info = np.iinfo(spec.dtype) + return info.min if policy.signed_int_strategy == "min" else info.max + if isinstance(spec, (uint8, uint16, uint32, uint64)): + info = np.iinfo(spec.dtype) + return info.min if policy.unsigned_int_strategy == "min" else info.max + if isinstance(spec, (float32, float64)): + return policy.float_value + if isinstance(spec, b2_bool): + return policy.bool_value + if isinstance(spec, string): + return policy.string_value + if isinstance(spec, b2_bytes): + return policy.bytes_value + return None + + @staticmethod + def _validate_null_value_for_spec(name: str, spec: SchemaSpec, null_value) -> None: + if isinstance(spec, (int8, int16, int32, int64, uint8, uint16, uint32, uint64)): + if isinstance(null_value, (bool, np.bool_)) or not isinstance(null_value, (int, np.integer)): + raise TypeError(f"Null sentinel for column {name!r} must be an integer") + info = np.iinfo(spec.dtype) + if not info.min <= int(null_value) <= info.max: + raise ValueError( + f"Null sentinel for column {name!r}={null_value!r} is outside {spec.dtype} range" + ) + return + if isinstance(spec, (float32, float64)): + if not isinstance(null_value, (int, float, np.integer, np.floating)): + raise TypeError(f"Null sentinel for column {name!r} must be numeric") + return + if isinstance(spec, b2_bool): + if null_value != 255: + raise ValueError(f"Null sentinel for nullable bool column {name!r} must be 255") + return + if isinstance(spec, string): + if not isinstance(null_value, str): + raise TypeError(f"Null sentinel for string column {name!r} must be str") + return + if isinstance(spec, b2_bytes) and not isinstance(null_value, bytes): + raise TypeError(f"Null sentinel for bytes column {name!r} must be bytes") + + @classmethod + def _resolve_nullable_specs( + cls, schema: CompiledSchema, *, validate_column_null_values: bool = True + ) -> None: + policy = get_null_policy() + schema_names = {col.name for col in schema.columns} + unknown_null_values = set(policy.column_null_values) - schema_names + if validate_column_null_values and unknown_null_values: + names = ", ".join(sorted(unknown_null_values)) + raise KeyError(f"column_null_values contains unknown columns: {names}") + for col in schema.columns: + spec = col.spec + if ( + isinstance(spec, (ListSpec, VLStringSpec, VLBytesSpec)) + or getattr(spec, "null_value", None) is not None + ): + continue + if not getattr(spec, "nullable", False): + continue + null_value = policy.column_null_values.get(col.name) + if null_value is None: + null_value = cls._policy_null_value_for_spec(spec, policy) + if null_value is None: + raise TypeError(f"Column {col.name!r} is nullable, but no null policy sentinel is available") + cls._validate_null_value_for_spec(col.name, spec, null_value) + spec.null_value = null_value + if isinstance(spec, string): + spec.max_length = max(spec.max_length, len(null_value), 1) + spec.dtype = np.dtype(f"U{spec.max_length}") + elif isinstance(spec, b2_bytes): + spec.max_length = max(spec.max_length, len(null_value), 1) + spec.dtype = np.dtype(f"S{spec.max_length}") + elif isinstance(spec, b2_bool): + spec.dtype = np.dtype(np.uint8) + col.dtype = getattr(spec, "dtype", None) + col.display_width = compute_display_width(spec) + + def _flush_varlen_columns(self) -> None: + for col in self._schema.columns: + if self._is_list_column(col) or self._is_varlen_scalar_column(col): + self._cols[col.name].flush() + def _init_columns( self, expected_size: int, default_chunks, default_blocks, storage: TableStorage ) -> None: - """Create one NDArray per column using the compiled schema.""" + """Create one physical column per compiled schema column.""" for col in self._schema.columns: self.col_names.append(col.name) self._col_widths[col.name] = max(len(col.name), col.display_width) col_storage = self._resolve_column_storage(col, default_chunks, default_blocks) + if self._is_list_column(col): + self._cols[col.name] = storage.create_list_column( + col.name, + spec=col.spec, + cparams=col_storage.get("cparams"), + dparams=col_storage.get("dparams"), + ) + continue + if self._is_varlen_scalar_column(col): + self._cols[col.name] = storage.create_varlen_scalar_column( + col.name, + spec=col.spec, + cparams=col_storage.get("cparams"), + dparams=col_storage.get("dparams"), + ) + continue + # Recompute chunks/blocks using the actual dtype so that wide + # string columns (e.g. U183642) don't produce multi-GB chunks. + chunks = col_storage["chunks"] + blocks = col_storage["blocks"] + if col.config.chunks is None and col.config.blocks is None: + chunks, blocks = compute_chunks_blocks((expected_size,), dtype=col.dtype) self._cols[col.name] = storage.create_column( col.name, dtype=col.dtype, shape=(expected_size,), - chunks=col_storage["chunks"], - blocks=col_storage["blocks"], + chunks=chunks, + blocks=blocks, cparams=col_storage.get("cparams"), dparams=col_storage.get("dparams"), ) @@ -1448,11 +1885,17 @@ def _normalize_row_input(self, data: Any) -> dict[str, Any]: return {name: data[i] for i, name in enumerate(stored)} def _coerce_row_to_storage(self, row: dict[str, Any]) -> dict[str, Any]: - """Coerce each value in *row* to the column's storage dtype.""" + """Coerce each value in *row* to the column's storage representation.""" result = {} for col in self._schema.columns: val = row[col.name] - result[col.name] = np.array(val, dtype=col.dtype).item() + if self._is_list_column(col): + result[col.name] = coerce_list_cell(col.spec, val) + elif self._is_varlen_scalar_column(col): + # Coercion is handled inside _ScalarVarLenArray.append. + result[col.name] = val + else: + result[col.name] = np.array(val, dtype=col.dtype).item() return result def _resolve_last_pos(self) -> int: @@ -1494,9 +1937,12 @@ def _resolve_last_pos(self) -> int: return self._last_pos def _grow(self) -> None: - """Double the physical capacity of all columns and the valid_rows mask.""" + """Double the scalar-column capacity and the valid_rows mask.""" c = len(self._valid_rows) - for col_arr in self._cols.values(): + for name, col_arr in self._cols.items(): + cc = self._schema.columns_by_name[name] + if self._is_list_column(cc) or self._is_varlen_scalar_column(cc): + continue col_arr.resize((c * 2,)) self._valid_rows.resize((c * 2,)) @@ -1504,72 +1950,78 @@ def _grow(self) -> None: # Display # ------------------------------------------------------------------ - def __str__(self) -> str: - _HEAD_TAIL = 10 # rows shown at each end - + def _display_positions(self, head_tail: int = 10): nrows = self._n_rows - ncols = len(self.col_names) - hidden = max(0, nrows - _HEAD_TAIL * 2) - - # -- physical positions for head and tail rows -- + hidden = max(0, nrows - head_tail * 2) valid_np = self._valid_rows[:] all_pos = np.where(valid_np)[0] + if nrows <= head_tail * 2: + return all_pos, np.array([], dtype=all_pos.dtype), 0 + return all_pos[:head_tail], all_pos[-head_tail:], hidden - if nrows <= _HEAD_TAIL * 2: - head_pos = all_pos - tail_pos = np.array([], dtype=all_pos.dtype) - hidden = 0 - else: - head_pos = all_pos[:_HEAD_TAIL] - tail_pos = all_pos[-_HEAD_TAIL:] - - # -- per-column display widths -- + def _display_widths(self) -> dict[str, int]: widths: dict[str, int] = {} + single_col = len(self.col_names) == 1 for name in self.col_names: - widths[name] = max( - self._col_widths[name], - len(str(self._col_dtype(name))), - ) - - sep = " ".join("─" * (w + 2) for w in widths.values()) - - def fmt_cell(value, width: int) -> str: - s = str(value) - if len(s) > width: - s = s[: width - 1] + "…" - return f" {s:<{width}} " + spec = self._schema.columns_by_name.get(name) + dtype_label = self._dtype_info_label(self._col_dtype(name), spec.spec if spec else None) + widths[name] = max(self._col_widths[name], len(dtype_label)) + if single_col: + widths[name] = max(widths[name], 80) + return widths - def fmt_row(values: dict) -> str: - return " ".join(fmt_cell(values[n], widths[n]) for n in self.col_names) + @staticmethod + def _format_cell(value, width: int) -> str: + s = str(value) + if len(s) > width: + s = s[: width - 1] + "…" + return f" {s:<{width}} " + + def _format_display_row(self, values: dict, widths: dict[str, int]) -> str: + return " ".join(self._format_cell(values[n], widths[n]) for n in self.col_names) + + def _rows_to_dicts(self, positions) -> list[dict]: + if len(positions) == 0: + return [] + col_data = {n: self._fetch_col_at_positions(n, positions) for n in self.col_names} + rows = [] + for i in range(len(positions)): + row = {} + for n in self.col_names: + row[n] = self._normalize_scalar_value(col_data[n][i]) + rows.append(row) + return rows - # -- batch-fetch values (one read per column, not one per cell) -- - def rows_to_dicts(positions) -> list[dict]: - if len(positions) == 0: - return [] - col_data = {n: self._fetch_col_at_positions(n, positions) for n in self.col_names} - return [{n: col_data[n][i].item() for n in self.col_names} for i in range(len(positions))] + def __str__(self) -> str: + nrows = self._n_rows + ncols = len(self.col_names) + head_pos, tail_pos, hidden = self._display_positions() + widths = self._display_widths() + sep = " ".join("─" * (w + 2) for w in widths.values()) lines = [ - fmt_row({n: n for n in self.col_names}), - fmt_row({n: str(self._col_dtype(n)) for n in self.col_names}), + self._format_display_row({n: n for n in self.col_names}, widths), + self._format_display_row( + { + n: self._dtype_info_label( + self._col_dtype(n), + self._schema.columns_by_name[n].spec if n in self._schema.columns_by_name else None, + ) + for n in self.col_names + }, + widths, + ), sep, ] - - for row in rows_to_dicts(head_pos): - lines.append(fmt_row(row)) - + lines.extend(self._format_display_row(row, widths) for row in self._rows_to_dicts(head_pos)) if hidden > 0: - lines.append(fmt_row(dict.fromkeys(self.col_names, "..."))) - - for row in rows_to_dicts(tail_pos): - lines.append(fmt_row(row)) - + lines.append(self._format_display_row(dict.fromkeys(self.col_names, "..."), widths)) + lines.extend(self._format_display_row(row, widths) for row in self._rows_to_dicts(tail_pos)) lines.append(sep) footer = f"{nrows:,} rows × {ncols} columns" if hidden > 0: footer += f" ({hidden:,} rows hidden)" lines.append(footer) - return "\n".join(lines) def __repr__(self) -> str: @@ -1581,7 +2033,111 @@ def __len__(self): def __iter__(self): for i in range(self.nrows): - yield _Row(self, i) + yield self._materialize_row(i) + + def _row_namedtuple_type(self): + visible = tuple(self.col_names) + if getattr(self, "_row_namedtuple_type_cache_cols", None) != visible: + self._row_namedtuple_type_cache = _make_namedtuple_row_type(visible) + self._row_namedtuple_type_cache_cols = visible + return self._row_namedtuple_type_cache + + @staticmethod + def _normalize_scalar_value(value): + if isinstance(value, np.generic): + return value.item() + if isinstance(value, np.ndarray) and value.ndim == 0: + return value.item() + return value + + def _physical_row_value(self, col_name: str, pos: int): + cc = self._computed_cols.get(col_name) + if cc is not None: + return self._normalize_scalar_value(np.asarray(cc["lazy"][pos]).ravel()[0]) + return self._normalize_scalar_value(self._cols[col_name][pos]) + + def _materialize_row(self, index: int): + n_rows = self.nrows + if index < 0: + index += n_rows + if not (0 <= index < n_rows): + raise IndexError(f"row index {index} is out of bounds for table with {n_rows} rows") + pos = _find_physical_index(self._valid_rows, index) + row_type = self._row_namedtuple_type() + return row_type(*(self._physical_row_value(name, int(pos)) for name in self.col_names)) + + def iter_sorted( + self, + cols: str | list[str], + ascending: bool | list[bool] = True, + *, + start: int | None = None, + stop: int | None = None, + step: int | None = None, + batch_size: int = 4096, + ): + """Iterate rows in sorted order without materializing a full copy. + + Uses a FULL index when available (no sort needed); otherwise falls + back to ``np.lexsort`` on live physical positions. Yields namedtuple-like + row objects in the same way as ``__iter__``. + + The sorted positions array is stored as a compressed ``blosc2.NDArray`` + to keep RAM usage low for large tables. ``batch_size`` positions are + decompressed at a time during iteration. + + Parameters + ---------- + cols: + Column name or list of column names to sort by. + ascending: + Sort direction. A single bool applies to all keys; a list must + have the same length as *cols*. + start, stop, step: + Optional slice applied to the sorted sequence before iteration. + E.g. ``stop=10`` yields only the top-10 rows; ``step=2`` yields + every other row in sorted order. + batch_size: + Number of positions decompressed per iteration step. Larger + values reduce decompression overhead; smaller values use less + transient RAM. Default is 4096. + """ + cols, ascending = self._normalise_sort_keys(cols, ascending) + + valid_np = self._valid_rows[:] + live_pos = np.where(valid_np)[0] + n = len(live_pos) + + if n == 0: + return + + sorted_pos = None + if len(cols) == 1: + sorted_pos = self._sorted_positions_from_full_index(cols[0], ascending[0]) + if sorted_pos is not None and len(sorted_pos) != n: + sorted_pos = None + + if sorted_pos is None: + order = np.lexsort(self._build_lex_keys(cols, ascending, live_pos, n)) + sorted_pos = live_pos[order] + + if start is not None or stop is not None or step is not None: + sorted_pos = sorted_pos[start:stop:step] + + # Compress positions into an NDArray to reduce RAM usage for large tables. + # The uncompressed numpy array is released immediately after. + sorted_pos_nd = blosc2.asarray(np.asarray(sorted_pos, dtype=np.int64)) + del sorted_pos + + # physical → logical index mapping + phys_to_logical = np.empty(valid_np.shape[0], dtype=np.intp) + phys_to_logical[live_pos] = np.arange(n, dtype=np.intp) + + total = len(sorted_pos_nd) + for i in range(0, total, batch_size): + chunk = sorted_pos_nd[i : i + batch_size] + for phys in chunk: + yield self._materialize_row(int(phys_to_logical[phys])) # ------------------------------------------------------------------ # Open existing table (classmethod) @@ -1626,14 +2182,18 @@ def open(cls, urlpath: str, *, mode: str = "r") -> CTable: obj._cols = {} obj._col_widths = {} obj.col_names = col_names - obj.row = _RowIndexer(obj) obj.auto_compact = False obj.base = None obj._valid_rows = storage.open_valid_rows() for name in col_names: - obj._cols[name] = storage.open_column(name) cc = schema.columns_by_name[name] + if obj._is_list_column(cc): + obj._cols[name] = storage.open_list_column(name) + elif obj._is_varlen_scalar_column(cc): + obj._cols[name] = storage.open_varlen_scalar_column(name, cc.spec) + else: + obj._cols[name] = storage.open_column(name) obj._col_widths[name] = max(len(name), cc.display_width) obj._n_rows = int(blosc2.count_nonzero(obj._valid_rows)) @@ -1676,6 +2236,8 @@ def save(self, urlpath: str, *, overwrite: bool = False) -> None: else: os.remove(target_path) + self._flush_varlen_columns() + # Collect live physical positions valid_np = self._valid_rows[:] live_pos = np.where(valid_np)[0] @@ -1696,8 +2258,28 @@ def save(self, urlpath: str, *, overwrite: bool = False) -> None: # --- columns --- for col in self._schema.columns: name = col.name - # Use dtype-aware defaults so large-itemsize columns (e.g. U4096) get - # sensible chunk/block sizes rather than the uint8-based defaults. + if self._is_list_column(col): + disk_col = file_storage.create_list_column( + name, + spec=col.spec, + cparams=col.config.cparams if col.config.cparams is not None else self._table_cparams, + dparams=col.config.dparams if col.config.dparams is not None else self._table_dparams, + ) + if n_live > 0: + disk_col.extend(self._cols[name][int(pos)] for pos in live_pos) + disk_col.flush() + continue + if self._is_varlen_scalar_column(col): + disk_col = file_storage.create_varlen_scalar_column( + name, + spec=col.spec, + cparams=col.config.cparams if col.config.cparams is not None else self._table_cparams, + dparams=col.config.dparams if col.config.dparams is not None else self._table_dparams, + ) + if n_live > 0: + disk_col.extend(self._cols[name][int(pos)] for pos in live_pos) + disk_col.flush() + continue dtype_chunks, dtype_blocks = compute_chunks_blocks((capacity,), dtype=col.dtype) col_storage = self._resolve_column_storage(col, dtype_chunks, dtype_blocks) disk_col = file_storage.create_column( @@ -1744,7 +2326,14 @@ def load(cls, urlpath: str) -> CTable: col_names = [c["name"] for c in schema_dict["columns"]] disk_valid = file_storage.open_valid_rows() - disk_cols = {name: file_storage.open_column(name) for name in col_names} + disk_cols = {} + for col in schema.columns: + if cls._is_list_column(col): + disk_cols[col.name] = file_storage.open_list_column(col.name) + elif cls._is_varlen_scalar_column(col): + disk_cols[col.name] = file_storage.open_varlen_scalar_column(col.name, col.spec) + else: + disk_cols[col.name] = file_storage.open_column(col.name) phys_size = len(disk_valid) n_live = int(blosc2.count_nonzero(disk_valid)) capacity = max(phys_size, 1) @@ -1760,9 +2349,21 @@ def load(cls, urlpath: str) -> CTable: if phys_size > 0: mem_valid[:phys_size] = disk_valid[:] - mem_cols: dict[str, blosc2.NDArray] = {} + mem_cols: dict[str, blosc2.NDArray | ListArray | _ScalarVarLenArray] = {} for col in schema.columns: name = col.name + if cls._is_list_column(col): + mem_col = mem_storage.create_list_column(name, spec=col.spec, cparams=None, dparams=None) + mem_col.extend(disk_cols[name][:]) + mem_col.flush() + mem_cols[name] = mem_col + continue + if cls._is_varlen_scalar_column(col): + mem_col = mem_storage.create_varlen_scalar_column(name, spec=col.spec) + mem_col.extend(iter(disk_cols[name])) + mem_col.flush() + mem_cols[name] = mem_col + continue col_chunks, col_blocks = compute_chunks_blocks((capacity,), dtype=col.dtype) mem_col = mem_storage.create_column( name, @@ -1790,7 +2391,6 @@ def load(cls, urlpath: str) -> CTable: obj._cols = mem_cols obj._col_widths = {col.name: max(len(col.name), col.display_width) for col in schema.columns} obj.col_names = col_names - obj.row = _RowIndexer(obj) obj.auto_compact = False obj.base = None obj._valid_rows = mem_valid @@ -1820,7 +2420,6 @@ def _make_view(cls, parent: CTable, new_valid_rows: blosc2.NDArray) -> CTable: obj._expr_index_arrays = parent._expr_index_arrays obj._col_widths = parent._col_widths obj.col_names = parent.col_names - obj.row = _RowIndexer(obj) obj.auto_compact = parent.auto_compact obj.base = parent obj._valid_rows = new_valid_rows @@ -1982,7 +2581,6 @@ def select(self, cols: list[str]) -> CTable: row_cls=self._schema.row_cls, ) obj._col_widths = {name: self._col_widths[name] for name in cols if name in self._col_widths} - obj.row = _RowIndexer(obj) return obj def describe(self) -> None: @@ -2000,7 +2598,9 @@ def describe(self) -> None: for name in self.col_names: col = self[name] dtype = col.dtype - lines.append(f" {name} [{dtype}]") + spec = self._schema.columns_by_name.get(name) + label = self._dtype_info_label(dtype, spec.spec if spec else None) + lines.append(f" {name} [{label}]") if n == 0: lines.append(" (empty)") @@ -2010,7 +2610,10 @@ def describe(self) -> None: nc = col.null_count() n_nonnull = n - nc - if dtype.kind in "biufc" and dtype.kind != "c": + if isinstance(spec.spec, ListSpec) if spec is not None else False: + lines.append(f" count : {n:,}") + lines.append(" (stats not available for list columns)") + elif dtype.kind in "biufc" and dtype.kind != "c": # numeric + bool if dtype.kind == "b": arr = col[:] @@ -2082,7 +2685,7 @@ def cov(self) -> np.ndarray: """ for name in self.col_names: dtype = self._col_dtype(name) - if not ( + if dtype is None or not ( np.issubdtype(dtype, np.integer) or np.issubdtype(dtype, np.floating) or dtype == np.bool_ ): raise TypeError( @@ -2127,206 +2730,603 @@ def cov(self) -> np.ndarray: # Arrow interop # ------------------------------------------------------------------ - def to_arrow(self): - """Convert all live rows to a :class:`pyarrow.Table`. - - Each column is materialized via ``col[:]`` and wrapped - in a ``pyarrow.array``. String columns are emitted as ``pa.string()`` - (variable-length UTF-8); bytes columns as ``pa.large_binary()``. - - Raises - ------ - ImportError - If ``pyarrow`` is not installed. - """ + @staticmethod + def _require_pyarrow(context: str): try: import pyarrow as pa except ImportError: raise ImportError( - "pyarrow is required for to_arrow(). Install it with: pip install pyarrow" + f"pyarrow is required for {context}. Install it with: pip install pyarrow" ) from None + return pa - arrays = {} - for name in self.col_names: - col = self[name] - arr = col[:] - # Only compute null mask when a sentinel is actually configured — - # avoids allocating a 1M-element zeros array for every non-nullable column. - nv = col.null_value - if nv is not None: - null_mask = col._null_mask_for(arr) - has_nulls = bool(null_mask.any()) - else: - null_mask = None - has_nulls = False - kind = arr.dtype.kind - if kind == "U": - values = arr.tolist() - if has_nulls: - values = [None if null_mask[i] else v for i, v in enumerate(values)] - pa_arr = pa.array(values, type=pa.string()) - elif kind == "S": - values = arr.tolist() - if has_nulls: - values = [None if null_mask[i] else v for i, v in enumerate(values)] - pa_arr = pa.array(values, type=pa.large_binary()) - else: - pa_arr = pa.array(arr, mask=null_mask if has_nulls else None) - arrays[name] = pa_arr - - return pa.table(arrays) - - @classmethod - def from_arrow(cls, arrow_table) -> CTable: - """Build a :class:`CTable` from a :class:`pyarrow.Table`. - - Schema is inferred from the Arrow field types. String columns - (``pa.string()``, ``pa.large_string()``) are stored with - ``max_length`` set to the longest value found in the data. - - Parameters - ---------- - arrow_table: - A ``pyarrow.Table`` instance. - - Returns - ------- - CTable - A new in-memory CTable containing all rows from *arrow_table*. - - Raises - ------ - ImportError - If ``pyarrow`` is not installed. - TypeError - If an Arrow field type has no corresponding blosc2 spec. - """ + @staticmethod + def _require_pyarrow_parquet(context: str): try: - import pyarrow as pa + import pyarrow.parquet as pq except ImportError: raise ImportError( - "pyarrow is required for from_arrow(). Install it with: pip install pyarrow" + f"pyarrow is required for {context}. Install it with: pip install pyarrow" ) from None + return pq - import blosc2.schema as b2s + @staticmethod + def _validate_arrow_batch_size(batch_size: int) -> None: + if batch_size <= 0: + raise ValueError("batch_size must be greater than 0") + + def _resolve_arrow_columns(self, columns, include_computed: bool = True) -> list[str]: + if columns is None: + names = list(self.col_names) + if not include_computed: + names = [name for name in names if name not in self._computed_cols] + else: + names = list(columns) + if len(set(names)) != len(names): + raise ValueError("columns must be unique") + for name in names: + if name not in self.col_names: + raise KeyError(f"No column named {name!r}. Available: {self.col_names}") + return names - def _arrow_type_to_spec(pa_type, arrow_col): - """Map a pyarrow DataType to a blosc2 SchemaSpec.""" - mapping = [ - (pa.int8(), b2s.int8), - (pa.int16(), b2s.int16), - (pa.int32(), b2s.int32), - (pa.int64(), b2s.int64), - (pa.uint8(), b2s.uint8), - (pa.uint16(), b2s.uint16), - (pa.uint32(), b2s.uint32), - (pa.uint64(), b2s.uint64), - (pa.float32(), b2s.float32), - (pa.float64(), b2s.float64), - (pa.bool_(), b2s.bool), - ] - for arrow_t, spec_cls in mapping: - if pa_type == arrow_t: - return spec_cls() + @staticmethod + def _pa_type_from_spec(pa, spec): + if isinstance(spec, VLStringSpec): + return pa.string() + if isinstance(spec, VLBytesSpec): + return pa.large_binary() + if isinstance(spec, ListSpec): + return pa.list_(CTable._pa_type_from_spec(pa, spec.item_spec)) + if isinstance(spec, StructSpec): + return pa.struct( + [pa.field(name, CTable._pa_type_from_spec(pa, child)) for name, child in spec.fields.items()] + ) + if spec.to_metadata_dict().get("kind") == "bool": + return pa.bool_() + dtype = getattr(spec, "dtype", None) + if dtype is None: + raise TypeError(f"No Arrow type for blosc2 spec {spec!r}") + kind = dtype.kind + if kind == "U": + return pa.string() + if kind == "S": + return pa.large_binary() + return pa.from_numpy_dtype(dtype) + + def _arrow_schema_for_columns(self, columns=None, *, include_computed: bool = True): + pa = self._require_pyarrow("to_arrow()/to_parquet()") + names = self._resolve_arrow_columns(columns, include_computed=include_computed) + fields = [] + for name in names: + cc = self._schema.columns_by_name.get(name) + if cc is not None: + pa_type = self._pa_type_from_spec(pa, cc.spec) + else: + pa_type = pa.from_numpy_dtype(np.asarray(self[name][:0]).dtype) + fields.append(pa.field(name, pa_type)) + return pa.schema(fields) - # String types: determine max_length from the data - if pa_type in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()): - values = [v for v in arrow_col.to_pylist() if v is not None] - max_len = max((len(v) for v in values), default=1) - return b2s.string(max_length=max(max_len, 1)) + def iter_arrow_batches( + self, + *, + columns: list[str] | None = None, + batch_size: int = _BATCH_SIZE_DEFAULT, + include_computed: bool = True, + ): + """Yield live rows as bounded-size :class:`pyarrow.RecordBatch` objects.""" + pa = self._require_pyarrow("iter_arrow_batches()") + self._validate_arrow_batch_size(batch_size) + self._flush_varlen_columns() + names = self._resolve_arrow_columns(columns, include_computed=include_computed) + + for start in range(0, self._n_rows, batch_size): + stop = min(start + batch_size, self._n_rows) + arrays = [] + for name in names: + col = self[name] + if col.is_list: + spec = self._schema.columns_by_name[name].spec + arrays.append(pa.array(col[start:stop], type=self._pa_type_from_spec(pa, spec))) + continue + if col.is_varlen_scalar: + spec = self._schema.columns_by_name[name].spec + values = col[start:stop] # list of str/bytes/None + arrays.append(pa.array(values, type=self._pa_type_from_spec(pa, spec))) + continue + arr = np.asarray(col[start:stop]) + nv = col.null_value + null_mask = col._null_mask_for(arr) if nv is not None else None + has_nulls = null_mask is not None and bool(null_mask.any()) + if arr.dtype.kind == "U": + values = arr.tolist() + if has_nulls: + values = [None if null_mask[i] else v for i, v in enumerate(values)] + arrays.append(pa.array(values, type=pa.string())) + elif arr.dtype.kind == "S": + values = arr.tolist() + if has_nulls: + values = [None if null_mask[i] else v for i, v in enumerate(values)] + arrays.append(pa.array(values, type=pa.large_binary())) + elif ( + self._schema.columns_by_name.get(name) is not None + and self._schema.columns_by_name[name].spec.to_metadata_dict().get("kind") == "bool" + ): + arrays.append(pa.array(arr == 1, mask=null_mask if has_nulls else None, type=pa.bool_())) + else: + arrays.append(pa.array(arr, mask=null_mask if has_nulls else None)) + yield pa.RecordBatch.from_arrays(arrays, names=names) - raise TypeError( - f"No blosc2 spec for Arrow type {pa_type!r}. " - "Supported: int8/16/32/64, uint8/16/32/64, float32/64, bool, string." + def to_arrow(self): + """Convert all live rows to a :class:`pyarrow.Table`.""" + pa = self._require_pyarrow("to_arrow()") + batches = list(self.iter_arrow_batches()) + schema = self._arrow_schema_for_columns() + return pa.Table.from_batches(batches, schema=schema) + + @staticmethod + def _auto_null_sentinel(pa, pa_type, *, null_policy: NullPolicy): + return null_policy.sentinel_for_arrow_type(pa, pa_type) + + @staticmethod + def _arrow_type_to_spec( # noqa: C901 + pa, + pa_type, + arrow_col=None, + *, + string_max_length=None, + null_value=None, + nullable=False, + ): + import blosc2.schema as b2s + + mapping = [ + (pa.int8(), b2s.int8), + (pa.int16(), b2s.int16), + (pa.int32(), b2s.int32), + (pa.int64(), b2s.int64), + (pa.uint8(), b2s.uint8), + (pa.uint16(), b2s.uint16), + (pa.uint32(), b2s.uint32), + (pa.uint64(), b2s.uint64), + (pa.float32(), b2s.float32), + (pa.float64(), b2s.float64), + (pa.bool_(), b2s.bool), + ] + for arrow_t, spec_cls in mapping: + if pa_type == arrow_t: + if null_value is not None and hasattr(spec_cls(), "null_value"): + return spec_cls(null_value=null_value) + if null_value is not None and spec_cls is b2s.bool: + return spec_cls(null_value=null_value) + return spec_cls() + + if pa.types.is_list(pa_type) or pa.types.is_large_list(pa_type): + if arrow_col is not None: + py_values = arrow_col.to_pylist() + flat_values = [item for cell in py_values if cell is not None for item in cell] + item_arrow_col = pa.array(flat_values, type=pa_type.value_type) + nullable = any(v is None for v in py_values) + else: + item_arrow_col = None + nullable = True + item_string_max_length = string_max_length + if pa_type.value_type in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()): + item_string_max_length = max(string_max_length or 1, 1_000_000) + item_spec = CTable._arrow_type_to_spec( + pa, pa_type.value_type, item_arrow_col, string_max_length=item_string_max_length ) + return b2s.list(item_spec, nullable=nullable, storage="batch", serializer="msgpack") + + if pa.types.is_struct(pa_type): + fields = {} + for field in pa_type: + child_col = None + if arrow_col is not None: + child_col = arrow_col.field(field.name) + child_string_max_length = string_max_length + if field.type in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()): + child_string_max_length = max(string_max_length or 1, 1_000_000) + fields[field.name] = CTable._arrow_type_to_spec( + pa, field.type, child_col, string_max_length=child_string_max_length + ) + return b2s.struct(fields) + + if pa_type in (pa.string(), pa.large_string(), pa.utf8(), pa.large_utf8()): + if string_max_length is None: + # No fixed-width threshold given: store as variable-length scalar string. + return b2s.vlstring(nullable=nullable) + max_length = max(string_max_length, len(null_value) if null_value is not None else 1, 1) + return b2s.string(max_length=max_length, null_value=null_value) + + if pa.types.is_binary(pa_type) or pa.types.is_large_binary(pa_type): + if string_max_length is None: + # No fixed-width threshold given: store as variable-length scalar bytes. + return b2s.vlbytes(nullable=nullable) + max_length = max(string_max_length, len(null_value) if null_value is not None else 1, 1) + return b2s.bytes(max_length=max_length, null_value=null_value) + + raise TypeError( + f"No blosc2 spec for Arrow type {pa_type!r}. Supported: int8/16/32/64, " + "uint8/16/32/64, float32/64, bool, string, binary, and list." + ) - # Build CompiledSchema from Arrow schema + @classmethod + def _compiled_columns_from_arrow( + cls, + pa, + schema, + table_for_inference, + string_max_length, + *, + auto_null_sentinels: bool, + ): + null_policy = get_null_policy() + column_null_values = null_policy.column_null_values + schema_names = set(schema.names) + unknown_null_values = set(column_null_values) - schema_names + if unknown_null_values: + names = ", ".join(sorted(unknown_null_values)) + raise KeyError(f"column_null_values contains unknown columns: {names}") columns: list[CompiledColumn] = [] - for field in arrow_table.schema: + for field in schema: name = field.name _validate_column_name(name) - spec = _arrow_type_to_spec(field.type, arrow_table.column(name)) - col_config = ColumnConfig(cparams=None, dparams=None, chunks=None, blocks=None) - columns.append( - CompiledColumn( - name=name, - py_type=spec.python_type, - spec=spec, - dtype=spec.dtype, - default=MISSING, - config=col_config, - display_width=compute_display_width(spec), + arrow_col = table_for_inference.column(name) if table_for_inference is not None else None + field_is_list = pa.types.is_list(field.type) or pa.types.is_large_list(field.type) + field_is_struct = pa.types.is_struct(field.type) + field_is_varlen_scalar = ( + not field_is_list + and not field_is_struct + and string_max_length is None + and ( + pa.types.is_string(field.type) + or pa.types.is_large_string(field.type) + or pa.types.is_binary(field.type) + or pa.types.is_large_binary(field.type) ) ) + null_value = None + has_null_value_override = name in column_null_values + if has_null_value_override and (field_is_list or field_is_struct): + raise TypeError(f"column_null_values only supports scalar columns; {name!r} is not scalar") + if has_null_value_override and field_is_varlen_scalar: + raise TypeError( + f"column_null_values is not supported for vlstring/vlbytes column {name!r}; " + "these columns represent nulls as native None." + ) + if has_null_value_override: + null_value = column_null_values[name] + elif ( + auto_null_sentinels + and field.nullable + and not (field_is_list or field_is_struct or field_is_varlen_scalar) + ): + null_value = cls._auto_null_sentinel(pa, field.type, null_policy=null_policy) + if ( + arrow_col is not None + and arrow_col.null_count + and not (field_is_list or field_is_struct or field_is_varlen_scalar) + and null_value is None + ): + raise TypeError( + f"Column {name!r} contains Parquet nulls. Provide a CTable schema with a " + "null_value sentinel for this column." + ) + spec = cls._arrow_type_to_spec( + pa, + field.type, + arrow_col, + string_max_length=string_max_length, + null_value=null_value, + nullable=field.nullable, + ) + if null_value is not None and not (field_is_list or field_is_struct or field_is_varlen_scalar): + cls._validate_null_value_for_spec(name, spec, null_value) + columns.append(cls._compiled_column_from_spec(name, spec)) + return columns - schema = CompiledSchema( - row_cls=None, - columns=columns, - columns_by_name={col.name: col for col in columns}, + @classmethod + def _compiled_column_from_spec(cls, name: str, spec: SchemaSpec) -> CompiledColumn: + col_config = ColumnConfig(cparams=None, dparams=None, chunks=None, blocks=None) + return CompiledColumn( + name=name, + py_type=spec.python_type, + spec=spec, + dtype=getattr(spec, "dtype", None), + default=MISSING, + config=col_config, + display_width=compute_display_width(spec), ) - n = len(arrow_table) - capacity = max(n, 1) - default_chunks, default_blocks = compute_chunks_blocks((capacity,)) - mem_storage = InMemoryTableStorage() + @staticmethod + def _storage_for_arrow_import(urlpath: str | None, mode: str) -> TableStorage: + if urlpath is None: + return InMemoryTableStorage() + if mode == "w" and os.path.exists(urlpath): + if os.path.isdir(urlpath): + shutil.rmtree(urlpath) + else: + os.remove(urlpath) + return FileTableStorage(urlpath, mode) - new_valid = mem_storage.create_valid_rows( - shape=(capacity,), - chunks=default_chunks, - blocks=default_blocks, + @classmethod + def _create_arrow_import_columns( + cls, storage: TableStorage, columns: list[CompiledColumn], capacity: int, cparams, dparams + ): + default_chunks, default_blocks = compute_chunks_blocks((capacity,)) + new_valid = storage.create_valid_rows( + shape=(capacity,), chunks=default_chunks, blocks=default_blocks ) - new_cols: dict[str, blosc2.NDArray] = {} + new_cols: dict[str, blosc2.NDArray | ListArray | _ScalarVarLenArray] = {} for col in columns: - new_cols[col.name] = mem_storage.create_column( - col.name, - dtype=col.dtype, - shape=(capacity,), - chunks=default_chunks, - blocks=default_blocks, - cparams=None, - dparams=None, - ) + if cls._is_list_column(col): + new_cols[col.name] = storage.create_list_column( + col.name, spec=col.spec, cparams=cparams, dparams=dparams + ) + elif cls._is_varlen_scalar_column(col): + new_cols[col.name] = storage.create_varlen_scalar_column( + col.name, spec=col.spec, cparams=cparams, dparams=dparams + ) + else: + chunks, blocks = default_chunks, default_blocks + if col.dtype is not None: + chunks, blocks = compute_chunks_blocks((capacity,), dtype=col.dtype) + new_cols[col.name] = storage.create_column( + col.name, + dtype=col.dtype, + shape=(capacity,), + chunks=chunks, + blocks=blocks, + cparams=cparams, + dparams=dparams, + ) + return new_cols, new_valid + @classmethod + def _new_arrow_import_ctable( + cls, compiled, storage, new_cols, new_valid, columns, *, cparams, dparams, validate + ): obj = cls.__new__(cls) obj._row_type = None - obj._validate = False - obj._table_cparams = None - obj._table_dparams = None - obj._storage = mem_storage - obj._read_only = False - obj._schema = schema + obj._validate = validate + obj._table_cparams = cparams + obj._table_dparams = dparams + obj._storage = storage + obj._read_only = storage.is_read_only() + obj._schema = compiled obj._cols = new_cols obj._col_widths = {col.name: max(len(col.name), col.display_width) for col in columns} obj.col_names = [col.name for col in columns] - obj.row = _RowIndexer(obj) obj.auto_compact = False obj.base = None - obj._computed_cols = {} # from_arrow creates no computed columns + obj._computed_cols = {} obj._materialized_cols = {} obj._expr_index_arrays = {} obj._valid_rows = new_valid obj._n_rows = 0 obj._last_pos = 0 + return obj - if n > 0: - # Write each column directly — one bulk slice assignment per column. - # String columns (dtype.kind == 'U') can't go through Arrow's zero-copy - # path, so we convert via to_pylist() and let NumPy handle the - # fixed-width unicode coercion. All other types use zero-copy numpy. - for col in columns: - arrow_col = arrow_table.column(col.name) - if col.dtype.kind in "US": - arr = np.array(arrow_col.to_pylist(), dtype=col.dtype) - else: - arr = arrow_col.to_numpy(zero_copy_only=False).astype(col.dtype) - new_cols[col.name][:n] = arr + @classmethod + def _write_arrow_batches(cls, obj, batches, columns, new_cols, new_valid) -> None: + pos = 0 + for batch in batches: + end = pos + len(batch) + while end > len(new_valid): + obj._grow() + new_valid = obj._valid_rows + pos = cls._write_arrow_batch(batch, columns, new_cols, new_valid, pos) + for col in columns: + if cls._is_list_column(col) or cls._is_varlen_scalar_column(col): + new_cols[col.name].flush() + obj._n_rows = pos + obj._last_pos = pos - new_valid[:n] = True - obj._n_rows = n - obj._last_pos = n + @classmethod + def _write_arrow_batch(cls, batch, columns, new_cols, new_valid, pos: int) -> int: + m = len(batch) + if m == 0: + return pos + for col in columns: + arrow_col = batch.column(batch.schema.get_field_index(col.name)) + if cls._is_list_column(col): + # Trusted Arrow-import fast path: schema has already been inferred, + # so avoid Python-level per-item coercion/validation here. + new_cols[col.name].extend(arrow_col.to_pylist(), validate=False) + elif cls._is_varlen_scalar_column(col): + new_cols[col.name].extend(arrow_col.to_pylist()) + else: + new_cols[col.name][pos : pos + m] = cls._arrow_column_to_numpy(arrow_col, col) + new_valid[pos : pos + m] = True + return pos + m + + @staticmethod + def _arrow_column_to_numpy(arrow_col, col: CompiledColumn) -> np.ndarray: + nv = getattr(col.spec, "null_value", None) + if col.spec.to_metadata_dict().get("kind") == "bool" and col.dtype == np.dtype(np.uint8): + return np.array([nv if v is None else int(v) for v in arrow_col.to_pylist()], dtype=np.uint8) + if col.dtype.kind in "US": + values = arrow_col.to_pylist() + if nv is not None: + values = [nv if v is None else v for v in values] + max_len = col.spec.max_length + too_long = [v for v in values if v is not None and len(v) > max_len] + if too_long: + raise ValueError(f"Column {col.name!r} contains values longer than max_length={max_len}.") + return np.array(values, dtype=col.dtype) + if arrow_col.null_count: + if nv is None: + raise TypeError( + f"Column {col.name!r} contains Arrow/Parquet nulls. Provide a CTable schema " + "with a null_value sentinel for this column." + ) + arrow_col = arrow_col.fill_null(nv) + return arrow_col.to_numpy(zero_copy_only=False).astype(col.dtype) + + @staticmethod + def _arrow_schema_metadata(schema) -> dict[str, Any]: + import base64 + + try: + schema_ipc = schema.serialize().to_pybytes() + schema_ipc_base64 = base64.b64encode(schema_ipc).decode("ascii") + except Exception: + schema_ipc_base64 = None + arrow_meta = {"schema_string": schema.to_string()} + if schema_ipc_base64 is not None: + arrow_meta["schema_ipc_base64"] = schema_ipc_base64 + return {"arrow": arrow_meta} + @classmethod + def from_arrow( + cls, + schema, + batches, + *, + urlpath: str | None = None, + mode: str = "w", + cparams=None, + dparams=None, + validate: bool = False, + capacity_hint: int | None = None, + string_max_length: int | None = None, + auto_null_sentinels: bool = True, + blosc2_batch_size: int | None = _BATCH_SIZE_DEFAULT, + ) -> CTable: + """Build a :class:`CTable` from an Arrow schema and iterable of record batches. + + When *string_max_length* is ``None`` (the default), scalar Arrow + ``string`` / ``large_string`` columns are imported as + :func:`~blosc2.vlstring` columns and ``binary`` / ``large_binary`` + columns are imported as :func:`~blosc2.vlbytes` columns. Null values + are represented as native ``None`` with no sentinel needed. + + When *string_max_length* is set to a positive integer, scalar string + and binary columns are imported as fixed-width + :func:`~blosc2.string` / :func:`~blosc2.bytes` columns whose dtype is + sized to *string_max_length* characters/bytes. + + ``blosc2_batch_size`` controls how many rows are buffered before + BatchArray-backed imported columns (list columns and varlen scalar + columns) are flushed to their backend. Set it to ``None`` to keep + those columns pending until the final flush. + """ + pa = cls._require_pyarrow("from_arrow()") + if blosc2_batch_size is not None and blosc2_batch_size <= 0: + raise ValueError("blosc2_batch_size must be a positive integer or None") + batches = iter(batches) + first_batch = None + table_for_inference = None + if string_max_length is None: + first_batch = next(batches, None) + if first_batch is not None: + table_for_inference = pa.Table.from_batches([first_batch], schema=schema) + columns = cls._compiled_columns_from_arrow( + pa, + schema, + table_for_inference, + string_max_length, + auto_null_sentinels=auto_null_sentinels, + ) + if blosc2_batch_size is not None: + for col in columns: + if ( + cls._is_list_column(col) and getattr(col.spec, "storage", None) == "batch" + ) or cls._is_varlen_scalar_column(col): + col.spec.batch_rows = blosc2_batch_size + compiled = CompiledSchema( + row_cls=None, + columns=columns, + columns_by_name={col.name: col for col in columns}, + metadata=cls._arrow_schema_metadata(schema), + ) + if first_batch is not None: + import itertools as _it + + batches = _it.chain([first_batch], batches) + capacity = max(capacity_hint or 1, 1) + storage = cls._storage_for_arrow_import(urlpath, mode) + new_cols, new_valid = cls._create_arrow_import_columns(storage, columns, capacity, cparams, dparams) + storage.save_schema(schema_to_dict(compiled)) + obj = cls._new_arrow_import_ctable( + compiled, + storage, + new_cols, + new_valid, + columns, + cparams=cparams, + dparams=dparams, + validate=validate, + ) + cls._write_arrow_batches(obj, batches, columns, new_cols, new_valid) return obj + def to_parquet( + self, + path, + *, + columns: list[str] | None = None, + batch_size: int = _BATCH_SIZE_DEFAULT, + compression: str | None = "zstd", + row_group_size: int | None = None, + include_computed: bool = True, + **kwargs, + ) -> None: + """Write this table to a Parquet file batch-wise using pyarrow.""" + pq = self._require_pyarrow_parquet("to_parquet()") + pa = self._require_pyarrow("to_parquet()") + self._validate_arrow_batch_size(batch_size) + schema = self._arrow_schema_for_columns(columns, include_computed=include_computed) + with pq.ParquetWriter(path, schema, compression=compression, **kwargs) as writer: + for batch in self.iter_arrow_batches( + columns=columns, batch_size=batch_size, include_computed=include_computed + ): + table = pa.Table.from_batches([batch], schema=batch.schema) + writer.write_table(table, row_group_size=row_group_size or len(batch)) + + @classmethod + def from_parquet( + cls, + path, + *, + columns: list[str] | None = None, + batch_size: int = _BATCH_SIZE_DEFAULT, + urlpath: str | None = None, + mode: str = "w", + cparams=None, + dparams=None, + validate: bool = False, + auto_null_sentinels: bool = True, + blosc2_batch_size: int | None = _BATCH_SIZE_DEFAULT, + **kwargs, + ) -> CTable: + """Read a Parquet file into a :class:`CTable` batch-wise using pyarrow.""" + pq = cls._require_pyarrow_parquet("from_parquet()") + pa = cls._require_pyarrow("from_parquet()") + cls._validate_arrow_batch_size(batch_size) + string_max_length = kwargs.pop("string_max_length", None) + pf = pq.ParquetFile(path, **kwargs) + arrow_schema = pf.schema_arrow + if columns is not None: + if len(set(columns)) != len(columns): + raise ValueError("columns must be unique") + fields = [arrow_schema.field(name) for name in columns] + arrow_schema = pa.schema(fields) + batches = pf.iter_batches(batch_size=batch_size, columns=columns) + return cls.from_arrow( + arrow_schema, + batches, + urlpath=urlpath, + mode=mode, + cparams=cparams, + dparams=dparams, + validate=validate, + capacity_hint=pf.metadata.num_rows if pf.metadata is not None else None, + string_max_length=string_max_length, + auto_null_sentinels=auto_null_sentinels, + blosc2_batch_size=blosc2_batch_size, + ) + # ------------------------------------------------------------------ # CSV interop # ------------------------------------------------------------------ @@ -2420,6 +3420,7 @@ def from_csv( import csv schema = compile_schema(row_cls) + cls._resolve_nullable_specs(schema) ncols = len(schema.columns) # Accumulate values per column as Python lists (one pass through file) @@ -2468,7 +3469,6 @@ def from_csv( obj._cols = new_cols obj._col_widths = {col.name: max(len(col.name), col.display_width) for col in schema.columns} obj.col_names = [col.name for col in schema.columns] - obj.row = _RowIndexer(obj) obj.auto_compact = False obj.base = None obj._computed_cols = {} # from_csv creates no computed columns @@ -2532,36 +3532,47 @@ def add_column( if name in self._computed_cols: raise ValueError(f"A computed column named {name!r} already exists.") - try: - default_val = spec.dtype.type(default) - except (ValueError, OverflowError) as exc: - raise TypeError(f"Cannot coerce default {default!r} to dtype {spec.dtype!r}: {exc}") from exc - - capacity = len(self._valid_rows) - default_chunks, default_blocks = compute_chunks_blocks((capacity,)) - new_col = self._storage.create_column( - name, - dtype=spec.dtype, - shape=(capacity,), - chunks=default_chunks, - blocks=default_blocks, - cparams=cparams, - dparams=None, + compiled_col = self._compiled_column_from_spec(name, spec) + self._resolve_nullable_specs( + CompiledSchema(row_cls=None, columns=[compiled_col], columns_by_name={name: compiled_col}), + validate_column_null_values=False, ) + spec = compiled_col.spec + + if self._is_varlen_scalar_column(compiled_col): + # Varlen scalar columns don't use fixed-width NDArray storage. + new_col = self._storage.create_varlen_scalar_column(name, spec=spec, cparams=cparams) + live_pos = np.where(self._valid_rows[:])[0] + for _ in live_pos: + new_col.append(default) + new_col.flush() + elif self._is_list_column(compiled_col): + raise TypeError( + "add_column() does not support list columns; use the constructor with a full schema." + ) + else: + try: + default_val = spec.dtype.type(default) + except (ValueError, OverflowError) as exc: + raise TypeError(f"Cannot coerce default {default!r} to dtype {spec.dtype!r}: {exc}") from exc - live_pos = np.where(self._valid_rows[:])[0] - if len(live_pos) > 0: - new_col[live_pos] = default_val + capacity = len(self._valid_rows) + default_chunks, default_blocks = compute_chunks_blocks((capacity,)) + new_col = self._storage.create_column( + name, + dtype=spec.dtype, + shape=(capacity,), + chunks=default_chunks, + blocks=default_blocks, + cparams=cparams, + dparams=None, + ) + live_pos = np.where(self._valid_rows[:])[0] + if len(live_pos) > 0: + new_col[live_pos] = default_val - compiled_col = CompiledColumn( - name=name, - py_type=spec.python_type, - spec=spec, - dtype=spec.dtype, - default=default, - config=ColumnConfig(cparams=cparams, dparams=None, chunks=None, blocks=None), - display_width=compute_display_width(spec), - ) + compiled_col.default = default + compiled_col.config = ColumnConfig(cparams=cparams, dparams=None, chunks=None, blocks=None) self._cols[name] = new_col self.col_names.append(name) self._col_widths[name] = max(len(name), compiled_col.display_width) @@ -2735,12 +3746,12 @@ def computed_columns(self) -> dict[str, dict]: """ return dict(self._computed_cols) # shallow copy so callers can't mutate - def _col_dtype(self, name: str) -> np.dtype: + def _col_dtype(self, name: str) -> np.dtype | None: """Return the dtype for *name*, routing through computed cols.""" cc = self._computed_cols.get(name) if cc is not None: return cc["dtype"] - return self._cols[name].dtype + return getattr(self._cols[name], "dtype", None) @staticmethod def _readable_computed_expr(cc: dict) -> str: @@ -2758,18 +3769,21 @@ def _sub(m: re.Match) -> str: return re.sub(r"\bo(\d+)\b", _sub, cc["expression"]) - def _fetch_col_at_positions(self, name: str, positions: np.ndarray) -> np.ndarray: + def _fetch_col_at_positions(self, name: str, positions: np.ndarray): """Fetch values at *positions* (physical indices) — used for display.""" cc = self._computed_cols.get(name) if cc is not None: if len(positions) == 0: return np.array([], dtype=cc["dtype"]) - # Evaluate element-by-element for scattered display positions (max ~20). return np.array( [np.asarray(cc["lazy"][int(p)]).ravel()[0] for p in positions], dtype=cc["dtype"], ) - return self._cols[name][positions] + col = self._cols[name] + spec = self._schema.columns_by_name[name].spec + if self._is_list_spec(spec) or isinstance(spec, (VLStringSpec, VLBytesSpec)): + return col[positions] + return col[positions] def _schema_dict_with_computed(self) -> dict: """Return the schema dict extended with computed/materialized metadata.""" @@ -3150,13 +4164,86 @@ def drop_computed_column(self, name: str) -> None: self._storage.save_schema(self._schema_dict_with_computed()) # ------------------------------------------------------------------ - # Column access + # Column / row access # ------------------------------------------------------------------ - def __getitem__(self, s: str): - if s in self._cols or s in self._computed_cols: - return Column(self, s) - return None + @staticmethod + def _all_strings(seq) -> bool: + return all(isinstance(v, str) for v in seq) + + @staticmethod + def _all_ints(seq) -> bool: + return all(isinstance(v, (int, np.integer)) and not isinstance(v, (bool, np.bool_)) for v in seq) + + def _getitem_arraylike(self, key): + if len(key) == 0: + return self._run_row_logic(key) + if getattr(key, "dtype", None) is not None: + if key.dtype == np.bool_: + return self._run_row_logic(key) + if np.issubdtype(key.dtype, np.integer): + return self._run_row_logic(key) + if key.dtype.kind in {"U", "S"}: + return self.select(key.tolist()) + values = key.tolist() if hasattr(key, "tolist") else list(key) + if self._all_strings(values): + return self.select(values) + return self._run_row_logic(key) + + def _getitem_row_selector(self, key): + if isinstance(key, (int, np.integer)) and not isinstance(key, (bool, np.bool_)): + return self._materialize_row(int(key)) + if isinstance(key, slice): + return self._run_row_logic(key) + if isinstance(key, np.ndarray): + return self._getitem_arraylike(key) + if isinstance(key, list): + if key and self._all_strings(key): + return self.select(key) + return self._run_row_logic(key) + if isinstance(key, Iterable) and not isinstance(key, (str, bytes, tuple)): + key = list(key) + if key and self._all_strings(key): + return self.select(key) + return self._run_row_logic(key) + raise TypeError( + "Row selectors must be an int, slice, integer array/list, or boolean mask; " + f"got {type(key).__name__}" + ) + + def _structured_array_dtype(self) -> np.dtype: + fields = [] + for name in self.col_names: + col_info = self._schema.columns_by_name.get(name) + if col_info is None: + dtype = np.asarray(self[name][:0]).dtype + elif self._is_list_column(col_info) or self._is_varlen_scalar_column(col_info): + dtype = np.dtype(object) + else: + dtype = col_info.dtype if col_info.dtype is not None else np.dtype(object) + fields.append((name, dtype)) + return np.dtype(fields) + + def __array__(self, dtype=None, copy=None): + arr = np.empty(self.nrows, dtype=self._structured_array_dtype()) + for name in self.col_names: + values = self[name][:] + target_dtype = arr.dtype.fields[name][0] + if target_dtype == np.dtype(object) and isinstance(values, np.ndarray): + values = values.tolist() + arr[name] = values + if dtype is not None: + arr = arr.astype(dtype, copy=True if copy is None else copy) + return arr.copy() if copy else arr + + def __getitem__(self, key): + if isinstance(key, str): + if key in self._cols or key in self._computed_cols: + return Column(self, key) + return self.where(key) + if isinstance(key, tuple): + raise TypeError("Tuple indexing is not supported for CTable in V1") + return self._getitem_row_selector(key) def __getattr__(self, s: str): if s in self._cols or s in self._computed_cols: @@ -3172,19 +4259,36 @@ def compact(self): raise ValueError("Table is read-only (opened with mode='r').") if self.base is not None: raise ValueError("Cannot compact a view.") + self._flush_varlen_columns() real_poss = blosc2.where(self._valid_rows, np.array(range(len(self._valid_rows)))).compute() - start = 0 - block_size = self._valid_rows.blocks[0] - end = min(block_size, self._n_rows) - while start < end: - for _k, v in self._cols.items(): + for col in self._schema.columns: + name = col.name + v = self._cols[name] + if self._is_list_column(col): + compacted = [v[int(pos)] for pos in real_poss[: self._n_rows]] + replacement = ListArray(spec=col.spec) + replacement.extend(compacted) + replacement.flush() + self._cols[name] = replacement + continue + if self._is_varlen_scalar_column(col): + compacted = [v[int(pos)] for pos in real_poss[: self._n_rows]] + replacement = _ScalarVarLenArray(col.spec) + replacement.extend(compacted) + replacement.flush() + self._cols[name] = replacement + continue + start = 0 + block_size = self._valid_rows.blocks[0] + end = min(block_size, self._n_rows) + while start < end: v[start:end] = v[real_poss[start:end]] - start += block_size - end = min(end + block_size, self._n_rows) + start += block_size + end = min(end + block_size, self._n_rows) self._valid_rows[: self._n_rows] = True self._valid_rows[self._n_rows :] = False - self._last_pos = self._n_rows # next write goes right after live rows + self._last_pos = self._n_rows self._mark_all_indexes_stale() def _normalise_sort_keys( @@ -3205,6 +4309,15 @@ def _normalise_sort_keys( if name not in self._cols and name not in self._computed_cols: raise KeyError(f"No column named {name!r}. Available: {self.col_names}") dtype = self._col_dtype(name) + if dtype is None: + cc = self._schema.columns_by_name.get(name) + if cc is not None and isinstance(cc.spec, (VLStringSpec, VLBytesSpec)): + raise TypeError( + f"Column {name!r} is a varlen scalar column and does not support sort ordering." + ) + raise TypeError( + f"Column {name!r} is a list column and does not support sort ordering in V1." + ) if np.issubdtype(dtype, np.complexfloating): raise TypeError( f"Column {name!r} has complex dtype {dtype} which does not support ordering." @@ -3212,27 +4325,26 @@ def _normalise_sort_keys( return cols, ascending def _sorted_positions_from_full_index(self, name: str, ascending: bool) -> np.ndarray | None: - """Return live physical positions from a matching FULL index, if available.""" - from blosc2.indexing import ordered_indices + """Return live physical positions from a matching FULL index, if available. + Reads the pre-sorted positions sidecar directly rather than going through + the ordered_indices query machinery, which is optimised for selective range + queries and is much slower for full-table streaming. + """ root = self._root_table catalog = root._storage.load_index_catalog() descriptor = None - target_arr = None if name in root._cols: + col_info = root._schema.columns_by_name.get(name) + if col_info is not None and getattr(col_info.spec, "null_value", None) is not None: + return None descriptor = catalog.get(name) - if ( - descriptor is not None - and descriptor.get("kind") == "full" - and not descriptor.get("stale", False) - ): - target_arr = root._cols[name] - else: + if descriptor is None or descriptor.get("kind") != "full" or descriptor.get("stale", False): descriptor = None elif name in root._computed_cols: cc = root._computed_cols[name] - for lookup_key, candidate in catalog.items(): + for _lookup_key, candidate in catalog.items(): target = candidate.get("target") or {} if ( target.get("source") == "expression" @@ -3242,14 +4354,32 @@ def _sorted_positions_from_full_index(self, name: str, ascending: bool) -> np.nd and list(target.get("dependencies", [])) == list(cc["col_deps"]) ): descriptor = candidate - target_arr = root._index_target_array(lookup_key, candidate) break - if descriptor is None or target_arr is None: + if descriptor is None: return None - positions = ordered_indices(target_arr, require_full=True) - if positions is None: - return None + positions_path = descriptor.get("full", {}).get("positions_path") + + # Read pre-sorted positions directly — bypasses the ordered_indices query + # machinery which is built for selective range queries and is ~70x slower + # for full-table streaming. + if positions_path is not None: + # Persistent table: positions live in a sidecar .b2nd file. + positions_nd = blosc2.open(positions_path, mode="r") + else: + # In-memory table: positions live in the sidecar handle cache. + from blosc2.indexing import _SIDECAR_HANDLE_CACHE, _sidecar_handle_cache_key + + target_arr = root._cols.get(name) + if target_arr is None: + return None + token = descriptor["token"] + cache_key = _sidecar_handle_cache_key(target_arr, token, "full", "positions") + positions_nd = _SIDECAR_HANDLE_CACHE.get(cache_key) + if positions_nd is None: + return None + + positions = np.asarray(positions_nd[:], dtype=np.int64) valid = root._valid_rows[:] positions = np.asarray(positions, dtype=np.int64) positions = positions[(positions >= 0) & (positions < len(valid))] @@ -3343,8 +4473,10 @@ def sort_by( If a column used as a sort key does not support ordering (e.g. complex numbers). """ - if self.base is not None: - raise ValueError("Cannot sort a view. Materialise it first with .to_table() or sort the parent.") + if self.base is not None and inplace: + raise ValueError( + "Cannot sort a view inplace (would modify shared column data). Use sort_by(inplace=False) to get a sorted copy." + ) if inplace and self._read_only: raise ValueError("Table is read-only (opened with mode='r').") @@ -3371,8 +4503,15 @@ def sort_by( sorted_pos = live_pos[order] if inplace: - for _col_name, arr in self._cols.items(): - arr[:n] = arr[sorted_pos] + for col in self._schema.columns: + arr = self._cols[col.name] + if self._is_list_column(col): + new_arr = ListArray(spec=col.spec) + new_arr.extend((arr[int(pos)] for pos in sorted_pos), validate=False) + new_arr.flush() + self._cols[col.name] = new_arr + else: + arr[:n] = arr[sorted_pos] self._valid_rows[:n] = True self._valid_rows[n:] = False self._n_rows = n @@ -3382,19 +4521,80 @@ def sort_by( else: # Build a new in-memory table with the sorted rows result = self._empty_copy() - for col_name, arr in self._cols.items(): - result._cols[col_name][:n] = arr[sorted_pos] + for col in self._schema.columns: + col_name = col.name + arr = self._cols[col_name] + if self._is_list_column(col): + result._cols[col_name].extend((arr[int(pos)] for pos in sorted_pos), validate=False) + result._cols[col_name].flush() + else: + result._cols[col_name][:n] = arr[sorted_pos] result._valid_rows[:n] = True result._valid_rows[n:] = False result._n_rows = n result._last_pos = n return result - def _empty_copy(self) -> CTable: + def copy(self, compact: bool = True) -> CTable: + """Return a new standalone in-memory copy of this table. + + Parameters + ---------- + compact: + If ``True`` (default), only live (non-deleted) rows are copied. + The result is a dense table with no tombstones and no parent + dependency — ideal for materialising a filtered view. + If ``False``, all physical slots are copied including deleted gaps, + preserving the tombstone state exactly. + """ + valid_np = self._valid_rows[:] + live_pos = np.where(valid_np)[0] + n_live = len(live_pos) + + if compact: + n = n_live + else: + # High watermark: number of slots ever written. + # List columns are written sequentially with no gaps — their length + # is the exact high watermark. For scalar-only tables fall back to + # the last live position + 1 (writes are always sequential so no + # deleted slot can exist beyond the last live one). + n = 0 + for col in self._schema.columns: + if self._is_list_column(col): + n = len(self._cols[col.name]) + break + if n == 0: + n = int(live_pos[-1]) + 1 if n_live > 0 else 0 + + result = self._empty_copy(capacity=n) + + for col in self._schema.columns: + col_name = col.name + arr = self._cols[col_name] + if self._is_list_column(col): + src = (arr[int(pos)] for pos in live_pos) if compact else (arr[i] for i in range(n)) + result._cols[col_name].extend(src, validate=False) + result._cols[col_name].flush() + else: + result._cols[col_name][:n] = arr[live_pos] if compact else arr[:n] + + if compact: + result._valid_rows[:n] = True + result._n_rows = n + result._last_pos = n - 1 if n > 0 else None + else: + result._valid_rows[:n] = valid_np[:n] + result._n_rows = n_live + result._last_pos = None # recomputed lazily on next append + + return result + + def _empty_copy(self, capacity: int | None = None) -> CTable: """Return a new empty in-memory CTable with the same schema and capacity.""" from blosc2 import compute_chunks_blocks - capacity = max(self._n_rows, 1) + capacity = max(capacity if capacity is not None else self._n_rows, 1) default_chunks, default_blocks = compute_chunks_blocks((capacity,)) mem_storage = InMemoryTableStorage() @@ -3406,15 +4606,23 @@ def _empty_copy(self) -> CTable: new_cols = {} for col in self._schema.columns: col_storage = self._resolve_column_storage(col, default_chunks, default_blocks) - new_cols[col.name] = mem_storage.create_column( - col.name, - dtype=col.dtype, - shape=(capacity,), - chunks=col_storage["chunks"], - blocks=col_storage["blocks"], - cparams=col_storage.get("cparams"), - dparams=col_storage.get("dparams"), - ) + if self._is_list_column(col): + new_cols[col.name] = mem_storage.create_list_column( + col.name, + spec=col.spec, + cparams=col_storage.get("cparams"), + dparams=col_storage.get("dparams"), + ) + else: + new_cols[col.name] = mem_storage.create_column( + col.name, + dtype=col.dtype, + shape=(capacity,), + chunks=col_storage["chunks"], + blocks=col_storage["blocks"], + cparams=col_storage.get("cparams"), + dparams=col_storage.get("dparams"), + ) obj = CTable.__new__(CTable) obj._schema = self._schema @@ -3441,7 +4649,6 @@ def _empty_copy(self) -> CTable: } obj.col_names.append(cc_name) obj._col_widths.setdefault(cc_name, max(len(cc_name), 15)) - obj.row = _RowIndexer(obj) obj._n_rows = 0 obj._last_pos = None obj._read_only = False @@ -3993,6 +5200,13 @@ def create_index( # noqa: C901 ) col_arr = self._cols[col_name] + if isinstance(self._schema.columns_by_name[col_name].spec, ListSpec): + raise ValueError(f"Cannot create an index on list column {col_name!r} in V1.") + if isinstance(self._schema.columns_by_name[col_name].spec, (VLStringSpec, VLBytesSpec)): + raise NotImplementedError( + f"Cannot create an index on varlen scalar column {col_name!r}: " + "indexing for vlstring/vlbytes columns is not supported yet." + ) is_persistent = self._storage.index_anchor_path(col_name) is not None if is_persistent: @@ -4216,7 +5430,7 @@ def _find_indexed_columns(root_cols, catalog, operands): seen.add(col_name) return indexed - def _try_index_where(self, expr_result: blosc2.LazyExpr) -> np.ndarray | None: + def _try_index_where(self, expr_result: blosc2.LazyExpr) -> np.ndarray | None: # noqa: C901 """Attempt to resolve *expr_result* via a column index. Returns a 1-D int64 array of physical row positions that satisfy the @@ -4251,12 +5465,29 @@ def _try_index_where(self, expr_result: blosc2.LazyExpr) -> np.ndarray | None: return None primary_col_name, primary_col_arr, _ = indexed_columns[0] + nullable_indexed = [ + name + for name, _arr, _descriptor in indexed_columns + if getattr(root._schema.columns_by_name[name].spec, "null_value", None) is not None + ] + + # Global null post-filtering is not correct for OR expressions. + if nullable_indexed and ("|" in expr_result.expression or " or " in expr_result.expression): + return None # Inject every usable table-owned descriptor so plan_query can combine them. + # In .b2z read mode all columns share the same urlpath, so _array_key() + # returns the same key for every column — causing _SIDECAR_HANDLE_CACHE + # collisions across queries. Clear stale handles before each injection so + # the upcoming query always loads the correct sidecar for this column. + from blosc2.indexing import _clear_cached_data + for _col_name, col_arr, descriptor in indexed_columns: arr_key = _array_key(col_arr) if _is_persistent_array(col_arr): store = _PERSISTENT_INDEXES.get(arr_key) or _default_index_store() + if store["indexes"].get(descriptor["token"]) is not descriptor: + _clear_cached_data(col_arr, descriptor["token"]) store["indexes"][descriptor["token"]] = descriptor _PERSISTENT_INDEXES[arr_key] = store else: @@ -4271,20 +5502,33 @@ def _try_index_where(self, expr_result: blosc2.LazyExpr) -> np.ndarray | None: if not plan.usable: return None + def _exclude_null_positions(positions): + positions = np.asarray(positions, dtype=np.int64) + for name in nullable_indexed: + col = root._schema.columns_by_name[name] + raw = root._cols[name][positions] + nv = getattr(col.spec, "null_value", None) + if isinstance(nv, float) and np.isnan(nv): + keep = ~np.isnan(raw) + else: + keep = raw != nv + positions = positions[keep] + return positions + if plan.exact_positions is not None: - return np.asarray(plan.exact_positions, dtype=np.int64) + return _exclude_null_positions(plan.exact_positions) if plan.bucket_masks is not None: _, positions = evaluate_bucket_query( expression, merged_operands, {}, where_dict, plan, return_positions=True ) - return np.asarray(positions, dtype=np.int64) + return _exclude_null_positions(positions) if plan.candidate_units is not None and plan.segment_len is not None: _, positions = evaluate_segment_query( expression, merged_operands, {}, where_dict, plan, return_positions=True ) - return np.asarray(positions, dtype=np.int64) + return _exclude_null_positions(positions) return None @@ -4301,7 +5545,12 @@ def info_items(self) -> list[tuple[str, object]]: f"{cc['dtype']} (computed: {self._readable_computed_expr(cc)})" ) else: - schema_summary[name] = _InfoLiteral(self._dtype_info_label(self._cols[name].dtype)) + col_meta = self._schema.columns_by_name.get(name) + schema_summary[name] = _InfoLiteral( + self._dtype_info_label( + getattr(self._cols[name], "dtype", None), col_meta.spec if col_meta else None + ) + ) index_summary = {} for idx in self.indexes: @@ -4339,8 +5588,16 @@ def info_items(self) -> list[tuple[str, object]]: return items @staticmethod - def _dtype_info_label(dtype: np.dtype) -> str: + def _dtype_info_label(dtype: np.dtype | None, spec: SchemaSpec | None = None) -> str: """Return a compact dtype label for info reports.""" + if isinstance(spec, VLStringSpec): + return "vlstring" + if isinstance(spec, VLBytesSpec): + return "vlbytes" + if isinstance(spec, ListSpec): + return spec.display_label() + if dtype is None: + return "None" if dtype.kind == "U": nchars = dtype.itemsize // 4 return f"U{nchars} (Unicode, max {nchars} chars)" @@ -4401,8 +5658,13 @@ def append(self, data: list | np.void | np.ndarray) -> None: if pos >= len(self._valid_rows): self._grow() - for name, col_array in self._cols.items(): - col_array[pos] = row[name] + for col in self._schema.columns: + name = col.name + col_array = self._cols[name] + if self._is_list_column(col) or self._is_varlen_scalar_column(col): + col_array.append(row[name]) + else: + col_array[pos] = row[name] self._valid_rows[pos] = True self._last_pos = pos + 1 @@ -4431,12 +5693,14 @@ def delete(self, ind: int | slice | str | Iterable) -> None: self._last_pos = None # recalculate on next write self._storage.bump_visibility_epoch() - def extend(self, data: list | CTable | Any, *, validate: bool | None = None) -> None: + def extend(self, data: list | CTable | Any, *, validate: bool | None = None) -> None: # noqa: C901 if self._read_only: raise ValueError("Table is read-only (opened with mode='r').") if self.base is not None: raise TypeError("Cannot extend view.") if len(data) <= 0: + if isinstance(data, dict): + raise ValueError("No columns provided for extend().") return # Resolve effective validate flag: per-call override takes precedence @@ -4457,7 +5721,27 @@ def extend(self, data: list | CTable | Any, *, validate: bool | None = None) -> raw_columns[name] = data._cols[name][: data._n_rows] provided_names.add(name) else: - if isinstance(data, np.ndarray) and data.dtype.names is not None: + if isinstance(data, dict): + known_names = [name for name in current_col_names if name in data] + if not known_names: + raise ValueError("No known stored columns provided for extend().") + column_lengths = {} + for name in known_names: + try: + column_lengths[name] = len(data[name]) + except TypeError as exc: + raise TypeError(f"Column {name!r} does not have a length.") from exc + new_nrows = column_lengths[known_names[0]] + mismatched = {name: n for name, n in column_lengths.items() if n != new_nrows} + if mismatched: + details = ", ".join(f"{name}={n}" for name, n in mismatched.items()) + raise ValueError( + f"All provided columns must have the same length; " + f"expected {new_nrows}, got {details}." + ) + provided_names = set(known_names) + raw_columns = {name: data[name] for name in known_names} + elif isinstance(data, np.ndarray) and data.dtype.names is not None: new_nrows = len(data) raw_columns = {name: data[name] for name in data.dtype.names if name in current_col_names} provided_names = set(raw_columns) @@ -4480,11 +5764,18 @@ def extend(self, data: list | CTable | Any, *, validate: bool | None = None) -> validate_column_batch(self._schema, raw_columns) - processed_cols = [] + scalar_processed_cols: dict[str, blosc2.NDArray] = {} + list_processed_cols: dict[str, list] = {} + varlen_scalar_processed_cols: dict[str, list] = {} for name in current_col_names: - target_dtype = self._cols[name].dtype - b2_arr = blosc2.asarray(raw_columns[name], dtype=target_dtype) - processed_cols.append(b2_arr) + col_meta = self._schema.columns_by_name[name] + if self._is_list_column(col_meta): + list_processed_cols[name] = list(raw_columns[name]) + elif self._is_varlen_scalar_column(col_meta): + varlen_scalar_processed_cols[name] = list(raw_columns[name]) + else: + target_dtype = self._cols[name].dtype + scalar_processed_cols[name] = np.ascontiguousarray(raw_columns[name], dtype=target_dtype) end_pos = start_pos + new_nrows @@ -4496,8 +5787,14 @@ def extend(self, data: list | CTable | Any, *, validate: bool | None = None) -> while end_pos > len(self._valid_rows): self._grow() - for j, name in enumerate(current_col_names): - self._cols[name][start_pos:end_pos] = processed_cols[j][:] + for name in current_col_names: + col_meta = self._schema.columns_by_name[name] + if self._is_list_column(col_meta): + self._cols[name].extend(list_processed_cols[name], validate=do_validate) + elif self._is_varlen_scalar_column(col_meta): + self._cols[name].extend(varlen_scalar_processed_cols[name]) + else: + self._cols[name][start_pos:end_pos] = scalar_processed_cols[name][:] self._valid_rows[start_pos:end_pos] = True self._last_pos = end_pos @@ -4508,12 +5805,117 @@ def extend(self, data: list | CTable | Any, *, validate: bool | None = None) -> # Filtering # ------------------------------------------------------------------ - @profile - def where(self, expr_result) -> CTable: + def _where_expression_operands(self) -> dict[str, blosc2.NDArray | blosc2.LazyExpr]: + operands = {} + for name, arr in self._cols.items(): + col = self._schema.columns_by_name.get(name) + if col is not None and not (self._is_list_column(col) or self._is_varlen_scalar_column(col)): + operands[name] = arr + operands.update({name: cc["lazy"] for name, cc in self._computed_cols.items()}) + return operands + + def _guard_varlen_scalar_expression(self, expr: str) -> None: + for col in self._schema.columns: + if self._is_varlen_scalar_column(col) and re.search( + rf"(? CTable: + """Return a row-filtered view matching a boolean predicate. + + Signature:: + + where(expr_result) -> CTable + + The predicate can be supplied as a boolean :class:`blosc2.LazyExpr`, + a boolean :class:`blosc2.NDArray`, a boolean NumPy array, a boolean + ``Column``, or a string expression evaluated against this table's + columns. String expressions can reference stored and computed columns + directly by name. + + The returned object is a :class:`CTable` view sharing the original + column data. The row-selection mask is evaluated immediately and + intersected with the table's current live rows; selected column data is + not copied. + + Parameters + ---------- + expr_result: + Boolean predicate selecting rows. Strings are converted to a + lazy expression with table columns as operands, e.g. + ``"value * category >= 150"``. Column objects can also be used in + Python expressions, e.g. ``(t.value * t.category) >= 150``. + + Returns + ------- + CTable + A view over the same columns containing only rows where the + predicate is true and the source row is live. When ``columns`` is + provided, the returned view is additionally projected to that + ordered subset of columns. + + Raises + ------ + TypeError + If *expr_result* does not evaluate to a boolean Blosc2/NumPy + array or lazy expression. + + Examples + -------- + Filter using a string expression:: + + view = t.where("value * category >= 150") + slim = t.where("value * category >= 150", columns=["value", "category"]) + + Filter using column arithmetic:: + + view = t.where((t.value * t.category) >= 150) + + Blosc2 lazy functions can be used in column expressions:: + + view = t.where(((t.value + 2) * blosc2.sin(t.category)) >= 10) + + For column names that are not valid Python identifiers, use item + access:: + + view = t.where((t["unit price"] * t["quantity"]) > 100) + + Notes + ----- + Use bitwise operators (``&``, ``|``, ``~``) or string expressions for + element-wise boolean logic. Python's logical operators ``and``, ``or`` + and ``not`` cannot be overloaded and therefore do not build lazy column + expressions. + + Use:: + + t.where((t.x > 0) & (t.y < 10)) + t.where(~t.returned) + t.where("not returned") + + not:: + + t.where((t.x > 0) and (t.y < 10)) + t.where(not t.returned) + """ + if isinstance(expr_result, str): + self._guard_varlen_scalar_expression(expr_result) + expr_result = blosc2.lazyexpr(expr_result, self._where_expression_operands()) if isinstance(expr_result, np.ndarray) and expr_result.dtype == np.bool_: expr_result = blosc2.asarray(expr_result) if isinstance(expr_result, Column): - expr_result = expr_result._raw_col + expr_result = ( + expr_result._raw_col == 1 if expr_result._is_nullable_bool else expr_result._raw_col + ) if not ( isinstance(expr_result, (blosc2.NDArray, blosc2.LazyExpr)) @@ -4530,7 +5932,8 @@ def where(self, expr_result) -> CTable: valid_pos = positions[(positions >= 0) & (positions < total)] mask[valid_pos] = True mask &= self._valid_rows[:] - return self.view(blosc2.asarray(mask)) + result = self.view(blosc2.asarray(mask)) + return result if columns is None else result.select(list(columns)) filter = expr_result.compute() if isinstance(expr_result, blosc2.LazyExpr) else expr_result @@ -4545,7 +5948,8 @@ def where(self, expr_result) -> CTable: filter = (filter & self._valid_rows).compute() - return self.view(filter) + result = self.view(filter) + return result if columns is None else result.select(list(columns)) def _run_row_logic(self, ind: int | slice | str | Iterable) -> CTable: valid_rows_np = self._valid_rows[:] diff --git a/src/blosc2/ctable_storage.py b/src/blosc2/ctable_storage.py index 54efa59bb..6ece618b2 100644 --- a/src/blosc2/ctable_storage.py +++ b/src/blosc2/ctable_storage.py @@ -22,11 +22,23 @@ import copy import json import os -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np import blosc2 +from blosc2.batch_array import BatchArray +from blosc2.list_array import ListArray +from blosc2.scalar_array import ( + _make_persistent_backend, + _open_persistent_backend, + _ScalarVarLenArray, + _validate_role_metadata, +) +from blosc2.schunk import process_opened_object + +if TYPE_CHECKING: + from blosc2.schema import ListSpec # Directory inside the table root that holds per-column index sidecar files. _INDEXES_DIR = "_indexes" @@ -56,6 +68,32 @@ def create_column( def open_column(self, name: str) -> blosc2.NDArray: raise NotImplementedError + def create_list_column( + self, + name: str, + *, + spec: ListSpec, + cparams: dict[str, Any] | None, + dparams: dict[str, Any] | None, + ) -> ListArray: + raise NotImplementedError + + def open_list_column(self, name: str) -> ListArray: + raise NotImplementedError + + def create_varlen_scalar_column( + self, + name: str, + *, + spec, + cparams=None, + dparams=None, + ) -> _ScalarVarLenArray: + raise NotImplementedError + + def open_varlen_scalar_column(self, name: str, spec) -> _ScalarVarLenArray: + raise NotImplementedError + def create_valid_rows( self, *, @@ -151,6 +189,23 @@ def create_column(self, name, *, dtype, shape, chunks, blocks, cparams, dparams) def open_column(self, name): raise RuntimeError("In-memory tables have no on-disk representation to open.") + def create_list_column(self, name, *, spec, cparams, dparams): + kwargs = {} + if cparams is not None: + kwargs["cparams"] = cparams + if dparams is not None: + kwargs["dparams"] = dparams + return ListArray(spec=spec, **kwargs) + + def open_list_column(self, name): + raise RuntimeError("In-memory tables have no on-disk representation to open.") + + def create_varlen_scalar_column(self, name, *, spec, cparams=None, dparams=None): + return _ScalarVarLenArray(spec) + + def open_varlen_scalar_column(self, name, spec): + raise RuntimeError("In-memory tables have no on-disk representation to open.") + def create_valid_rows(self, *, shape, chunks, blocks): return blosc2.zeros(shape, dtype=np.bool_, chunks=chunks, blocks=blocks) @@ -249,6 +304,12 @@ def _valid_rows_path(self) -> str: def _col_path(self, name: str) -> str: return self._key_to_path(self._col_key(name)) + def _list_col_path(self, name: str) -> str: + rel_key = self._col_key(name).lstrip("/") + # Use working_dir so .b2z stores write into their temp dir (gets zipped on close). + # For .b2d, working_dir == self._root, so behaviour is unchanged. + return os.path.join(self._open_store().working_dir, rel_key + ".b2b") + def _col_key(self, name: str) -> str: return f"/{_COLS_DIR}/{name}" @@ -299,6 +360,47 @@ def create_column(self, name, *, dtype, shape, chunks, blocks, cparams, dparams) def open_column(self, name: str) -> blosc2.NDArray: return self._open_store()[self._col_key(name)] + def create_list_column(self, name, *, spec, cparams, dparams): + kwargs: dict[str, Any] = {"urlpath": self._list_col_path(name), "mode": "w", "contiguous": True} + if cparams is not None: + kwargs["cparams"] = cparams + if dparams is not None: + kwargs["dparams"] = dparams + return ListArray(spec=spec, **kwargs) + + def open_list_column(self, name: str) -> ListArray: + store = self._open_store() + if store.is_zip_store and self._mode == "r": + # In read mode, .b2z is never extracted — read the member at its zip offset directly. + rel = f"{_COLS_DIR}/{name}.b2b" + if rel not in store.offsets: + raise KeyError(f"List column {name!r} not found in {self._root!r}") + opened = blosc2.blosc2_ext.open(store.b2z_path, mode="r", offset=store.offsets[rel]["offset"]) + return process_opened_object(opened) + return blosc2.open(self._list_col_path(name), mode=self._mode) + + def create_varlen_scalar_column(self, name, *, spec, cparams=None, dparams=None) -> _ScalarVarLenArray: + urlpath = self._list_col_path(name) + backend = _make_persistent_backend(spec, urlpath, "w", cparams=cparams, dparams=dparams) + return _ScalarVarLenArray(spec, backend) + + def open_varlen_scalar_column(self, name: str, spec) -> _ScalarVarLenArray: + store = self._open_store() + path = self._list_col_path(name) + if store.is_zip_store and self._mode == "r": + rel = f"{_COLS_DIR}/{name}.b2b" + if rel not in store.offsets: + raise KeyError(f"Varlen scalar column {name!r} not found in {self._root!r}") + backend = BatchArray( + _from_schunk=blosc2.blosc2_ext.open( + store.b2z_path, mode="r", offset=store.offsets[rel]["offset"] + ) + ) + else: + backend = _open_persistent_backend(path, self._mode, spec=spec) + _validate_role_metadata(backend, spec) + return _ScalarVarLenArray(spec, backend) + def create_valid_rows(self, *, shape, chunks, blocks): valid_rows = blosc2.zeros( shape, @@ -358,15 +460,31 @@ def column_names_from_schema(self) -> list[str]: return [c["name"] for c in d["columns"]] def delete_column(self, name: str) -> None: - del self._open_store()[self._col_key(name)] + key = self._col_key(name) + if key in self._open_store(): + del self._open_store()[key] + return + list_path = self._list_col_path(name) + if os.path.exists(list_path): + blosc2.remove_urlpath(list_path) + return + raise KeyError(name) - def rename_column(self, old: str, new: str) -> blosc2.NDArray: + def rename_column(self, old: str, new: str): store = self._open_store() old_key = self._col_key(old) new_key = self._col_key(new) - store[new_key] = store[old_key] - del store[old_key] - return store[new_key] + if old_key in store: + store[new_key] = store[old_key] + del store[old_key] + return store[new_key] + old_path = self._list_col_path(old) + new_path = self._list_col_path(new) + if os.path.exists(old_path): + os.makedirs(os.path.dirname(new_path), exist_ok=True) + os.replace(old_path, new_path) + return blosc2.open(new_path, mode=self._mode) + raise KeyError(old) def close(self) -> None: if self._store is not None: @@ -383,16 +501,87 @@ def discard(self) -> None: # -- Index catalog and epoch helpers ------------------------------------- + @staticmethod + def _walk_descriptor_paths(descriptor: dict): + """Yield (obj, key) for every string value that looks like a file path.""" + _PATH_KEYS = {"path", "values_path", "positions_path", "l1_path", "l2_path"} + stack = [descriptor] + while stack: + obj = stack.pop() + if isinstance(obj, dict): + for k, v in obj.items(): + if k in _PATH_KEYS and isinstance(v, str): + yield obj, k + elif isinstance(v, (dict, list)): + stack.append(v) + elif isinstance(obj, list): + for item in obj: + if isinstance(item, (dict, list)): + stack.append(item) + + @staticmethod + def _relativize_descriptor(descriptor: dict, working_dir: str) -> dict: + """Replace absolute paths inside *working_dir* with ``_indexes/…`` relative paths.""" + prefix = working_dir.rstrip("/") + "/" + d = copy.deepcopy(descriptor) + for obj, key in FileTableStorage._walk_descriptor_paths(d): + v = obj[key] + if v.startswith(prefix): + obj[key] = v[len(prefix) :] + return d + + @staticmethod + def _absolutize_descriptor(descriptor: dict, working_dir: str) -> dict: + """Expand ``_indexes/…`` relative paths back to absolute using *working_dir*.""" + d = copy.deepcopy(descriptor) + for obj, key in FileTableStorage._walk_descriptor_paths(d): + v = obj[key] + if v.startswith(_INDEXES_DIR + "/") or v.startswith(_INDEXES_DIR + os.sep): + obj[key] = os.path.join(working_dir, v) + return d + + def _ensure_index_files_extracted(self, store, rel_paths: list[str]) -> None: + """Extract *rel_paths* from the zip into the working_dir (read mode only).""" + import zipfile + + for rel in rel_paths: + dest = os.path.join(store.working_dir, rel) + if os.path.exists(dest): + continue + os.makedirs(os.path.dirname(dest), exist_ok=True) + info = store.offsets.get(rel) + if info is None: + continue + with zipfile.ZipFile(store.b2z_path, "r") as zf, zf.open(rel) as src, open(dest, "wb") as dst: + dst.write(src.read()) + def load_index_catalog(self) -> dict: meta = self._open_meta() raw = meta.vlmeta.get("index_catalog") - if isinstance(raw, dict): - return copy.deepcopy(raw) - return {} + if not isinstance(raw, dict): + return {} + catalog = copy.deepcopy(raw) + store = self._open_store() + working_dir = store.working_dir + # Expand relative paths and, for b2z read mode, extract sidecar files. + rel_paths_needed = [] + for col_name, descriptor in catalog.items(): + catalog[col_name] = self._absolutize_descriptor(descriptor, working_dir) + if store.is_zip_store and self._mode == "r": + for obj, key in self._walk_descriptor_paths(catalog[col_name]): + v = obj[key] + rel = os.path.relpath(v, working_dir) + if not os.path.exists(v): + rel_paths_needed.append(rel.replace(os.sep, "/")) + if rel_paths_needed and store.is_zip_store and self._mode == "r": + self._ensure_index_files_extracted(store, rel_paths_needed) + return catalog def save_index_catalog(self, catalog: dict) -> None: meta = self._open_meta() - meta.vlmeta["index_catalog"] = copy.deepcopy(catalog) + working_dir = self._open_store().working_dir + relativized = {col: self._relativize_descriptor(desc, working_dir) for col, desc in catalog.items()} + meta.vlmeta["index_catalog"] = relativized def get_epoch_counters(self) -> tuple[int, int]: meta = self._open_meta() @@ -413,4 +602,4 @@ def bump_visibility_epoch(self) -> int: return vis_e def index_anchor_path(self, col_name: str) -> str | None: - return os.path.join(self._root, _INDEXES_DIR, col_name, "_anchor") + return os.path.join(self._open_store().working_dir, _INDEXES_DIR, col_name, "_anchor") diff --git a/src/blosc2/dict_store.py b/src/blosc2/dict_store.py index 3166bfebb..7510a2fcc 100644 --- a/src/blosc2/dict_store.py +++ b/src/blosc2/dict_store.py @@ -231,14 +231,14 @@ def _expected_ext_from_kind(kind: str) -> str: """Return the canonical write-time suffix for a supported external leaf kind.""" if kind == "ndarray": return ".b2nd" - if kind == "batcharray": + if kind in ("batcharray", "listarray"): return ".b2b" return ".b2f" @classmethod def _opened_external_kind( cls, - opened: blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | C2Array | Any, + opened: blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray | C2Array | Any, rel_path: str, ) -> str | None: """Return the supported external leaf kind for an already opened object.""" @@ -254,12 +254,14 @@ def _opened_external_kind( processed_name = type(processed).__name__ if isinstance(processed, blosc2.BatchArray): kind = "batcharray" - elif isinstance(processed, blosc2.VLArray): + elif isinstance(processed, blosc2.ObjectArray): kind = "vlarray" elif isinstance(processed, blosc2.NDArray): kind = "ndarray" elif isinstance(processed, SChunk): kind = "schunk" + elif processed_name == "ListArray": + kind = "listarray" else: warnings.warn( f"Ignoring unsupported Blosc2 object at '{rel_path}' during DictStore discovery: " @@ -362,7 +364,7 @@ def _update_map_tree_from_offsets(self): def _annotate_external_value( self, key: str, - value: blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | C2Array, + value: blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray | C2Array, ): """Attach DictStore origin metadata so structured msgpack can preserve member identity.""" value._blosc2_ref = blosc2.Ref.dictstore_key(self.localpath, key) @@ -374,19 +376,19 @@ def estore(self) -> EmbedStore: return self._estore @staticmethod - def _value_nbytes(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray) -> int: - if isinstance(value, blosc2.VLArray | blosc2.BatchArray): + def _value_nbytes(value: blosc2.Array | SChunk | blosc2.ObjectArray | blosc2.BatchArray) -> int: + if isinstance(value, blosc2.ObjectArray | blosc2.BatchArray): return value.schunk.nbytes return value.nbytes @staticmethod - def _is_external_value(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray) -> bool: - return isinstance(value, blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray) and bool( + def _is_external_value(value: blosc2.Array | SChunk | blosc2.ObjectArray | blosc2.BatchArray) -> bool: + return isinstance(value, blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray) and bool( getattr(value, "urlpath", None) ) @staticmethod - def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray) -> str: + def _external_ext(value: blosc2.Array | SChunk | blosc2.ObjectArray | blosc2.BatchArray) -> str: if isinstance(value, blosc2.NDArray): return ".b2nd" if isinstance(value, blosc2.BatchArray): @@ -394,7 +396,7 @@ def _external_ext(value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchAr return ".b2f" def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray + self, key: str, value: blosc2.Array | SChunk | blosc2.ObjectArray | blosc2.BatchArray ) -> None: """Add a node to the DictStore.""" self._modified = True @@ -435,7 +437,7 @@ def __setitem__( elif hasattr(value, "save"): value.save(urlpath=dest_path) else: - # SChunk, VLArray and BatchArray can all be persisted via their cframe. + # SChunk, ObjectArray and BatchArray can all be persisted via their cframe. with open(dest_path, "wb") as f: f.write(value.to_cframe()) else: @@ -455,7 +457,7 @@ def __setitem__( def __getitem__( self, key: str - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | C2Array: + ) -> blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray | C2Array: """Retrieve a node from the DictStore.""" # Check map_tree first if key in self.map_tree: @@ -490,7 +492,7 @@ def __getitem__( def get( self, key: str, default: Any = None - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | C2Array | Any: + ) -> blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray | C2Array | Any: """Retrieve a node, or default if not found.""" try: return self[key] diff --git a/src/blosc2/embed_store.py b/src/blosc2/embed_store.py index e1c8b0d20..20d32d142 100644 --- a/src/blosc2/embed_store.py +++ b/src/blosc2/embed_store.py @@ -174,7 +174,7 @@ def _ensure_capacity(self, needed_bytes: int) -> None: self._store.resize((new_size,)) def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray + self, key: str, value: blosc2.Array | SChunk | blosc2.ObjectArray | blosc2.BatchArray ) -> None: """Add a node to the embed store.""" if self.mode == "r": @@ -198,7 +198,7 @@ def __setitem__( self._embed_map[key] = {"offset": offset, "length": data_len} self._save_metadata() - def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray: + def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray: """Retrieve a node from the embed store.""" if key not in self._embed_map: raise KeyError(f"Key '{key}' not found in the embed store.") @@ -216,7 +216,7 @@ def __getitem__(self, key: str) -> blosc2.NDArray | SChunk | blosc2.VLArray | bl def get( self, key: str, default: Any = None - ) -> blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray | Any: + ) -> blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray | Any: """Retrieve a node, or default if not found.""" return self[key] if key in self._embed_map else default @@ -243,12 +243,14 @@ def keys(self) -> KeysView[str]: """Return all keys.""" return self._embed_map.keys() - def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray]: + def values(self) -> Iterator[blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray]: """Iterate over all values.""" for key in self._embed_map: yield self[key] - def items(self) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.VLArray | blosc2.BatchArray]]: + def items( + self, + ) -> Iterator[tuple[str, blosc2.NDArray | SChunk | blosc2.ObjectArray | blosc2.BatchArray]]: """Iterate over (key, value) pairs.""" for key in self._embed_map: yield key, self[key] diff --git a/src/blosc2/indexing.py b/src/blosc2/indexing.py index 1b5ed7bdd..16bb5eddc 100644 --- a/src/blosc2/indexing.py +++ b/src/blosc2/indexing.py @@ -69,7 +69,7 @@ _HOT_CACHE_ORDER: list[tuple[tuple[str, str | int], str]] = [] # Total bytes of arrays currently in the hot cache. _HOT_CACHE_BYTES: int = 0 -# Persistent VLArray handles: resolved urlpath -> open VLArray object. +# Persistent ObjectArray handles: resolved urlpath -> open ObjectArray object. _QUERY_CACHE_STORE_HANDLES: dict[str, object] = {} # Cached mmap handles for data arrays used in full-query gather: urlpath -> NDArray. _GATHER_MMAP_HANDLES: dict[str, object] = {} @@ -380,7 +380,7 @@ def _save_store(array: blosc2.NDArray, store: dict) -> None: def _query_cache_payload_path(array: blosc2.NDArray) -> str: - """Return the path for the persistent query-cache VLArray payload store.""" + """Return the path for the persistent query-cache ObjectArray payload store.""" path, root = _sanitize_sidecar_root(array.urlpath) return str(path.with_name(f"{root}.__query_cache__.b2frame")) @@ -450,7 +450,7 @@ def _save_query_cache_catalog(array: blosc2.NDArray, catalog: dict) -> None: def _open_query_cache_store(array: blosc2.NDArray, *, create: bool = False): - """Return an open (writable) VLArray for the persistent payload store. + """Return an open (writable) ObjectArray for the persistent payload store. Returns ``None`` if the array is not persistent. When *create* is True the store is created if it does not yet exist. @@ -463,18 +463,18 @@ def _open_query_cache_store(array: blosc2.NDArray, *, create: bool = False): if cached is not None: return cached if Path(path).exists(): - vla = blosc2.VLArray(storage=blosc2.Storage(urlpath=path, mode="a")) + vla = blosc2.ObjectArray(storage=blosc2.Storage(urlpath=path, mode="a")) _QUERY_CACHE_STORE_HANDLES[path] = vla return vla if not create: return None - vla = blosc2.VLArray(storage=blosc2.Storage(urlpath=path, mode="w")) + vla = blosc2.ObjectArray(storage=blosc2.Storage(urlpath=path, mode="w")) _QUERY_CACHE_STORE_HANDLES[path] = vla return vla def _close_query_cache_store(path: str) -> None: - """Drop a cached VLArray handle for *path*.""" + """Drop a cached ObjectArray handle for *path*.""" _QUERY_CACHE_STORE_HANDLES.pop(path, None) diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index baaab395a..1826c36fd 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -3077,6 +3077,8 @@ def __init__(self, new_op): # noqa: C901 self.operands = {} return value1, op, value2 = new_op + value1 = value1._raw_col if hasattr(value1, "_raw_col") else value1 + value2 = value2._raw_col if hasattr(value2, "_raw_col") else value2 dtype_ = check_dtype(op, value1, value2) # perform some checks # Check that operands are proper Operands, LazyArray or scalars; if not, convert to NDArray objects value1 = ( @@ -3170,6 +3172,8 @@ def update_expr(self, new_op): # One of the two operands are LazyExpr instances try: value1, op, value2 = new_op + value1 = value1._raw_col if hasattr(value1, "_raw_col") else value1 + value2 = value2._raw_col if hasattr(value2, "_raw_col") else value2 dtype_ = check_dtype(op, value1, value2) # conserve dtype # The new expression and operands expression = None diff --git a/src/blosc2/list_array.py b/src/blosc2/list_array.py new file mode 100644 index 000000000..eb0eaa276 --- /dev/null +++ b/src/blosc2/list_array.py @@ -0,0 +1,678 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +from __future__ import annotations + +import copy +from bisect import bisect_right +from collections import defaultdict +from collections.abc import Iterable, Iterator +from functools import lru_cache +from typing import Any + +import numpy as np + +import blosc2 +from blosc2.batch_array import BatchArray +from blosc2.info import InfoReporter, format_nbytes_info +from blosc2.objectarray import ObjectArray +from blosc2.schema import ListSpec, SchemaSpec, StructSpec +from blosc2.schema import list as list_spec_builder + +_SUPPORTED_SERIALIZERS = {"msgpack", "arrow"} +_SUPPORTED_STORAGES = {"batch", "vl"} + + +def _spec_label(spec: SchemaSpec) -> str: + if isinstance(spec, ListSpec): + return spec.display_label() + meta = spec.to_metadata_dict() + kind = meta.get("kind", type(spec).__name__) + if kind == "string": + return "string" + if kind == "bytes": + return "bytes" + return str(kind) + + +@lru_cache(maxsize=1) +def _require_pyarrow(): + try: + import pyarrow as pa + except ImportError as exc: + raise ImportError("ListArray serializer='arrow' requires pyarrow") from exc + return pa + + +def _arrow_type_for_spec(pa, spec: SchemaSpec): + if isinstance(spec, StructSpec): + return pa.struct( + [pa.field(name, _arrow_type_for_spec(pa, child)) for name, child in spec.fields.items()] + ) + mapping = { + "int8": pa.int8(), + "int16": pa.int16(), + "int32": pa.int32(), + "int64": pa.int64(), + "uint8": pa.uint8(), + "uint16": pa.uint16(), + "uint32": pa.uint32(), + "uint64": pa.uint64(), + "float32": pa.float32(), + "float64": pa.float64(), + "bool": pa.bool_(), + "string": pa.string(), + "bytes": pa.large_binary(), + } + return mapping.get(spec.to_metadata_dict()["kind"]) + + +def _arrow_list_item_type_to_spec(pa, value_type): + import blosc2.schema as b2s + + mapping = { + pa.int8(): b2s.int8(), + pa.int16(): b2s.int16(), + pa.int32(): b2s.int32(), + pa.int64(): b2s.int64(), + pa.uint8(): b2s.uint8(), + pa.uint16(): b2s.uint16(), + pa.uint32(): b2s.uint32(), + pa.uint64(): b2s.uint64(), + pa.float32(): b2s.float32(), + pa.float64(): b2s.float64(), + pa.bool_(): b2s.bool(), + pa.string(): b2s.string(), + pa.large_string(): b2s.string(), + pa.binary(): b2s.bytes(), + pa.large_binary(): b2s.bytes(), + } + if pa.types.is_struct(value_type): + return b2s.struct( + {field.name: _arrow_list_item_type_to_spec(pa, field.type) for field in value_type} + ) + return mapping.get(value_type) + + +def _validate_list_spec(spec: ListSpec) -> None: + if spec.storage not in _SUPPORTED_STORAGES: + raise ValueError(f"Unsupported list storage: {spec.storage!r}") + if spec.serializer not in _SUPPORTED_SERIALIZERS: + raise ValueError(f"Unsupported list serializer: {spec.serializer!r}") + if spec.storage == "vl" and spec.serializer != "msgpack": + raise ValueError("ListArray storage='vl' only supports serializer='msgpack'") + if spec.serializer == "arrow" and spec.storage != "batch": + raise ValueError("ListArray serializer='arrow' requires storage='batch'") + if isinstance(spec.item_spec, ListSpec): + raise TypeError("Nested list item specs are not supported in V1") + if spec.batch_rows is not None and spec.batch_rows <= 0: + raise ValueError("batch_rows must be a positive integer") + if spec.items_per_block is not None and spec.items_per_block <= 0: + raise ValueError("items_per_block must be a positive integer") + + +def _coerce_struct_item(spec: StructSpec, value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + value = dict(value) + result = {} + for name, child_spec in spec.fields.items(): + if name not in value: + raise ValueError(f"Struct list item is missing field {name!r}") + result[name] = None if value[name] is None else _coerce_scalar_item(child_spec, value[name]) + return result + + +def _coerce_scalar_item(spec: SchemaSpec, value: Any) -> Any: # noqa: C901 + if value is None: + raise ValueError("ListArray does not support nullable items inside a list in V1") + + if isinstance(spec, StructSpec): + return _coerce_struct_item(spec, value) + + if getattr(spec, "python_type", None) is str: + if not isinstance(value, str): + value = str(value) + elif getattr(spec, "python_type", None) is bytes: + if isinstance(value, str): + value = value.encode() + elif not isinstance(value, (bytes, bytearray, memoryview)): + value = bytes(value) + value = bytes(value) + else: + dtype = getattr(spec, "dtype", None) + if dtype is None: + raise TypeError(f"Unsupported list item spec {type(spec).__name__!r}") + value = np.array(value, dtype=dtype).item() + + ge = getattr(spec, "ge", None) + if ge is not None and value < ge: + raise ValueError(f"List item {value!r} violates ge={ge}") + gt = getattr(spec, "gt", None) + if gt is not None and value <= gt: + raise ValueError(f"List item {value!r} violates gt={gt}") + le = getattr(spec, "le", None) + if le is not None and value > le: + raise ValueError(f"List item {value!r} violates le={le}") + lt = getattr(spec, "lt", None) + if lt is not None and value >= lt: + raise ValueError(f"List item {value!r} violates lt={lt}") + + max_length = getattr(spec, "max_length", None) + min_length = getattr(spec, "min_length", None) + if max_length is not None and len(value) > max_length: + raise ValueError(f"List item {value!r} exceeds max_length={max_length}") + if min_length is not None and len(value) < min_length: + raise ValueError(f"List item {value!r} is shorter than min_length={min_length}") + return value + + +def coerce_list_cell(spec: ListSpec, value: Any) -> list[Any] | None: + _validate_list_spec(spec) + if value is None: + if not spec.nullable: + raise ValueError("Null list cells are not allowed for this column") + return None + if isinstance(value, (str, bytes, bytearray, memoryview)): + raise TypeError("ListArray cells must be list-like, not strings or bytes") + if not isinstance(value, Iterable): + raise TypeError("ListArray cells must be list-like") + return [_coerce_scalar_item(spec.item_spec, item) for item in list(value)] + + +class ListArray: + """A row-oriented container for list-valued data. + + Backed internally by either :class:`blosc2.ObjectArray` or + :class:`blosc2.BatchArray`. + """ + + def __init__( + self, + spec: ListSpec | None = None, + *, + item_spec: SchemaSpec | None = None, + nullable: bool = False, + storage: str = "batch", + serializer: str = "msgpack", + batch_rows: int | None = None, + items_per_block: int | None = None, + _from_schunk=None, + **kwargs: Any, + ) -> None: + if _from_schunk is not None: + if spec is not None or item_spec is not None or kwargs: + raise ValueError("Cannot pass schema/storage arguments together with _from_schunk") + self._init_from_schunk(_from_schunk) + return + + if spec is None: + if item_spec is None: + raise ValueError("ListArray requires either spec=... or item_spec=...") + spec = list_spec_builder( + item_spec, + nullable=nullable, + storage=storage, + serializer=serializer, + batch_rows=batch_rows, + items_per_block=items_per_block, + ) + self.spec = spec + _validate_list_spec(self.spec) + self._pending_cells: list[list[Any] | None] = [] + self._persisted_row_count = 0 + self._persisted_prefix_cache: list[int] | None = None + self._cached_batch_index: int | None = None + self._cached_batch_values: list[list[Any] | None] | None = None + + storage_obj = self._coerce_storage(kwargs) + fixed_meta = dict(storage_obj.meta or {}) + fixed_meta["listarray"] = self.spec.to_listarray_metadata() + storage_obj.meta = fixed_meta + + if self.spec.storage == "vl": + self._backend = ObjectArray(storage=storage_obj, **kwargs) + else: + self._backend = BatchArray( + storage=storage_obj, + serializer=self.spec.serializer, + items_per_block=self.spec.items_per_block, + **kwargs, + ) + self._persisted_row_count = self._persisted_rows_count() + + @staticmethod + def _coerce_storage(kwargs: dict[str, Any]) -> blosc2.Storage: + storage = kwargs.pop("storage", None) + if storage is None: + storage_kwargs = { + name: kwargs.pop(name) for name in list(blosc2.Storage.__annotations__) if name in kwargs + } + return blosc2.Storage(**storage_kwargs) + if isinstance(storage, blosc2.Storage): + return copy.deepcopy(storage) + return blosc2.Storage(**storage) + + def _init_from_schunk(self, schunk) -> None: + meta = schunk.meta + if "listarray" not in meta: + raise ValueError("The supplied SChunk is not tagged as a ListArray") + la_meta = meta["listarray"] + self.spec = ListSpec.from_metadata_dict(la_meta) + self._pending_cells = [] + self._persisted_prefix_cache = None + self._cached_batch_index = None + self._cached_batch_values = None + if self.spec.storage == "vl": + if "vlarray" not in meta: + raise ValueError("ListArray metadata says backend='vl' but ObjectArray tag is missing") + self._backend = ObjectArray(_from_schunk=schunk) + self._persisted_row_count = len(self._backend) + else: + if "batcharray" not in meta: + raise ValueError("ListArray metadata says backend='batch' but BatchArray tag is missing") + self._backend = BatchArray(_from_schunk=schunk) + self._persisted_row_count = self._persisted_rows_count() + + def _invalidate_batch_caches(self) -> None: + self._persisted_prefix_cache = None + self._cached_batch_index = None + self._cached_batch_values = None + + def _persisted_rows_count(self) -> int: + if self.spec.storage == "vl": + return len(self._backend) + lengths = self._backend._load_or_compute_batch_lengths() + return int(sum(lengths)) + + def _persisted_prefix_sums(self) -> list[int]: + if self._persisted_prefix_cache is not None: + return self._persisted_prefix_cache + lengths = self._backend._load_or_compute_batch_lengths() + prefix = [0] + total = 0 + for length in lengths: + total += int(length) + prefix.append(total) + self._persisted_prefix_cache = prefix + return prefix + + def _get_batch_values(self, batch_index: int) -> list[list[Any] | None]: + if self._cached_batch_index == batch_index and self._cached_batch_values is not None: + return self._cached_batch_values + batch_values = self._backend[batch_index][:] + self._cached_batch_index = batch_index + self._cached_batch_values = batch_values + return batch_values + + def _normalize_index(self, index: int) -> int: + if not isinstance(index, int): + raise TypeError("ListArray indices must be integers") + n = len(self) + if index < 0: + index += n + if index < 0 or index >= n: + raise IndexError("ListArray index out of range") + return index + + def _normalize_indices(self, indices: Iterable[int]) -> list[int]: + return [self._normalize_index(int(index)) for index in indices] + + def _locate_persisted_row(self, row_index: int) -> tuple[int, int]: + prefix = self._persisted_prefix_sums() + batch_index = bisect_right(prefix, row_index) - 1 + inner_index = row_index - prefix[batch_index] + return batch_index, inner_index + + def _flush_full_batches(self) -> None: + if self.spec.storage != "batch": + return + batch_rows = self.batch_rows + if batch_rows is None: + return + while len(self._pending_cells) >= batch_rows: + batch = self._pending_cells[:batch_rows] + self._backend.append(batch) + self._pending_cells = self._pending_cells[batch_rows:] + self._persisted_row_count += len(batch) + self._invalidate_batch_caches() + + def append(self, value: Any) -> int: + cell = coerce_list_cell(self.spec, value) + if self.spec.storage == "vl": + self._backend.append(cell) + self._persisted_row_count = len(self._backend) + return len(self) + self._pending_cells.append(cell) + self._flush_full_batches() + return len(self) + + def extend(self, values: Iterable[Any], *, validate: bool = True) -> None: + if validate: + cells = [coerce_list_cell(self.spec, v) for v in values] + else: + # Trusted fast path used by Arrow/Parquet import and internal row reordering. + # Preserve nullable list cells as native None and skip all per-item coercion. + cells = list(values) + if self.spec.storage == "vl": + self._backend.extend(iter(cells)) + self._persisted_row_count = len(self._backend) + return + batch_rows = self.batch_rows + if batch_rows is None: + self._pending_cells.extend(cells) + return + self._pending_cells.extend(cells) + self._flush_full_batches() + + def flush(self) -> None: + if self.spec.storage != "batch": + return + if self._pending_cells: + batch = list(self._pending_cells) + self._backend.append(batch) + self._persisted_row_count += len(batch) + self._pending_cells.clear() + self._invalidate_batch_caches() + + def close(self) -> None: + self.flush() + + def _get_many_monotonic(self, indices: list[int]) -> list[Any]: + out: list[Any] = [None] * len(indices) + prefix = self._persisted_prefix_sums() + batch_index = 0 + batch_values: list[list[Any] | None] | None = None + + i = 0 + while i < len(indices): + index = indices[i] + if index >= self._persisted_row_count: + pending_start = index - self._persisted_row_count + j = i + 1 + while ( + j < len(indices) + and indices[j] >= self._persisted_row_count + and indices[j] == indices[j - 1] + 1 + ): + j += 1 + span = j - i + out[i:j] = self._pending_cells[pending_start : pending_start + span] + i = j + continue + + while batch_index + 1 < len(prefix) and index >= prefix[batch_index + 1]: + batch_index += 1 + batch_values = None + if batch_values is None: + batch_values = self._get_batch_values(batch_index) + + batch_start = prefix[batch_index] + batch_end = prefix[batch_index + 1] + local_start = index - batch_start + j = i + 1 + while j < len(indices) and indices[j] == indices[j - 1] + 1 and indices[j] < batch_end: + j += 1 + span = j - i + out[i:j] = batch_values[local_start : local_start + span] + i = j + + return out + + def _get_many_grouped(self, indices: list[int]) -> list[Any]: + out: list[Any] = [None] * len(indices) + grouped: dict[int, list[tuple[int, int]]] = defaultdict(list) + for out_i, index in enumerate(indices): + if index >= self._persisted_row_count: + out[out_i] = self._pending_cells[index - self._persisted_row_count] + else: + batch_index, inner_index = self._locate_persisted_row(index) + grouped[batch_index].append((out_i, inner_index)) + + for batch_index, refs in grouped.items(): + batch_values = self._get_batch_values(batch_index) + for out_i, inner_index in refs: + out[out_i] = batch_values[inner_index] + return out + + def _get_many(self, indices: list[int]) -> list[Any]: + if self.spec.storage == "vl": + return [self._backend[index] for index in indices] + if len(indices) <= 1: + return self._get_many_grouped(indices) + monotonic = True + prev = indices[0] + for index in indices[1:]: + if index < prev: + monotonic = False + break + prev = index + if monotonic: + return self._get_many_monotonic(indices) + return self._get_many_grouped(indices) + + def __getitem__(self, index: int | slice | list[int] | tuple[int, ...] | np.ndarray) -> Any: + if isinstance(index, slice): + indices = list(range(*index.indices(len(self)))) + return self._get_many(indices) + if isinstance(index, np.ndarray): + if index.dtype == np.bool_: + if len(index) != len(self): + raise IndexError( + f"Boolean mask length {len(index)} does not match ListArray length {len(self)}" + ) + return self._get_many(np.flatnonzero(index).tolist()) + return self._get_many(self._normalize_indices(index.tolist())) + if isinstance(index, (list, tuple)): + return self._get_many(self._normalize_indices(index)) + index = self._normalize_index(index) + if self.spec.storage == "vl": + return self._backend[index] + if index >= self._persisted_row_count: + return self._pending_cells[index - self._persisted_row_count] + batch_index, inner_index = self._locate_persisted_row(index) + return self._get_batch_values(batch_index)[inner_index] + + def __setitem__(self, index: int, value: Any) -> None: + cell = coerce_list_cell(self.spec, value) + index = self._normalize_index(index) + if self.spec.storage == "vl": + self._backend[index] = cell + return + if index >= self._persisted_row_count: + self._pending_cells[index - self._persisted_row_count] = cell + return + batch_index, inner_index = self._locate_persisted_row(index) + batch = self._get_batch_values(batch_index).copy() + batch[inner_index] = cell + self._backend[batch_index] = batch + self._cached_batch_index = batch_index + self._cached_batch_values = batch + + def __len__(self) -> int: + if self.spec.storage == "vl": + return len(self._backend) + return self._persisted_row_count + len(self._pending_cells) + + def __iter__(self) -> Iterator[Any]: + yield from self[:] + + def copy(self, **kwargs: Any) -> ListArray: + out = ListArray(spec=self.spec, **kwargs) + out.extend(self) + if self.spec.storage == "batch": + out.flush() + return out + + @property + def schunk(self): + return self._backend.schunk + + @property + def meta(self): + return self._backend.meta + + @property + def vlmeta(self): + return self._backend.vlmeta + + @property + def cparams(self): + return self._backend.cparams + + @property + def dparams(self): + return self._backend.dparams + + @property + def urlpath(self) -> str | None: + return self._backend.urlpath + + @property + def contiguous(self) -> bool: + return self._backend.contiguous + + @property + def batch_rows(self) -> int | None: + if self.spec.batch_rows is not None: + return self.spec.batch_rows + return None + + @property + def nbytes(self) -> int: + return self._backend.nbytes + + @property + def cbytes(self) -> int: + return self._backend.cbytes + + @property + def cratio(self) -> float: + return self._backend.cratio + + @property + def info(self) -> InfoReporter: + return InfoReporter(self) + + @property + def info_items(self) -> list: + return [ + ("type", "ListArray"), + ("logical_type", self.spec.display_label()), + ("backend", self.spec.storage), + ("serializer", self.spec.serializer), + ("rows", len(self)), + ("pending_rows", len(self._pending_cells) if self.spec.storage == "batch" else 0), + ("nbytes", format_nbytes_info(self.nbytes)), + ("cbytes", format_nbytes_info(self.cbytes)), + ("cratio", f"{self.cratio:.2f}"), + ] + + def to_cframe(self) -> bytes: + self.flush() + return self._backend.to_cframe() + + def _arrow_item_type(self): + pa = _require_pyarrow() + kind = self.spec.item_spec.to_metadata_dict()["kind"] + mapping = { + "int8": pa.int8(), + "int16": pa.int16(), + "int32": pa.int32(), + "int64": pa.int64(), + "uint8": pa.uint8(), + "uint16": pa.uint16(), + "uint32": pa.uint32(), + "uint64": pa.uint64(), + "float32": pa.float32(), + "float64": pa.float64(), + "bool": pa.bool_(), + "string": pa.string(), + "bytes": pa.large_binary(), + } + if isinstance(self.spec.item_spec, StructSpec): + return pa.struct( + [ + pa.field(name, _arrow_type_for_spec(pa, child_spec)) + for name, child_spec in self.spec.item_spec.fields.items() + ] + ) + return mapping.get(kind) + + def to_arrow(self): + pa = _require_pyarrow() + self.flush() + item_type = self._arrow_item_type() + if item_type is not None: + return pa.array(list(self), type=pa.list_(item_type)) + return pa.array(list(self)) + + @classmethod + def from_arrow( + cls, + arrow_array, + *, + item_spec: SchemaSpec | None = None, + nullable: bool = True, + storage: str = "batch", + serializer: str = "msgpack", + batch_rows: int | None = None, + items_per_block: int | None = None, + **kwargs: Any, + ) -> ListArray: + pa = _require_pyarrow() + if isinstance(arrow_array, pa.ChunkedArray): + arrow_array = arrow_array.combine_chunks() + if item_spec is None: + value_type = arrow_array.type.value_type + import blosc2.schema as b2s + + mapping = { + pa.int8(): b2s.int8(), + pa.int16(): b2s.int16(), + pa.int32(): b2s.int32(), + pa.int64(): b2s.int64(), + pa.uint8(): b2s.uint8(), + pa.uint16(): b2s.uint16(), + pa.uint32(): b2s.uint32(), + pa.uint64(): b2s.uint64(), + pa.float32(): b2s.float32(), + pa.float64(): b2s.float64(), + pa.bool_(): b2s.bool(), + pa.string(): b2s.string(), + pa.large_string(): b2s.string(), + pa.binary(): b2s.bytes(), + pa.large_binary(): b2s.bytes(), + } + if pa.types.is_struct(value_type): + item_spec = b2s.struct( + {field.name: _arrow_list_item_type_to_spec(pa, field.type) for field in value_type} + ) + else: + item_spec = mapping.get(value_type) + if item_spec is None: + raise TypeError(f"Unsupported Arrow list item type {value_type!r}") + arr = cls( + item_spec=item_spec, + nullable=nullable, + storage=storage, + serializer=serializer, + batch_rows=batch_rows, + items_per_block=items_per_block, + **kwargs, + ) + arr.extend(arrow_array.to_pylist(), validate=False) + return arr + + def __enter__(self) -> ListArray: + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + self.close() + return False + + def __repr__(self) -> str: + return f"ListArray(type={self.spec.display_label()}, len={len(self)}, urlpath={self.urlpath!r})" diff --git a/src/blosc2/msgpack_utils.py b/src/blosc2/msgpack_utils.py index 2aa8a01c7..79d14899d 100644 --- a/src/blosc2/msgpack_utils.py +++ b/src/blosc2/msgpack_utils.py @@ -59,7 +59,7 @@ def _encode_msgpack_ext(obj): import blosc2 if isinstance( - obj, blosc2.NDArray | blosc2.SChunk | blosc2.VLArray | blosc2.BatchArray | blosc2.EmbedStore + obj, blosc2.NDArray | blosc2.SChunk | blosc2.ObjectArray | blosc2.BatchArray | blosc2.EmbedStore ): return ExtType(_BLOSC2_EXT_CODE, obj.to_cframe()) structured = _encode_structured_reference(obj) diff --git a/src/blosc2/vlarray.py b/src/blosc2/objectarray.py similarity index 91% rename from src/blosc2/vlarray.py rename to src/blosc2/objectarray.py index 7e70350a3..f602b7a2e 100644 --- a/src/blosc2/vlarray.py +++ b/src/blosc2/objectarray.py @@ -20,6 +20,8 @@ from blosc2.schunk import SChunk +# On-disk metadata tag is kept as "vlarray" for backward compatibility with +# existing stored files. The public class name is ObjectArray. _VLARRAY_META = {"version": 1, "serializer": "msgpack"} @@ -28,12 +30,12 @@ def _check_serialized_size(buffer: bytes) -> None: raise ValueError(f"Serialized objects cannot be larger than {blosc2.MAX_BUFFERSIZE} bytes") -class VLArray: +class ObjectArray: """A variable-length array backed by an :class:`blosc2.SChunk`. Entries are serialized with msgpack before compression. Standard Python objects are supported, and Blosc2 containers such as - :class:`blosc2.NDArray`, :class:`blosc2.SChunk`, :class:`blosc2.VLArray`, + :class:`blosc2.NDArray`, :class:`blosc2.SChunk`, :class:`blosc2.ObjectArray`, :class:`blosc2.BatchArray`, and :class:`blosc2.EmbedStore` are serialized transparently via :meth:`to_cframe` / :func:`blosc2.from_cframe`. @@ -61,14 +63,14 @@ def _set_typesize_one(cparams: blosc2.CParams | dict | None) -> blosc2.CParams | if isinstance(cparams, blosc2.CParams): cparams.typesize = 1 if auto_use_dict and cparams.codec == blosc2.Codec.ZSTD and cparams.clevel > 0: - # VLArray stores many small serialized payloads, where Zstd dicts help materially. + # ObjectArray stores many small serialized payloads; Zstd dicts help materially. cparams.use_dict = True else: cparams["typesize"] = 1 codec = cparams.get("codec", blosc2.Codec.ZSTD) clevel = cparams.get("clevel", 5) if auto_use_dict and codec == blosc2.Codec.ZSTD and clevel > 0: - # VLArray stores many small serialized payloads, where Zstd dicts help materially. + # ObjectArray stores many small serialized payloads; Zstd dicts help materially. cparams["use_dict"] = True return cparams @@ -94,9 +96,9 @@ def _coerce_storage(storage: blosc2.Storage | dict | None, kwargs: dict[str, Any @staticmethod def _validate_storage(storage: blosc2.Storage) -> None: if storage.mmap_mode not in (None, "r"): - raise ValueError("For VLArray containers, mmap_mode must be None or 'r'") + raise ValueError("For ObjectArray containers, mmap_mode must be None or 'r'") if storage.mmap_mode == "r" and storage.mode != "r": - raise ValueError("For VLArray containers, mmap_mode='r' requires mode='r'") + raise ValueError("For ObjectArray containers, mmap_mode='r' requires mode='r'") def _attach_schunk(self, schunk: SChunk) -> None: self.schunk = schunk @@ -145,7 +147,7 @@ def __init__( if kwargs: unexpected = ", ".join(sorted(kwargs)) - raise ValueError(f"Unsupported VLArray keyword argument(s): {unexpected}") + raise ValueError(f"Unsupported ObjectArray keyword argument(s): {unexpected}") self._validate_storage(storage) cparams = self._set_typesize_one(cparams) @@ -168,24 +170,24 @@ def __init__( def _validate_tag(self) -> None: if "vlarray" not in self.schunk.meta: - raise ValueError("The supplied SChunk is not tagged as a VLArray") + raise ValueError("The supplied SChunk is not tagged as an ObjectArray") def _check_writable(self) -> None: if self.mode == "r": - raise ValueError("Cannot modify a VLArray opened in read-only mode") + raise ValueError("Cannot modify an ObjectArray opened in read-only mode") def _normalize_index(self, index: int) -> int: if not isinstance(index, int): - raise TypeError("VLArray indices must be integers") + raise TypeError("ObjectArray indices must be integers") if index < 0: index += len(self) if index < 0 or index >= len(self): - raise IndexError("VLArray index out of range") + raise IndexError("ObjectArray index out of range") return index def _normalize_insert_index(self, index: int) -> int: if not isinstance(index, int): - raise TypeError("VLArray indices must be integers") + raise TypeError("ObjectArray indices must be integers") if index < 0: index += len(self) if index < 0: @@ -244,7 +246,7 @@ def pop(self, index: int = -1) -> Any: """Remove and return the value at ``index``.""" self._check_writable() if isinstance(index, slice): - raise NotImplementedError("Slicing is not supported for VLArray") + raise NotImplementedError("Slicing is not supported for ObjectArray.pop()") index = self._normalize_index(index) value = self[index] self.schunk.delete_chunk(index) @@ -362,12 +364,12 @@ def contiguous(self) -> bool: @property def info(self) -> InfoReporter: - """Print information about this VLArray.""" + """Print information about this ObjectArray.""" return InfoReporter(self) @property def info_items(self) -> list: - """A list of tuples with summary information about this VLArray.""" + """A list of tuples with summary information about this ObjectArray.""" item_nbytes, chunk_cbytes = self._item_size_stats() avg_item_nbytes = sum(item_nbytes) / len(item_nbytes) if item_nbytes else 0.0 avg_chunk_cbytes = sum(chunk_cbytes) / len(chunk_cbytes) if chunk_cbytes else 0.0 @@ -390,7 +392,7 @@ def info_items(self) -> list: def to_cframe(self) -> bytes: return self.schunk.to_cframe() - def copy(self, **kwargs: Any) -> VLArray: + def copy(self, **kwargs: Any) -> ObjectArray: """Create a copy of the container with optional constructor overrides.""" if "meta" in kwargs: raise ValueError("meta should not be passed to copy") @@ -405,22 +407,22 @@ def copy(self, **kwargs: Any) -> VLArray: if "urlpath" in kwargs and "mode" not in kwargs: kwargs["mode"] = "w" - out = VLArray(**kwargs) + out = ObjectArray(**kwargs) out.extend(self) return out - def __enter__(self) -> VLArray: + def __enter__(self) -> ObjectArray: return self def __exit__(self, exc_type, exc_val, exc_tb) -> bool: return False def __repr__(self) -> str: - return f"VLArray(len={len(self)}, urlpath={self.urlpath!r})" + return f"ObjectArray(len={len(self)}, urlpath={self.urlpath!r})" -def vlarray_from_cframe(cframe: bytes, copy: bool = True) -> VLArray: - """Deserialize a CFrame buffer into a :class:`VLArray`.""" +def objectarray_from_cframe(cframe: bytes, copy: bool = True) -> ObjectArray: + """Deserialize a CFrame buffer into an :class:`ObjectArray`.""" schunk = blosc2.schunk_from_cframe(cframe, copy=copy) - return VLArray(_from_schunk=schunk) + return ObjectArray(_from_schunk=schunk) diff --git a/src/blosc2/scalar_array.py b/src/blosc2/scalar_array.py new file mode 100644 index 000000000..60fe346c5 --- /dev/null +++ b/src/blosc2/scalar_array.py @@ -0,0 +1,399 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# This source code is licensed under a BSD-style license (found in the +# LICENSE file in the root directory of this source tree) +####################################################################### + +"""Internal variable-length scalar column adapter over BatchArray. + +This module is *not* part of the public API. It provides row-wise scalar +semantics (one str or bytes value per row) backed by batched msgpack storage +via :class:`blosc2.BatchArray`. + +Physical layout: each chunk in the backing BatchArray stores a list of +scalar values, e.g. ``["foo", None, "bar", "baz"]``. Nulls are represented +as native Python ``None`` and never converted to a sentinel value. +""" + +from __future__ import annotations + +from bisect import bisect_right +from collections import defaultdict +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + +from blosc2.batch_array import BatchArray + +# Meta tag written on the backing BatchArray's SChunk so the storage layer +# can identify the role of this container on reopen. +_CTABLE_VARLEN_SCALAR_META_KEY = "ctable_varlen_scalar" + + +def _role_metadata_for_spec(spec) -> dict[str, Any]: + """Return the fixed metadata role tag for a CTable varlen scalar backend.""" + return { + "version": 1, + "py_type": "str" if spec.python_type is str else "bytes", + "nullable": bool(getattr(spec, "nullable", False)), + "batch_rows": getattr(spec, "batch_rows", 2048), + } + + +def _storage_with_role_meta(spec, **storage_kwargs: Any): + import blosc2 + + storage = blosc2.Storage(**storage_kwargs) + fixed_meta = dict(storage.meta or {}) + fixed_meta[_CTABLE_VARLEN_SCALAR_META_KEY] = _role_metadata_for_spec(spec) + storage.meta = fixed_meta + return storage + + +def _validate_role_metadata(backend: BatchArray, spec) -> None: + """Validate the optional CTable varlen scalar role tag on an opened backend.""" + meta = backend.schunk.meta + if _CTABLE_VARLEN_SCALAR_META_KEY not in meta: + # Older local artifacts may only have the BatchArray tag; the CTable schema + # still identifies the logical role, so keep reopen tolerant. + return + role = meta[_CTABLE_VARLEN_SCALAR_META_KEY] + expected_py_type = "str" if spec.python_type is str else "bytes" + if role.get("py_type") != expected_py_type: + raise ValueError( + f"Varlen scalar backend type mismatch: expected {expected_py_type!r}, " + f"found {role.get('py_type')!r}." + ) + + +def _make_backend(spec) -> BatchArray: + """Create a fresh in-memory BatchArray for a varlen scalar spec.""" + storage = _storage_with_role_meta(spec) + return BatchArray( + storage=storage, + items_per_block=getattr(spec, "items_per_block", None), + serializer=getattr(spec, "serializer", "msgpack"), + ) + + +def _make_persistent_backend(spec, urlpath: str, mode: str, *, cparams=None, dparams=None) -> BatchArray: + """Create or open a persistent BatchArray for a varlen scalar spec.""" + kwargs: dict[str, Any] = {} + if cparams is not None: + kwargs["cparams"] = cparams + if dparams is not None: + kwargs["dparams"] = dparams + storage = _storage_with_role_meta(spec, urlpath=urlpath, mode=mode, contiguous=True) + return BatchArray( + storage=storage, + items_per_block=getattr(spec, "items_per_block", None), + serializer=getattr(spec, "serializer", "msgpack"), + **kwargs, + ) + + +def _open_persistent_backend(urlpath: str, mode: str, spec=None) -> BatchArray: + """Reopen an existing persistent BatchArray (any mode).""" + backend = BatchArray(urlpath=urlpath, mode=mode) + if spec is not None: + _validate_role_metadata(backend, spec) + return backend + + +class _ScalarVarLenArray: + """Row-wise variable-length scalar array backed by a :class:`~blosc2.BatchArray`. + + Provides the same row-oriented interface expected by CTable columns: + ``append``, ``extend``, ``flush``, ``__len__``, ``__getitem__``, and + ``__setitem__``. + + This class is internal; do not use it directly. + + Parameters + ---------- + spec: + A :class:`~blosc2.schema.VLStringSpec` or + :class:`~blosc2.schema.VLBytesSpec` describing this column. + backend: + Pre-constructed :class:`~blosc2.BatchArray`. If ``None``, a fresh + in-memory backend is created from *spec*. + """ + + def __init__(self, spec, backend: BatchArray | None = None) -> None: + from blosc2.schema import VLBytesSpec, VLStringSpec + + if not isinstance(spec, (VLStringSpec, VLBytesSpec)): + raise TypeError(f"_ScalarVarLenArray requires a VLStringSpec or VLBytesSpec, got {type(spec)!r}") + self._spec = spec + self._py_type: type = spec.python_type # str or bytes + self._nullable: bool = getattr(spec, "nullable", False) + self._batch_rows: int = int(getattr(spec, "batch_rows", 2048) or 2048) + + if backend is None: + backend = _make_backend(spec) + self._backend: BatchArray = backend + + # Pending rows not yet flushed to the backend. + self._pending: list[Any] = [] + # Cumulative row count flushed into the backend (sum of all batch lengths). + self._persisted_row_count: int = self._compute_persisted_rows() + # Cache for prefix sums over batch lengths (invalidated on flush/setitem). + self._prefix_cache: list[int] | None = None + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _compute_persisted_rows(self) -> int: + """Compute the total number of rows persisted in the backend.""" + if len(self._backend) == 0: + return 0 + return sum(self._backend._load_or_compute_batch_lengths()) + + def _persisted_prefix_sums(self) -> list[int]: + """Return a list of cumulative batch start positions (length = n_batches + 1).""" + if self._prefix_cache is not None: + return self._prefix_cache + lengths = self._backend._load_or_compute_batch_lengths() + prefix: list[int] = [0] + total = 0 + for length in lengths: + total += int(length) + prefix.append(total) + self._prefix_cache = prefix + return prefix + + def _invalidate_prefix_cache(self) -> None: + self._prefix_cache = None + + def _locate_persisted_row(self, row_index: int) -> tuple[int, int]: + """Return (batch_index, inner_index) for *row_index* in the persisted region.""" + prefix = self._persisted_prefix_sums() + batch_index = bisect_right(prefix, row_index) - 1 + inner_index = row_index - prefix[batch_index] + return batch_index, inner_index + + def _get_batch_items(self, batch_index: int) -> list[Any]: + """Return the items in batch *batch_index* as a plain Python list.""" + return self._backend[batch_index][:] + + def _coerce(self, value: Any) -> Any: + """Coerce *value* to the column's Python type, respecting nullability.""" + if value is None: + if not self._nullable: + raise TypeError(f"Column {self._py_type.__name__!r} is not nullable; received None.") + return None + if self._py_type is str: + if isinstance(value, str): + return value + raise TypeError(f"Expected str for vlstring column, got {type(value).__name__!r}.") + # bytes + if isinstance(value, (bytes, bytearray, memoryview)): + return bytes(value) + raise TypeError(f"Expected bytes for vlbytes column, got {type(value).__name__!r}.") + + def _flush_full_batches(self) -> None: + """Flush as many full batches as possible from _pending.""" + while len(self._pending) >= self._batch_rows: + batch = self._pending[: self._batch_rows] + self._backend.append(batch) + self._pending = self._pending[self._batch_rows :] + self._persisted_row_count += len(batch) + self._invalidate_prefix_cache() + + # ------------------------------------------------------------------ + # Public write interface + # ------------------------------------------------------------------ + + def append(self, value: Any) -> None: + """Append one scalar row.""" + self._pending.append(self._coerce(value)) + self._flush_full_batches() + + def extend(self, values: Iterable[Any]) -> None: + """Append many scalar rows.""" + for v in values: + self._pending.append(self._coerce(v)) + if len(self._pending) >= self._batch_rows: + self._flush_full_batches() + + def flush(self) -> None: + """Flush any remaining pending rows to the backend as one batch.""" + if self._pending: + batch = list(self._pending) + self._backend.append(batch) + self._persisted_row_count += len(batch) + self._pending.clear() + self._invalidate_prefix_cache() + + # ------------------------------------------------------------------ + # Public read interface + # ------------------------------------------------------------------ + + def __len__(self) -> int: + return self._persisted_row_count + len(self._pending) + + def __iter__(self) -> Iterator[Any]: + yield from self[:] + + def __getitem__(self, index: int | slice | list | tuple) -> Any | list[Any]: + if isinstance(index, int): + n = len(self) + if index < 0: + index += n + if not (0 <= index < n): + raise IndexError("_ScalarVarLenArray index out of range") + if index >= self._persisted_row_count: + return self._pending[index - self._persisted_row_count] + batch_index, inner_index = self._locate_persisted_row(index) + return self._get_batch_items(batch_index)[inner_index] + + if isinstance(index, slice): + indices = list(range(*index.indices(len(self)))) + return self._get_many(indices) + + # numpy array or list/tuple of indices + try: + import numpy as np + + if isinstance(index, np.ndarray): + if index.dtype == bool: + if len(index) != len(self): + raise IndexError( + f"Boolean mask length {len(index)} does not match array length {len(self)}" + ) + return self._get_many(np.flatnonzero(index).tolist()) + return self._get_many(index.tolist()) + except ImportError: + pass + if isinstance(index, (list, tuple)): + return self._get_many(list(index)) + + raise TypeError(f"_ScalarVarLenArray indices must be int, slice, or array; got {type(index)!r}") + + def __setitem__(self, index: int, value: Any) -> None: + value = self._coerce(value) + n = len(self) + if index < 0: + index += n + if not (0 <= index < n): + raise IndexError("_ScalarVarLenArray index out of range") + if index >= self._persisted_row_count: + self._pending[index - self._persisted_row_count] = value + return + # Rewrite the persisted batch. + batch_index, inner_index = self._locate_persisted_row(index) + items = self._get_batch_items(batch_index) + items[inner_index] = value + self._backend[batch_index] = items + self._invalidate_prefix_cache() + + # ------------------------------------------------------------------ + # Bulk access helpers + # ------------------------------------------------------------------ + + def _get_many_grouped(self, indices: list[int]) -> list[Any]: + out: list[Any] = [None] * len(indices) + grouped: dict[int, list[tuple[int, int]]] = defaultdict(list) + for out_i, index in enumerate(indices): + if index >= self._persisted_row_count: + out[out_i] = self._pending[index - self._persisted_row_count] + else: + batch_index, inner_index = self._locate_persisted_row(index) + grouped[batch_index].append((out_i, inner_index)) + for batch_index, refs in grouped.items(): + items = self._get_batch_items(batch_index) + for out_i, inner_index in refs: + out[out_i] = items[inner_index] + return out + + def _get_many_monotonic(self, indices: list[int]) -> list[Any]: + out: list[Any] = [None] * len(indices) + prefix = self._persisted_prefix_sums() + batch_index = 0 + batch_items: list[Any] | None = None + + i = 0 + while i < len(indices): + index = indices[i] + if index >= self._persisted_row_count: + pending_start = index - self._persisted_row_count + j = i + 1 + while ( + j < len(indices) + and indices[j] >= self._persisted_row_count + and indices[j] == indices[j - 1] + 1 + ): + j += 1 + span = j - i + out[i:j] = self._pending[pending_start : pending_start + span] + i = j + continue + + while batch_index + 1 < len(prefix) and index >= prefix[batch_index + 1]: + batch_index += 1 + batch_items = None + if batch_items is None: + batch_items = self._get_batch_items(batch_index) + + batch_start = prefix[batch_index] + batch_end = prefix[batch_index + 1] + local_start = index - batch_start + j = i + 1 + while j < len(indices) and indices[j] == indices[j - 1] + 1 and indices[j] < batch_end: + j += 1 + span = j - i + out[i:j] = batch_items[local_start : local_start + span] + i = j + + return out + + def _get_many(self, indices: list[int]) -> list[Any]: + if len(indices) <= 1: + return self._get_many_grouped(indices) + # Check if monotonic (allows faster sequential scan) + monotonic = all(indices[k] < indices[k + 1] for k in range(len(indices) - 1)) + if monotonic: + return self._get_many_monotonic(indices) + return self._get_many_grouped(indices) + + # ------------------------------------------------------------------ + # Properties mirroring NDArray / ListArray interface expected by CTable + # ------------------------------------------------------------------ + + @property + def dtype(self): + """Always ``None`` for varlen scalar columns (no fixed NumPy dtype).""" + return None + + @property + def schunk(self): + return self._backend.schunk + + @property + def urlpath(self) -> str | None: + return self._backend.urlpath + + @property + def nbytes(self) -> int: + return self._backend.nbytes + + @property + def cbytes(self) -> int: + return self._backend.cbytes + + @property + def cratio(self) -> float: + return self._backend.cratio + + def copy(self, spec=None, **kwargs: Any) -> _ScalarVarLenArray: + """Return an in-memory copy.""" + if spec is None: + spec = self._spec + out = _ScalarVarLenArray(spec) + out.extend(self) + out.flush() + return out diff --git a/src/blosc2/schema.py b/src/blosc2/schema.py index 560d7117f..e7bd47a53 100644 --- a/src/blosc2/schema.py +++ b/src/blosc2/schema.py @@ -22,6 +22,7 @@ # after our spec classes shadow them. _builtin_bool = bool _builtin_bytes = bytes +_builtin_list = list # --------------------------------------------------------------------------- @@ -73,15 +74,21 @@ def to_metadata_dict(self) -> dict[str, Any]: class _NumericSpec(SchemaSpec): - """Mixin for numeric specs that support ge / gt / le / lt constraints.""" + """Mixin for numeric specs that support constraints and null sentinels. + + ``nullable=True`` asks CTable to choose a null sentinel from the current + null policy when the schema is compiled. An explicit ``null_value`` takes + precedence. + """ _kind: str # set by each concrete subclass - def __init__(self, *, ge=None, gt=None, le=None, lt=None, null_value=None): + def __init__(self, *, ge=None, gt=None, le=None, lt=None, nullable: bool = False, null_value=None): self.ge = ge self.gt = gt self.le = le self.lt = lt + self.nullable = nullable or null_value is not None self.null_value = null_value def to_pydantic_kwargs(self) -> dict[str, Any]: @@ -94,6 +101,8 @@ def to_pydantic_kwargs(self) -> dict[str, Any]: def to_metadata_dict(self) -> dict[str, Any]: d: dict[str, Any] = {"kind": self._kind, **self.to_pydantic_kwargs()} + if self.nullable: + d["nullable"] = True if self.null_value is not None: d["null_value"] = self.null_value return d @@ -221,19 +230,31 @@ def to_metadata_dict(self) -> dict[str, Any]: class bool(SchemaSpec): - """Boolean column.""" + """Boolean column. + + Nullable bool columns use uint8 physical storage with values + ``0`` (false), ``1`` (true), and ``255`` (null). + """ dtype = np.dtype(np.bool_) python_type = _builtin_bool - def __init__(self): - pass + def __init__(self, *, nullable: bool = False, null_value=None): + if null_value is not None and null_value != 255: + raise ValueError("Nullable bool null_value must be 255") + self.nullable = nullable or null_value is not None + self.null_value = null_value + self.dtype = np.dtype(np.uint8) if self.nullable else np.dtype(np.bool_) def to_pydantic_kwargs(self) -> dict[str, Any]: return {} def to_metadata_dict(self) -> dict[str, Any]: - return {"kind": "bool"} + d: dict[str, Any] = {"kind": "bool"} + if self.nullable: + d["nullable"] = True + d["null_value"] = self.null_value + return d # --------------------------------------------------------------------------- @@ -253,15 +274,23 @@ class string(SchemaSpec): Minimum number of characters (validation only, no effect on dtype). pattern: Regex pattern the value must match (validation only). + nullable: + If ``True`` and ``null_value`` is not set, choose a null sentinel from + the current CTable null policy when the schema is compiled. + null_value: + Explicit null sentinel. Takes precedence over ``nullable=True``. """ python_type = str _DEFAULT_MAX_LENGTH = 32 - def __init__(self, *, min_length=None, max_length=None, pattern=None, null_value=None): + def __init__( + self, *, min_length=None, max_length=None, pattern=None, nullable: bool = False, null_value=None + ): self.min_length = min_length self.max_length = max_length if max_length is not None else self._DEFAULT_MAX_LENGTH self.pattern = pattern + self.nullable = nullable or null_value is not None self.null_value = null_value self.dtype = np.dtype(f"U{self.max_length}") @@ -277,6 +306,8 @@ def to_pydantic_kwargs(self) -> dict[str, Any]: def to_metadata_dict(self) -> dict[str, Any]: d: dict[str, Any] = {"kind": "string", **self.to_pydantic_kwargs()} + if self.nullable: + d["nullable"] = True if self.null_value is not None: d["null_value"] = self.null_value return d @@ -292,14 +323,20 @@ class bytes(SchemaSpec): Defaults to 32 if not specified. min_length: Minimum number of bytes (validation only, no effect on dtype). + nullable: + If ``True`` and ``null_value`` is not set, choose a null sentinel from + the current CTable null policy when the schema is compiled. + null_value: + Explicit null sentinel. Takes precedence over ``nullable=True``. """ python_type = _builtin_bytes _DEFAULT_MAX_LENGTH = 32 - def __init__(self, *, min_length=None, max_length=None, null_value=None): + def __init__(self, *, min_length=None, max_length=None, nullable: bool = False, null_value=None): self.min_length = min_length self.max_length = max_length if max_length is not None else self._DEFAULT_MAX_LENGTH + self.nullable = nullable or null_value is not None self.null_value = null_value self.dtype = np.dtype(f"S{self.max_length}") @@ -313,11 +350,327 @@ def to_pydantic_kwargs(self) -> dict[str, Any]: def to_metadata_dict(self) -> dict[str, Any]: d: dict[str, Any] = {"kind": "bytes", **self.to_pydantic_kwargs()} + if self.nullable: + d["nullable"] = True if self.null_value is not None: d["null_value"] = self.null_value return d +# --------------------------------------------------------------------------- +# List spec +# --------------------------------------------------------------------------- + + +class StructSpec(SchemaSpec): + """Logical schema descriptor for dict-like structured values.""" + + python_type = dict + dtype = None + + def __init__(self, fields: dict[str, SchemaSpec]): + if not isinstance(fields, dict) or not fields: + raise TypeError("StructSpec fields must be a non-empty dict") + for name, spec in fields.items(): + if not isinstance(name, str): + raise TypeError("StructSpec field names must be strings") + if not isinstance(spec, SchemaSpec): + raise TypeError("StructSpec field values must be SchemaSpec instances") + self.fields = dict(fields) + + def to_pydantic_kwargs(self) -> dict[str, Any]: + return {} + + def to_metadata_dict(self) -> dict[str, Any]: + return { + "kind": "struct", + "fields": [{"name": name, **spec.to_metadata_dict()} for name, spec in self.fields.items()], + } + + def display_label(self) -> str: + return "struct[" + ", ".join(self.fields) + "]" + + @classmethod + def from_metadata_dict(cls, data: dict[str, Any]) -> StructSpec: + from blosc2.schema_compiler import spec_from_metadata_dict + + fields = {} + for field in data["fields"]: + field = dict(field) + name = field.pop("name") + fields[name] = spec_from_metadata_dict(field) + return cls(fields) + + +class ListSpec(SchemaSpec): + """Logical schema descriptor for a list-valued column.""" + + python_type = _builtin_list + dtype = None + + def __init__( + self, + item_spec: SchemaSpec, + *, + nullable: bool = False, + storage: str = "batch", + serializer: str = "msgpack", + batch_rows: int | None = None, + items_per_block: int | None = None, + ): + if not isinstance(item_spec, SchemaSpec): + raise TypeError("ListSpec item_spec must be a SchemaSpec instance") + if isinstance(item_spec, ListSpec): + raise TypeError("Nested list item specs are not supported in V1") + if storage not in {"batch", "vl"}: + raise ValueError("storage must be 'batch' or 'vl'") + if serializer not in {"msgpack", "arrow"}: + raise ValueError("serializer must be 'msgpack' or 'arrow'") + if storage == "vl" and serializer != "msgpack": + raise ValueError("storage='vl' only supports serializer='msgpack'") + if serializer == "arrow" and storage != "batch": + raise ValueError("serializer='arrow' requires storage='batch'") + self.item_spec = item_spec + self.nullable = nullable + self.storage = storage + self.serializer = serializer + self.batch_rows = batch_rows + self.items_per_block = items_per_block + + def to_pydantic_kwargs(self) -> dict[str, Any]: + return {} + + def to_metadata_dict(self) -> dict[str, Any]: + d = { + "kind": "list", + "item": self.item_spec.to_metadata_dict(), + "nullable": self.nullable, + "storage": self.storage, + "serializer": self.serializer, + } + if self.batch_rows is not None: + d["batch_rows"] = self.batch_rows + if self.items_per_block is not None: + d["items_per_block"] = self.items_per_block + return d + + def to_listarray_metadata(self) -> dict[str, Any]: + d = {"version": 1, **self.to_metadata_dict()} + d["backend"] = d.pop("storage") + return d + + def display_label(self) -> str: + item_kind = self.item_spec.to_metadata_dict().get("kind", type(self.item_spec).__name__) + return f"list[{item_kind}]" + + @classmethod + def from_metadata_dict(cls, data: dict[str, Any]) -> ListSpec: + from blosc2.schema_compiler import spec_from_metadata_dict + + backend = data.get("backend") + return cls( + spec_from_metadata_dict(data["item_spec"] if "item_spec" in data else data["item"]), + nullable=data.get("nullable", False), + storage=backend if backend is not None else data.get("storage", "batch"), + serializer=data.get("serializer", "msgpack"), + batch_rows=data.get("batch_rows"), + items_per_block=data.get("items_per_block"), + ) + + +# --------------------------------------------------------------------------- +# Variable-length scalar spec classes +# --------------------------------------------------------------------------- + + +class VLStringSpec(SchemaSpec): + """Variable-length scalar string column backed by batched object storage. + + Unlike :class:`string`, this spec does not use a fixed-width NumPy dtype. + Each row value is a plain Python ``str`` (or ``None`` when nullable). + Physical storage uses batched msgpack serialization via + :class:`blosc2.BatchArray` internally. + + Parameters + ---------- + nullable: + If ``True``, ``None`` is a valid row value representing a missing entry. + Nullability is represented natively — no sentinel value is used. + serializer: + Serialization backend. Currently only ``"msgpack"`` is supported. + batch_rows: + Target number of rows per storage batch. Defaults to 2048. + items_per_block: + Optional items-per-block hint passed to the underlying BatchArray. + """ + + python_type = str + dtype = None + + def __init__( + self, + *, + nullable: bool = False, + serializer: str = "msgpack", + batch_rows: int | None = 2048, + items_per_block: int | None = None, + ): + if serializer != "msgpack": + raise ValueError("vlstring currently only supports serializer='msgpack'") + if batch_rows is not None and batch_rows <= 0: + raise ValueError("batch_rows must be positive or None") + if items_per_block is not None and items_per_block <= 0: + raise ValueError("items_per_block must be positive or None") + self.nullable = nullable + self.serializer = serializer + self.batch_rows = batch_rows + self.items_per_block = items_per_block + + def to_pydantic_kwargs(self) -> dict[str, Any]: + return {} + + def to_metadata_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "kind": "vlstring", + "nullable": self.nullable, + "serializer": self.serializer, + } + if self.batch_rows is not None: + d["batch_rows"] = self.batch_rows + if self.items_per_block is not None: + d["items_per_block"] = self.items_per_block + return d + + +class VLBytesSpec(SchemaSpec): + """Variable-length scalar bytes column backed by batched object storage. + + Unlike :class:`bytes`, this spec does not use a fixed-width NumPy dtype. + Each row value is a plain Python ``bytes`` (or ``None`` when nullable). + Physical storage uses batched msgpack serialization via + :class:`blosc2.BatchArray` internally. + + Parameters + ---------- + nullable: + If ``True``, ``None`` is a valid row value representing a missing entry. + Nullability is represented natively — no sentinel value is used. + serializer: + Serialization backend. Currently only ``"msgpack"`` is supported. + batch_rows: + Target number of rows per storage batch. Defaults to 2048. + items_per_block: + Optional items-per-block hint passed to the underlying BatchArray. + """ + + python_type = _builtin_bytes + dtype = None + + def __init__( + self, + *, + nullable: bool = False, + serializer: str = "msgpack", + batch_rows: int | None = 2048, + items_per_block: int | None = None, + ): + if serializer != "msgpack": + raise ValueError("vlbytes currently only supports serializer='msgpack'") + if batch_rows is not None and batch_rows <= 0: + raise ValueError("batch_rows must be positive or None") + if items_per_block is not None and items_per_block <= 0: + raise ValueError("items_per_block must be positive or None") + self.nullable = nullable + self.serializer = serializer + self.batch_rows = batch_rows + self.items_per_block = items_per_block + + def to_pydantic_kwargs(self) -> dict[str, Any]: + return {} + + def to_metadata_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "kind": "vlbytes", + "nullable": self.nullable, + "serializer": self.serializer, + } + if self.batch_rows is not None: + d["batch_rows"] = self.batch_rows + if self.items_per_block is not None: + d["items_per_block"] = self.items_per_block + return d + + +def vlstring( + *, + nullable: bool = False, + serializer: str = "msgpack", + batch_rows: int | None = 2048, + items_per_block: int | None = None, +) -> VLStringSpec: + """Build a variable-length scalar string schema descriptor. + + Use this as an explicit opt-in when a CTable column holds long or + wildly variable-length strings that would waste space in a fixed-width + ``string(max_length=N)`` column. Must be requested via + ``blosc2.field(blosc2.vlstring())`` — it is never inferred automatically + from plain ``str`` annotations. + """ + return VLStringSpec( + nullable=nullable, + serializer=serializer, + batch_rows=batch_rows, + items_per_block=items_per_block, + ) + + +def vlbytes( + *, + nullable: bool = False, + serializer: str = "msgpack", + batch_rows: int | None = 2048, + items_per_block: int | None = None, +) -> VLBytesSpec: + """Build a variable-length scalar bytes schema descriptor. + + Use this as an explicit opt-in when a CTable column holds long or + wildly variable-length byte strings. Must be requested via + ``blosc2.field(blosc2.vlbytes())`` — it is never inferred automatically + from plain ``bytes`` annotations. + """ + return VLBytesSpec( + nullable=nullable, + serializer=serializer, + batch_rows=batch_rows, + items_per_block=items_per_block, + ) + + +def struct(fields: dict[str, SchemaSpec]) -> StructSpec: + """Build a structured schema descriptor for nested CTable values.""" + return StructSpec(fields) + + +def list( + item_spec: SchemaSpec, + *, + nullable: bool = False, + storage: str = "batch", + serializer: str = "msgpack", + batch_rows: int | None = None, + items_per_block: int | None = None, +) -> ListSpec: + """Build a list-valued schema descriptor for CTable and ListArray.""" + return ListSpec( + item_spec, + nullable=nullable, + storage=storage, + serializer=serializer, + batch_rows=batch_rows, + items_per_block=items_per_block, + ) + + # --------------------------------------------------------------------------- # Field helper # --------------------------------------------------------------------------- diff --git a/src/blosc2/schema_compiler.py b/src/blosc2/schema_compiler.py index 19a3d0c1b..21e27172b 100644 --- a/src/blosc2/schema_compiler.py +++ b/src/blosc2/schema_compiler.py @@ -10,6 +10,7 @@ from __future__ import annotations +import copy import dataclasses import typing from dataclasses import MISSING @@ -19,7 +20,11 @@ from blosc2.schema import ( BLOSC2_FIELD_METADATA_KEY, + ListSpec, SchemaSpec, + StructSpec, + VLBytesSpec, + VLStringSpec, complex64, complex128, float32, @@ -59,10 +64,12 @@ # complex "complex64": complex64, "complex128": complex128, - # bool / string / bytes + # bool / string / bytes / varlen "bool": b2_bool, "string": string, "bytes": b2_bytes, + "vlstring": VLStringSpec, + "vlbytes": VLBytesSpec, } # --------------------------------------------------------------------------- @@ -88,7 +95,13 @@ def compute_display_width(spec: SchemaSpec) -> int: """Return a reasonable terminal display width for *spec*'s column.""" + if isinstance(spec, (VLStringSpec, VLBytesSpec)): + return 40 + if isinstance(spec, ListSpec): + return max(40, len(spec.display_label()) + 4) dtype = spec.dtype + if dtype is None: + return 20 if dtype.kind == "U": # fixed-width unicode (string spec) return max(10, min(dtype.itemsize // 4, 50)) if dtype.kind == "S": # fixed-width bytes @@ -132,7 +145,7 @@ class CompiledColumn: name: str py_type: Any spec: SchemaSpec - dtype: np.dtype + dtype: np.dtype | None default: Any # MISSING means required (no default) config: ColumnConfig display_width: int = 20 # terminal column width for __str__ / info() @@ -151,6 +164,7 @@ class CompiledSchema: columns: list[CompiledColumn] columns_by_name: dict[str, CompiledColumn] validator_model: type[Any] | None = None # filled in by schema_validation + metadata: dict[str, Any] = dataclasses.field(default_factory=dict) # --------------------------------------------------------------------------- @@ -183,17 +197,27 @@ def infer_spec_from_annotation(annotation: Any) -> SchemaSpec: def validate_annotation_matches_spec(name: str, annotation: Any, spec: SchemaSpec) -> None: - """Raise :exc:`TypeError` if *annotation* is incompatible with *spec*. + """Raise :exc:`TypeError` if *annotation* is incompatible with *spec*.""" + if isinstance(spec, ListSpec): + origin = typing.get_origin(annotation) + if origin not in (list, list): + raise TypeError( + f"Column {name!r}: annotation {annotation!r} is incompatible with list spec; expected list[T]." + ) + args = typing.get_args(annotation) + if len(args) != 1: + raise TypeError(f"Column {name!r}: list annotations must specify exactly one item type.") + item_annotation = args[0] + expected = spec.item_spec.python_type + if item_annotation is not expected: + raise TypeError( + f"Column {name!r}: list item annotation {item_annotation!r} is incompatible with " + f"item spec {type(spec.item_spec).__name__!r} (expected {expected.__name__!r})." + ) + return - Parameters - ---------- - name: - Column name, used only in the error message. - annotation: - The resolved Python type from the dataclass field. - spec: - The :class:`SchemaSpec` attached via ``b2.field(...)``. - """ + # VLStringSpec and VLBytesSpec are only reachable via blosc2.field(blosc2.vlstring()/vlbytes()), + # so annotation must match python_type (str or bytes respectively). expected = spec.python_type if annotation is not expected: raise TypeError( @@ -274,7 +298,7 @@ def compile_schema(row_cls: type[Any]) -> CompiledSchema: if meta is not None: # Explicit b2.field(...) path - spec = meta["spec"] + spec = copy.deepcopy(meta["spec"]) if not isinstance(spec, SchemaSpec): raise TypeError( f"Column {name!r}: b2.field() requires a SchemaSpec as its first " @@ -305,7 +329,7 @@ def compile_schema(row_cls: type[Any]) -> CompiledSchema: name=name, py_type=annotation, spec=spec, - dtype=spec.dtype, + dtype=getattr(spec, "dtype", None), default=default, config=config, display_width=compute_display_width(spec), @@ -342,6 +366,21 @@ def _default_from_json(value: Any) -> Any: return value +def spec_from_metadata_dict(data: dict[str, Any]) -> SchemaSpec: + """Reconstruct one SchemaSpec from serialized metadata.""" + data = dict(data) + kind = data.pop("kind") + if kind == "list": + item_spec = spec_from_metadata_dict(data.pop("item")) + return ListSpec(item_spec, **data) + if kind == "struct": + return StructSpec.from_metadata_dict({"fields": data.pop("fields")}) + spec_cls = _KIND_TO_SPEC.get(kind) + if spec_cls is None: + raise ValueError(f"Unknown column kind {kind!r}") + return spec_cls(**data) + + def schema_to_dict(schema: CompiledSchema) -> dict[str, Any]: """Serialize *schema* to a JSON-compatible dict. @@ -376,11 +415,14 @@ def schema_to_dict(schema: CompiledSchema) -> dict[str, Any]: entry["blocks"] = list(col.config.blocks) cols.append(entry) - return { + result = { "version": 1, "row_cls": schema.row_cls.__name__ if schema.row_cls is not None else None, "columns": cols, } + if schema.metadata: + result["metadata"] = schema.metadata + return result def schema_from_dict(data: dict[str, Any]) -> CompiledSchema: @@ -410,19 +452,14 @@ def schema_from_dict(data: dict[str, Any]) -> CompiledSchema: chunks = tuple(entry.pop("chunks")) if "chunks" in entry else None blocks = tuple(entry.pop("blocks")) if "blocks" in entry else None - spec_cls = _KIND_TO_SPEC.get(kind) - if spec_cls is None: - raise ValueError(f"Unknown column kind {kind!r}") - - # Remaining keys in entry are constraint kwargs (ge, le, max_length, …) - spec = spec_cls(**entry) + spec = spec_from_metadata_dict({"kind": kind, **entry}) columns.append( CompiledColumn( name=name, py_type=spec.python_type, spec=spec, - dtype=spec.dtype, + dtype=getattr(spec, "dtype", None), default=default, config=ColumnConfig(cparams=cparams, dparams=dparams, chunks=chunks, blocks=blocks), display_width=compute_display_width(spec), @@ -433,4 +470,5 @@ def schema_from_dict(data: dict[str, Any]) -> CompiledSchema: row_cls=None, columns=columns, columns_by_name={col.name: col for col in columns}, + metadata=dict(data.get("metadata", {})), ) diff --git a/src/blosc2/schema_validation.py b/src/blosc2/schema_validation.py index 91f157f71..882607014 100644 --- a/src/blosc2/schema_validation.py +++ b/src/blosc2/schema_validation.py @@ -19,6 +19,8 @@ from pydantic import BaseModel, Field, ValidationError, create_model +from blosc2.list_array import coerce_list_cell +from blosc2.schema import ListSpec from blosc2.schema_compiler import CompiledSchema # noqa: TC001 @@ -41,8 +43,14 @@ def build_validator_model(schema: CompiledSchema) -> type[BaseModel]: field_definitions: dict[str, Any] = {} for col in schema.columns: pydantic_kwargs = col.spec.to_pydantic_kwargs() - is_nullable = getattr(col.spec, "null_value", None) is not None - py_type = col.py_type | None if is_nullable else col.py_type + is_nullable = getattr(col.spec, "null_value", None) is not None or ( + isinstance(col.spec, ListSpec) and col.spec.nullable + ) + if isinstance(col.spec, ListSpec): + item_type = col.spec.item_spec.python_type + py_type = list[item_type] | None if is_nullable else list[item_type] + else: + py_type = col.py_type | None if is_nullable else col.py_type if col.default is MISSING: default = None if is_nullable else MISSING @@ -118,7 +126,11 @@ def validate_row(schema: CompiledSchema, row: dict[str, Any]) -> dict[str, Any]: name and the violated constraint. """ model_cls = build_validator_model(schema) - masked_row, nulled = _mask_nulls(schema, row) + normalized = dict(row) + for col in schema.columns: + if isinstance(col.spec, ListSpec) and col.name in normalized: + normalized[col.name] = coerce_list_cell(col.spec, normalized[col.name]) + masked_row, nulled = _mask_nulls(schema, normalized) try: instance = model_cls(**masked_row) except ValidationError as exc: @@ -148,7 +160,11 @@ def validate_rows_rowwise(schema: CompiledSchema, rows: list[dict[str, Any]]) -> model_cls = build_validator_model(schema) result = [] for i, row in enumerate(rows): - masked_row, nulled = _mask_nulls(schema, row) + normalized = dict(row) + for col in schema.columns: + if isinstance(col.spec, ListSpec) and col.name in normalized: + normalized[col.name] = coerce_list_cell(col.spec, normalized[col.name]) + masked_row, nulled = _mask_nulls(schema, normalized) try: instance = model_cls(**masked_row) except ValidationError as exc: diff --git a/src/blosc2/schema_vectorized.py b/src/blosc2/schema_vectorized.py index c15f2dd26..e2e6de6ba 100644 --- a/src/blosc2/schema_vectorized.py +++ b/src/blosc2/schema_vectorized.py @@ -19,6 +19,8 @@ import numpy as np +from blosc2.list_array import coerce_list_cell +from blosc2.schema import ListSpec from blosc2.schema_compiler import CompiledColumn, CompiledSchema # noqa: TC001 @@ -75,6 +77,11 @@ def validate_column_values(col: CompiledColumn, values: Any) -> None: If any value violates a constraint declared on the column's spec. """ spec = col.spec + if isinstance(spec, ListSpec): + for value in values: + coerce_list_cell(spec, value) + return + arr = np.asarray(values) # Compute null mask so sentinels bypass constraint checks diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index 55bf31db0..b10b579f8 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -34,7 +34,7 @@ class vlmeta(MutableMapping, blosc2_ext.vlmeta): msgpack-safe Python values, this includes: - CFrame-backed Blosc2 objects such as :class:`blosc2.NDArray`, - :class:`blosc2.SChunk`, :class:`blosc2.VLArray`, + :class:`blosc2.SChunk`, :class:`blosc2.ObjectArray`, :class:`blosc2.BatchArray`, and :class:`blosc2.EmbedStore` - structured references and lazy objects such as :class:`blosc2.Ref`, :class:`blosc2.C2Array`, :class:`blosc2.LazyExpr`, and @@ -876,6 +876,35 @@ def update_chunk(self, nchunk: int, chunk: bytes) -> int: blosc2_ext.check_access_mode(self.urlpath, self.mode) return super().update_chunk(nchunk, chunk) + def reorder_offsets(self, order: Any) -> None: + """Reorder the chunk offsets of the SChunk in place. + + This is a low-level storage operation that changes the physical chunk + order of the underlying SChunk. Higher-level containers backed by this + SChunk will observe the reordered chunk traversal afterwards. + + Parameters + ---------- + order: array-like of int + A one-dimensional permutation of ``range(self.nchunks)`` describing + the new chunk order. + + Raises + ------ + ValueError + If ``order`` is not one-dimensional or its length does not match + the number of chunks in the SChunk. + RuntimeError + If the underlying reorder operation fails. + """ + blosc2_ext.check_access_mode(self.urlpath, self.mode) + order = np.asarray(order, dtype=np.int64) + if order.ndim != 1: + raise ValueError("`order` must be a one-dimensional sequence") + if len(order) != self.nchunks: + raise ValueError("`order` must have exactly `self.nchunks` elements") + super().reorder_offsets(order) + def update_data(self, nchunk: int, data: object, copy: bool) -> int: """Update the chunk in the specified position with the given data. @@ -1653,10 +1682,15 @@ def process_opened_object(res): if "b2o" in meta: return blosc2.open_b2object(res) + if "listarray" in meta: + from blosc2.list_array import ListArray + + return ListArray(_from_schunk=getattr(res, "schunk", res)) + if "vlarray" in meta: - from blosc2.vlarray import VLArray + from blosc2.objectarray import ObjectArray - return VLArray(_from_schunk=getattr(res, "schunk", res)) + return ObjectArray(_from_schunk=getattr(res, "schunk", res)) if "batcharray" in meta: from blosc2.batch_array import BatchArray @@ -1736,7 +1770,7 @@ def open( blosc2.SChunk | blosc2.NDArray | blosc2.BatchArray - | blosc2.VLArray + | blosc2.ObjectArray | blosc2.C2Array | blosc2.LazyArray | blosc2.Proxy diff --git a/src/blosc2/tree_store.py b/src/blosc2/tree_store.py index 6fd02e9a1..6f743cbf3 100644 --- a/src/blosc2/tree_store.py +++ b/src/blosc2/tree_store.py @@ -232,7 +232,7 @@ def _validate_key(self, key: str) -> str: return key def __setitem__( - self, key: str, value: blosc2.Array | SChunk | blosc2.VLArray | blosc2.BatchArray + self, key: str, value: blosc2.Array | SChunk | blosc2.ObjectArray | blosc2.BatchArray ) -> None: """Add a node with hierarchical key validation. @@ -277,7 +277,7 @@ def __setitem__( def __getitem__( self, key: str - ) -> NDArray | C2Array | SChunk | blosc2.VLArray | blosc2.BatchArray | TreeStore: + ) -> NDArray | C2Array | SChunk | blosc2.ObjectArray | blosc2.BatchArray | TreeStore: """Retrieve a node or subtree view. If the key points to a subtree (intermediate path with children), @@ -291,7 +291,7 @@ def __getitem__( Returns ------- - out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.VLArray or blosc2.BatchArray or TreeStore + out : blosc2.NDArray or blosc2.C2Array or blosc2.SChunk or blosc2.ObjectArray or blosc2.BatchArray or TreeStore The stored array/chunk if key is a leaf node, or a TreeStore subtree view if key is an intermediate path with children. diff --git a/tests/ctable/test_arrow_interop.py b/tests/ctable/test_arrow_interop.py index e016e0319..c4d8d40a1 100644 --- a/tests/ctable/test_arrow_interop.py +++ b/tests/ctable/test_arrow_interop.py @@ -118,50 +118,52 @@ def test_to_arrow_where_view(): def test_from_arrow_returns_ctable(): t = CTable(Row, new_data=DATA10) at = t.to_arrow() - t2 = CTable.from_arrow(at) + t2 = CTable.from_arrow(at.schema, at.to_batches()) assert isinstance(t2, CTable) def test_from_arrow_row_count(): t = CTable(Row, new_data=DATA10) at = t.to_arrow() - t2 = CTable.from_arrow(at) + t2 = CTable.from_arrow(at.schema, at.to_batches()) assert len(t2) == 10 def test_from_arrow_column_names(): t = CTable(Row, new_data=DATA10) at = t.to_arrow() - t2 = CTable.from_arrow(at) + t2 = CTable.from_arrow(at.schema, at.to_batches()) assert t2.col_names == ["id", "score", "active", "label"] def test_from_arrow_int_values(): t = CTable(Row, new_data=DATA10) at = t.to_arrow() - t2 = CTable.from_arrow(at) + t2 = CTable.from_arrow(at.schema, at.to_batches()) np.testing.assert_array_equal(t2["id"][:], t["id"][:]) def test_from_arrow_float_values(): t = CTable(Row, new_data=DATA10) at = t.to_arrow() - t2 = CTable.from_arrow(at) + t2 = CTable.from_arrow(at.schema, at.to_batches()) np.testing.assert_allclose(t2["score"][:], t["score"][:]) def test_from_arrow_bool_values(): t = CTable(Row, new_data=DATA10) at = t.to_arrow() - t2 = CTable.from_arrow(at) + t2 = CTable.from_arrow(at.schema, at.to_batches()) np.testing.assert_array_equal(t2["active"][:], t["active"][:]) def test_from_arrow_string_values(): + # Without string_max_length, scalar strings become vlstring columns. + # Accessing [:] on a vlstring column returns a Python list, not an ndarray. t = CTable(Row, new_data=DATA10) at = t.to_arrow() - t2 = CTable.from_arrow(at) - assert t2["label"][:].tolist() == t["label"][:].tolist() + t2 = CTable.from_arrow(at.schema, at.to_batches()) + assert list(t2["label"][:]) == t["label"][:].tolist() def test_from_arrow_empty_table(): @@ -172,7 +174,7 @@ def test_from_arrow_empty_table(): ] ) at = pa.table({"id": pa.array([], type=pa.int64()), "val": pa.array([], type=pa.float64())}) - t = CTable.from_arrow(at) + t = CTable.from_arrow(at.schema, at.to_batches()) assert len(t) == 0 assert t.col_names == ["id", "val"] @@ -180,10 +182,12 @@ def test_from_arrow_empty_table(): def test_from_arrow_roundtrip(): """to_arrow then from_arrow preserves all values.""" t = CTable(Row, new_data=DATA10) - t2 = CTable.from_arrow(t.to_arrow()) + at = t.to_arrow() + t2 = CTable.from_arrow(at.schema, at.to_batches()) for name in ["id", "score", "active"]: np.testing.assert_array_equal(t2[name][:], t[name][:]) - assert t2["label"][:].tolist() == t["label"][:].tolist() + # label is re-imported as vlstring (no string_max_length given) → compare as lists + assert list(t2["label"][:]) == t["label"][:].tolist() def test_from_arrow_all_numeric_types(): @@ -202,23 +206,60 @@ def test_from_arrow_all_numeric_types(): "f64": pa.array([1.0, 2.0, 3.0], type=pa.float64()), } ) - t = CTable.from_arrow(at) + t = CTable.from_arrow(at.schema, at.to_batches()) assert len(t) == 3 assert t.col_names == list(at.column_names) -def test_from_arrow_string_max_length(): - """String max_length is set from the longest value in the data.""" +def test_from_arrow_string_default_is_vlstring(): + """Without string_max_length, scalar string columns become vlstring (variable-length).""" + at = pa.table({"name": pa.array(["hi", "hello world", "!"], type=pa.string())}) + t = CTable.from_arrow(at.schema, at.to_batches()) + assert t["name"].is_varlen_scalar + assert t["name"].dtype is None + assert list(t["name"][:]) == ["hi", "hello world", "!"] + + +def test_from_arrow_string_fixed_width_with_max_length(): + """Passing string_max_length gives a fixed-width NDArray string column.""" at = pa.table({"name": pa.array(["hi", "hello world", "!"], type=pa.string())}) - t = CTable.from_arrow(at) - # "hello world" is 11 chars — stored dtype must accommodate it + t = CTable.from_arrow(at.schema, at.to_batches(), string_max_length=32) + # "hello world" is 11 chars — stored dtype must accommodate string_max_length assert t["name"].dtype.itemsize // 4 >= 11 + assert not t["name"].is_varlen_scalar + assert t["name"][:].tolist() == ["hi", "hello world", "!"] + + +def test_from_arrow_list_struct_nullable_values_roundtrip(): + nutrient_type = pa.struct( + [ + pa.field("name", pa.string()), + pa.field("value", pa.float64()), + ] + ) + at = pa.table( + { + "id": pa.array([1, 2, 3], type=pa.int64()), + "nutriments": pa.array( + [ + [{"name": "fat", "value": 1.5}, {"name": "salt", "value": 0.2}], + None, + [{"name": "energy", "value": 42.0}], + ], + type=pa.list_(nutrient_type), + ), + } + ) + t = CTable.from_arrow(at.schema, at.to_batches()) + assert t[0].nutriments == [{"name": "fat", "value": 1.5}, {"name": "salt", "value": 0.2}] + assert t[1].nutriments is None + assert t[2].nutriments == [{"name": "energy", "value": 42.0}] def test_from_arrow_unsupported_type_raises(): at = pa.table({"ts": pa.array([1, 2, 3], type=pa.timestamp("s"))}) with pytest.raises(TypeError, match="No blosc2 spec"): - CTable.from_arrow(at) + CTable.from_arrow(at.schema, at.to_batches()) if __name__ == "__main__": diff --git a/tests/ctable/test_column_ndarray_like.py b/tests/ctable/test_column_ndarray_like.py new file mode 100644 index 000000000..d12de3e76 --- /dev/null +++ b/tests/ctable/test_column_ndarray_like.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +import blosc2 + + +@dataclass +class Row: + x: int = blosc2.field(blosc2.int32()) + y: int = blosc2.field(blosc2.int32()) + flag: bool = blosc2.field(blosc2.bool()) + + +DATA = [(1, 5, True), (-1, 5, True), (2, 20, False), (3, 7, False)] + + +def test_column_logical_metadata(): + t = blosc2.CTable(Row, new_data=DATA) + view = t.where(t.x > 0) + + assert view.x.shape == (3,) + assert view.x.ndim == 1 + assert view.x.size == 3 + + +def test_column_boolean_operators_build_lazy_expressions(): + t = blosc2.CTable(Row, new_data=DATA) + + view = t.where(t.flag & (t.x > 0)) + + np.testing.assert_array_equal(view.x[:], np.array([1], dtype=np.int32)) + + +def test_column_boolean_invert_builds_lazy_expression(): + t = blosc2.CTable(Row, new_data=DATA) + + view = t.where(~t.flag) + + np.testing.assert_array_equal(view.x[:], np.array([2, 3], dtype=np.int32)) + + +def test_column_sum_accepts_dtype(): + t = blosc2.CTable(Row, new_data=DATA) + + result = t.x.sum(dtype=np.float64) + + assert isinstance(result, np.floating) + assert result == 5.0 diff --git a/tests/ctable/test_ctable_computed_cols.py b/tests/ctable/test_ctable_computed_cols.py index d31b32d50..4e0923c8b 100644 --- a/tests/ctable/test_ctable_computed_cols.py +++ b/tests/ctable/test_ctable_computed_cols.py @@ -334,7 +334,8 @@ def test_drop_computed_column(): t.drop_computed_column("total") assert "total" not in t.col_names assert "total" not in t._computed_cols - assert t["total"] is None + with pytest.raises(ValueError, match="Unknown symbol: total"): + _ = t["total"] def test_drop_computed_column_missing_raises(): diff --git a/tests/ctable/test_ctable_dataclass_schema.py b/tests/ctable/test_ctable_dataclass_schema.py index 902832692..e5acb06e1 100644 --- a/tests/ctable/test_ctable_dataclass_schema.py +++ b/tests/ctable/test_ctable_dataclass_schema.py @@ -64,23 +64,23 @@ def test_append_tuple(): t = CTable(Row) t.append((1, 50.0, True)) assert len(t) == 1 - assert t.row[0].id[0] == 1 - assert t.row[0].score[0] == 50.0 - assert t.row[0].active[0] + assert t[0].id == 1 + assert t[0].score == 50.0 + assert t[0].active def test_append_list(): t = CTable(Row) t.append([2, 75.0, False]) assert len(t) == 1 - assert t.row[0].id[0] == 2 + assert t[0].id == 2 def test_append_dict(): t = CTable(Row) t.append({"id": 3, "score": 25.0, "active": True}) assert len(t) == 1 - assert t.row[0].id[0] == 3 + assert t[0].id == 3 def test_append_dataclass_instance(): @@ -95,15 +95,15 @@ class Row2: t2 = CTable(Row2) # Simulate appending a dict (dataclass instance path) t2.append({"id": 4, "score": 10.0, "active": False}) - assert t2.row[0].id[0] == 4 + assert t2[0].id == 4 def test_append_defaults_filled(): """Omitting optional fields fills them from defaults.""" t = CTable(Row) t.append((5,)) # only id; score=0.0 and active=True filled in - assert t.row[0].score[0] == 0.0 - assert t.row[0].active[0] + assert t[0].score == 0.0 + assert t[0].active # ------------------------------------------------------------------- @@ -125,7 +125,7 @@ def test_extend_list_of_dicts(): data = [(i, float(i * 10), True) for i in range(5)] t.extend(data) for i in range(5): - assert t.row[i].id[0] == i + assert t[i].id == i def test_extend_numpy_structured(): @@ -134,8 +134,8 @@ def test_extend_numpy_structured(): t = CTable(Row, expected_size=5) t.extend(arr) assert len(t) == 2 - assert t.row[0].id[0] == 1 - assert t.row[1].score[0] == 75.0 + assert t[0].id == 1 + assert t[1].score == 75.0 # ------------------------------------------------------------------- diff --git a/tests/ctable/test_extend_delete.py b/tests/ctable/test_extend_delete.py index 41e82e4a7..98e017165 100644 --- a/tests/ctable/test_extend_delete.py +++ b/tests/ctable/test_extend_delete.py @@ -50,6 +50,40 @@ def assert_data_at_positions(table: CTable, positions: list, expected_ids: list) # ------------------------------------------------------------------- +def test_extend_dict_rejects_mismatched_column_lengths(): + t = CTable(Row, expected_size=8) + with pytest.raises(ValueError, match="same length"): + t.extend( + { + "id": [1, 2], + "c_val": [0j], + "score": [10.0, 20.0], + "active": [True, False], + } + ) + + +def test_extend_dict_rejects_no_known_columns(): + t = CTable(Row, expected_size=8) + with pytest.raises(ValueError, match="No known stored columns"): + t.extend({"unknown": [1, 2]}) + + +def test_extend_dict_uses_known_column_lengths(): + t = CTable(Row, expected_size=8) + t.extend( + { + "unknown": [0], + "id": [1, 2], + "c_val": [0j, 1j], + "score": [10.0, 20.0], + "active": [True, False], + } + ) + assert len(t) == 2 + assert list(t["id"][:]) == [1, 2] + + def test_gap_fill_mask_and_positions(): """extend and append fill from last valid position; mask is updated correctly.""" # extend after deletions: mask and physical positions @@ -189,13 +223,13 @@ def test_complex_scenarios(): Row, new_data=[(1, 1j, 10.0, True), (2, 2j, 20.0, False), (3, 3j, 30.0, True)], expected_size=10 ) t3.delete(1) - assert t3.row[0].id[0] == 1 - assert t3.row[1].id[0] == 3 + assert t3[0].id == 1 + assert t3[1].id == 3 t3.extend([(10, 10j, 100.0, True), (11, 11j, 100.0, False)]) - assert t3.row[0].id[0] == 1 - assert t3.row[1].id[0] == 3 - assert t3.row[2].id[0] == 10 - assert t3.row[3].id[0] == 11 + assert t3[0].id == 1 + assert t3[1].id == 3 + assert t3[2].id == 10 + assert t3[3].id == 11 # Full workflow t4 = CTable(Row, expected_size=20, compact=False) diff --git a/tests/ctable/test_nullable.py b/tests/ctable/test_nullable.py index 3f88b8fe3..07d9d4478 100644 --- a/tests/ctable/test_nullable.py +++ b/tests/ctable/test_nullable.py @@ -95,6 +95,83 @@ def test_null_value_string(): assert t["label"].null_value == "" +def test_nullable_true_uses_default_null_policy(): + @dataclass + class Row: + i: int = blosc2.field(blosc2.int32(nullable=True)) + u: int = blosc2.field(blosc2.uint32(nullable=True)) + f: float = blosc2.field(blosc2.float64(nullable=True)) + flag: bool = blosc2.field(blosc2.bool(nullable=True)) + s: str = blosc2.field(blosc2.string(max_length=4, nullable=True)) + b: bytes = blosc2.field(blosc2.bytes(max_length=4, nullable=True)) + + t = CTable(Row) + assert t["i"].null_value == np.iinfo(np.int32).min + assert t["u"].null_value == np.iinfo(np.uint32).max + assert math.isnan(t["f"].null_value) + assert t["flag"].null_value == 255 + assert t["s"].null_value == "__BLOSC2_NULL__" + assert t["b"].null_value == b"__BLOSC2_NULL__" + assert t["s"].dtype.itemsize // 4 >= len("__BLOSC2_NULL__") + assert t["b"].dtype.itemsize >= len(b"__BLOSC2_NULL__") + + +def test_nullable_true_uses_null_policy_context_and_column_null_values(): + @dataclass + class Row: + i: int = blosc2.field(blosc2.int32(nullable=True)) + s: str = blosc2.field(blosc2.string(max_length=4, nullable=True)) + + policy = blosc2.NullPolicy( + signed_int_strategy="max", string_value="", column_null_values={"i": -1} + ) + with blosc2.null_policy(policy): + t = CTable(Row) + + assert t["i"].null_value == -1 + assert t["s"].null_value == "" + + +def test_explicit_null_value_overrides_nullable_policy(): + @dataclass + class Row: + i: int = blosc2.field(blosc2.int32(nullable=True, null_value=-5)) + + policy = blosc2.NullPolicy(signed_int_strategy="max") + with blosc2.null_policy(policy): + t = CTable(Row) + + assert t["i"].null_value == -5 + + +def test_add_column_nullable_true_uses_null_policy(): + t = CTable(IntRow) + with blosc2.null_policy(blosc2.NullPolicy(signed_int_strategy="max")): + t.add_column("extra", blosc2.int32(nullable=True), 0) + + assert t["extra"].null_value == np.iinfo(np.int32).max + + +def test_nullable_policy_rejects_out_of_range_integer_sentinel(): + @dataclass + class Row: + x: int = blosc2.field(blosc2.int8(nullable=True)) + + with blosc2.null_policy(blosc2.NullPolicy(column_null_values={"x": 1000})): + with pytest.raises(ValueError, match="outside int8 range"): + CTable(Row) + + +def test_nullable_policy_rejects_wrong_string_sentinel_type(): + @dataclass + class Row: + s: str = blosc2.field(blosc2.string(nullable=True)) + + with blosc2.null_policy(blosc2.NullPolicy(column_null_values={"s": b"NA"})): + with pytest.raises(TypeError, match="must be str"): + CTable(Row) + + # =========================================================================== # is_null / notnull / null_count # =========================================================================== diff --git a/tests/ctable/test_parquet_interop.py b/tests/ctable/test_parquet_interop.py new file mode 100644 index 000000000..efd014dee --- /dev/null +++ b/tests/ctable/test_parquet_interop.py @@ -0,0 +1,622 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +"""Tests for CTable.to_parquet(), from_parquet(), iter_arrow_batches(), +and from_arrow().""" + +from dataclasses import dataclass + +import numpy as np +import pytest + +import blosc2 +from blosc2 import CTable + +pa = pytest.importorskip("pyarrow") +pq = pytest.importorskip("pyarrow.parquet") + + +# --------------------------------------------------------------------------- +# Shared fixtures / dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class Row: + id: int = blosc2.field(blosc2.int64(ge=0)) + score: float = blosc2.field(blosc2.float64(ge=0, le=100), default=0.0) + active: bool = blosc2.field(blosc2.bool(), default=True) + label: str = blosc2.field(blosc2.string(max_length=32), default="") + + +DATA10 = [(i, float(i * 10 % 100), i % 2 == 0, f"row{i}") for i in range(10)] + + +# --------------------------------------------------------------------------- +# iter_arrow_batches +# --------------------------------------------------------------------------- + + +class TestIterArrowBatches: + def test_yields_record_batches(self): + t = CTable(Row, new_data=DATA10) + batches = list(t.iter_arrow_batches()) + assert all(isinstance(b, pa.RecordBatch) for b in batches) + + def test_total_row_count(self): + t = CTable(Row, new_data=DATA10) + total = sum(len(b) for b in t.iter_arrow_batches()) + assert total == 10 + + def test_batching_splits_correctly(self): + t = CTable(Row, new_data=DATA10) + batches = list(t.iter_arrow_batches(batch_size=3)) + sizes = [len(b) for b in batches] + assert sizes == [3, 3, 3, 1] + + def test_column_names(self): + t = CTable(Row, new_data=DATA10) + (batch,) = t.iter_arrow_batches() + assert batch.schema.names == ["id", "score", "active", "label"] + + def test_int_values(self): + t = CTable(Row, new_data=DATA10) + (batch,) = t.iter_arrow_batches() + np.testing.assert_array_equal(batch.column("id").to_pylist(), [r[0] for r in DATA10]) + + def test_float_values(self): + t = CTable(Row, new_data=DATA10) + (batch,) = t.iter_arrow_batches() + np.testing.assert_allclose(batch.column("score").to_pylist(), [r[1] for r in DATA10]) + + def test_bool_values(self): + t = CTable(Row, new_data=DATA10) + (batch,) = t.iter_arrow_batches() + assert batch.column("active").to_pylist() == [r[2] for r in DATA10] + + def test_string_values(self): + t = CTable(Row, new_data=DATA10) + (batch,) = t.iter_arrow_batches() + assert batch.column("label").to_pylist() == [r[3] for r in DATA10] + + def test_empty_table_yields_nothing(self): + t = CTable(Row) + batches = list(t.iter_arrow_batches()) + assert batches == [] + + def test_column_projection(self): + t = CTable(Row, new_data=DATA10) + (batch,) = t.iter_arrow_batches(columns=["id", "score"]) + assert batch.schema.names == ["id", "score"] + assert len(batch) == 10 + + def test_column_projection_unknown_raises(self): + t = CTable(Row, new_data=DATA10) + with pytest.raises(KeyError, match="nope"): + list(t.iter_arrow_batches(columns=["nope"])) + + def test_skips_deleted_rows(self): + t = CTable(Row, new_data=DATA10) + t.delete([0, 1, 2]) + total = sum(len(b) for b in t.iter_arrow_batches()) + assert total == 7 + + def test_include_computed_false(self): + t = CTable(Row, new_data=DATA10) + t.add_computed_column("double_id", "id * 2") + (batch,) = t.iter_arrow_batches(include_computed=False) + assert "double_id" not in batch.schema.names + + def test_computed_column_values(self): + t = CTable(Row, new_data=DATA10) + t.add_computed_column("double_id", "id * 2") + (batch,) = t.iter_arrow_batches() + assert batch.column("double_id").to_pylist() == [i * 2 for i in range(10)] + + def test_invalid_batch_size(self): + t = CTable(Row, new_data=DATA10) + with pytest.raises(ValueError, match="batch_size"): + list(t.iter_arrow_batches(batch_size=0)) + + +# --------------------------------------------------------------------------- +# to_parquet / from_parquet round-trips +# --------------------------------------------------------------------------- + + +class TestParquetRoundTrip: + def test_basic_roundtrip(self, tmp_path): + t = CTable(Row, new_data=DATA10) + path = tmp_path / "basic.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path) + assert len(t2) == 10 + assert t2.col_names == ["id", "score", "active", "label"] + np.testing.assert_array_equal(t2["id"][:], t["id"][:]) + np.testing.assert_allclose(t2["score"][:], t["score"][:]) + np.testing.assert_array_equal(t2["active"][:], t["active"][:]) + # label is re-imported as vlstring when no string_max_length is given + assert list(t2["label"][:]) == t["label"][:].tolist() + + def test_roundtrip_all_numeric_types(self, tmp_path): + at = pa.table( + { + "i8": pa.array([1, 2, 3], type=pa.int8()), + "i16": pa.array([1, 2, 3], type=pa.int16()), + "i32": pa.array([1, 2, 3], type=pa.int32()), + "i64": pa.array([1, 2, 3], type=pa.int64()), + "u8": pa.array([1, 2, 3], type=pa.uint8()), + "u16": pa.array([1, 2, 3], type=pa.uint16()), + "u32": pa.array([1, 2, 3], type=pa.uint32()), + "u64": pa.array([1, 2, 3], type=pa.uint64()), + "f32": pa.array([1.0, 2.0, 3.0], type=pa.float32()), + "f64": pa.array([1.0, 2.0, 3.0], type=pa.float64()), + } + ) + t = CTable.from_arrow(at.schema, at.to_batches()) + path = tmp_path / "numeric.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path) + assert t2.col_names == list(at.column_names) + assert len(t2) == 3 + + def test_roundtrip_bool(self, tmp_path): + at = pa.table({"flag": pa.array([True, False, True], type=pa.bool_())}) + t = CTable.from_arrow(at.schema, at.to_batches()) + path = tmp_path / "bool.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path) + assert t2["flag"][:].tolist() == [True, False, True] + + def test_roundtrip_strings(self, tmp_path): + at = pa.table({"name": pa.array(["alice", "bob", "carol"], type=pa.string())}) + t = CTable.from_arrow(at.schema, at.to_batches()) + path = tmp_path / "strings.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path) + # vlstring column — [:] returns a Python list, not a numpy array + assert list(t2["name"][:]) == ["alice", "bob", "carol"] + + def test_roundtrip_bytes(self, tmp_path): + at = pa.table({"data": pa.array([b"hello", b"world", b"foo"], type=pa.large_binary())}) + t = CTable.from_arrow(at.schema, at.to_batches()) + path = tmp_path / "bytes.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path) + # vlbytes column — [:] returns a Python list of bytes objects + raw = t2["data"][:] + assert raw == [b"hello", b"world", b"foo"] + + def test_roundtrip_list_column(self, tmp_path): + @dataclass + class ListRow: + vals: list[int] = blosc2.field( # noqa: RUF009 + blosc2.list(blosc2.int64(), storage="batch", serializer="msgpack") + ) + + data = [([1, 2, 3],), ([4, 5],), ([],)] + t = CTable(ListRow, new_data=data) + path = tmp_path / "lists.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path) + assert len(t2) == 3 + assert t2["vals"][0] == [1, 2, 3] + assert t2["vals"][1] == [4, 5] + assert t2["vals"][2] == [] + + def test_roundtrip_list_struct_column(self, tmp_path): + struct_type = pa.struct([pa.field("a", pa.int32()), pa.field("b", pa.string())]) + at = pa.table( + { + "items": pa.array( + [[{"a": 1, "b": "x"}], None, [{"a": 2, "b": "yy"}]], + type=pa.list_(struct_type), + ) + } + ) + path = tmp_path / "list_struct.parquet" + pq.write_table(at, path) + t = CTable.from_parquet(path) + assert t["items"][0] == [{"a": 1, "b": "x"}] + assert t["items"][1] is None + out = tmp_path / "list_struct_out.parquet" + t.to_parquet(out) + rt = pq.read_table(out) + assert rt.schema == at.schema + assert rt["items"].to_pylist() == at["items"].to_pylist() + assert "arrow" in t._schema.metadata + + def test_empty_table_export_import(self, tmp_path): + t = CTable(Row) + path = tmp_path / "empty.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path) + assert len(t2) == 0 + assert t2.col_names == ["id", "score", "active", "label"] + + def test_column_projection_export(self, tmp_path): + t = CTable(Row, new_data=DATA10) + path = tmp_path / "proj.parquet" + t.to_parquet(path, columns=["id", "score"]) + t2 = CTable.from_parquet(path) + assert t2.col_names == ["id", "score"] + assert len(t2) == 10 + + def test_column_projection_import(self, tmp_path): + t = CTable(Row, new_data=DATA10) + path = tmp_path / "full.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path, columns=["id", "label"]) + assert t2.col_names == ["id", "label"] + assert len(t2) == 10 + + def test_computed_column_exported_as_values(self, tmp_path): + t = CTable(Row, new_data=DATA10) + t.add_computed_column("double_id", "id * 2") + path = tmp_path / "computed.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path) + assert "double_id" in t2.col_names + # double_id is stored, not computed in t2 + assert "double_id" not in t2._computed_cols + np.testing.assert_array_equal( + t2["double_id"][:], np.array([i * 2 for i in range(10)], dtype=np.int64) + ) + + def test_exclude_computed_columns(self, tmp_path): + t = CTable(Row, new_data=DATA10) + t.add_computed_column("double_id", "id * 2") + path = tmp_path / "no_computed.parquet" + t.to_parquet(path, include_computed=False) + t2 = CTable.from_parquet(path) + assert "double_id" not in t2.col_names + + def test_only_live_rows_exported(self, tmp_path): + t = CTable(Row, new_data=DATA10) + t.delete([0, 1]) + path = tmp_path / "deleted.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path) + assert len(t2) == 8 + np.testing.assert_array_equal(t2["id"][:], list(range(2, 10))) + + def test_multiple_batches_written(self, tmp_path): + t = CTable(Row, new_data=DATA10) + path = tmp_path / "multi.parquet" + t.to_parquet(path, batch_size=3) + meta = pq.read_metadata(path) + assert meta.num_row_groups == 4 # 3+3+3+1 + + def test_persistent_urlpath(self, tmp_path): + t = CTable(Row, new_data=DATA10) + parquet_path = tmp_path / "data.parquet" + ctable_path = str(tmp_path / "data.b2d") + t.to_parquet(parquet_path) + t2 = CTable.from_parquet(parquet_path, urlpath=ctable_path) + assert len(t2) == 10 + import os + + assert os.path.exists(ctable_path) + + def test_different_compression(self, tmp_path): + t = CTable(Row, new_data=DATA10) + for codec in ["snappy", "lz4", None]: + path = tmp_path / f"{codec}.parquet" + t.to_parquet(path, compression=codec) + t2 = CTable.from_parquet(path) + assert len(t2) == 10 + + def test_roundtrip_larger_table_batch_import(self, tmp_path): + """Verify correct row count with batch_size < n_rows on import.""" + t = CTable(Row, new_data=DATA10) + path = tmp_path / "large.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path, batch_size=3) + assert len(t2) == 10 + np.testing.assert_array_equal(t2["id"][:], t["id"][:]) + + def test_interop_read_with_pyarrow(self, tmp_path): + """CTable-written Parquet is readable by PyArrow.""" + t = CTable(Row, new_data=DATA10) + path = tmp_path / "compat.parquet" + t.to_parquet(path) + at = pq.read_table(path) + assert len(at) == 10 + assert at.column_names == ["id", "score", "active", "label"] + + def test_interop_write_with_pyarrow(self, tmp_path): + """PyArrow-written Parquet is readable by CTable.""" + at = pa.table( + { + "x": pa.array([1, 2, 3], type=pa.int32()), + "y": pa.array([1.1, 2.2, 3.3], type=pa.float64()), + } + ) + path = tmp_path / "pyarrow_written.parquet" + pq.write_table(at, path) + t = CTable.from_parquet(path) + assert len(t) == 3 + assert t.col_names == ["x", "y"] + + def test_from_arrow_blosc2_batch_size_default(self): + at = pa.table({"vals": pa.array([[1], [2, 3]], type=pa.list_(pa.int64()))}) + t = CTable.from_arrow(at.schema, at.to_batches()) + assert t._schema.columns_by_name["vals"].spec.batch_rows == 2048 + assert t["vals"][0] == [1] + assert t["vals"][1] == [2, 3] + + def test_from_arrow_blosc2_batch_size_override_and_none(self): + at = pa.table({"vals": pa.array([[1], [2], [3]], type=pa.list_(pa.int64()))}) + t = CTable.from_arrow(at.schema, at.to_batches(max_chunksize=1), blosc2_batch_size=2) + assert t._schema.columns_by_name["vals"].spec.batch_rows == 2 + + t2 = CTable.from_arrow(at.schema, at.to_batches(max_chunksize=1), blosc2_batch_size=None) + assert t2._schema.columns_by_name["vals"].spec.batch_rows is None + + def test_from_arrow_invalid_blosc2_batch_size_raises(self): + at = pa.table({"vals": pa.array([[1]], type=pa.list_(pa.int64()))}) + with pytest.raises(ValueError, match="blosc2_batch_size"): + CTable.from_arrow(at.schema, at.to_batches(), blosc2_batch_size=0) + + def test_vlstring_arrow_roundtrip_no_singleton_list(self): + """Scalar string columns import as vlstring (not list) without singleton wrapping.""" + long_str = "x" * 500 + at = pa.table({"txt": pa.array(["short", long_str, None, "end"], type=pa.string())}) + t = CTable.from_arrow(at.schema, at.to_batches()) + assert t["txt"].is_varlen_scalar + assert list(t["txt"][:]) == ["short", long_str, None, "end"] + # Export back to Arrow → still a scalar string column, not list + out = t.to_arrow() + assert pa.types.is_string(out.schema.field("txt").type) + assert out.column("txt").to_pylist() == ["short", long_str, None, "end"] + + def test_vlbytes_arrow_roundtrip_no_singleton_list(self): + """Scalar binary columns import as vlbytes (not list) without singleton wrapping.""" + long_bin = b"b" * 500 + at = pa.table({"bin": pa.array([b"short", long_bin, None, b"end"], type=pa.large_binary())}) + t = CTable.from_arrow(at.schema, at.to_batches()) + assert t["bin"].is_varlen_scalar + assert list(t["bin"][:]) == [b"short", long_bin, None, b"end"] + out = t.to_arrow() + assert pa.types.is_large_binary(out.schema.field("bin").type) + assert out.column("bin").to_pylist() == [b"short", long_bin, None, b"end"] + + def test_vlstring_parquet_roundtrip(self, tmp_path): + """Parquet import/export round-trips long scalar strings without singleton-list wrapping.""" + long_str = "y" * 1000 + at = pa.table( + { + "id": pa.array([0, 1, 2, 3], type=pa.int64()), + "txt": pa.array(["short", long_str, None, "end"], type=pa.string()), + } + ) + path = tmp_path / "vlstring.parquet" + pq.write_table(at, path) + + t = CTable.from_parquet(path) + assert t["txt"].is_varlen_scalar + assert list(t["txt"][:]) == ["short", long_str, None, "end"] + + out = tmp_path / "vlstring_out.parquet" + t.to_parquet(out) + rt = pq.read_table(out) + assert pa.types.is_string(rt.schema.field("txt").type) + assert rt.column("txt").to_pylist() == ["short", long_str, None, "end"] + + +# --------------------------------------------------------------------------- +# Null handling +# --------------------------------------------------------------------------- + + +class TestNullHandling: + def test_nullable_list_column_roundtrip(self, tmp_path): + @dataclass + class NullableListRow: + vals: list[int] = blosc2.field( # noqa: RUF009 + blosc2.list(blosc2.int64(), nullable=True, storage="batch", serializer="msgpack") + ) + + data = [([1, 2],), (None,), ([3],)] + t = CTable(NullableListRow, new_data=data) + path = tmp_path / "nullable_list.parquet" + t.to_parquet(path) + t2 = CTable.from_parquet(path) + assert len(t2) == 3 + assert t2["vals"][0] == [1, 2] + assert t2["vals"][1] is None + assert t2["vals"][2] == [3] + + def test_scalar_null_no_sentinel_raises(self, tmp_path): + """Importing Parquet scalar nulls without a null_value sentinel fails.""" + at = pa.table({"score": pa.array([1.0, None, 3.0], type=pa.float64())}) + path = tmp_path / "nulls.parquet" + pq.write_table(at, path) + with pytest.raises(TypeError, match="null_value sentinel"): + CTable.from_parquet(path, auto_null_sentinels=False) + + def test_scalar_null_exported_as_parquet_null(self, tmp_path): + """Sentinel values become Parquet nulls on export.""" + + @dataclass + class NullRow: + score: float = blosc2.field(blosc2.float64(null_value=float("nan")), default=float("nan")) + + t = CTable(NullRow, new_data=[(1.0,), (float("nan"),), (3.0,)]) + path = tmp_path / "null_export.parquet" + t.to_parquet(path) + at = pq.read_table(path) + nulls = at["score"].is_null().to_pylist() + assert nulls == [False, True, False] + + def test_auto_nullable_scalars_roundtrip(self, tmp_path): + at = pa.table( + { + "i": pa.array([1, None, 3], type=pa.int32()), + "f": pa.array([1.0, None, 3.0], type=pa.float64()), + "s": pa.array(["a", None, "c"], type=pa.string()), + "b": pa.array([b"a", None, b"c"], type=pa.large_binary()), + "flag": pa.array([True, None, False], type=pa.bool_()), + } + ) + path = tmp_path / "nullable_scalars.parquet" + pq.write_table(at, path) + t = CTable.from_parquet(path) + assert t["i"].null_count() == 1 + assert t["f"].null_count() == 1 + assert t["s"].null_count() == 1 + assert t["b"].null_count() == 1 + assert t["flag"].null_count() == 1 + assert t["flag"][:].tolist() == [1, 255, 0] + out = tmp_path / "nullable_scalars_out.parquet" + t.to_parquet(out) + rt = pq.read_table(out) + assert rt.schema == at.schema + assert rt.to_pylist() == at.to_pylist() + + def test_null_policy_controls_default_sentinels(self): + # Null policy sentinels apply to fixed-width scalar columns (int, float, bool, string + # with an explicit string_max_length). vlstring / vlbytes columns represent + # nulls natively as None and do NOT use sentinels. + at = pa.table( + { + "i": pa.array([1, None, 3], type=pa.int32()), + } + ) + policy = blosc2.NullPolicy(signed_int_strategy="max") + with blosc2.null_policy(policy): + t = CTable.from_arrow(at.schema, at.to_batches()) + assert blosc2.get_null_policy() is blosc2.DEFAULT_NULL_POLICY + assert t._schema.columns_by_name["i"].spec.null_value == np.iinfo(np.int32).max + assert t["i"].null_count() == 1 + + def test_null_policy_string_value_applies_to_fixed_width_strings(self): + """string_value in NullPolicy applies when string_max_length is given explicitly.""" + at = pa.table( + { + "i": pa.array([1, None, 3], type=pa.int32()), + "s": pa.array(["a", None, "c"], type=pa.string()), + } + ) + policy = blosc2.NullPolicy(signed_int_strategy="max", string_value="") + with blosc2.null_policy(policy): + t = CTable.from_arrow(at.schema, at.to_batches(), string_max_length=32) + assert t._schema.columns_by_name["i"].spec.null_value == np.iinfo(np.int32).max + assert t._schema.columns_by_name["s"].spec.null_value == "" + assert t["i"].null_count() == 1 + assert t["s"].null_count() == 1 + + def test_null_values_override_policy_and_auto_false(self, tmp_path): + # column_null_values only applies to fixed-width scalar columns. + # vlstring / vlbytes columns represent nulls as native None and do not + # accept column_null_values overrides. + at = pa.table( + { + "i": pa.array([1, None, 3], type=pa.int32()), + } + ) + policy = blosc2.NullPolicy(column_null_values={"i": -1}) + with blosc2.null_policy(policy): + t = CTable.from_arrow(at.schema, at.to_batches(), auto_null_sentinels=False) + assert t._schema.columns_by_name["i"].spec.null_value == -1 + assert t["i"].null_count() == 1 + + path = tmp_path / "null_values.parquet" + pq.write_table(at, path) + with blosc2.null_policy(policy): + t2 = CTable.from_parquet(path, auto_null_sentinels=False) + assert t2._schema.columns_by_name["i"].spec.null_value == -1 + + def test_null_policy_rejects_vlstring_column_null_values(self): + """Passing column_null_values for a vlstring column raises TypeError.""" + at = pa.table({"s": pa.array(["a", None, "c"], type=pa.string())}) + policy = blosc2.NullPolicy(column_null_values={"s": "NA"}) + with blosc2.null_policy(policy), pytest.raises(TypeError, match="vlstring"): + CTable.from_arrow(at.schema, at.to_batches()) + + def test_null_policy_unknown_column_raises(self): + at = pa.table({"i": pa.array([1, None], type=pa.int32())}) + policy = blosc2.NullPolicy(column_null_values={"missing": -1}) + with blosc2.null_policy(policy), pytest.raises(KeyError, match="unknown columns"): + CTable.from_arrow(at.schema, at.to_batches()) + + def test_null_policy_rejects_list_columns(self): + at = pa.table({"vals": pa.array([[1], None], type=pa.list_(pa.int64()))}) + policy = blosc2.NullPolicy(column_null_values={"vals": []}) + with blosc2.null_policy(policy), pytest.raises(TypeError, match="only supports scalar columns"): + CTable.from_arrow(at.schema, at.to_batches()) + + def test_nullable_bool_filter_semantics(self, tmp_path): + at = pa.table({"flag": pa.array([True, None, False], type=pa.bool_())}) + path = tmp_path / "nullable_bool.parquet" + pq.write_table(at, path) + t = CTable.from_parquet(path) + assert t.where(t.flag).flag[:].tolist() == [1] + assert t.where(~t.flag).flag[:].tolist() == [0] + assert t.where(t.flag == True).flag[:].tolist() == [1] # noqa: E712 + assert t.where(t.flag == False).flag[:].tolist() == [0] # noqa: E712 + assert t.where(t.flag != True).flag[:].tolist() == [0] # noqa: E712 + assert t.where(t.flag != False).flag[:].tolist() == [1] # noqa: E712 + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestErrors: + def test_missing_pyarrow_to_parquet(self, tmp_path, monkeypatch): + """to_parquet raises ImportError when pyarrow is not available.""" + import sys + + monkeypatch.setitem(sys.modules, "pyarrow.parquet", None) + t = CTable(Row, new_data=DATA10) + with pytest.raises(ImportError, match="pyarrow"): + t.to_parquet(tmp_path / "x.parquet") + + def test_missing_pyarrow_from_parquet(self, tmp_path, monkeypatch): + """from_parquet raises ImportError when pyarrow is not available.""" + import sys + + t = CTable(Row, new_data=DATA10) + path = tmp_path / "x.parquet" + t.to_parquet(path) + monkeypatch.setitem(sys.modules, "pyarrow.parquet", None) + with pytest.raises(ImportError, match="pyarrow"): + CTable.from_parquet(path) + + def test_unsupported_arrow_type(self, tmp_path): + at = pa.table({"ts": pa.array([1, 2, 3], type=pa.timestamp("s"))}) + path = tmp_path / "ts.parquet" + pq.write_table(at, path) + with pytest.raises(TypeError, match="No blosc2 spec"): + CTable.from_parquet(path) + + def test_invalid_batch_size_to_parquet(self, tmp_path): + t = CTable(Row, new_data=DATA10) + with pytest.raises(ValueError, match="batch_size"): + t.to_parquet(tmp_path / "x.parquet", batch_size=0) + + def test_invalid_batch_size_from_parquet(self, tmp_path): + t = CTable(Row, new_data=DATA10) + path = tmp_path / "x.parquet" + t.to_parquet(path) + with pytest.raises(ValueError, match="batch_size"): + CTable.from_parquet(path, batch_size=0) + + def test_string_truncation_error(self, tmp_path): + """Importing longer strings than max_length raises ValueError.""" + at = pa.table({"name": pa.array(["a" * 300, "b"], type=pa.string())}) + path = tmp_path / "long_str.parquet" + pq.write_table(at, path) + # Explicit small max_length should raise on import + with pytest.raises(ValueError, match="max_length"): + CTable.from_parquet(path, string_max_length=10) + + +if __name__ == "__main__": + pytest.main(["-v", __file__]) diff --git a/tests/ctable/test_row_logic.py b/tests/ctable/test_row_logic.py index bece75c02..1f6b22077 100644 --- a/tests/ctable/test_row_logic.py +++ b/tests/ctable/test_row_logic.py @@ -37,30 +37,28 @@ def test_row_int_indexing(): # No holes: spot checks t = CTable(Row, new_data=data) - r = t.row[0] - assert isinstance(r, CTable) - assert len(r) == 1 - assert r.id[0] == 0 - assert r.score[0] == 0.0 - assert r.active[0] - assert t.row[10].id[0] == 10 - assert t.row[10].score[0] == 100.0 + r = t[0] + assert r.id == 0 + assert r.score == 0.0 + assert r.active + assert t[10].id == 10 + assert t[10].score == 100.0 # Negative indices - assert t.row[-1].id[0] == 19 - assert t.row[-5].id[0] == 15 + assert t[-1].id == 19 + assert t[-5].id == 15 # With holes: delete odd positions -> valid: 0,2,4,6,8,10... t.delete([1, 3, 5, 7, 9]) - assert t.row[0].id[0] == 0 - assert t.row[1].id[0] == 2 - assert t.row[5].id[0] == 10 + assert t[0].id == 0 + assert t[1].id == 2 + assert t[5].id == 10 # Out of range t2 = CTable(Row, new_data=generate_test_data(10)) for idx in [10, 100, -11]: with pytest.raises(IndexError): - _ = t2.row[idx] + _ = t2[idx] def test_row_slice_indexing(): @@ -69,34 +67,34 @@ def test_row_slice_indexing(): # No holes t = CTable(Row, new_data=data) - assert isinstance(t.row[0:5], CTable) - assert list(t.row[0:5].id) == [0, 1, 2, 3, 4] - assert list(t.row[10:15].id) == [10, 11, 12, 13, 14] - assert list(t.row[::2].id) == [0, 2, 4, 6, 8, 10, 12, 14, 16, 18] + assert isinstance(t[0:5], CTable) + assert list(t[0:5].id) == [0, 1, 2, 3, 4] + assert list(t[10:15].id) == [10, 11, 12, 13, 14] + assert list(t[::2].id) == [0, 2, 4, 6, 8, 10, 12, 14, 16, 18] # With step - assert list(t.row[0:10:2].id) == [0, 2, 4, 6, 8] - assert list(t.row[1:10:3].id) == [1, 4, 7] + assert list(t[0:10:2].id) == [0, 2, 4, 6, 8] + assert list(t[1:10:3].id) == [1, 4, 7] # Negative indices - assert list(t.row[-5:].id) == [15, 16, 17, 18, 19] - assert list(t.row[-10:-5].id) == [10, 11, 12, 13, 14] + assert list(t[-5:].id) == [15, 16, 17, 18, 19] + assert list(t[-10:-5].id) == [10, 11, 12, 13, 14] # With holes: delete odd positions t.delete([1, 3, 5, 7, 9]) - assert list(t.row[0:5].id) == [0, 2, 4, 6, 8] - assert list(t.row[5:10].id) == [10, 11, 12, 13, 14] + assert list(t[0:5].id) == [0, 2, 4, 6, 8] + assert list(t[5:10].id) == [10, 11, 12, 13, 14] # Beyond bounds t2 = CTable(Row, new_data=generate_test_data(10)) - assert len(t2.row[11:20]) == 0 - assert list(t2.row[5:100].id) == [5, 6, 7, 8, 9] - assert len(t2.row[100:]) == 0 + assert len(t2[11:20]) == 0 + assert list(t2[5:100].id) == [5, 6, 7, 8, 9] + assert len(t2[100:]) == 0 # Empty and full slices - assert len(t2.row[5:5]) == 0 - assert len(t2.row[0:0]) == 0 - result = t2.row[:] + assert len(t2[5:5]) == 0 + assert len(t2[0:0]) == 0 + result = t2[:] assert len(result) == 10 assert list(result.id) == list(range(10)) @@ -107,36 +105,36 @@ def test_row_list_indexing(): # No holes t = CTable(Row, new_data=data) - r = t.row[[0, 5, 10, 15]] + r = t[[0, 5, 10, 15]] assert isinstance(r, CTable) assert len(r) == 4 assert set(r.id) == {0, 5, 10, 15} - assert set(t.row[[19, 0, 10]].id) == {0, 10, 19} + assert set(t[[19, 0, 10]].id) == {0, 10, 19} # With holes: delete [1,3,5,7,9] -> logical 0->id0, 1->id2, 2->id4... t.delete([1, 3, 5, 7, 9]) - assert set(t.row[[0, 2, 4]].id) == {0, 4, 8} - assert set(t.row[[5, 3, 1]].id) == {2, 6, 10} + assert set(t[[0, 2, 4]].id) == {0, 4, 8} + assert set(t[[5, 3, 1]].id) == {2, 6, 10} # Negative indices in list t2 = CTable(Row, new_data=generate_test_data(10)) - assert set(t2.row[[0, -1, 5]].id) == {0, 5, 9} + assert set(t2[[0, -1, 5]].id) == {0, 5, 9} # Single element - assert t2.row[[5]].id[0] == 5 + assert t2[[5]].id[0] == 5 # Duplicate indices -> deduplicated - r_dup = t2.row[[5, 5, 5]] + r_dup = t2[[5, 5, 5]] assert len(r_dup) == 1 assert r_dup.id[0] == 5 # Empty list - assert len(t2.row[[]]) == 0 + assert len(t2[[]]) == 0 # Out of range for bad in [[0, 5, 100], [0, 1, -11]]: with pytest.raises(IndexError): - _ = t2.row[bad] + _ = t2[bad] def test_row_view_properties(): @@ -148,7 +146,7 @@ def test_row_view_properties(): assert tabla0.base is None # View properties are shared with parent - v = tabla0.row[0:10] + v = tabla0[0:10] assert v.base is tabla0 assert v._row_type == tabla0._row_type assert v._cols is tabla0._cols @@ -156,7 +154,7 @@ def test_row_view_properties(): assert v.col_names == tabla0.col_names # Read ops on view - view = tabla0.row[5:15] + view = tabla0[5:15] assert view.id[0] == 5 assert view.score[0] == 50.0 assert not view.active[0] @@ -171,26 +169,26 @@ def test_row_view_properties(): assert col._table is view # Chained views: base always points to immediate parent - tabla1 = tabla0.row[:50] + tabla1 = tabla0[:50] assert tabla1.base is tabla0 assert len(tabla1) == 50 - tabla2 = tabla1.row[:10] + tabla2 = tabla1[:10] assert tabla2.base is tabla1 assert len(tabla2) == 10 assert list(tabla2.id) == list(range(10)) - tabla3 = tabla2.row[5:] + tabla3 = tabla2[5:] assert tabla3.base is tabla2 assert len(tabla3) == 5 assert list(tabla3.id) == [5, 6, 7, 8, 9] # Chained view with holes on parent tabla0.delete([5, 10, 15, 20, 25]) - tv1 = tabla0.row[:30] + tv1 = tabla0[:30] assert tv1.base is tabla0 assert len(tv1) == 30 - tv2 = tv1.row[10:20] + tv2 = tv1[10:20] assert tv2.base is tv1 assert len(tv2) == 10 @@ -200,17 +198,17 @@ def test_row_edge_cases(): # Empty table empty = CTable(Row) with pytest.raises(IndexError): - _ = empty.row[0] - assert len(empty.row[:]) == 0 - assert len(empty.row[0:10]) == 0 + _ = empty[0] + assert len(empty[:]) == 0 + assert len(empty[0:10]) == 0 # All rows deleted data = generate_test_data(10) t = CTable(Row, new_data=data) t.delete(list(range(10))) with pytest.raises(IndexError): - _ = t.row[0] - assert len(t.row[:]) == 0 + _ = t[0] + assert len(t[:]) == 0 if __name__ == "__main__": diff --git a/tests/ctable/test_schema_validation.py b/tests/ctable/test_schema_validation.py index 2d51d29f0..e6f4169bb 100644 --- a/tests/ctable/test_schema_validation.py +++ b/tests/ctable/test_schema_validation.py @@ -63,7 +63,7 @@ def test_append_default_fill(): # Only id is required; score and active have defaults t.append((5,)) # score=0.0, active=True filled by defaults assert len(t) == 1 - assert t.row[0].id[0] == 5 + assert t[0].id == 5 def test_append_validate_false(): diff --git a/tests/ctable/test_sort_by.py b/tests/ctable/test_sort_by.py index cf4b335ec..26b8bbd1e 100644 --- a/tests/ctable/test_sort_by.py +++ b/tests/ctable/test_sort_by.py @@ -29,6 +29,14 @@ class StrRow: rank: int = blosc2.field(blosc2.int64(ge=0), default=0) +@dataclass +class ListRow: + id: int = blosc2.field(blosc2.int64(ge=0)) + tags: list[str] = blosc2.field( # noqa: RUF009 + blosc2.list(blosc2.string(max_length=16), nullable=True, batch_rows=2) + ) + + DATA = [ (3, 80.0, True), (1, 50.0, False), @@ -185,6 +193,28 @@ def test_sort_all_columns_consistent(): assert v == pytest.approx(expected[int(i)]) +def test_sort_copy_keeps_list_columns_aligned(): + data = [(3, ["c"]), (1, ["a", "one"]), (4, ["d"]), (2, None), (0, [])] + t = CTable(ListRow, new_data=data) + + s = t.sort_by("id") + + assert list(s["id"][:]) == [0, 1, 2, 3, 4] + assert s["tags"][:] == [[], ["a", "one"], None, ["c"], ["d"]] + assert t["tags"][:] == [["c"], ["a", "one"], ["d"], None, []] + + +def test_sort_inplace_keeps_list_columns_aligned(): + data = [(3, ["c"]), (1, ["a", "one"]), (4, ["d"]), (2, None), (0, [])] + t = CTable(ListRow, new_data=data) + + result = t.sort_by("id", inplace=True) + + assert result is t + assert list(t["id"][:]) == [0, 1, 2, 3, 4] + assert t["tags"][:] == [[], ["a", "one"], None, ["c"], ["d"]] + + # =========================================================================== # Edge cases # =========================================================================== @@ -221,11 +251,19 @@ def test_sort_reverse_sorted(): # =========================================================================== -def test_sort_view_raises(): +def test_sort_view_inplace_raises(): + t = CTable(Row, new_data=DATA) + view = t.where(t["id"] > 2) + with pytest.raises(ValueError, match="inplace"): + view.sort_by("id", inplace=True) + + +def test_sort_view_copy_works(): t = CTable(Row, new_data=DATA) view = t.where(t["id"] > 2) - with pytest.raises(ValueError, match="view"): - view.sort_by("id") + sorted_view = view.sort_by("id", ascending=False) + ids = [sorted_view["id"][i] for i in range(len(sorted_view))] + assert ids == sorted(ids, reverse=True) def test_sort_unknown_column_raises(): diff --git a/tests/ctable/test_table_persistency.py b/tests/ctable/test_table_persistency.py index 815c9be01..18be04caa 100644 --- a/tests/ctable/test_table_persistency.py +++ b/tests/ctable/test_table_persistency.py @@ -97,6 +97,19 @@ def test_reopen_with_ctable_constructor(): assert list(t2["score"][:]) == [10.0, 20.0, 30.0] +def test_close_propagates_varlen_flush_errors(monkeypatch): + """User-called close() must not hide pending varlen flush failures.""" + path = table_path("close_flush_error") + t = CTable(Row, urlpath=path, mode="w", expected_size=16) + + def fail_flush(): + raise RuntimeError("flush failed") + + monkeypatch.setattr(t, "_flush_varlen_columns", fail_flush) + with pytest.raises(RuntimeError, match="flush failed"): + t.close() + + def test_reopen_with_open_classmethod(): """CTable.open() returns a read-only table with correct data.""" path = table_path("ro") @@ -148,7 +161,7 @@ def test_append_after_reopen(): t2 = CTable(Row, urlpath=path, mode="a") t2.append((3, 30.0, True)) assert len(t2) == 3 - assert t2.row[2].id[0] == 3 + assert t2[2].id == 3 # Verify it's visible in a third open t3 = CTable(Row, urlpath=path, mode="a") @@ -280,7 +293,7 @@ def test_read_only_allows_reads(): t2 = CTable.open(path, mode="r") assert len(t2) == 3 - assert t2.row[0].id[0] == 1 + assert t2[0].id == 1 assert list(t2["score"][:]) == [10.0, 20.0, 30.0] assert len(t2.head(2)) == 2 assert len(t2.tail(1)) == 1 diff --git a/tests/ctable/test_varlen_columns.py b/tests/ctable/test_varlen_columns.py new file mode 100644 index 000000000..1678a8495 --- /dev/null +++ b/tests/ctable/test_varlen_columns.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +import blosc2 + + +@dataclass +class Product: + code: str = blosc2.field(blosc2.string(max_length=8)) + qty: int = blosc2.field(blosc2.int32()) + tags: list[str] = blosc2.field( # noqa: RUF009 + blosc2.list(blosc2.string(max_length=16), nullable=True, batch_rows=2) + ) + + +DATA = [ + ("a", 1, ["x", "y"]), + ("b", 2, []), + ("c", 3, None), + ("d", 4, ["z"]), +] + + +def test_ctable_varlen_append_extend_and_reads(): + t = blosc2.CTable(Product) + t.append(DATA[0]) + t.extend(DATA[1:]) + + assert len(t) == 4 + assert t.tags[0] == ["x", "y"] + assert t.tags[1:4] == [[], None, ["z"]] + assert t[2].tags is None + + t.tags[2] = ["r", "s"] + assert t.tags[2] == ["r", "s"] + + +def test_ctable_varlen_where_select_head_tail_and_compact(): + t = blosc2.CTable(Product, new_data=DATA) + view = t.where(t.qty >= 2) + assert view.tags[:] == [[], None, ["z"]] + sel = t.select(["code", "tags"]) + assert sel.tags[:] == [["x", "y"], [], None, ["z"]] + assert t.head(2).tags[:] == [["x", "y"], []] + assert t.tail(2).tags[:] == [None, ["z"]] + + t.delete([1]) + t.compact() + assert t.tags[:] == [["x", "y"], None, ["z"]] + + +def test_ctable_varlen_persistence_save_load_open(tmp_path): + path = tmp_path / "products.b2d" + t = blosc2.CTable(Product, new_data=DATA, urlpath=str(path), mode="w") + t.close() + + opened = blosc2.CTable.open(str(path), mode="r") + assert opened.tags[:] == [["x", "y"], [], None, ["z"]] + + loaded = blosc2.CTable.load(str(path)) + assert loaded.tags[:] == [["x", "y"], [], None, ["z"]] + loaded.tags[1] = ["changed"] + assert loaded.tags[1] == ["changed"] + + save_path = tmp_path / "products-save.b2d" + loaded.save(str(save_path)) + reopened = blosc2.CTable.open(str(save_path), mode="r") + assert reopened.tags[:] == [["x", "y"], ["changed"], None, ["z"]] + + +def test_ctable_varlen_arrow_roundtrip(): + pytest.importorskip("pyarrow") + + t = blosc2.CTable(Product, new_data=DATA) + arrow = t.to_arrow() + assert arrow.column("tags").to_pylist() == [["x", "y"], [], None, ["z"]] + + roundtrip = blosc2.CTable.from_arrow(arrow.schema, arrow.to_batches()) + assert roundtrip.tags[:] == [["x", "y"], [], None, ["z"]] diff --git a/tests/ctable/test_varlen_schema_compiler.py b/tests/ctable/test_varlen_schema_compiler.py new file mode 100644 index 000000000..80e8d3cb8 --- /dev/null +++ b/tests/ctable/test_varlen_schema_compiler.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +import blosc2 +from blosc2.schema import ListSpec +from blosc2.schema_compiler import compile_schema, schema_from_dict, schema_to_dict + + +@dataclass +class Product: + code: str = blosc2.field(blosc2.string(max_length=8)) + tags: list[str] = blosc2.field( # noqa: RUF009 + blosc2.list(blosc2.string(max_length=16), nullable=True, batch_rows=32) + ) + + +def test_list_builder_and_compile_schema(): + spec = blosc2.list(blosc2.string(max_length=10), nullable=True, storage="batch", serializer="msgpack") + assert isinstance(spec, ListSpec) + assert spec.nullable is True + assert spec.display_label() == "list[string]" + + schema = compile_schema(Product) + assert isinstance(schema.columns_by_name["tags"].spec, ListSpec) + assert schema.columns_by_name["tags"].dtype is None + + +def test_list_schema_roundtrip(): + schema = compile_schema(Product) + d = schema_to_dict(schema) + tags = next(c for c in d["columns"] if c["name"] == "tags") + assert tags["kind"] == "list" + assert tags["item"]["kind"] == "string" + restored = schema_from_dict(d) + assert isinstance(restored.columns_by_name["tags"].spec, ListSpec) + assert restored.columns_by_name["tags"].spec.batch_rows == 32 + + +def test_list_annotation_mismatch_rejected(): + @dataclass + class Bad: + tags: str = blosc2.field(blosc2.list(blosc2.string())) + + with pytest.raises(TypeError, match="list spec"): + compile_schema(Bad) diff --git a/tests/ctable/test_vlstring_vlbytes.py b/tests/ctable/test_vlstring_vlbytes.py new file mode 100644 index 000000000..29dbb64a3 --- /dev/null +++ b/tests/ctable/test_vlstring_vlbytes.py @@ -0,0 +1,589 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# This source code is licensed under a BSD-style license (found in the +# LICENSE file in the root directory of this source tree) +####################################################################### + +"""Tests for vlstring / vlbytes schema specs and CTable integration (Phases 2+3).""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +import blosc2 + +# --------------------------------------------------------------------------- +# Schema spec round-trip tests +# --------------------------------------------------------------------------- + + +def test_vlstring_spec_defaults(): + spec = blosc2.vlstring() + assert spec.nullable is False + assert spec.serializer == "msgpack" + assert spec.batch_rows == 2048 + assert spec.items_per_block is None + assert spec.dtype is None + assert spec.python_type is str + + +def test_vlstring_rejects_unsupported_serializer(): + with pytest.raises(ValueError, match="serializer='msgpack'"): + blosc2.vlstring(serializer="arrow") + + +def test_vlbytes_spec_defaults(): + spec = blosc2.vlbytes() + assert spec.nullable is False + assert spec.serializer == "msgpack" + assert spec.batch_rows == 2048 + assert spec.items_per_block is None + assert spec.dtype is None + assert spec.python_type is bytes + + +def test_vlstring_spec_metadata_round_trip(): + from blosc2.schema_compiler import spec_from_metadata_dict + + spec = blosc2.vlstring(nullable=True, batch_rows=512) + d = spec.to_metadata_dict() + assert d["kind"] == "vlstring" + assert d["nullable"] is True + assert d["batch_rows"] == 512 + assert "items_per_block" not in d # None → omitted + + restored = spec_from_metadata_dict(d) + assert type(restored).__name__ == "VLStringSpec" + assert restored.nullable is True + assert restored.batch_rows == 512 + + +def test_vlbytes_spec_metadata_round_trip(): + from blosc2.schema_compiler import spec_from_metadata_dict + + spec = blosc2.vlbytes(nullable=False, items_per_block=64) + d = spec.to_metadata_dict() + assert d["kind"] == "vlbytes" + assert d["nullable"] is False + assert d["items_per_block"] == 64 + + restored = spec_from_metadata_dict(d) + assert type(restored).__name__ == "VLBytesSpec" + assert restored.items_per_block == 64 + + +def test_vlstring_display_width(): + from blosc2.schema_compiler import compute_display_width + + assert compute_display_width(blosc2.vlstring()) == 40 + assert compute_display_width(blosc2.vlbytes()) == 40 + assert compute_display_width(blosc2.list(blosc2.int64())) == 40 + + +# --------------------------------------------------------------------------- +# _ScalarVarLenArray internal adapter tests +# --------------------------------------------------------------------------- + + +def _make_sva(spec): + from blosc2.scalar_array import _ScalarVarLenArray + + return _ScalarVarLenArray(spec) + + +def test_scalar_varlen_array_str_basic(): + spec = blosc2.vlstring(batch_rows=4) + sva = _make_sva(spec) + + sva.append("hello") + sva.append("world") + assert len(sva) == 2 + assert sva[0] == "hello" + assert sva[1] == "world" + assert sva[-1] == "world" + + +def test_scalar_varlen_array_bytes_basic(): + spec = blosc2.vlbytes(batch_rows=4) + sva = _make_sva(spec) + + sva.append(b"foo") + sva.append(bytearray(b"bar")) + assert len(sva) == 2 + assert sva[0] == b"foo" + assert sva[1] == b"bar" + + +def test_scalar_varlen_array_flush_and_persist(): + """Flushing moves items from pending to backend.""" + spec = blosc2.vlstring(batch_rows=4) + sva = _make_sva(spec) + + sva.extend(["a", "bb", "ccc"]) + assert sva._persisted_row_count == 0 + assert len(sva._pending) == 3 + + sva.flush() + assert sva._persisted_row_count == 3 + assert len(sva._pending) == 0 + assert len(sva) == 3 + assert sva[0] == "a" + assert sva[2] == "ccc" + + +def test_scalar_varlen_array_auto_flush_on_full_batch(): + """Auto-flush happens when pending reaches batch_rows.""" + spec = blosc2.vlstring(batch_rows=3) + sva = _make_sva(spec) + + sva.extend(["x", "y", "z"]) # exactly batch_rows → auto-flush + assert sva._persisted_row_count == 3 + assert len(sva._pending) == 0 + + sva.append("w") + assert len(sva) == 4 + assert sva[3] == "w" + + +def test_scalar_varlen_array_cross_batch_access(): + """Access items that span multiple persisted batches plus pending.""" + spec = blosc2.vlstring(batch_rows=3) + sva = _make_sva(spec) + + values = [f"item{i}" for i in range(8)] + sva.extend(values) # 6 auto-flushed + 2 pending + sva.flush() # flush remaining 2 + + for i, v in enumerate(values): + assert sva[i] == v, f"mismatch at {i}" + + +def test_scalar_varlen_array_setitem_pending(): + spec = blosc2.vlstring(batch_rows=10) + sva = _make_sva(spec) + sva.extend(["a", "b", "c"]) + sva[1] = "B" + assert sva[1] == "B" + + +def test_scalar_varlen_array_setitem_persisted(): + spec = blosc2.vlstring(batch_rows=3) + sva = _make_sva(spec) + sva.extend(["a", "b", "c"]) # auto-flushed + assert sva._persisted_row_count == 3 + sva[1] = "B" + assert sva[1] == "B" + assert sva[0] == "a" + assert sva[2] == "c" + + +def test_scalar_varlen_array_bulk_getitem(): + spec = blosc2.vlstring(batch_rows=3) + sva = _make_sva(spec) + values = ["a", "b", "c", "d", "e"] + sva.extend(values) + sva.flush() + + result = sva[[0, 2, 4]] + assert result == ["a", "c", "e"] + + result = sva[1:4] + assert result == ["b", "c", "d"] + + +def test_scalar_varlen_array_nullable(): + spec = blosc2.vlstring(nullable=True, batch_rows=4) + sva = _make_sva(spec) + sva.extend(["hello", None, "world", None]) + sva.flush() + + assert sva[0] == "hello" + assert sva[1] is None + assert sva[3] is None + + +def test_scalar_varlen_array_rejects_none_when_not_nullable(): + spec = blosc2.vlstring(nullable=False) + sva = _make_sva(spec) + with pytest.raises(TypeError, match="not nullable"): + sva.append(None) + + +def test_scalar_varlen_array_rejects_wrong_type_str(): + spec = blosc2.vlstring() + sva = _make_sva(spec) + with pytest.raises(TypeError): + sva.append(b"bytes instead of str") + + +def test_scalar_varlen_array_rejects_wrong_type_bytes(): + spec = blosc2.vlbytes() + sva = _make_sva(spec) + with pytest.raises(TypeError): + sva.append("str instead of bytes") + + +def test_scalar_varlen_array_iter(): + spec = blosc2.vlstring(batch_rows=3) + sva = _make_sva(spec) + values = ["x", "y", "z", "w"] + sva.extend(values) + sva.flush() + assert list(sva) == values + + +# --------------------------------------------------------------------------- +# CTable schema definition helpers +# --------------------------------------------------------------------------- + + +@dataclass +class VLRow: + id: int = blosc2.field(blosc2.int64()) + text: str = blosc2.field(blosc2.vlstring()) + data: bytes = blosc2.field(blosc2.vlbytes()) + + +@dataclass +class VLNullRow: + id: int = blosc2.field(blosc2.int64()) + text: str = blosc2.field(blosc2.vlstring(nullable=True)) + + +# --------------------------------------------------------------------------- +# CTable basic CRUD tests +# --------------------------------------------------------------------------- + + +ROWS = [ + (0, "hello world", b"bin0"), + (1, "a" * 200, b"x" * 500), + (2, "", b""), + (3, "unicode: \u00e9\u00e0\u00fc", b"\x00\x01\x02"), + (4, "short", b"also short"), +] + + +def test_ctable_vlstring_vl_bytes_append_getitem(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + assert len(ct) == 5 + + for i, (exp_id, exp_text, exp_data) in enumerate(ROWS): + assert int(ct.id[i]) == exp_id + assert ct.text[i] == exp_text + assert ct.data[i] == exp_data + + +def test_ctable_vlstring_column_getitem(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + col = ct.text + + assert col[0] == "hello world" + assert col[-1] == "short" + assert col[1:3] == ["a" * 200, ""] + assert col[[0, 2, 4]] == ["hello world", "", "short"] + + +def test_ctable_vlbytes_column_getitem(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + col = ct.data + assert col[0] == b"bin0" + assert col[2] == b"" + + +def test_ctable_vlstring_column_setitem(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + ct.text[0] = "changed" + assert ct.text[0] == "changed" + + +def test_ctable_vlstring_column_iter(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + texts = list(ct.text) + assert texts == [r[1] for r in ROWS] + + +def test_ctable_vlstring_column_is_not_list(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + assert not ct.text.is_list + assert ct.text.is_varlen_scalar + + +def test_ctable_vlstring_column_null_count_non_nullable(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + # Non-nullable: no Nones → null_count = 0 + assert ct.text.null_count() == 0 + assert ct.text.null_value is None + + +def test_ctable_vlstring_nullable_null_count(): + data = [(0, "hello"), (1, None), (2, "world"), (3, None)] + ct = blosc2.CTable(VLNullRow, new_data=data) + assert ct.text.null_count() == 2 + assert list(ct.text.is_null()) == [False, True, False, True] + assert list(ct.text.notnull()) == [True, False, True, False] + + +def test_ctable_vlstring_nullable_getitem(): + data = [(0, "hello"), (1, None), (2, "world")] + ct = blosc2.CTable(VLNullRow, new_data=data) + assert ct.text[0] == "hello" + assert ct.text[1] is None + assert ct.text[2] == "world" + + +def test_ctable_vlstring_iter_chunks_not_supported(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + with pytest.raises(TypeError, match="varlen scalar"): + list(ct.text.iter_chunks()) + + +def test_ctable_vlstring_dtype_is_none(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + assert ct.text.dtype is None + + +# --------------------------------------------------------------------------- +# CTable with deletions +# --------------------------------------------------------------------------- + + +def test_ctable_vlstring_with_deletions(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + ct.delete(1) # delete "a" * 200 + assert len(ct) == 4 + texts = list(ct.text) + assert "a" * 200 not in texts + assert "hello world" in texts + + +def test_ctable_vlstring_compact(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + ct.delete([1, 3]) + ct.compact() + assert len(ct) == 3 + texts = list(ct.text) + assert texts == ["hello world", "", "short"] + + +# --------------------------------------------------------------------------- +# CTable save / load / open (persistence) +# --------------------------------------------------------------------------- + + +def test_ctable_vlstring_save_load(tmp_path): + urlpath = str(tmp_path / "vl_test.b2d") + ct = blosc2.CTable(VLRow, new_data=ROWS, urlpath=urlpath, mode="w") + ct.close() + + ct2 = blosc2.CTable.open(urlpath) + assert len(ct2) == len(ROWS) + for i, (_exp_id, exp_text, exp_data) in enumerate(ROWS): + assert ct2.text[i] == exp_text + assert ct2.data[i] == exp_data + ct2.close() + + +def test_ctable_vlstring_backend_role_metadata(tmp_path): + urlpath = str(tmp_path / "vl_meta.b2d") + ct = blosc2.CTable(VLRow, new_data=ROWS[:2], urlpath=urlpath, mode="w") + ct.close() + + backend = blosc2.open(str(tmp_path / "vl_meta.b2d" / "_cols" / "text.b2b"), mode="r") + assert backend.schunk.meta["ctable_varlen_scalar"] == { + "version": 1, + "py_type": "str", + "nullable": False, + "batch_rows": 2048, + } + + +def test_ctable_constructor_reopens_vlstring_persistent_table(tmp_path): + urlpath = str(tmp_path / "vl_ctor_reopen.b2d") + ct = blosc2.CTable(VLRow, new_data=ROWS[:2], urlpath=urlpath, mode="w") + ct.close() + + reopened = blosc2.CTable(VLRow, urlpath=urlpath, mode="a") + assert reopened.text[1] == ROWS[1][1] + reopened.append((42, "ctor append", b"ctor bytes")) + assert reopened.text[2] == "ctor append" + reopened.close() + + +def test_ctable_vlstring_save_reload_b2z(tmp_path): + urlpath = str(tmp_path / "vl_test.b2z") + ct = blosc2.CTable(VLRow, new_data=ROWS, urlpath=urlpath, mode="w") + ct.close() + + ct2 = blosc2.CTable.open(urlpath) + assert len(ct2) == len(ROWS) + for i, (_, exp_text, _) in enumerate(ROWS): + assert ct2.text[i] == exp_text + ct2.close() + + +def test_ctable_vlstring_load_into_memory(tmp_path): + urlpath = str(tmp_path / "vl_load.b2d") + ct = blosc2.CTable(VLRow, new_data=ROWS, urlpath=urlpath, mode="w") + ct.close() + + ct_mem = blosc2.CTable.load(urlpath) + assert len(ct_mem) == len(ROWS) + assert list(ct_mem.text) == [r[1] for r in ROWS] + + # In-memory table is writable + ct_mem.text[0] = "mutated" + assert ct_mem.text[0] == "mutated" + + +def test_ctable_vlstring_save_from_memory(tmp_path): + urlpath = str(tmp_path / "vl_save.b2d") + ct = blosc2.CTable(VLRow, new_data=ROWS) # in-memory + ct.save(urlpath) + + ct2 = blosc2.CTable.open(urlpath) + assert list(ct2.text) == [r[1] for r in ROWS] + ct2.close() + + +# --------------------------------------------------------------------------- +# CTable append + extend after open (writable mode) +# --------------------------------------------------------------------------- + + +def test_ctable_vlstring_append_to_persistent(tmp_path): + urlpath = str(tmp_path / "vl_append.b2d") + ct = blosc2.CTable(VLRow, new_data=ROWS[:3], urlpath=urlpath, mode="w") + ct.close() + + ct2 = blosc2.CTable.open(urlpath, mode="a") + ct2.append((99, "appended", b"appended_bytes")) + ct2.close() + + ct3 = blosc2.CTable.open(urlpath) + assert len(ct3) == 4 + assert ct3.text[3] == "appended" + ct3.close() + + +# --------------------------------------------------------------------------- +# Arrow export +# --------------------------------------------------------------------------- + + +def test_ctable_vlstring_arrow_export(): + pa = pytest.importorskip("pyarrow") + ct = blosc2.CTable(VLRow, new_data=ROWS) + table = ct.to_arrow() + assert pa.types.is_string(table.schema.field("text").type) + assert pa.types.is_large_binary(table.schema.field("data").type) + + texts = table.column("text").to_pylist() + assert texts == [r[1] for r in ROWS] + + datas = table.column("data").to_pylist() + assert datas == [r[2] for r in ROWS] + + +def test_ctable_vlstring_nullable_arrow_export(): + pa = pytest.importorskip("pyarrow") + data = [(0, "hello"), (1, None), (2, "world")] + ct = blosc2.CTable(VLNullRow, new_data=data) + table = ct.to_arrow() + texts = table.column("text").to_pylist() + assert texts == ["hello", None, "world"] + assert table.column("text").null_count == 1 + + +# --------------------------------------------------------------------------- +# Sort / index guards +# --------------------------------------------------------------------------- + + +def test_ctable_vlstring_sort_raises(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + gen = ct.iter_sorted("text") + with pytest.raises(TypeError, match="varlen scalar"): + next(gen) + + +def test_ctable_vlstring_lazy_expression_raises(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + with pytest.raises(NotImplementedError, match="vlstring/vlbytes"): + ct.where('text == "hello world"') + with pytest.raises(NotImplementedError, match="vlstring/vlbytes"): + _ = ct.text == "hello world" + + +def test_ctable_vlstring_build_index_raises(tmp_path): + urlpath = str(tmp_path / "vl_idx.b2d") + ct = blosc2.CTable(VLRow, new_data=ROWS, urlpath=urlpath, mode="w") + with pytest.raises(NotImplementedError, match="vlstring"): + ct.create_index("text") + ct.close() + + +# --------------------------------------------------------------------------- +# add_column with vlstring +# --------------------------------------------------------------------------- + + +@dataclass +class SimpleRow: + id: int = blosc2.field(blosc2.int64()) + + +def test_ctable_add_vlstring_column(): + ct = blosc2.CTable(SimpleRow, new_data=[(i,) for i in range(5)]) + ct.add_column("label", blosc2.vlstring(), default="unknown") + assert "label" in ct.col_names + assert ct.label[0] == "unknown" + assert ct.label[4] == "unknown" + assert ct.label.is_varlen_scalar + + +# --------------------------------------------------------------------------- +# Schema serialization round-trip (schema_to_dict / schema_from_dict) +# --------------------------------------------------------------------------- + + +def test_ctable_schema_dict_round_trip(): + from blosc2.schema_compiler import schema_from_dict, schema_to_dict + + ct = blosc2.CTable(VLRow, new_data=ROWS) + d = schema_to_dict(ct._schema) + + kinds = {c["name"]: c["kind"] for c in d["columns"]} + assert kinds["text"] == "vlstring" + assert kinds["data"] == "vlbytes" + + restored = schema_from_dict(d) + assert {c.name: type(c.spec).__name__ for c in restored.columns} == { + "id": "int64", + "text": "VLStringSpec", + "data": "VLBytesSpec", + } + + +# --------------------------------------------------------------------------- +# Display / info sanity +# --------------------------------------------------------------------------- + + +def test_ctable_vlstring_str_display(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + s = str(ct) + assert "vlstring" in s + assert "vlbytes" in s + assert "hello world" in s + + +def test_ctable_vlstring_repr(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + r = repr(ct) + assert "CTable" in r + assert "5" in r diff --git a/tests/ctable/test_where_expressions.py b/tests/ctable/test_where_expressions.py new file mode 100644 index 000000000..0850ef9a5 --- /dev/null +++ b/tests/ctable/test_where_expressions.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import pytest + +import blosc2 + + +@dataclass +class Row: + value: int = blosc2.field(blosc2.int32()) + category: int = blosc2.field(blosc2.int32()) + + +DATA = [(10, 1), (20, 8), (30, 5), (2, 99)] + + +def test_where_accepts_string_expression(): + t = blosc2.CTable(Row, new_data=DATA) + + view = t.where("value * category >= 150") + + np.testing.assert_array_equal(view.value[:], np.array([20, 30, 2], dtype=np.int32)) + np.testing.assert_array_equal(view.category[:], np.array([8, 5, 99], dtype=np.int32)) + + +def test_where_accepts_column_arithmetic_expression(): + t = blosc2.CTable(Row, new_data=DATA) + + view = t.where((t.value * t.category) >= 150) + + np.testing.assert_array_equal(view.value[:], np.array([20, 30, 2], dtype=np.int32)) + np.testing.assert_array_equal(view.category[:], np.array([8, 5, 99], dtype=np.int32)) + + +def test_where_column_arithmetic_can_be_composed(): + t = blosc2.CTable(Row, new_data=DATA) + + view = t.where(((t.value + 2) * t.category) >= 100) + + np.testing.assert_array_equal(view.value[:], np.array([20, 30, 2], dtype=np.int32)) + + +def test_where_column_expression_accepts_transcendental_functions(): + t = blosc2.CTable(Row, new_data=DATA) + + view = t.where(((t.value + 2) * blosc2.sin(t.category)) >= 10) + + np.testing.assert_array_equal(view.value[:], np.array([10, 20], dtype=np.int32)) + + +def test_where_string_expression_accepts_transcendental_functions(): + t = blosc2.CTable(Row, new_data=DATA) + + view = t.where("(value + 2) * sin(category) >= 10") + + np.testing.assert_array_equal(view.value[:], np.array([10, 20], dtype=np.int32)) + + +def test_where_string_expression_can_reference_computed_columns(): + t = blosc2.CTable(Row, new_data=DATA) + t.add_computed_column("score", "value * category") + + view = t.where("score >= 150") + + np.testing.assert_array_equal(view.value[:], np.array([20, 30, 2], dtype=np.int32)) + + +def test_where_string_expression_must_be_boolean(): + t = blosc2.CTable(Row, new_data=DATA) + + with pytest.raises(TypeError, match="Expected boolean"): + t.where("value * category") diff --git a/tests/ndarray/test_indexing.py b/tests/ndarray/test_indexing.py index 26fa23bcf..9d69844e3 100644 --- a/tests/ndarray/test_indexing.py +++ b/tests/ndarray/test_indexing.py @@ -1933,7 +1933,7 @@ def test_value_path_cache_hit_persistent(tmp_path): assert len(catalog["entries"]) == 1 # Warm call: serve from cache. - _clear_caches() # only clears hot cache; persistent VLArray remains + _clear_caches() # only clears hot cache; persistent ObjectArray remains arr2 = blosc2.open(urlpath, mode="r") cond2 = blosc2.lazyexpr("(id >= 10_000) & (id < 12_000)", arr2.fields) result2 = arr2[cond2][:] diff --git a/tests/test_dict_store.py b/tests/test_dict_store.py index 7c9c9dd83..69d7dacb1 100644 --- a/tests/test_dict_store.py +++ b/tests/test_dict_store.py @@ -324,47 +324,47 @@ def test_external_schunk_file_and_reopen(): os.remove(path) -def test_store_and_retrieve_vlarray_in_dict(tmp_path): - path = tmp_path / "test_dstore_vlarray_embed.b2z" +def test_store_and_retrieve_objectarray_in_dict(tmp_path): + path = tmp_path / "test_dstore_objectarray_embed.b2z" values = [{"name": "alpha", "count": 1}, None, ("tuple", 2), [1, "two", b"three"]] - vlarray = blosc2.VLArray() - vlarray.extend(values) + oarr = blosc2.ObjectArray() + oarr.extend(values) with DictStore(str(path), mode="w") as dstore: - dstore["/vlarray"] = vlarray - value = dstore["/vlarray"] - assert isinstance(value, blosc2.VLArray) + dstore["/objectarray"] = oarr + value = dstore["/objectarray"] + assert isinstance(value, blosc2.ObjectArray) assert list(value) == values with DictStore(str(path), mode="r") as dstore_read: - value = dstore_read["/vlarray"] - assert isinstance(value, blosc2.VLArray) + value = dstore_read["/objectarray"] + assert isinstance(value, blosc2.ObjectArray) assert list(value) == values -def test_external_vlarray_file_and_reopen(tmp_path): - ext_path = tmp_path / "ext_vlarray.b2frame" - path = tmp_path / "test_dstore_vlarray_external.b2z" +def test_external_objectarray_file_and_reopen(tmp_path): + ext_path = tmp_path / "ext_objectarray.b2frame" + path = tmp_path / "test_dstore_objectarray_external.b2z" values = ["alpha", {"nested": True}, None, (1, 2, 3)] - vlarray = blosc2.VLArray(urlpath=str(ext_path), mode="w", contiguous=True) - vlarray.extend(values) - vlarray.vlmeta["description"] = "External VLArray" + oarr = blosc2.ObjectArray(urlpath=str(ext_path), mode="w", contiguous=True) + oarr.extend(values) + oarr.vlmeta["description"] = "External ObjectArray" with DictStore(str(path), mode="w", threshold=None) as dstore: - dstore["/dir1/vlarray_ext"] = vlarray - assert "/dir1/vlarray_ext" in dstore.map_tree - assert dstore.map_tree["/dir1/vlarray_ext"].endswith(".b2f") + dstore["/dir1/objectarray_ext"] = oarr + assert "/dir1/objectarray_ext" in dstore.map_tree + assert dstore.map_tree["/dir1/objectarray_ext"].endswith(".b2f") with zipfile.ZipFile(path, "r") as zf: - assert "dir1/vlarray_ext.b2f" in zf.namelist() + assert "dir1/objectarray_ext.b2f" in zf.namelist() with DictStore(str(path), mode="r") as dstore_read: - value = dstore_read["/dir1/vlarray_ext"] - assert isinstance(value, blosc2.VLArray) + value = dstore_read["/dir1/objectarray_ext"] + assert isinstance(value, blosc2.ObjectArray) assert list(value) == values - assert value.vlmeta["description"] == "External VLArray" + assert value.vlmeta["description"] == "External ObjectArray" @pytest.mark.parametrize("storage_type", ["b2d", "b2z"]) @@ -392,30 +392,30 @@ def test_metadata_discovery_reopens_renamed_external_ndarray(storage_type, tmp_p @pytest.mark.parametrize("storage_type", ["b2d", "b2z"]) -def test_metadata_discovery_reopens_renamed_external_vlarray(storage_type, tmp_path): - path = tmp_path / f"test_renamed_vlarray.{storage_type}" - ext_path = tmp_path / "renamed_vlarray_source.b2frame" +def test_metadata_discovery_reopens_renamed_external_objectarray(storage_type, tmp_path): + path = tmp_path / f"test_renamed_objectarray.{storage_type}" + ext_path = tmp_path / "renamed_objectarray_source.b2frame" values = ["alpha", {"nested": True}, None, (1, 2, 3)] - vlarray = blosc2.VLArray(urlpath=str(ext_path), mode="w", contiguous=True) - vlarray.extend(values) - vlarray.vlmeta["description"] = "Renamed VLArray" + oarr = blosc2.ObjectArray(urlpath=str(ext_path), mode="w", contiguous=True) + oarr.extend(values) + oarr.vlmeta["description"] = "Renamed ObjectArray" with DictStore(str(path), mode="w", threshold=None) as dstore: - dstore["/dir1/vlarray_ext"] = vlarray + dstore["/dir1/objectarray_ext"] = oarr - old_name = "dir1/vlarray_ext.b2f" - new_name = "dir1/vlarray_ext.renamed" + old_name = "dir1/objectarray_ext.b2f" + new_name = "dir1/objectarray_ext.renamed" _rename_store_member(str(path), old_name, new_name) - with pytest.warns(UserWarning, match=r"vlarray_ext\.renamed'.*VLArray.*expected '\.b2f'"): + with pytest.warns(UserWarning, match=r"objectarray_ext\.renamed'.*ObjectArray.*expected '\.b2f'"): dstore_read = DictStore(str(path), mode="r") with dstore_read: - assert dstore_read.map_tree["/dir1/vlarray_ext"] == new_name - value = dstore_read["/dir1/vlarray_ext"] - assert isinstance(value, blosc2.VLArray) + assert dstore_read.map_tree["/dir1/objectarray_ext"] == new_name + value = dstore_read["/dir1/objectarray_ext"] + assert isinstance(value, blosc2.ObjectArray) assert list(value) == values - assert value.vlmeta["description"] == "Renamed VLArray" + assert value.vlmeta["description"] == "Renamed ObjectArray" def test_metadata_discovery_reopens_lazyexpr_leaf(tmp_path): diff --git a/tests/test_list_array.py b/tests/test_list_array.py new file mode 100644 index 000000000..36f8e65a7 --- /dev/null +++ b/tests/test_list_array.py @@ -0,0 +1,90 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +from __future__ import annotations + +import pytest + +import blosc2 + + +@pytest.mark.parametrize("storage", ["vl", "batch"]) +def test_listarray_append_extend_and_replace(storage, tmp_path): + urlpath = tmp_path / f"values-{storage}.b2b" + arr = blosc2.ListArray( + item_spec=blosc2.string(max_length=16), + nullable=True, + storage=storage, + batch_rows=2, + urlpath=str(urlpath), + mode="w", + ) + arr.append(["a", "b"]) + arr.append([]) + arr.append(None) + arr.extend([["c"], ["d", "e"]]) + + assert len(arr) == 5 + assert arr[0] == ["a", "b"] + assert arr[1] == [] + assert arr[2] is None + assert arr[1:4] == [[], None, ["c"]] + assert arr[[0, 2, 4]] == [["a", "b"], None, ["d", "e"]] + + arr[3] = ["x", "y"] + assert arr[3] == ["x", "y"] + + arr.flush() + reopened = blosc2.open(str(urlpath), mode="r") + assert isinstance(reopened, blosc2.ListArray) + assert reopened[:] == [["a", "b"], [], None, ["x", "y"], ["d", "e"]] + + restored = blosc2.from_cframe(arr.to_cframe()) + assert isinstance(restored, blosc2.ListArray) + assert restored[:] == reopened[:] + + +def test_listarray_batch_pending_rows_visible_before_flush(): + arr = blosc2.ListArray(item_spec=blosc2.int32(), storage="batch", batch_rows=4) + arr.append([1, 2]) + arr.append([]) + arr.append([3]) + + assert len(arr) == 3 + assert arr[:] == [[1, 2], [], [3]] + + +def test_listarray_rejects_invalid_cells(): + arr = blosc2.ListArray(item_spec=blosc2.int32(), nullable=False) + with pytest.raises(ValueError): + arr.append(None) + with pytest.raises(TypeError): + arr.append("abc") + with pytest.raises(ValueError): + arr.append([1, None]) + + +def test_listarray_boolean_fancy_indexing(): + arr = blosc2.ListArray(item_spec=blosc2.int32(), nullable=True, storage="batch", batch_rows=2) + arr.extend([[1], None, [], [2, 3]]) + assert arr[[3, 0]] == [[2, 3], [1]] + assert arr[blosc2.asarray([True, False, True, False])[:]] == [[1], []] + + +def test_listarray_arrow_roundtrip(): + pa = pytest.importorskip("pyarrow") + + values = pa.array([["a"], None, ["b", "c"]]) + arr = blosc2.ListArray.from_arrow(values, item_spec=blosc2.string(), nullable=True) + assert arr[:] == [["a"], None, ["b", "c"]] + assert arr.to_arrow().to_pylist() == [["a"], None, ["b", "c"]] + + +def test_listarray_extend_validate_false_preserves_none(): + arr = blosc2.ListArray(item_spec=blosc2.int32(), nullable=True, storage="batch", batch_rows=2) + arr.extend([[1], None, [2, 3]], validate=False) + assert arr[:] == [[1], None, [2, 3]] diff --git a/tests/test_vlarray.py b/tests/test_objectarray.py similarity index 60% rename from tests/test_vlarray.py rename to tests/test_objectarray.py index ef142599a..f2e851956 100644 --- a/tests/test_vlarray.py +++ b/tests/test_objectarray.py @@ -45,8 +45,8 @@ def _make_nested_blosc2_objects(): schunk = blosc2.SChunk(chunksize=16) schunk.append_data(np.arange(4, dtype=np.int32)) - nested_vlarray = blosc2.VLArray() - nested_vlarray.extend(["alpha", {"beta": 2}]) + nested_oarr = blosc2.ObjectArray() + nested_oarr.extend(["alpha", {"beta": 2}]) nested_batcharray = blosc2.BatchArray(items_per_block=2) nested_batcharray.extend([[1, 2], ["x", {"y": 3}]]) @@ -54,7 +54,7 @@ def _make_nested_blosc2_objects(): estore = blosc2.EmbedStore() estore["/node"] = blosc2.arange(3, dtype=np.int32) - return ndarray, schunk, nested_vlarray, nested_batcharray, estore + return ndarray, schunk, nested_oarr, nested_batcharray, estore def _make_c2array(monkeypatch, path="@public/examples/ds-1d.b2nd", urlbase="https://cat2.cloud/demo/"): @@ -103,43 +103,43 @@ def _make_persistent_ref(tmp_path): [ (False, None), (True, None), - (True, "test_vlarray.b2frame"), - (False, "test_vlarray_s.b2frame"), + (True, "test_objectarray.b2frame"), + (False, "test_objectarray_s.b2frame"), ], ) -def test_vlarray_roundtrip(contiguous, urlpath): +def test_objectarray_roundtrip(contiguous, urlpath): blosc2.remove_urlpath(urlpath) - vlarray = blosc2.VLArray(storage=_storage(contiguous, urlpath)) - assert vlarray.meta["vlarray"]["serializer"] == "msgpack" + oarr = blosc2.ObjectArray(storage=_storage(contiguous, urlpath)) + assert oarr.meta["vlarray"]["serializer"] == "msgpack" for i, value in enumerate(VALUES, start=1): - assert vlarray.append(value) == i + assert oarr.append(value) == i - assert len(vlarray) == len(VALUES) - assert list(vlarray) == VALUES - assert vlarray[-1] == VALUES[-1] + assert len(oarr) == len(VALUES) + assert list(oarr) == VALUES + assert oarr[-1] == VALUES[-1] expected = list(VALUES) expected[1] = {"updated": ("tuple", 7)} expected[-1] = "tiny" - vlarray[1] = expected[1] - vlarray[-1] = expected[-1] - assert vlarray.insert(0, "head") == len(expected) + 1 + oarr[1] = expected[1] + oarr[-1] = expected[-1] + assert oarr.insert(0, "head") == len(expected) + 1 expected.insert(0, "head") - assert vlarray.insert(-1, {"between": 5}) == len(expected) + 1 + assert oarr.insert(-1, {"between": 5}) == len(expected) + 1 expected.insert(-1, {"between": 5}) - assert vlarray.insert(999, "tail") == len(expected) + 1 + assert oarr.insert(999, "tail") == len(expected) + 1 expected.insert(999, "tail") - assert vlarray.delete(2) == len(expected) - 1 + assert oarr.delete(2) == len(expected) - 1 del expected[2] - del vlarray[-2] + del oarr[-2] del expected[-2] - assert list(vlarray) == expected + assert list(oarr) == expected if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") - assert isinstance(reopened, blosc2.VLArray) + assert isinstance(reopened, blosc2.ObjectArray) assert list(reopened) == expected with pytest.raises(ValueError): reopened.append("nope") @@ -165,45 +165,45 @@ def test_vlarray_roundtrip(contiguous, urlpath): if contiguous: reopened_mmap = blosc2.open(urlpath, mode="r", mmap_mode="r") - assert isinstance(reopened_mmap, blosc2.VLArray) + assert isinstance(reopened_mmap, blosc2.ObjectArray) assert list(reopened_mmap) == expected blosc2.remove_urlpath(urlpath) -def test_vlarray_from_cframe(): - vlarray = blosc2.VLArray() - vlarray.extend(VALUES) - vlarray.insert(1, {"inserted": True}) - del vlarray[3] +def test_objectarray_from_cframe(): + oarr = blosc2.ObjectArray() + oarr.extend(VALUES) + oarr.insert(1, {"inserted": True}) + del oarr[3] expected = list(VALUES) expected.insert(1, {"inserted": True}) del expected[3] - restored = blosc2.from_cframe(vlarray.to_cframe()) - assert isinstance(restored, blosc2.VLArray) + restored = blosc2.from_cframe(oarr.to_cframe()) + assert isinstance(restored, blosc2.ObjectArray) assert list(restored) == expected - restored2 = blosc2.vlarray_from_cframe(vlarray.to_cframe()) - assert isinstance(restored2, blosc2.VLArray) + restored2 = blosc2.objectarray_from_cframe(oarr.to_cframe()) + assert isinstance(restored2, blosc2.ObjectArray) assert list(restored2) == expected -def test_vlarray_msgpack_supports_blosc2_objects(): - ndarray, schunk, nested_vlarray, nested_batcharray, estore = _make_nested_blosc2_objects() +def test_objectarray_msgpack_supports_blosc2_objects(): + ndarray, schunk, nested_oarr, nested_batcharray, estore = _make_nested_blosc2_objects() - vlarray = blosc2.VLArray() - vlarray.append( + oarr = blosc2.ObjectArray() + oarr.append( { "ndarray": ndarray, "schunk": schunk, - "vlarray": nested_vlarray, + "objectarray": nested_oarr, "batcharray": nested_batcharray, "estore": estore, } ) - restored = vlarray[0] + restored = oarr[0] assert isinstance(restored["ndarray"], blosc2.NDArray) assert np.array_equal(restored["ndarray"][:], ndarray[:]) @@ -211,8 +211,8 @@ def test_vlarray_msgpack_supports_blosc2_objects(): assert isinstance(restored["schunk"], blosc2.SChunk) assert restored["schunk"].decompress_chunk(0) == schunk.decompress_chunk(0) - assert isinstance(restored["vlarray"], blosc2.VLArray) - assert list(restored["vlarray"]) == list(nested_vlarray) + assert isinstance(restored["objectarray"], blosc2.ObjectArray) + assert list(restored["objectarray"]) == list(nested_oarr) assert isinstance(restored["batcharray"], blosc2.BatchArray) assert [batch[:] for batch in restored["batcharray"]] == [batch[:] for batch in nested_batcharray] @@ -222,13 +222,13 @@ def test_vlarray_msgpack_supports_blosc2_objects(): assert np.array_equal(restored["estore"]["/node"][:], estore["/node"][:]) -def test_vlarray_msgpack_supports_c2array(monkeypatch): +def test_objectarray_msgpack_supports_c2array(monkeypatch): c2array = _make_c2array(monkeypatch) - vlarray = blosc2.VLArray() - vlarray.append(c2array) + oarr = blosc2.ObjectArray() + oarr.append(c2array) - restored = vlarray[0] + restored = oarr[0] assert isinstance(restored, blosc2.C2Array) assert restored.path == c2array.path @@ -236,67 +236,67 @@ def test_vlarray_msgpack_supports_c2array(monkeypatch): assert restored.auth_token is None -def test_vlarray_msgpack_supports_ref(tmp_path): +def test_objectarray_msgpack_supports_ref(tmp_path): ref, expected = _make_persistent_ref(tmp_path) - vlarray = blosc2.VLArray() - vlarray.append(ref) + oarr = blosc2.ObjectArray() + oarr.append(ref) - restored = vlarray[0] + restored = oarr[0] assert isinstance(restored, blosc2.Ref) assert restored == ref np.testing.assert_array_equal(restored.open()[:], expected) -def test_vlarray_msgpack_supports_lazyexpr(tmp_path): +def test_objectarray_msgpack_supports_lazyexpr(tmp_path): expr, expected = _make_persistent_lazyexpr(tmp_path) - vlarray = blosc2.VLArray() - vlarray.append(expr) + oarr = blosc2.ObjectArray() + oarr.append(expr) - restored = vlarray[0] + restored = oarr[0] assert isinstance(restored, blosc2.LazyExpr) np.testing.assert_array_equal(restored[:], expected) -def test_vlarray_msgpack_supports_lazyudf_dslkernel(tmp_path): +def test_objectarray_msgpack_supports_lazyudf_dslkernel(tmp_path): udf, expected = _make_persistent_lazyudf(tmp_path) - vlarray = blosc2.VLArray() - vlarray.append(udf) - restored = vlarray[0] + oarr = blosc2.ObjectArray() + oarr.append(udf) + restored = oarr[0] assert isinstance(restored, blosc2.LazyUDF) np.testing.assert_allclose(restored[:], expected) -def test_vlarray_msgpack_rejects_lazyexpr_with_in_memory_operands(): +def test_objectarray_msgpack_rejects_lazyexpr_with_in_memory_operands(): expr = _make_in_memory_lazyexpr() - vlarray = blosc2.VLArray() + oarr = blosc2.ObjectArray() with pytest.raises(ValueError, match="stored on disk/network"): - vlarray.append(expr) + oarr.append(expr) -def test_vlarray_msgpack_rejects_plain_python_lazyudf(tmp_path): +def test_objectarray_msgpack_rejects_plain_python_lazyudf(tmp_path): udf = _make_persistent_python_lazyudf(tmp_path) - vlarray = blosc2.VLArray() + oarr = blosc2.ObjectArray() with pytest.raises(TypeError, match="DSLKernel"): - vlarray.append(udf) + oarr.append(udf) @pytest.mark.network -def test_vlarray_msgpack_roundtrip_c2array_network(cat2_context): +def test_objectarray_msgpack_roundtrip_c2array_network(cat2_context): path = "@public/expr/ds-1-2-linspace-float64-b2-(5,)d.b2nd" original = blosc2.C2Array(path) - vlarray = blosc2.VLArray() - vlarray.append(original) + oarr = blosc2.ObjectArray() + oarr.append(original) - restored = vlarray[0] + restored = oarr[0] assert isinstance(restored, blosc2.C2Array) assert restored.path == original.path @@ -304,16 +304,16 @@ def test_vlarray_msgpack_roundtrip_c2array_network(cat2_context): np.testing.assert_allclose(restored[:], original[:]) -def test_vlarray_info(): - vlarray = blosc2.VLArray() - vlarray.extend(VALUES) +def test_objectarray_info(): + oarr = blosc2.ObjectArray() + oarr.extend(VALUES) - assert vlarray.typesize == 1 - assert vlarray.contiguous == vlarray.schunk.contiguous - assert vlarray.urlpath == vlarray.schunk.urlpath + assert oarr.typesize == 1 + assert oarr.contiguous == oarr.schunk.contiguous + assert oarr.urlpath == oarr.schunk.urlpath - items = dict(vlarray.info_items) - assert items["type"] == "VLArray" + items = dict(oarr.info_items) + assert items["type"] == "ObjectArray" assert items["entries"] == len(VALUES) assert items["item_nbytes_min"] > 0 assert items["item_nbytes_max"] >= items["item_nbytes_min"] @@ -325,57 +325,57 @@ def test_vlarray_info(): assert "(" in items["nbytes"] assert "(" in items["cbytes"] - text = repr(vlarray.info) + text = repr(oarr.info) assert "type" in text - assert "VLArray" in text + assert "ObjectArray" in text assert "item_nbytes_avg" in text -def test_vlarray_zstd_uses_dict_by_default(): - vlarray = blosc2.VLArray() - assert vlarray.cparams.codec == blosc2.Codec.ZSTD - assert vlarray.cparams.use_dict is True +def test_objectarray_zstd_uses_dict_by_default(): + oarr = blosc2.ObjectArray() + assert oarr.cparams.codec == blosc2.Codec.ZSTD + assert oarr.cparams.use_dict is True -def test_vlarray_respects_explicit_use_dict_and_non_zstd(): - vlarray = blosc2.VLArray(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) - assert vlarray.cparams.codec == blosc2.Codec.LZ4 - assert vlarray.cparams.use_dict is False +def test_objectarray_respects_explicit_use_dict_and_non_zstd(): + oarr = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) + assert oarr.cparams.codec == blosc2.Codec.LZ4 + assert oarr.cparams.use_dict is False - vlarray = blosc2.VLArray(cparams={"codec": blosc2.Codec.LZ4HC, "clevel": 1, "use_dict": True}) - assert vlarray.cparams.codec == blosc2.Codec.LZ4HC - assert vlarray.cparams.use_dict is True + oarr = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.LZ4HC, "clevel": 1, "use_dict": True}) + assert oarr.cparams.codec == blosc2.Codec.LZ4HC + assert oarr.cparams.use_dict is True - vlarray = blosc2.VLArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) - assert vlarray.cparams.codec == blosc2.Codec.ZSTD - assert vlarray.cparams.use_dict is False + oarr = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 0}) + assert oarr.cparams.codec == blosc2.Codec.ZSTD + assert oarr.cparams.use_dict is False - vlarray = blosc2.VLArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 5, "use_dict": False}) - assert vlarray.cparams.use_dict is False + oarr = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.ZSTD, "clevel": 5, "use_dict": False}) + assert oarr.cparams.use_dict is False - vlarray = blosc2.VLArray(cparams=blosc2.CParams(codec=blosc2.Codec.ZSTD, clevel=5, use_dict=False)) - assert vlarray.cparams.use_dict is False + oarr = blosc2.ObjectArray(cparams=blosc2.CParams(codec=blosc2.Codec.ZSTD, clevel=5, use_dict=False)) + assert oarr.cparams.use_dict is False -def test_vlarray_constructor_kwargs(): - urlpath = "test_vlarray_kwargs.b2frame" +def test_objectarray_constructor_kwargs(): + urlpath = "test_objectarray_kwargs.b2frame" blosc2.remove_urlpath(urlpath) - vlarray = blosc2.VLArray(urlpath=urlpath, mode="w", contiguous=True) + oarr = blosc2.ObjectArray(urlpath=urlpath, mode="w", contiguous=True) for value in VALUES: - vlarray.append(value) + oarr.append(value) - reopened = blosc2.VLArray(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") + reopened = blosc2.ObjectArray(urlpath=urlpath, mode="r", contiguous=True, mmap_mode="r") assert list(reopened) == VALUES blosc2.remove_urlpath(urlpath) -def test_vlarray_size_guard(monkeypatch): - vlarray = blosc2.VLArray() +def test_objectarray_size_guard(monkeypatch): + oarr = blosc2.ObjectArray() monkeypatch.setattr(blosc2, "MAX_BUFFERSIZE", 4) with pytest.raises(ValueError, match="Serialized objects cannot be larger"): - vlarray.append("payload") + oarr.append("payload") @pytest.mark.parametrize( @@ -383,26 +383,26 @@ def test_vlarray_size_guard(monkeypatch): [ (False, None), (True, None), - (True, "test_vlarray_list_ops.b2frame"), - (False, "test_vlarray_list_ops_s.b2frame"), + (True, "test_objectarray_list_ops.b2frame"), + (False, "test_objectarray_list_ops_s.b2frame"), ], ) -def test_vlarray_list_like_ops(contiguous, urlpath): +def test_objectarray_list_like_ops(contiguous, urlpath): blosc2.remove_urlpath(urlpath) - vlarray = blosc2.VLArray(storage=_storage(contiguous, urlpath)) - vlarray.extend([1, 2, 3]) - assert list(vlarray) == [1, 2, 3] - assert vlarray.pop() == 3 - assert vlarray.pop(0) == 1 - assert list(vlarray) == [2] + oarr = blosc2.ObjectArray(storage=_storage(contiguous, urlpath)) + oarr.extend([1, 2, 3]) + assert list(oarr) == [1, 2, 3] + assert oarr.pop() == 3 + assert oarr.pop(0) == 1 + assert list(oarr) == [2] - vlarray.clear() - assert len(vlarray) == 0 - assert list(vlarray) == [] + oarr.clear() + assert len(oarr) == 0 + assert list(oarr) == [] - vlarray.extend(["a", "b"]) - assert list(vlarray) == ["a", "b"] + oarr.extend(["a", "b"]) + assert list(oarr) == ["a", "b"] if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") @@ -416,31 +416,31 @@ def test_vlarray_list_like_ops(contiguous, urlpath): [ (False, None), (True, None), - (True, "test_vlarray_slices.b2frame"), - (False, "test_vlarray_slices_s.b2frame"), + (True, "test_objectarray_slices.b2frame"), + (False, "test_objectarray_slices_s.b2frame"), ], ) -def test_vlarray_slices(contiguous, urlpath): +def test_objectarray_slices(contiguous, urlpath): blosc2.remove_urlpath(urlpath) expected = list(range(8)) - vlarray = blosc2.VLArray(storage=_storage(contiguous, urlpath)) - vlarray.extend(expected) + oarr = blosc2.ObjectArray(storage=_storage(contiguous, urlpath)) + oarr.extend(expected) - assert vlarray[1:6:2] == expected[1:6:2] - assert vlarray[::-2] == expected[::-2] + assert oarr[1:6:2] == expected[1:6:2] + assert oarr[::-2] == expected[::-2] - vlarray[2:5] = ["a", "b"] + oarr[2:5] = ["a", "b"] expected[2:5] = ["a", "b"] - assert list(vlarray) == expected + assert list(oarr) == expected - vlarray[1:6:2] = [100, 101, 102] + oarr[1:6:2] = [100, 101, 102] expected[1:6:2] = [100, 101, 102] - assert list(vlarray) == expected + assert list(oarr) == expected - del vlarray[::3] + del oarr[::3] del expected[::3] - assert list(vlarray) == expected + assert list(oarr) == expected if urlpath is not None: reopened = blosc2.open(urlpath, mode="r") @@ -453,25 +453,25 @@ def test_vlarray_slices(contiguous, urlpath): blosc2.remove_urlpath(urlpath) -def test_vlarray_slice_errors(): - vlarray = blosc2.VLArray() - vlarray.extend([0, 1, 2, 3]) +def test_objectarray_slice_errors(): + oarr = blosc2.ObjectArray() + oarr.extend([0, 1, 2, 3]) with pytest.raises(ValueError, match="extended slice"): - vlarray[::2] = [9] + oarr[::2] = [9] with pytest.raises(TypeError): - vlarray[1:2] = 3 + oarr[1:2] = 3 with pytest.raises(ValueError): - _ = vlarray[::0] + _ = oarr[::0] -def test_vlarray_copy(): - urlpath = "test_vlarray_copy.b2frame" - copy_path = "test_vlarray_copy_out.b2frame" +def test_objectarray_copy(): + urlpath = "test_objectarray_copy.b2frame" + copy_path = "test_objectarray_copy_out.b2frame" blosc2.remove_urlpath(urlpath) blosc2.remove_urlpath(copy_path) - original = blosc2.VLArray(urlpath=urlpath, mode="w", contiguous=True) + original = blosc2.ObjectArray(urlpath=urlpath, mode="w", contiguous=True) original.extend(VALUES) original.insert(1, {"copy": True}) @@ -495,29 +495,29 @@ def test_vlarray_copy(): blosc2.remove_urlpath(copy_path) -def test_vlarray_empty_list_roundtrip(): +def test_objectarray_empty_list_roundtrip(): values = [[], {"a": []}, [[], ["nested"]], None, ("tuple", []), {"rows": [[], []]}] - vlarray = blosc2.VLArray() - vlarray.extend(values) - assert list(vlarray) == values + oarr = blosc2.ObjectArray() + oarr.extend(values) + assert list(oarr) == values -def test_vlarray_empty_tuple_roundtrip(): +def test_objectarray_empty_tuple_roundtrip(): values = [(), {"a": ()}, [(), ("nested",)], None, ("tuple", ()), {"rows": [[], ()]}] - vlarray = blosc2.VLArray() - vlarray.extend(values) - assert list(vlarray) == values + oarr = blosc2.ObjectArray() + oarr.extend(values) + assert list(oarr) == values -def test_vlarray_insert_delete_errors(): - vlarray = blosc2.VLArray() - vlarray.append("value") +def test_objectarray_insert_delete_errors(): + oarr = blosc2.ObjectArray() + oarr.append("value") with pytest.raises(TypeError): - vlarray.insert("0", "bad") + oarr.insert("0", "bad") with pytest.raises(IndexError): - vlarray.delete(3) + oarr.delete(3) with pytest.raises(IndexError): - blosc2.VLArray().pop() + blosc2.ObjectArray().pop() with pytest.raises(NotImplementedError): - vlarray.pop(slice(0, 1)) + oarr.pop(slice(0, 1)) diff --git a/tests/test_schunk_reorder_offsets.py b/tests/test_schunk_reorder_offsets.py new file mode 100644 index 000000000..16db37826 --- /dev/null +++ b/tests/test_schunk_reorder_offsets.py @@ -0,0 +1,73 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +import numpy as np +import pytest + +import blosc2 + + +@pytest.mark.parametrize("contiguous", [True, False]) +@pytest.mark.parametrize("urlpath", [None, "reorder_offsets.b2frame"]) +@pytest.mark.parametrize("nchunks", [1, 5, 12]) +def test_schunk_reorder_offsets(contiguous, urlpath, nchunks): + blosc2.remove_urlpath(urlpath) + schunk = blosc2.SChunk( + chunksize=200 * 1000 * 4, + contiguous=contiguous, + urlpath=urlpath, + cparams={"typesize": 4, "nthreads": 2}, + dparams={"nthreads": 2}, + ) + + for i in range(nchunks): + buffer = np.arange(200 * 1000, dtype=np.int32) + i * 200 * 1000 + assert schunk.append_data(buffer) == (i + 1) + + order = np.array([(i + 3) % nchunks for i in range(nchunks)], dtype=np.int64) + schunk.reorder_offsets(order) + + for i in range(nchunks): + expected = np.arange(200 * 1000, dtype=np.int32) + order[i] * 200 * 1000 + dest = np.empty(200 * 1000, dtype=np.int32) + schunk.decompress_chunk(i, dest) + assert np.array_equal(dest, expected) + + blosc2.remove_urlpath(urlpath) + + +@pytest.mark.parametrize( + "order", + [ + [[0, 1]], + [0, 1], + [0, 0, 1], + [0, 1, 3], + ], +) +def test_schunk_reorder_offsets_invalid_order(order): + schunk = blosc2.SChunk(chunksize=16, cparams={"typesize": 1}) + for payload in (b"a" * 16, b"b" * 16, b"c" * 16): + schunk.append_data(payload) + + if order == [[0, 1]] or order == [0, 1]: + with pytest.raises(ValueError): + schunk.reorder_offsets(order) + else: + with pytest.raises(RuntimeError): + schunk.reorder_offsets(order) + + +def test_schunk_reorder_offsets_read_only(tmp_path): + urlpath = tmp_path / "reorder_offsets_read_only.b2frame" + schunk = blosc2.SChunk(chunksize=16, urlpath=urlpath, contiguous=True, cparams={"typesize": 1}) + schunk.append_data(b"a" * 16) + schunk.append_data(b"b" * 16) + + reopened = blosc2.open(urlpath, mode="r") + with pytest.raises(ValueError, match="reading mode"): + reopened.reorder_offsets([1, 0]) diff --git a/tests/test_tree_store.py b/tests/test_tree_store.py index 40382311d..ec4390dc0 100644 --- a/tests/test_tree_store.py +++ b/tests/test_tree_store.py @@ -621,54 +621,54 @@ def test_schunk_support(): os.remove("test_schunk.b2z") -def test_vlarray_support(): - """Test that TreeStore supports embedded VLArray objects.""" +def test_objectarray_support(): + """Test that TreeStore supports embedded ObjectArray objects.""" values = [{"name": "alpha", "count": 1}, None, ("tuple", 2), [1, "two", b"three"]] - with TreeStore("test_vlarray.b2z", mode="w") as tstore: - vlarray = blosc2.VLArray() - vlarray.extend(values) - tstore["/data/vlarray1"] = vlarray + with TreeStore("test_objectarray.b2z", mode="w") as tstore: + oarr = blosc2.ObjectArray() + oarr.extend(values) + tstore["/data/objectarray1"] = oarr - retrieved = tstore["/data/vlarray1"] - assert isinstance(retrieved, blosc2.VLArray) + retrieved = tstore["/data/objectarray1"] + assert isinstance(retrieved, blosc2.ObjectArray) assert list(retrieved) == values data_subtree = tstore["/data"] assert isinstance(data_subtree, TreeStore) - assert set(data_subtree.keys()) == {"/vlarray1"} + assert set(data_subtree.keys()) == {"/objectarray1"} - with TreeStore("test_vlarray.b2z", mode="r") as tstore: - retrieved = tstore["/data/vlarray1"] - assert isinstance(retrieved, blosc2.VLArray) + with TreeStore("test_objectarray.b2z", mode="r") as tstore: + retrieved = tstore["/data/objectarray1"] + assert isinstance(retrieved, blosc2.ObjectArray) assert list(retrieved) == values - os.remove("test_vlarray.b2z") + os.remove("test_objectarray.b2z") -def test_external_vlarray_support(): - """Test that TreeStore supports external VLArray objects.""" - ext_path = "ext_vlarray.b2frame" +def test_external_objectarray_support(): + """Test that TreeStore supports external ObjectArray objects.""" + ext_path = "ext_objectarray.b2frame" values = ["alpha", {"nested": True}, None, (1, 2, 3)] if os.path.exists(ext_path): os.remove(ext_path) - vlarray = blosc2.VLArray(urlpath=ext_path, mode="w", contiguous=True) - vlarray.extend(values) - vlarray.vlmeta["description"] = "External VLArray for TreeStore" + oarr = blosc2.ObjectArray(urlpath=ext_path, mode="w", contiguous=True) + oarr.extend(values) + oarr.vlmeta["description"] = "External ObjectArray for TreeStore" - with TreeStore("test_vlarray_external.b2z", mode="w", threshold=None) as tstore: - tstore["/data/vlarray_ext"] = vlarray - assert "/data/vlarray_ext" in tstore + with TreeStore("test_objectarray_external.b2z", mode="w", threshold=None) as tstore: + tstore["/data/objectarray_ext"] = oarr + assert "/data/objectarray_ext" in tstore - with TreeStore("test_vlarray_external.b2z", mode="r") as tstore: - retrieved = tstore["/data/vlarray_ext"] - assert isinstance(retrieved, blosc2.VLArray) + with TreeStore("test_objectarray_external.b2z", mode="r") as tstore: + retrieved = tstore["/data/objectarray_ext"] + assert isinstance(retrieved, blosc2.ObjectArray) assert list(retrieved) == values - assert retrieved.vlmeta["description"] == "External VLArray for TreeStore" + assert retrieved.vlmeta["description"] == "External ObjectArray for TreeStore" if os.path.exists(ext_path): os.remove(ext_path) - os.remove("test_vlarray_external.b2z") + os.remove("test_objectarray_external.b2z") def test_external_batcharray_support(tmp_path):