Skip to content

Commit 2434e06

Browse files
Merge pull request #661 from Blosc/b2view-improvements
B2view improvements: CTable filtering is here!
2 parents 5ae207d + ab60f53 commit 2434e06

17 files changed

Lines changed: 1854 additions & 84 deletions

File tree

README.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ Conda users can install from conda-forge:
4848
4949
conda install -c conda-forge python-blosc2
5050
51+
Command line tools
52+
==================
53+
54+
Two CLI tools are installed along with the package:
55+
56+
- ``b2view``: an interactive terminal browser (TUI) for TreeStore bundles
57+
(``.b2d`` directories or ``.b2z`` files), with paged views of NDArray and
58+
CTable data of any size
59+
(`walkthrough <https://www.blosc.org/python-blosc2/getting_started/b2view.html>`_).
60+
- ``parquet-to-blosc2``: converts Parquet files to Blosc2 columnar table
61+
stores, and back
62+
(`walkthrough <https://www.blosc.org/python-blosc2/getting_started/parquet_to_blosc2.html>`_;
63+
requires ``pip install "blosc2[parquet]"``).
64+
5165
Documentation
5266
=============
5367

bench/tree-store.py

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
Benchmark for TreeStore hierarchical creation, opening, and listing.
1010
1111
Creates a hierarchy of N1 levels, each with N2 NDArray leaves and one
12-
CTable (4 cols: bool, int, float, string) with N5 rows. Leaf ``N``
12+
CTable (20 cols: bool, int, float, string plus 16 numeric columns) with
13+
N5 rows. Leaf ``N``
1314
receives an *N*-dimensional array (leaf0 is 0‑d, leaf1 is 1‑d, …) with
1415
each side ``int(MAX_ELEMS ** (1/N))`` so that no array exceeds MAX_ELEMS
1516
elements. Everything is written to ``tree-store.b2z`` and the script
@@ -33,13 +34,58 @@
3334

3435
# ── Row schema for the CTable ────────────────────────────────────────────
3536

37+
# 4 base columns plus 16 extra numeric ones (v04..v19), wide enough to
38+
# exceed the data panel viewport of b2view.
39+
NCOLS = 20
40+
3641

3742
@dataclasses.dataclass
3843
class _Row:
3944
a: bool = blosc2.field(blosc2.bool(), default=False)
4045
b: int = blosc2.field(blosc2.int64(), default=0)
4146
c: float = blosc2.field(blosc2.float64(), default=0.0)
4247
d: str = ""
48+
v04: int = blosc2.field(blosc2.int64(), default=0)
49+
v05: float = blosc2.field(blosc2.float64(), default=0.0)
50+
v06: int = blosc2.field(blosc2.int64(), default=0)
51+
v07: float = blosc2.field(blosc2.float64(), default=0.0)
52+
v08: int = blosc2.field(blosc2.int64(), default=0)
53+
v09: float = blosc2.field(blosc2.float64(), default=0.0)
54+
v10: int = blosc2.field(blosc2.int64(), default=0)
55+
v11: float = blosc2.field(blosc2.float64(), default=0.0)
56+
v12: int = blosc2.field(blosc2.int64(), default=0)
57+
v13: float = blosc2.field(blosc2.float64(), default=0.0)
58+
v14: int = blosc2.field(blosc2.int64(), default=0)
59+
v15: float = blosc2.field(blosc2.float64(), default=0.0)
60+
v16: int = blosc2.field(blosc2.int64(), default=0)
61+
v17: float = blosc2.field(blosc2.float64(), default=0.0)
62+
v18: int = blosc2.field(blosc2.int64(), default=0)
63+
v19: float = blosc2.field(blosc2.float64(), default=0.0)
64+
65+
66+
def ctable_values(nrows: int) -> dict[str, np.ndarray]:
67+
"""Deterministic column values for the CTable; row *i* is predictable.
68+
69+
Tests (e.g. tests/b2view/test_basics.py) rely on these formulas to check
70+
that a given viewport shows the expected values:
71+
72+
- a: i % 2 == 0
73+
- b: i
74+
- c: i * 1.5
75+
- d: "str_%06d" % i
76+
- v{k}, even k: i * k
77+
- v{k}, odd k: linspace(0, k, nrows)[i] == i * k / (nrows - 1)
78+
"""
79+
i = np.arange(nrows)
80+
values: dict[str, np.ndarray] = {
81+
"a": i % 2 == 0,
82+
"b": i,
83+
"c": i * 1.5,
84+
"d": np.char.add("str_", np.char.zfill(i.astype("U6"), 6)),
85+
}
86+
for k in range(4, NCOLS):
87+
values[f"v{k:02d}"] = i * k if k % 2 == 0 else np.linspace(0, k, num=nrows)
88+
return values
4389

4490

4591
# ── Helpers ──────────────────────────────────────────────────────────────
@@ -87,9 +133,16 @@ def create_store(
87133
max_elems: int,
88134
nrows: int,
89135
no_vlmeta: bool = False,
136+
output: str = OUTPUT_FILE,
137+
verbose: bool = True,
90138
) -> tuple[float, int]:
91139
"""Create the TreeStore; return (wall_clock, total_elements_written)."""
92-
_clean(OUTPUT_FILE)
140+
141+
def log(*args, **kwargs):
142+
if verbose:
143+
print(*args, **kwargs)
144+
145+
_clean(output)
93146

94147
# Pre-build one array per unique dimensionality (leaf ``i`` → *i*‑d).
95148
leaf_arrays_np: dict[int, np.ndarray] = {}
@@ -109,25 +162,30 @@ def create_store(
109162
total_elements = sum(leaf_arrays_np[ndim].size for ndim in range(nleaves)) * nlevels
110163

111164
# Pre-populate a single CTable that we will copy for every level.
165+
# Columns are filled from vectorized, predictable sequences (arange /
166+
# linspace flavored) so they are fast to build and compress very well.
112167
tmpl_table = blosc2.CTable(_Row, expected_size=nrows, validate=False)
113-
rows = [(i % 2 == 0, i, float(i) * 1.5, f"str_{i:06d}") for i in range(nrows)]
114-
tmpl_table.extend(rows, validate=False)
168+
cols = ctable_values(nrows)
169+
struct = np.empty(nrows, dtype=[(name, vals.dtype) for name, vals in cols.items()])
170+
for name, vals in cols.items():
171+
struct[name] = vals
172+
tmpl_table.extend(struct, validate=False)
115173

116-
print(
174+
log(
117175
f"\nCreating TreeStore with {nlevels} level(s), "
118176
f"{nleaves} leave(s) each, {nrows} CTable row(s) per level..."
119177
)
120-
print(f" Max elements per leaf: {max_elems:,}")
178+
log(f" Max elements per leaf: {max_elems:,}")
121179
for ndim in range(min(nleaves, 10)):
122180
shape = _leaf_shape(ndim, max_elems)
123181
nelem = int(np.prod(shape)) if shape else 1
124-
print(f" leaf{ndim}: shape={shape}, elements={nelem:,}, uncompressed={_fmt_bytes(nelem * 8)}")
182+
log(f" leaf{ndim}: shape={shape}, elements={nelem:,}, uncompressed={_fmt_bytes(nelem * 8)}")
125183
if nleaves > 10:
126-
print(f" ... ({nleaves - 10} more)")
127-
print(f" CTable rows: {nrows} | uncompressed table size: {_fmt_bytes(tmpl_table.nbytes)}")
184+
log(f" ... ({nleaves - 10} more)")
185+
log(f" CTable rows: {nrows} | uncompressed table size: {_fmt_bytes(tmpl_table.nbytes)}")
128186

129187
t0 = time.perf_counter()
130-
tstore = blosc2.TreeStore(OUTPUT_FILE, mode="w")
188+
tstore = blosc2.TreeStore(output, mode="w")
131189

132190
try:
133191
if not no_vlmeta:
@@ -160,12 +218,12 @@ def create_store(
160218
ct = tstore[table_key]
161219
ct.vlmeta["description"] = f"Level {level} CTable"
162220
ct.vlmeta["author"] = "blosc2"
163-
ct.vlmeta["ncols"] = 4
221+
ct.vlmeta["ncols"] = tmpl_table.ncols
164222
ct.vlmeta["has_index"] = True
165223
ct.vlmeta["tags_list"] = ["benchmark", "testing", f"level_{level}"]
166224

167225
if (level + 1) % max(1, nlevels // 10) == 0 or level == nlevels - 1:
168-
print(f" Level {level + 1}/{nlevels} done ({time.perf_counter() - t0:.2f}s so far)")
226+
log(f" Level {level + 1}/{nlevels} done ({time.perf_counter() - t0:.2f}s so far)")
169227
finally:
170228
tstore.close()
171229

@@ -308,7 +366,8 @@ def main() -> None:
308366
for d in range(args.nleaves)
309367
)
310368
total_data_bytes = (
311-
total_elements * 8 + args.nlevels * args.nrows * (1 + 8 + 8 + 16) # rough for table
369+
# rough per-row table size: bool + int64 + float64 + str + 16 numeric cols
370+
total_elements * 8 + args.nlevels * args.nrows * (1 + 8 + 8 + 16 + 16 * 8)
312371
)
313372
file_size = os.path.getsize(OUTPUT_FILE)
314373

doc/getting_started/b2view.rst

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
b2view: Browse TreeStore Bundles in the Terminal
2+
================================================
3+
4+
The ``b2view`` CLI opens an interactive terminal browser (TUI) for Blosc2
5+
TreeStore bundles, either sparse directories (``.b2d``) or compact
6+
zip-backed files (``.b2z``). It shows the tree of groups and nodes, the
7+
metadata and vlmeta of the selected node, and a paged view of the data
8+
itself — NDArrays of any dimensionality as well as CTables.
9+
10+
``b2view`` is installed with python-blosc2; no extra dependencies are
11+
needed.
12+
13+
Step 1 — Create a sample store
14+
------------------------------
15+
16+
Run the snippet below once to produce ``sample.b2z`` with a couple of
17+
arrays and some metadata:
18+
19+
.. code-block:: python
20+
21+
import blosc2
22+
23+
with blosc2.TreeStore("sample.b2z", mode="w") as tstore:
24+
tstore.vlmeta["author"] = "me"
25+
a = blosc2.linspace(0, 1, num=1_000_000, shape=(1000, 1000))
26+
a.vlmeta["description"] = "a 2-D linspace"
27+
tstore["/dense/a"] = a
28+
tstore["/dense/b"] = blosc2.arange(10_000, shape=(10, 100, 10))
29+
30+
Any existing TreeStore bundle works too — for instance the output of the
31+
``parquet-to-blosc2`` converter (see :doc:`parquet_to_blosc2`).
32+
33+
Step 2 — Open it
34+
----------------
35+
36+
.. code-block:: console
37+
38+
b2view sample.b2z
39+
40+
The screen is split into four panels: the **tree** of the bundle on the
41+
left, and **meta**, **vlmeta** and **data** panels for the node selected
42+
in the tree. Move between panels with ``tab`` / ``shift+tab``, maximize
43+
the focused one with ``m`` (``r`` restores it), and quit with ``q``.
44+
45+
By default the mouse is left to the terminal, so selecting and copying text
46+
works as in any other command line program. Pass ``--mouse`` to let b2view
47+
capture it instead: panels become clickable and the wheel scrolls the data
48+
grid (paging at the boundaries), at the cost of native text selection.
49+
50+
You can also jump straight to a node and panel:
51+
52+
.. code-block:: console
53+
54+
b2view sample.b2z /dense/a --panel data
55+
56+
Step 3 — Navigate the data panel
57+
--------------------------------
58+
59+
The data panel pages through objects far larger than the screen. Press
60+
``?`` at any time for the full key reference; the essentials are:
61+
62+
================================ =============================================
63+
Key Action
64+
================================ =============================================
65+
``up`` / ``down`` move the cursor; pages at the edges
66+
``pageup`` / ``pagedown`` previous / next page of rows
67+
``t`` / ``b`` first / last row
68+
``g`` go to a row number
69+
``left`` / ``right`` move across columns; pages at the edges
70+
``s`` / ``e`` (``home``/``end``) first / last column window
71+
``c`` go to a column index or name
72+
================================ =============================================
73+
74+
For N-D arrays, press ``d`` to enter *dim mode*: ``left`` / ``right``
75+
select the active dimension, ``up`` / ``down`` change its fixed index (or
76+
scroll the viewport), ``enter`` toggles a dimension between fixed and
77+
navigable, and ``escape`` leaves dim mode.
78+
79+
Step 4 — Filter CTable rows
80+
---------------------------
81+
82+
On a CTable node, press ``f`` and type a filter expression to page through
83+
only the matching rows — the same expressions ``CTable.where()`` accepts,
84+
including dotted nested column names and ``and`` / ``or``:
85+
86+
.. code-block:: text
87+
88+
payment.tips > 100 and trip.km > 0 and trip.sec > 0
89+
90+
The data header shows the active filter and the matching row count; all
91+
navigation (paging, ``g``, ``t`` / ``b``) then operates on the filtered
92+
rows. Press ``escape`` (or submit an empty expression) to go back to the
93+
unfiltered table; each node remembers its filter for the session.
94+
95+
Columns can be filtered too: press ``/`` and type a case-insensitive
96+
substring (e.g. ``payment``) to show only the matching columns — column
97+
paging and the ``c`` goto-column modal then operate on that subset. Row
98+
and column filters combine freely; ``escape`` clears them one layer at a
99+
time (row filter first, then columns).
100+
101+
CLI options
102+
-----------
103+
104+
``--preview-rows N`` and ``--preview-cols N`` bound the size of each data
105+
page (20 rows by 10 columns by default), and ``--panel`` chooses the panel
106+
focused on startup (``tree``, ``meta``, ``vlmeta`` or ``data``).

doc/getting_started/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ Getting Started
99
tutorials
1010
dsl_syntax
1111
parquet_to_blosc2
12+
b2view

doc/getting_started/installation.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Source code
2323
2424
git clone https://github.com/Blosc/python-blosc2/
2525
cd python-blosc2
26-
pip install .[test] # install with test dependencies
26+
pip install . --group test # install with test dependencies
2727
2828
That's all. You can proceed with testing section now.
2929

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ dependencies = [
3838
"numexpr>=2.14.1; platform_machine != 'wasm32'",
3939
"pydantic",
4040
"requests",
41+
"rich",
42+
"textual",
4143
"threadpoolctl; platform_machine != 'wasm32'",
4244
]
4345
version = "4.4.4.dev0"
@@ -50,7 +52,6 @@ documentation = "https://www.blosc.org/python-blosc2/python-blosc2.html"
5052

5153
[project.optional-dependencies]
5254
parquet = ["pyarrow"]
53-
tui = ["textual", "rich"]
5455

5556
[project.scripts]
5657
parquet-to-blosc2 = "blosc2.cli.parquet_to_blosc2:main"
@@ -74,6 +75,8 @@ dev = [
7475
]
7576
test = [
7677
"pytest",
78+
# for the b2view Pilot tests
79+
"pytest-asyncio",
7780
"psutil; platform_machine != 'wasm32'",
7881
# torch is optional because it is quite large (but will still be used if found)
7982
# "torch; platform_machine != 'wasm32'",

pytest.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ testpaths =
99
markers =
1010
heavy: tests that take long time to complete.
1111
network: tests that require network access.
12+
tui: b2view Textual UI tests; each one boots a headless app session.
1213

1314
filterwarnings =
1415
error

0 commit comments

Comments
 (0)