This is an automated email from the ASF dual-hosted git repository.
fresh-borzoni pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fluss-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 470c3c6d [python] Split examples in Python and enforce executability
in CI for python examples (#610)
470c3c6d is described below
commit 470c3c6d5864c78e28ac57a82114f9374d7a15d5
Author: Kaiqi Dong <[email protected]>
AuthorDate: Wed Jun 10 00:02:33 2026 +0200
[python] Split examples in Python and enforce executability in CI for
python examples (#610)
* enforce executability in CI for python examples
* [python] Add correctness assertions to examples and fix two .pyi stub bugs
Co-authored-by: Cursor <[email protected]>
* add more examples with complex types
---------
Co-authored-by: Cursor <[email protected]>
---
bindings/python/DEVELOPMENT.md | 27 +-
bindings/python/example/complex_types.py | 238 ++++++
bindings/python/example/example.py | 971 ------------------------
bindings/python/example/log_table.py | 388 ++++++++++
bindings/python/example/partitioned_kv_table.py | 138 ++++
bindings/python/example/partitioned_table.py | 176 +++++
bindings/python/example/pk_table.py | 225 ++++++
bindings/python/fluss/__init__.pyi | 3 +-
bindings/python/test/test_examples.py | 66 ++
9 files changed, 1256 insertions(+), 976 deletions(-)
diff --git a/bindings/python/DEVELOPMENT.md b/bindings/python/DEVELOPMENT.md
index cccd0d1e..65bb37bc 100644
--- a/bindings/python/DEVELOPMENT.md
+++ b/bindings/python/DEVELOPMENT.md
@@ -46,10 +46,25 @@ uv run mypy python/
## Run Examples
+Each example is standalone and runnable on its own. They default to a local
+cluster at `127.0.0.1:9123`; override with `FLUSS_BOOTSTRAP_SERVERS`.
+
```bash
-uv run python example/example.py
+uv run python example/log_table.py
+uv run python example/pk_table.py
+uv run python example/complex_types.py
+uv run python example/partitioned_table.py
+uv run python example/partitioned_kv_table.py
+
+# Point at a specific cluster:
+FLUSS_BOOTSTRAP_SERVERS=host:port uv run python example/log_table.py
```
+CI runs every example against an ephemeral test cluster via
+`test/test_examples.py`, which auto-discovers any `example/*.py` exposing a
+callable `main(bootstrap_servers)`. New examples are checked automatically with
+no test changes.
+
## Build API Docs
```bash
@@ -86,8 +101,14 @@ bindings/python/
│ ├── __init__.py
│ ├── __init__.pyi # Type stubs
│ └── py.typed
-└── example/
- └── example.py
+├── example/ # Standalone, CI-checked examples
+│ ├── log_table.py
+│ ├── pk_table.py
+│ ├── complex_types.py
+│ ├── partitioned_table.py
+│ └── partitioned_kv_table.py
+└── test/
+ └── test_examples.py # Runs every example against the cluster
```
## License
diff --git a/bindings/python/example/complex_types.py
b/bindings/python/example/complex_types.py
new file mode 100644
index 00000000..f8082c80
--- /dev/null
+++ b/bindings/python/example/complex_types.py
@@ -0,0 +1,238 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Complex types example: ARRAY, MAP, and ROW (including nesting).
+
+Shows how to define ARRAY / MAP / ROW columns, the write shapes each accepts,
+and how they read back through both the record (dict) scan path and the Arrow
+scan path on a log table, plus upsert + lookup on a primary-key table.
+
+Write/read shapes:
+ ARRAY<T> write list/tuple -> read list
+ MAP<K, V> write dict or [(k, v), ...] -> read list of (k, v) tuples
+ ROW<...> write dict (by name) or list/tuple -> read dict
+
+Complex types may be nested arbitrarily, but cannot be primary-key or
+bucket-key columns.
+
+Run standalone against a local cluster:
+
+ python example/complex_types.py
+
+Or point it at a specific cluster:
+
+ FLUSS_BOOTSTRAP_SERVERS=host:port python example/complex_types.py
+"""
+
+import asyncio
+import os
+import time
+from typing import Optional
+
+import pyarrow as pa
+
+import fluss
+
+DEFAULT_BOOTSTRAP_SERVERS = "127.0.0.1:9123"
+
+
+def _complex_value_fields():
+ """The complex (ARRAY / MAP / ROW) value columns, shared by both tables."""
+ return [
+ pa.field("tags", pa.list_(pa.string())), # array<string>
+ pa.field("scores", pa.list_(pa.int32())), # array<int> (with nulls)
+ pa.field("attrs", pa.map_(pa.string(), pa.int32())), # map<string,
int>
+ pa.field(
+ "profile",
+ pa.struct([("age", pa.int32()), ("city", pa.string())]), #
row<...>
+ ),
+ pa.field("matrix", pa.list_(pa.list_(pa.int32()))), #
array<array<int>>
+ pa.field(
+ "arr_of_map", pa.list_(pa.map_(pa.string(), pa.int32()))
+ ), # array<map<string, int>>
+ ]
+
+
+# Row 1 uses the "canonical" write shapes (map as dict, row as dict).
+ROW1 = {
+ "id": 1,
+ "tags": ["a", "b"],
+ "scores": [10, 20, 30],
+ "attrs": {"x": 1, "y": 2},
+ "profile": {"age": 30, "city": "NYC"},
+ "matrix": [[1, 2], [3, 4]],
+ "arr_of_map": [{"k": 1}],
+}
+
+# Row 2 uses the alternative write shapes accepted by the client:
+# map as a list of (key, value) pairs, row as a positional list, and a null
+# array element.
+ROW2 = {
+ "id": 2,
+ "tags": ["c"],
+ "scores": [5, None],
+ "attrs": [("p", 7), ("q", 8)],
+ "profile": [40, "LA"],
+ "matrix": [[9]],
+ "arr_of_map": [{"m": 2}, {"n": 3}],
+}
+
+# Row 3 omits every nullable complex column from the dict, so each defaults to
+# null on write.
+ROW3 = {"id": 3}
+
+COMPLEX_COLUMNS = [f.name for f in _complex_value_fields()]
+
+
+def _assert_rows(rows):
+ """Safety net: confirm the rows read back match what we wrote.
+
+ Examples exist to teach the API, so this stays out of the way -- a single
+ call per read path. If it ever fails, CI fails too (the example is run as a
+ test), which is exactly when we want to know the docs have drifted.
+ MAP reads back as a list of (key, value) tuples, so we wrap with dict().
+ """
+ assert len(rows) == 3, f"expected 3 rows, got {len(rows)}"
+ r1, r2, r3 = rows
+
+ assert r1["tags"] == ["a", "b"]
+ assert r1["scores"] == [10, 20, 30]
+ assert dict(r1["attrs"]) == {"x": 1, "y": 2}
+ assert r1["profile"] == {"age": 30, "city": "NYC"}
+ assert r1["matrix"] == [[1, 2], [3, 4]]
+ assert [dict(m) for m in r1["arr_of_map"]] == [{"k": 1}]
+
+ assert r2["tags"] == ["c"]
+ assert r2["scores"] == [5, None]
+ assert dict(r2["attrs"]) == {"p": 7, "q": 8}
+ assert r2["profile"] == {"age": 40, "city": "LA"}
+ assert r2["matrix"] == [[9]]
+ assert [dict(m) for m in r2["arr_of_map"]] == [{"m": 2}, {"n": 3}]
+
+ assert all(r3[col] is None for col in COMPLEX_COLUMNS)
+
+
+async def main(bootstrap_servers: Optional[str] = None):
+ bootstrap_servers = bootstrap_servers or os.environ.get(
+ "FLUSS_BOOTSTRAP_SERVERS", DEFAULT_BOOTSTRAP_SERVERS
+ )
+
+ config = fluss.Config({"bootstrap.servers": bootstrap_servers})
+ conn = await fluss.FlussConnection.create(config)
+ try:
+ await _run_log_table(conn)
+ await _run_pk_table(conn)
+ finally:
+ await conn.close()
+ print("\nConnection closed")
+
+
+async def _run_log_table(conn):
+ print("\n=== Log table: append + scan complex types ===")
+ admin = conn.get_admin()
+ schema = fluss.Schema(
+ pa.schema([pa.field("id", pa.int32())] + _complex_value_fields())
+ )
+ table_path = fluss.TablePath("fluss", "example_complex_log")
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ # bucket_count=1 keeps record-scan ordering deterministic for the example.
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema, bucket_count=1),
ignore_if_exists=True
+ )
+ print(f"Created log table: {table_path}")
+
+ table = await conn.get_table(table_path)
+ writer = table.new_append().create_writer()
+ writer.append(ROW1)
+ writer.append(ROW2)
+ writer.append(ROW3)
+ await writer.flush()
+ print("Appended 3 rows (canonical shapes, alternative shapes, all-null)")
+
+ print("\n--- Record (dict) scan path ---")
+ scanner = await table.new_scan().create_log_scanner()
+ scanner.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
+ records = await _poll_until(scanner, expected=3)
+ rows = sorted((r.row for r in records), key=lambda r: r["id"])
+ _assert_rows(rows)
+ print("Verified ARRAY/MAP/ROW values via record.row dicts")
+
+ print("\n--- Arrow scan path (must agree with the dict path) ---")
+ scanner2 = await table.new_scan().create_record_batch_log_scanner()
+ scanner2.subscribe_buckets({0: fluss.EARLIEST_OFFSET})
+ arrow = await scanner2.to_arrow()
+ _assert_rows(arrow.sort_by("id").to_pylist())
+ print("Verified the Arrow path returns identical nested values")
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ print(f"Dropped log table: {table_path}")
+
+
+async def _run_pk_table(conn):
+ print("\n=== Primary-key table: upsert + lookup complex types ===")
+ admin = conn.get_admin()
+ # Complex types are value columns only; the primary/bucket key must be a
+ # simple type (the server rejects complex key columns).
+ schema = fluss.Schema(
+ pa.schema([pa.field("id", pa.int32())] + _complex_value_fields()),
+ primary_keys=["id"],
+ )
+ table_path = fluss.TablePath("fluss", "example_complex_kv")
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ await admin.create_table(
+ table_path, fluss.TableDescriptor(schema, bucket_count=1),
ignore_if_exists=True
+ )
+ print(f"Created PK table: {table_path}")
+
+ table = await conn.get_table(table_path)
+ writer = table.new_upsert().create_writer()
+ writer.upsert(ROW1)
+ writer.upsert(ROW2)
+ handle = writer.upsert(ROW3)
+ await handle.wait()
+ print("Upserted 3 rows")
+
+ lookuper = table.new_lookup().create_lookuper()
+ rows = []
+ for i in (1, 2, 3):
+ row = await lookuper.lookup({"id": i})
+ assert row is not None, f"expected to find id={i}"
+ rows.append(row)
+ _assert_rows(rows)
+ print("Verified ARRAY/MAP/ROW values via lookup")
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ print(f"Dropped PK table: {table_path}")
+
+
+async def _poll_until(scanner, expected, timeout_ms=15000):
+ """Poll the record scanner until ``expected`` records arrive or we time
out.
+
+ A single poll is not guaranteed to drain everything, so accumulate across
+ polls rather than asserting on one call.
+ """
+ deadline = time.monotonic() + timeout_ms / 1000
+ collected = []
+ while len(collected) < expected and time.monotonic() < deadline:
+ collected.extend(list(await scanner.poll(2000)))
+ return collected
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/bindings/python/example/example.py
b/bindings/python/example/example.py
deleted file mode 100644
index 23ccc6d1..00000000
--- a/bindings/python/example/example.py
+++ /dev/null
@@ -1,971 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-import asyncio
-import traceback
-from datetime import date, datetime
-from datetime import time as dt_time
-from decimal import Decimal
-
-import pandas as pd
-import pyarrow as pa
-
-import fluss
-
-
-async def main():
- # Create connection configuration
- config_spec = {
- "bootstrap.servers": "127.0.0.1:9123",
- # Add other configuration options as needed
- "writer.request-max-size": "10485760", # 10 MB
- "writer.acks": "all", # Wait for all replicas to acknowledge
- "writer.retries": "3", # Retry up to 3 times on failure
- "writer.batch-size": "1000", # Batch size for writes
- }
- config = fluss.Config(config_spec)
-
- # Create connection using the static create method
- conn = await fluss.FlussConnection.create(config)
-
- # Define fields for PyArrow
- fields = [
- pa.field("id", pa.int32()),
- pa.field("name", pa.string()),
- pa.field("score", pa.float32()),
- pa.field("age", pa.int32()),
- pa.field("birth_date", pa.date32()),
- pa.field("check_in_time", pa.time32("ms")),
- pa.field("created_at", pa.timestamp("us")), # TIMESTAMP (NTZ)
- pa.field("updated_at", pa.timestamp("us", tz="UTC")), # TIMESTAMP_LTZ
- pa.field("salary", pa.decimal128(10, 2)),
- ]
-
- # Create a PyArrow schema
- schema = pa.schema(fields)
-
- # Create a Fluss Schema first (this is what TableDescriptor expects)
- fluss_schema = fluss.Schema(schema)
-
- # Create a Fluss TableDescriptor
- table_descriptor = fluss.TableDescriptor(fluss_schema)
-
- # Get the admin for Fluss
- admin = conn.get_admin()
-
- # Create a Fluss table
- table_path = fluss.TablePath("fluss", "sample_table_types")
-
- try:
- await admin.create_table(table_path, table_descriptor, True)
- print(f"Created table: {table_path}")
- except Exception as e:
- print(f"Table creation failed: {e}")
-
- # Get table information via admin
- try:
- table_info = await admin.get_table_info(table_path)
- print(f"Table info: {table_info}")
- print(f"Table ID: {table_info.table_id}")
- print(f"Schema ID: {table_info.schema_id}")
- print(f"Created time: {table_info.created_time}")
- print(f"Primary keys: {table_info.get_primary_keys()}")
- except Exception as e:
- print(f"Failed to get table info: {e}")
-
- # Demo: List offsets
- print("\n--- Testing list_offsets() ---")
- try:
- # Query latest offsets using OffsetSpec factory method
- offsets = await admin.list_offsets(
- table_path,
- bucket_ids=[0],
- offset_spec=fluss.OffsetSpec.latest()
- )
- print(f"Latest offsets for table (before writes): {offsets}")
- except Exception as e:
- print(f"Failed to list offsets: {e}")
-
- # Get the table instance
- table = await conn.get_table(table_path)
- print(f"Got table: {table}")
-
- # Create a writer for the table
- append_writer = table.new_append().create_writer()
- print(f"Created append writer: {append_writer}")
-
- try:
- # Demo: Write PyArrow Table
- print("\n--- Testing PyArrow Table write ---")
- pa_table = pa.Table.from_arrays(
- [
- pa.array([1, 2, 3], type=pa.int32()),
- pa.array(["Alice", "Bob", "Charlie"], type=pa.string()),
- pa.array([95.2, 87.2, 92.1], type=pa.float32()),
- pa.array([25, 30, 35], type=pa.int32()),
- pa.array(
- [date(1999, 5, 15), date(1994, 3, 20), date(1989, 11, 8)],
- type=pa.date32(),
- ),
- pa.array(
- [dt_time(9, 0, 0), dt_time(9, 30, 0), dt_time(10, 0, 0)],
- type=pa.time32("ms"),
- ),
- pa.array(
- [
- datetime(2024, 1, 15, 10, 30),
- datetime(2024, 1, 15, 11, 0),
- datetime(2024, 1, 15, 11, 30),
- ],
- type=pa.timestamp("us"),
- ),
- pa.array(
- [
- datetime(2024, 1, 15, 10, 30),
- datetime(2024, 1, 15, 11, 0),
- datetime(2024, 1, 15, 11, 30),
- ],
- type=pa.timestamp("us", tz="UTC"),
- ),
- pa.array(
- [Decimal("75000.00"), Decimal("82000.50"),
Decimal("95000.75")],
- type=pa.decimal128(10, 2),
- ),
- ],
- schema=schema,
- )
-
- append_writer.write_arrow(pa_table)
- print("Successfully wrote PyArrow Table")
-
- # Demo: Write PyArrow RecordBatch
- print("\n--- Testing PyArrow RecordBatch write ---")
- pa_record_batch = pa.RecordBatch.from_arrays(
- [
- pa.array([4, 5], type=pa.int32()),
- pa.array(["David", "Eve"], type=pa.string()),
- pa.array([88.5, 91.0], type=pa.float32()),
- pa.array([28, 32], type=pa.int32()),
- pa.array([date(1996, 7, 22), date(1992, 12, 1)],
type=pa.date32()),
- pa.array([dt_time(14, 15, 0), dt_time(8, 45, 0)],
type=pa.time32("ms")),
- pa.array(
- [datetime(2024, 1, 16, 9, 0), datetime(2024, 1, 16, 9,
30)],
- type=pa.timestamp("us"),
- ),
- pa.array(
- [datetime(2024, 1, 16, 9, 0), datetime(2024, 1, 16, 9,
30)],
- type=pa.timestamp("us", tz="UTC"),
- ),
- pa.array(
- [Decimal("68000.00"), Decimal("72500.25")],
- type=pa.decimal128(10, 2),
- ),
- ],
- schema=schema,
- )
-
- append_writer.write_arrow_batch(pa_record_batch)
- print("Successfully wrote PyArrow RecordBatch")
-
- # Test 3: Append single rows with Date, Time, Timestamp, Decimal
- print("\n--- Testing single row append with temporal/decimal types
---")
- # Dict input with all types including Date, Time, Timestamp, Decimal
- append_writer.append(
- {
- "id": 8,
- "name": "Helen",
- "score": 93.5,
- "age": 26,
- "birth_date": date(1998, 4, 10),
- "check_in_time": dt_time(11, 30, 45),
- "created_at": datetime(2024, 1, 17, 14, 0, 0),
- "updated_at": datetime(2024, 1, 17, 14, 0, 0),
- "salary": Decimal("88000.00"),
- }
- )
- print("Successfully appended row (dict with Date, Time, Timestamp,
Decimal)")
-
- # List input with all types
- append_writer.append(
- [
- 9,
- "Ivan",
- 90.0,
- 31,
- date(1993, 8, 25),
- dt_time(16, 45, 0),
- datetime(2024, 1, 17, 15, 30, 0),
- datetime(2024, 1, 17, 15, 30, 0),
- Decimal("91500.50"),
- ]
- )
- print("Successfully appended row (list with Date, Time, Timestamp,
Decimal)")
-
- # Demo: Write Pandas DataFrame
- print("\n--- Testing Pandas DataFrame write ---")
- df = pd.DataFrame(
- {
- "id": [10, 11],
- "name": ["Frank", "Grace"],
- "score": [89.3, 94.7],
- "age": [29, 27],
- "birth_date": [date(1995, 2, 14), date(1997, 9, 30)],
- "check_in_time": [dt_time(10, 0, 0), dt_time(10, 30, 0)],
- "created_at": [
- datetime(2024, 1, 18, 8, 0),
- datetime(2024, 1, 18, 8, 30),
- ],
- "updated_at": [
- datetime(2024, 1, 18, 8, 0),
- datetime(2024, 1, 18, 8, 30),
- ],
- "salary": [Decimal("79000.00"), Decimal("85500.75")],
- }
- )
-
- append_writer.write_pandas(df)
- print("Successfully wrote Pandas DataFrame")
-
- # Flush all pending data
- print("\n--- Flushing data ---")
- await append_writer.flush()
- print("Successfully flushed data")
-
- # Demo: Check offsets after writes
- print("\n--- Checking offsets after writes ---")
- try:
- offsets = await admin.list_offsets(
- table_path,
- bucket_ids=[0],
- offset_spec=fluss.OffsetSpec.latest()
- )
- print(f"Latest offsets after writing 7 records: {offsets}")
- except Exception as e:
- print(f"Failed to list offsets: {e}")
-
- except Exception as e:
- print(f"Error during writing: {e}")
-
- # Now scan the table to verify data was written
- print("\n--- Scanning table (batch scanner) ---")
- try:
- # Use new_scan().create_record_batch_log_scanner() for batch-based
operations
- batch_scanner = await
table.new_scan().create_record_batch_log_scanner()
- print(f"Created batch scanner: {batch_scanner}")
-
- # Subscribe to buckets (required before to_arrow/to_pandas)
- # Use subscribe_buckets to subscribe all buckets from EARLIEST_OFFSET
- num_buckets = (await admin.get_table_info(table_path)).num_buckets
- batch_scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
- print(f"Subscribed to {num_buckets} buckets from EARLIEST_OFFSET")
-
- # Read all data using to_arrow()
- print("Scanning results using to_arrow():")
-
- # Try to get as PyArrow Table
- try:
- pa_table_result = await batch_scanner.to_arrow()
- print(f"\nAs PyArrow Table: {pa_table_result}")
- except Exception as e:
- print(f"Could not convert to PyArrow: {e}")
-
- # Create a new batch scanner for to_pandas() test
- batch_scanner2 = await
table.new_scan().create_record_batch_log_scanner()
- batch_scanner2.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
-
- # Try to get as Pandas DataFrame
- try:
- df_result = await batch_scanner2.to_pandas()
- print(f"\nAs Pandas DataFrame:\n{df_result}")
- except Exception as e:
- print(f"Could not convert to Pandas: {e}")
-
- # to_arrow_batch_reader() — returns a lazy PyArrow RecordBatchReader
- batch_scanner_reader = await
table.new_scan().create_record_batch_log_scanner()
- batch_scanner_reader.subscribe_buckets(
- {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
- )
- arrow_reader = batch_scanner_reader.to_arrow_batch_reader()
- reader_table = pa.Table.from_batches(list(arrow_reader),
schema=arrow_reader.schema)
- print(f"\nVia to_arrow_batch_reader(): {reader_table.num_rows} rows")
-
- # TODO: support to_duckdb()
-
- # Test poll_arrow() method for incremental reading as Arrow Table
- print("\n--- Testing poll_arrow() method ---")
- batch_scanner3 = await
table.new_scan().create_record_batch_log_scanner()
- batch_scanner3.subscribe(bucket_id=0,
start_offset=fluss.EARLIEST_OFFSET)
- print(f"Subscribed to bucket 0 at EARLIEST_OFFSET
({fluss.EARLIEST_OFFSET})")
-
- # Poll with a timeout of 5000ms (5 seconds)
- # Note: poll_arrow() returns an empty table (not an error) on timeout
- try:
- poll_result = await batch_scanner3.poll_arrow(5000)
- print(f"Number of rows: {poll_result.num_rows}")
-
- if poll_result.num_rows > 0:
- poll_df = poll_result.to_pandas()
- print(f"Polled data:\n{poll_df}")
- else:
- print("Empty result (no records available)")
- # Empty table still has schema - this is useful!
- print(f"Schema: {poll_result.schema}")
-
- except Exception as e:
- print(f"Error during poll_arrow: {e}")
-
- # Test poll_record_batch() method for batches with metadata
- print("\n--- Testing poll_record_batch() method ---")
- batch_scanner4 = await
table.new_scan().create_record_batch_log_scanner()
- batch_scanner4.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
-
- try:
- batches = await batch_scanner4.poll_record_batch(5000)
- print(f"Number of batches: {len(batches)}")
-
- for i, batch in enumerate(batches):
- print(f" Batch {i}: bucket={batch.bucket}, "
- f"offsets={batch.base_offset}-{batch.last_offset}, "
- f"rows={batch.batch.num_rows}")
-
- except Exception as e:
- print(f"Error during poll_record_batch: {e}")
-
- except Exception as e:
- print(f"Error during batch scanning: {e}")
-
- # Test record-based scanning with poll()
- print("\n--- Scanning table (record scanner) ---")
- try:
- # Use new_scan().create_log_scanner() for record-based operations
- record_scanner = await table.new_scan().create_log_scanner()
- print(f"Created record scanner: {record_scanner}")
-
- record_scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
-
- # Poll returns ScanRecords — records grouped by bucket
- print("\n--- Testing poll() method (record-by-record) ---")
- try:
- scan_records = await record_scanner.poll(5000)
- print(f"Total records: {scan_records.count()}, buckets:
{len(scan_records.buckets())}")
-
- # Flat iteration over all records (regardless of bucket)
- print(f" Flat iteration: {scan_records.count()} records")
- for record in scan_records:
- print(f" offset={record.offset},
timestamp={record.timestamp}")
-
- # Per-bucket access
- for bucket in scan_records.buckets():
- bucket_recs = scan_records.records(bucket)
- print(f" Bucket {bucket}: {len(bucket_recs)} records")
- for record in bucket_recs[:3]:
- print(f" offset={record.offset}, "
- f"timestamp={record.timestamp}, "
- f"change_type={record.change_type}, "
- f"row={record.row}")
-
- except Exception as e:
- print(f"Error during poll: {e}")
-
- except Exception as e:
- print(f"Error during record scanning: {e}")
-
- # Demo: unsubscribe — unsubscribe from a bucket (non-partitioned tables)
- print("\n--- Testing unsubscribe ---")
- try:
- unsub_scanner = await
table.new_scan().create_record_batch_log_scanner()
- unsub_scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
- print(f"Subscribed to {num_buckets} buckets")
- # Unsubscribe from bucket 0 — future polls will skip this bucket
- unsub_scanner.unsubscribe(bucket_id=0)
- print("Unsubscribed from bucket 0")
- remaining = await unsub_scanner.poll_arrow(5000)
- print(f"After unsubscribe, got {remaining.num_rows} records (from
remaining buckets)")
- except Exception as e:
- print(f"Error during unsubscribe test: {e}")
-
- # =====================================================
- # Demo: Primary Key Table with Lookup and Upsert
- # =====================================================
- print("\n" + "=" * 60)
- print("--- Testing Primary Key Table (Lookup & Upsert) ---")
- print("=" * 60)
-
- # Create a primary key table for lookup/upsert tests
- # Include temporal and decimal types to test full conversion
- pk_table_fields = [
- pa.field("user_id", pa.int32()),
- pa.field("name", pa.string()),
- pa.field("email", pa.string()),
- pa.field("age", pa.int32()),
- pa.field("birth_date", pa.date32()),
- pa.field("login_time", pa.time32("ms")),
- pa.field("created_at", pa.timestamp("us")), # TIMESTAMP (NTZ)
- pa.field("updated_at", pa.timestamp("us", tz="UTC")), # TIMESTAMP_LTZ
- pa.field("balance", pa.decimal128(10, 2)),
- ]
- pk_schema = pa.schema(pk_table_fields)
- fluss_pk_schema = fluss.Schema(pk_schema, primary_keys=["user_id"])
-
- # Create table descriptor
- pk_table_descriptor = fluss.TableDescriptor(
- fluss_pk_schema,
- bucket_count=3,
- )
-
- pk_table_path = fluss.TablePath("fluss", "users_pk_table_v3")
-
- try:
- await admin.create_table(pk_table_path, pk_table_descriptor, True)
- print(f"Created PK table: {pk_table_path}")
- except Exception as e:
- print(f"PK Table creation failed (may already exist): {e}")
-
- # Get the PK table
- pk_table = await conn.get_table(pk_table_path)
- print(f"Got PK table: {pk_table}")
- print(f"Has primary key: {pk_table.has_primary_key()}")
-
- # --- Test Upsert ---
- print("\n--- Testing Upsert (fire-and-forget) ---")
- try:
- upsert_writer = pk_table.new_upsert().create_writer()
- print(f"Created upsert writer: {upsert_writer}")
-
- # Fire-and-forget: queue writes synchronously, flush at end.
- # Records are batched internally for efficiency.
- upsert_writer.upsert(
- {
- "user_id": 1,
- "name": "Alice",
- "email": "[email protected]",
- "age": 25,
- "birth_date": date(1999, 5, 15),
- "login_time": dt_time(9, 30, 45, 123000), # 09:30:45.123
- "created_at": datetime(
- 2024, 1, 15, 10, 30, 45, 123456
- ), # with microseconds
- "updated_at": datetime(2024, 1, 15, 10, 30, 45, 123456),
- "balance": Decimal("1234.56"),
- }
- )
- print("Queued user_id=1 (Alice)")
-
- upsert_writer.upsert(
- {
- "user_id": 2,
- "name": "Bob",
- "email": "[email protected]",
- "age": 30,
- "birth_date": date(1994, 3, 20),
- "login_time": dt_time(14, 15, 30, 500000), # 14:15:30.500
- "created_at": datetime(2024, 1, 16, 11, 22, 33, 444555),
- "updated_at": datetime(2024, 1, 16, 11, 22, 33, 444555),
- "balance": Decimal("5678.91"),
- }
- )
- print("Queued user_id=2 (Bob)")
-
- upsert_writer.upsert(
- {
- "user_id": 3,
- "name": "Charlie",
- "email": "[email protected]",
- "age": 35,
- "birth_date": date(1989, 11, 8),
- "login_time": dt_time(16, 45, 59, 999000), # 16:45:59.999
- "created_at": datetime(2024, 1, 17, 23, 59, 59, 999999),
- "updated_at": datetime(2024, 1, 17, 23, 59, 59, 999999),
- "balance": Decimal("9876.54"),
- }
- )
- print("Queued user_id=3 (Charlie)")
-
- # flush() waits for all queued writes to be acknowledged by the server
- await upsert_writer.flush()
- print("Flushed — all 3 rows acknowledged by server")
-
- # Per-record acknowledgment: await the returned handle to block until
- # the server confirms this specific write, useful when you need to
- # read-after-write or verify critical updates.
- print("\n--- Testing Upsert (per-record acknowledgment) ---")
- handle = upsert_writer.upsert(
- {
- "user_id": 1,
- "name": "Alice Updated",
- "email": "[email protected]",
- "age": 26,
- "birth_date": date(1999, 5, 15),
- "login_time": dt_time(10, 11, 12, 345000), # 10:11:12.345
- "created_at": datetime(2024, 1, 15, 10, 30, 45, 123456), #
unchanged
- "updated_at": datetime(
- 2024, 1, 20, 15, 45, 30, 678901
- ), # new update time
- "balance": Decimal("2345.67"),
- }
- )
- await handle.wait() # wait for server acknowledgment
- print("Updated user_id=1 (Alice -> Alice Updated) — server
acknowledged")
-
- except Exception as e:
- print(f"Error during upsert: {e}")
- traceback.print_exc()
-
- # --- Test Lookup ---
- print("\n--- Testing Lookup ---")
- try:
- lookuper = pk_table.new_lookup().create_lookuper()
- print(f"Created lookuper: {lookuper}")
-
- result = await lookuper.lookup({"user_id": 1})
- if result:
- print("Lookup user_id=1: Found!")
- print(f" name: {result['name']}")
- print(f" email: {result['email']}")
- print(f" age: {result['age']}")
- print(
- f" birth_date: {result['birth_date']} (type:
{type(result['birth_date']).__name__})"
- )
- print(
- f" login_time: {result['login_time']} (type:
{type(result['login_time']).__name__})"
- )
- print(
- f" created_at: {result['created_at']} (type:
{type(result['created_at']).__name__})"
- )
- print(
- f" updated_at: {result['updated_at']} (type:
{type(result['updated_at']).__name__})"
- )
- print(
- f" balance: {result['balance']} (type:
{type(result['balance']).__name__})"
- )
- else:
- print("Lookup user_id=1: Not found")
-
- # Lookup another row
- result = await lookuper.lookup({"user_id": 2})
- if result:
- print(f"Lookup user_id=2: Found! -> {result}")
- else:
- print("Lookup user_id=2: Not found")
-
- # Lookup non-existent row
- result = await lookuper.lookup({"user_id": 999})
- if result:
- print(f"Lookup user_id=999: Found! -> {result}")
- else:
- print("Lookup user_id=999: Not found (as expected)")
-
- except Exception as e:
- print(f"Error during lookup: {e}")
- traceback.print_exc()
-
- # --- Test Delete ---
- print("\n--- Testing Delete ---")
- try:
- upsert_writer = pk_table.new_upsert().create_writer()
-
- handle = upsert_writer.delete({"user_id": 3})
- await handle.wait()
- print("Deleted user_id=3 — server acknowledged")
-
- lookuper = pk_table.new_lookup().create_lookuper()
- result = await lookuper.lookup({"user_id": 3})
- if result:
- print(f"Lookup user_id=3 after delete: Still found! -> {result}")
- else:
- print("Lookup user_id=3 after delete: Not found (deletion
confirmed)")
-
- except Exception as e:
- print(f"Error during delete: {e}")
- traceback.print_exc()
-
- # --- Test Partial Update by column names ---
- print("\n--- Testing Partial Update (by column names) ---")
- try:
- partial_writer =
pk_table.new_upsert().partial_update_by_name(["user_id",
"balance"]).create_writer()
- handle = partial_writer.upsert({"user_id": 1, "balance":
Decimal("9999.99")})
- await handle.wait()
- print("Partial update: set balance=9999.99 for user_id=1")
-
- lookuper = pk_table.new_lookup().create_lookuper()
- result = await lookuper.lookup({"user_id": 1})
- if result:
- print(f"Partial update verified:"
- f"\n name={result['name']} (unchanged)"
- f"\n balance={result['balance']} (updated)")
- else:
- print("ERROR: Expected to find user_id=1")
-
- except Exception as e:
- print(f"Error during partial update by names: {e}")
- traceback.print_exc()
-
- # --- Test Partial Update by column indices ---
- print("\n--- Testing Partial Update (by column indices) ---")
- try:
- # Columns: 0=user_id (PK), 1=name — update name only
- partial_writer_idx = pk_table.new_upsert().partial_update_by_index([0,
1]).create_writer()
- handle = partial_writer_idx.upsert([1, "Alice Renamed"])
- await handle.wait()
- print("Partial update by indices: set name='Alice Renamed' for
user_id=1")
-
- lookuper = pk_table.new_lookup().create_lookuper()
- result = await lookuper.lookup({"user_id": 1})
- if result:
- print(f"Partial update by indices verified:"
- f"\n name={result['name']} (updated)"
- f"\n balance={result['balance']} (unchanged)")
- else:
- print("ERROR: Expected to find user_id=1")
-
- except Exception as e:
- print(f"Error during partial update by indices: {e}")
- traceback.print_exc()
-
- # Demo: Column projection using builder pattern
- print("\n--- Testing Column Projection ---")
- try:
- # Get bucket count for subscriptions
- num_buckets = (await admin.get_table_info(table_path)).num_buckets
-
- # Project specific columns by index (using batch scanner for to_pandas)
- print("\n1. Projection by index [0, 1] (id, name):")
- scanner_index = await table.new_scan().project([0,
1]).create_record_batch_log_scanner()
- scanner_index.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
- df_projected = await scanner_index.to_pandas()
- print(df_projected.head())
- print(
- f" Projected {df_projected.shape[1]} columns:
{list(df_projected.columns)}"
- )
-
- # Project specific columns by name (Pythonic!)
- print("\n2. Projection by name ['name', 'score'] (Pythonic):")
- scanner_names = await table.new_scan() \
- .project_by_name(["name", "score"]) \
- .create_record_batch_log_scanner()
- scanner_names.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
- df_named = await scanner_names.to_pandas()
- print(df_named.head())
- print(f" Projected {df_named.shape[1]} columns:
{list(df_named.columns)}")
-
- # Test empty result schema with projection
- print("\n3. Testing empty result schema with projection:")
- scanner_proj = await table.new_scan().project([0,
2]).create_record_batch_log_scanner()
- scanner_proj.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
- # Quick poll that may return empty
- result = await scanner_proj.poll_arrow(100)
- print(f" Schema columns: {result.schema.names}")
-
- except Exception as e:
- print(f"Error during projection: {e}")
-
-
- print("\n--- New: async context manager demo ---")
- async with await fluss.FlussConnection.create(config) as demo_conn:
- demo_table = await demo_conn.get_table(table_path)
- async with demo_table.new_append().create_writer() as writer:
- writer.append(
- {
- "id": 1,
- "name": "demo",
- "score": 1.0,
- "age": 25,
- "birth_date": date(2000, 1, 1),
- "check_in_time": dt_time(12, 0, 0),
- "created_at": datetime(2024, 1, 1, 12, 0, 0),
- "updated_at": datetime(2024, 1, 1, 12, 0, 0),
- "salary": Decimal("100.00"),
- }
- )
- # auto-flushes on exit
-
- # Demo: Drop tables
- print("\n--- Testing drop_table() ---")
- try:
- # Drop the log table
- await admin.drop_table(table_path, ignore_if_not_exists=True)
- print(f"Successfully dropped table: {table_path}")
- # Drop the PK table
- await admin.drop_table(pk_table_path, ignore_if_not_exists=True)
- print(f"Successfully dropped table: {pk_table_path}")
- except Exception as e:
- print(f"Failed to drop table: {e}")
-
- # =====================================================
- # Demo: Partitioned Table with list_partition_offsets
- # =====================================================
- print("\n" + "=" * 60)
- print("--- Testing Partitioned Table ---")
- print("=" * 60)
-
- # Create a partitioned log table
- partitioned_fields = [
- pa.field("id", pa.int32()),
- pa.field("region", pa.string()), # partition key
- pa.field("value", pa.int64()),
- ]
- partitioned_schema = pa.schema(partitioned_fields)
- fluss_partitioned_schema = fluss.Schema(partitioned_schema)
-
- partitioned_table_descriptor = fluss.TableDescriptor(
- fluss_partitioned_schema,
- partition_keys=["region"], # Partition by region
- bucket_count=1,
- )
-
- partitioned_table_path = fluss.TablePath("fluss",
"partitioned_log_table_py")
-
- try:
- # Drop if exists first
- await admin.drop_table(partitioned_table_path,
ignore_if_not_exists=True)
- print(f"Dropped existing table: {partitioned_table_path}")
-
- # Create the partitioned table
- await admin.create_table(partitioned_table_path,
partitioned_table_descriptor, False)
- print(f"Created partitioned table: {partitioned_table_path}")
-
- # Create partitions for US and EU regions
- print("\n--- Creating partitions ---")
- await admin.create_partition(partitioned_table_path, {"region": "US"},
ignore_if_exists=True)
- print("Created partition: region=US")
- await admin.create_partition(partitioned_table_path, {"region": "EU"},
ignore_if_exists=True)
- print("Created partition: region=EU")
-
- # List partitions
- print("\n--- Listing partitions ---")
- partition_infos = await
admin.list_partition_infos(partitioned_table_path)
- for p in partition_infos:
- print(f" {p}") # PartitionInfo(partition_id=...,
partition_name='region=...')
-
- # Get the table and write some data
- partitioned_table = await conn.get_table(partitioned_table_path)
- partitioned_writer = partitioned_table.new_append().create_writer()
-
- # Append data to US partition
- partitioned_writer.append({"id": 1, "region": "US", "value": 100})
- partitioned_writer.append({"id": 2, "region": "US", "value": 200})
- # Append data to EU partition
- partitioned_writer.append({"id": 3, "region": "EU", "value": 300})
- partitioned_writer.append({"id": 4, "region": "EU", "value": 400})
- await partitioned_writer.flush()
- print("\nWrote 4 records (2 to US, 2 to EU)")
-
- # Demo: list_partition_infos with partial spec filter
- print("\n--- Testing list_partition_infos with spec ---")
- us_partitions = await admin.list_partition_infos(
- partitioned_table_path, partition_spec={"region": "US"}
- )
- print(f"Filtered partitions (region=US): {us_partitions}")
-
- # Demo: list_partition_offsets
- print("\n--- Testing list_partition_offsets ---")
-
- # Query offsets for US partition
- # Note: partition_name is just the value (e.g., "US"), not "region=US"
- us_offsets = await admin.list_partition_offsets(
- partitioned_table_path,
- partition_name="US",
- bucket_ids=[0],
- offset_spec=fluss.OffsetSpec.latest()
- )
- print(f"US partition latest offsets: {us_offsets}")
-
- # Query offsets for EU partition
- eu_offsets = await admin.list_partition_offsets(
- partitioned_table_path,
- partition_name="EU",
- bucket_ids=[0],
- offset_spec=fluss.OffsetSpec.latest()
- )
- print(f"EU partition latest offsets: {eu_offsets}")
-
- # Demo: subscribe_partition for reading partitioned data
- print("\n--- Testing subscribe_partition + to_arrow() ---")
- partitioned_scanner = await
partitioned_table.new_scan().create_record_batch_log_scanner()
-
- # Subscribe to each partition using partition_id
- for p in partition_infos:
- partitioned_scanner.subscribe_partition(
- partition_id=p.partition_id,
- bucket_id=0,
- start_offset=fluss.EARLIEST_OFFSET
- )
- print(f"Subscribed to partition {p.partition_name}
(id={p.partition_id})")
-
- # Use to_arrow() - now works for partitioned tables!
- partitioned_arrow = await partitioned_scanner.to_arrow()
- print(f"\nto_arrow() returned {partitioned_arrow.num_rows} records
from partitioned table:")
- print(partitioned_arrow.to_pandas())
-
- # Demo: subscribe_partition_buckets for batch subscribing to multiple
partitions at once
- print("\n--- Testing subscribe_partition_buckets + to_arrow() ---")
- partitioned_scanner_batch = await
partitioned_table.new_scan().create_record_batch_log_scanner()
- partition_bucket_offsets = {
- (p.partition_id, 0): fluss.EARLIEST_OFFSET for p in partition_infos
- }
-
partitioned_scanner_batch.subscribe_partition_buckets(partition_bucket_offsets)
- print(f"Batch subscribed to {len(partition_bucket_offsets)}
partition+bucket combinations")
- partitioned_batch_arrow = await partitioned_scanner_batch.to_arrow()
- print(f"to_arrow() returned {partitioned_batch_arrow.num_rows}
records:")
- print(partitioned_batch_arrow.to_pandas())
-
- # Demo: unsubscribe_partition - unsubscribe from one partition, read
remaining
- print("\n--- Testing unsubscribe_partition ---")
- partitioned_scanner3 = await
partitioned_table.new_scan().create_record_batch_log_scanner()
- for p in partition_infos:
- partitioned_scanner3.subscribe_partition(p.partition_id, 0,
fluss.EARLIEST_OFFSET)
- # Unsubscribe from the first partition
- first_partition = partition_infos[0]
-
partitioned_scanner3.unsubscribe_partition(first_partition.partition_id, 0)
- print(f"Unsubscribed from partition {first_partition.partition_name}
(id={first_partition.partition_id})")
- remaining_arrow = await partitioned_scanner3.to_arrow()
- print(f"After unsubscribe, to_arrow() returned
{remaining_arrow.num_rows} records (from remaining partitions):")
- print(remaining_arrow.to_pandas())
-
- # Demo: to_pandas() also works for partitioned tables
- print("\n--- Testing to_pandas() on partitioned table ---")
- partitioned_scanner2 = await
partitioned_table.new_scan().create_record_batch_log_scanner()
- for p in partition_infos:
- partitioned_scanner2.subscribe_partition(p.partition_id, 0,
fluss.EARLIEST_OFFSET)
- partitioned_df = await partitioned_scanner2.to_pandas()
- print(f"to_pandas() returned {len(partitioned_df)} records:")
- print(partitioned_df)
-
- # Cleanup
- await admin.drop_table(partitioned_table_path,
ignore_if_not_exists=True)
- print(f"\nDropped partitioned table: {partitioned_table_path}")
-
- except Exception as e:
- print(f"Error with partitioned table: {e}")
- traceback.print_exc()
-
- # =====================================================
- # Demo: Partitioned KV Table (Upsert, Lookup, Delete)
- # =====================================================
- print("\n" + "=" * 60)
- print("--- Testing Partitioned KV Table ---")
- print("=" * 60)
-
- partitioned_kv_fields = [
- pa.field("region", pa.string()), # partition key + part of PK
- pa.field("user_id", pa.int32()), # part of PK
- pa.field("name", pa.string()),
- pa.field("score", pa.int64()),
- ]
- partitioned_kv_schema = pa.schema(partitioned_kv_fields)
- fluss_partitioned_kv_schema = fluss.Schema(
- partitioned_kv_schema, primary_keys=["region", "user_id"]
- )
-
- partitioned_kv_descriptor = fluss.TableDescriptor(
- fluss_partitioned_kv_schema,
- partition_keys=["region"],
- )
-
- partitioned_kv_path = fluss.TablePath("fluss", "partitioned_kv_table_py")
-
- try:
- await admin.drop_table(partitioned_kv_path, ignore_if_not_exists=True)
- await admin.create_table(partitioned_kv_path,
partitioned_kv_descriptor, False)
- print(f"Created partitioned KV table: {partitioned_kv_path}")
-
- # Create partitions
- await admin.create_partition(partitioned_kv_path, {"region": "US"})
- await admin.create_partition(partitioned_kv_path, {"region": "EU"})
- await admin.create_partition(partitioned_kv_path, {"region": "APAC"})
- print("Created partitions: US, EU, APAC")
-
- partitioned_kv_table = await conn.get_table(partitioned_kv_path)
- upsert_writer = partitioned_kv_table.new_upsert().create_writer()
-
- # Upsert rows across partitions
- test_data = [
- ("US", 1, "Gustave", 100),
- ("US", 2, "Lune", 200),
- ("EU", 1, "Sciel", 150),
- ("EU", 2, "Maelle", 250),
- ("APAC", 1, "Noco", 300),
- ]
- for region, user_id, name, score in test_data:
- upsert_writer.upsert({
- "region": region, "user_id": user_id,
- "name": name, "score": score,
- })
- await upsert_writer.flush()
- print(f"Upserted {len(test_data)} rows across 3 partitions")
-
- # Lookup all rows across partitions
- print("\n--- Lookup across partitions ---")
- lookuper = partitioned_kv_table.new_lookup().create_lookuper()
- for region, user_id, name, score in test_data:
- result = await lookuper.lookup({"region": region, "user_id":
user_id})
- assert result is not None, f"Expected to find region={region}
user_id={user_id}"
- assert result["name"] == name, f"Name mismatch: {result['name']}
!= {name}"
- assert result["score"] == score, f"Score mismatch:
{result['score']} != {score}"
- print(f"All {len(test_data)} rows verified across partitions")
-
- # Update within a partition
- print("\n--- Update within partition ---")
- handle = upsert_writer.upsert({
- "region": "US", "user_id": 1,
- "name": "Gustave Updated", "score": 999,
- })
- await handle.wait()
- result = await lookuper.lookup({"region": "US", "user_id": 1})
- assert result is not None, "Expected to find region=US user_id=1 after
update"
- assert result["name"] == "Gustave Updated"
- assert result["score"] == 999
- print(f"Update verified: US/1 name={result['name']}
score={result['score']}")
-
- # Lookup in non-existent partition
- print("\n--- Lookup in non-existent partition ---")
- result = await lookuper.lookup({"region": "UNKNOWN", "user_id": 1})
- assert result is None, "Expected UNKNOWN partition lookup to return
None"
- print("UNKNOWN partition lookup: not found (expected)")
-
- # Delete within a partition
- print("\n--- Delete within partition ---")
- handle = upsert_writer.delete({"region": "EU", "user_id": 1})
- await handle.wait()
- result = await lookuper.lookup({"region": "EU", "user_id": 1})
- assert result is None, "Expected EU/1 to be deleted"
- print("Delete verified: EU/1 not found")
-
- # Verify sibling record still exists
- result = await lookuper.lookup({"region": "EU", "user_id": 2})
- assert result is not None, "Expected EU/2 to still exist"
- assert result["name"] == "Maelle"
- print(f"EU/2 still exists: name={result['name']}")
-
- # Cleanup
- await admin.drop_table(partitioned_kv_path, ignore_if_not_exists=True)
- print(f"\nDropped partitioned KV table: {partitioned_kv_path}")
-
- except Exception as e:
- print(f"Error with partitioned KV table: {e}")
- traceback.print_exc()
-
-
-
- # Close connection
- await conn.close()
- print("\nConnection closed")
-
-
-if __name__ == "__main__":
- # Run the async main function
- asyncio.run(main())
diff --git a/bindings/python/example/log_table.py
b/bindings/python/example/log_table.py
new file mode 100644
index 00000000..37da1da9
--- /dev/null
+++ b/bindings/python/example/log_table.py
@@ -0,0 +1,388 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Log table example: append-only writes and log scanning.
+
+Covers the append writer (Arrow Table/RecordBatch, dict, list, pandas inputs),
+flushing, offset queries, batch and record scanners, column projection, and the
+async context-manager API.
+
+Run standalone against a local cluster:
+
+ python example/log_table.py
+
+Or point it at a specific cluster:
+
+ FLUSS_BOOTSTRAP_SERVERS=host:port python example/log_table.py
+"""
+
+import asyncio
+import os
+from datetime import date, datetime
+from datetime import time as dt_time
+from decimal import Decimal
+from typing import Optional
+
+import pandas as pd
+import pyarrow as pa
+
+import fluss
+
+DEFAULT_BOOTSTRAP_SERVERS = "127.0.0.1:9123"
+
+# Total rows written before the scans run (3 + 2 + 1 + 1 + 2). The
+# context-manager demo writes one more row, but only after the scans.
+EXPECTED_ROWS = 9
+
+
+async def main(bootstrap_servers: Optional[str] = None):
+ bootstrap_servers = bootstrap_servers or os.environ.get(
+ "FLUSS_BOOTSTRAP_SERVERS", DEFAULT_BOOTSTRAP_SERVERS
+ )
+
+ config = fluss.Config(
+ {
+ "bootstrap.servers": bootstrap_servers,
+ "writer.request-max-size": "10485760", # 10 MB
+ "writer.acks": "all", # Wait for all replicas to acknowledge
+ "writer.retries": "3", # Retry up to 3 times on failure
+ "writer.batch-size": "2097152", # 2 MB batch size (in bytes)
+ }
+ )
+ conn = await fluss.FlussConnection.create(config)
+ try:
+ await _run(conn)
+ finally:
+ await conn.close()
+ print("\nConnection closed")
+
+
+async def _run(conn):
+ fields = [
+ pa.field("id", pa.int32()),
+ pa.field("name", pa.string()),
+ pa.field("score", pa.float32()),
+ pa.field("age", pa.int32()),
+ pa.field("birth_date", pa.date32()),
+ pa.field("check_in_time", pa.time32("ms")),
+ pa.field("created_at", pa.timestamp("us")), # TIMESTAMP (NTZ)
+ pa.field("updated_at", pa.timestamp("us", tz="UTC")), # TIMESTAMP_LTZ
+ pa.field("salary", pa.decimal128(10, 2)),
+ ]
+ schema = pa.schema(fields)
+ table_descriptor = fluss.TableDescriptor(fluss.Schema(schema))
+
+ admin = conn.get_admin()
+ table_path = fluss.TablePath("fluss", "example_log_table")
+
+ # Drop-then-create keeps the example rerunnable on a shared cluster.
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ await admin.create_table(table_path, table_descriptor,
ignore_if_exists=True)
+ print(f"Created table: {table_path}")
+
+ table_info = await admin.get_table_info(table_path)
+ print(f"Table info: {table_info}")
+ print(f"Table ID: {table_info.table_id}")
+ print(f"Primary keys: {table_info.get_primary_keys()}")
+ num_buckets = table_info.num_buckets
+
+ print("\n--- list_offsets() before writes ---")
+ offsets = await admin.list_offsets(
+ table_path, bucket_ids=[0], offset_spec=fluss.OffsetSpec.latest()
+ )
+ print(f"Latest offsets (before writes): {offsets}")
+
+ table = await conn.get_table(table_path)
+ append_writer = table.new_append().create_writer()
+
+ print("\n--- Writing a PyArrow Table ---")
+ pa_table = pa.Table.from_arrays(
+ [
+ pa.array([1, 2, 3], type=pa.int32()),
+ pa.array(["Alice", "Bob", "Charlie"], type=pa.string()),
+ pa.array([95.2, 87.2, 92.1], type=pa.float32()),
+ pa.array([25, 30, 35], type=pa.int32()),
+ pa.array(
+ [date(1999, 5, 15), date(1994, 3, 20), date(1989, 11, 8)],
+ type=pa.date32(),
+ ),
+ pa.array(
+ [dt_time(9, 0, 0), dt_time(9, 30, 0), dt_time(10, 0, 0)],
+ type=pa.time32("ms"),
+ ),
+ pa.array(
+ [
+ datetime(2024, 1, 15, 10, 30),
+ datetime(2024, 1, 15, 11, 0),
+ datetime(2024, 1, 15, 11, 30),
+ ],
+ type=pa.timestamp("us"),
+ ),
+ pa.array(
+ [
+ datetime(2024, 1, 15, 10, 30),
+ datetime(2024, 1, 15, 11, 0),
+ datetime(2024, 1, 15, 11, 30),
+ ],
+ type=pa.timestamp("us", tz="UTC"),
+ ),
+ pa.array(
+ [Decimal("75000.00"), Decimal("82000.50"),
Decimal("95000.75")],
+ type=pa.decimal128(10, 2),
+ ),
+ ],
+ schema=schema,
+ )
+ append_writer.write_arrow(pa_table)
+ print("Wrote PyArrow Table (3 rows)")
+
+ print("\n--- Writing a PyArrow RecordBatch ---")
+ pa_record_batch = pa.RecordBatch.from_arrays(
+ [
+ pa.array([4, 5], type=pa.int32()),
+ pa.array(["David", "Eve"], type=pa.string()),
+ pa.array([88.5, 91.0], type=pa.float32()),
+ pa.array([28, 32], type=pa.int32()),
+ pa.array([date(1996, 7, 22), date(1992, 12, 1)], type=pa.date32()),
+ pa.array([dt_time(14, 15, 0), dt_time(8, 45, 0)],
type=pa.time32("ms")),
+ pa.array(
+ [datetime(2024, 1, 16, 9, 0), datetime(2024, 1, 16, 9, 30)],
+ type=pa.timestamp("us"),
+ ),
+ pa.array(
+ [datetime(2024, 1, 16, 9, 0), datetime(2024, 1, 16, 9, 30)],
+ type=pa.timestamp("us", tz="UTC"),
+ ),
+ pa.array(
+ [Decimal("68000.00"), Decimal("72500.25")],
+ type=pa.decimal128(10, 2),
+ ),
+ ],
+ schema=schema,
+ )
+ append_writer.write_arrow_batch(pa_record_batch)
+ print("Wrote PyArrow RecordBatch (2 rows)")
+
+ print("\n--- Appending single rows (dict and list) ---")
+ append_writer.append(
+ {
+ "id": 8,
+ "name": "Helen",
+ "score": 93.5,
+ "age": 26,
+ "birth_date": date(1998, 4, 10),
+ "check_in_time": dt_time(11, 30, 45),
+ "created_at": datetime(2024, 1, 17, 14, 0, 0),
+ "updated_at": datetime(2024, 1, 17, 14, 0, 0),
+ "salary": Decimal("88000.00"),
+ }
+ )
+ print("Appended row (dict input)")
+ append_writer.append(
+ [
+ 9,
+ "Ivan",
+ 90.0,
+ 31,
+ date(1993, 8, 25),
+ dt_time(16, 45, 0),
+ datetime(2024, 1, 17, 15, 30, 0),
+ datetime(2024, 1, 17, 15, 30, 0),
+ Decimal("91500.50"),
+ ]
+ )
+ print("Appended row (list input)")
+
+ print("\n--- Writing a Pandas DataFrame ---")
+ df = pd.DataFrame(
+ {
+ "id": [10, 11],
+ "name": ["Frank", "Grace"],
+ "score": [89.3, 94.7],
+ "age": [29, 27],
+ "birth_date": [date(1995, 2, 14), date(1997, 9, 30)],
+ "check_in_time": [dt_time(10, 0, 0), dt_time(10, 30, 0)],
+ "created_at": [
+ datetime(2024, 1, 18, 8, 0),
+ datetime(2024, 1, 18, 8, 30),
+ ],
+ "updated_at": [
+ datetime(2024, 1, 18, 8, 0),
+ datetime(2024, 1, 18, 8, 30),
+ ],
+ "salary": [Decimal("79000.00"), Decimal("85500.75")],
+ }
+ )
+ append_writer.write_pandas(df)
+ print("Wrote Pandas DataFrame (2 rows)")
+
+ print("\n--- Flushing ---")
+ await append_writer.flush()
+ print("Flushed all pending data")
+
+ offsets = await admin.list_offsets(
+ table_path, bucket_ids=[0], offset_spec=fluss.OffsetSpec.latest()
+ )
+ print(f"Latest offsets (after writes): {offsets}")
+
+ await _scan_batch(table, num_buckets)
+ await _scan_records(table, num_buckets)
+ await _projection(table, num_buckets)
+ await _context_manager_demo(conn, table_path)
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ print(f"\nDropped table: {table_path}")
+
+
+async def _scan_batch(table, num_buckets):
+ print("\n--- Batch scanner: to_arrow() / to_pandas() ---")
+ scanner = await table.new_scan().create_record_batch_log_scanner()
+ scanner.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
+ pa_table_result = await scanner.to_arrow()
+ assert pa_table_result.num_rows == EXPECTED_ROWS, (
+ f"to_arrow() returned {pa_table_result.num_rows}, expected
{EXPECTED_ROWS}"
+ )
+ print(f"to_arrow() returned {pa_table_result.num_rows} rows")
+
+ scanner2 = await table.new_scan().create_record_batch_log_scanner()
+ scanner2.subscribe_buckets({i: fluss.EARLIEST_OFFSET for i in
range(num_buckets)})
+ df_result = await scanner2.to_pandas()
+ assert len(df_result) == EXPECTED_ROWS, (
+ f"to_pandas() returned {len(df_result)}, expected {EXPECTED_ROWS}"
+ )
+ print(f"to_pandas() returned {len(df_result)} rows")
+
+ print("\n--- Batch scanner: to_arrow_batch_reader() (lazy) ---")
+ reader_scanner = await table.new_scan().create_record_batch_log_scanner()
+ reader_scanner.subscribe_buckets(
+ {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
+ )
+ arrow_reader = reader_scanner.to_arrow_batch_reader()
+ reader_table = pa.Table.from_batches(list(arrow_reader),
schema=arrow_reader.schema)
+ assert reader_table.num_rows == EXPECTED_ROWS, (
+ f"to_arrow_batch_reader() yielded {reader_table.num_rows}, "
+ f"expected {EXPECTED_ROWS}"
+ )
+ print(f"to_arrow_batch_reader() yielded {reader_table.num_rows} rows")
+
+ print("\n--- Batch scanner: poll_arrow() ---")
+ poll_scanner = await table.new_scan().create_record_batch_log_scanner()
+ poll_scanner.subscribe(bucket_id=0, start_offset=fluss.EARLIEST_OFFSET)
+ # poll_arrow() returns an empty (but schema-bearing) table on timeout.
+ poll_result = await poll_scanner.poll_arrow(5000)
+ print(f"poll_arrow() returned {poll_result.num_rows} rows")
+
+ print("\n--- Batch scanner: poll_record_batch() ---")
+ batch_scanner = await table.new_scan().create_record_batch_log_scanner()
+ batch_scanner.subscribe_buckets(
+ {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
+ )
+ batches = await batch_scanner.poll_record_batch(5000)
+ print(f"poll_record_batch() returned {len(batches)} batches")
+ for i, batch in enumerate(batches):
+ print(
+ f" Batch {i}: bucket={batch.bucket}, "
+ f"offsets={batch.base_offset}-{batch.last_offset}, "
+ f"rows={batch.batch.num_rows}"
+ )
+
+
+async def _scan_records(table, num_buckets):
+ print("\n--- Record scanner: poll() ---")
+ record_scanner = await table.new_scan().create_log_scanner()
+ record_scanner.subscribe_buckets(
+ {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
+ )
+ scan_records = await record_scanner.poll(5000)
+ print(
+ f"poll() returned {scan_records.count()} records "
+ f"across {len(scan_records.buckets())} bucket(s)"
+ )
+ for bucket in scan_records.buckets():
+ bucket_recs = scan_records.records(bucket)
+ print(f" Bucket {bucket}: {len(bucket_recs)} records")
+ for record in bucket_recs[:3]:
+ print(
+ f" offset={record.offset}, timestamp={record.timestamp}, "
+ f"change_type={record.change_type}"
+ )
+
+ print("\n--- unsubscribe() ---")
+ unsub_scanner = await table.new_scan().create_record_batch_log_scanner()
+ unsub_scanner.subscribe_buckets(
+ {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
+ )
+ unsub_scanner.unsubscribe(bucket_id=0)
+ remaining = await unsub_scanner.poll_arrow(5000)
+ print(f"After unsubscribing bucket 0: {remaining.num_rows} rows from the
rest")
+
+
+async def _projection(table, num_buckets):
+ print("\n--- Projection by index [0, 1] (id, name) ---")
+ scanner_index = (
+ await table.new_scan().project([0,
1]).create_record_batch_log_scanner()
+ )
+ scanner_index.subscribe_buckets(
+ {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
+ )
+ df_projected = await scanner_index.to_pandas()
+ assert list(df_projected.columns) == ["id", "name"], (
+ f"Unexpected projected columns: {list(df_projected.columns)}"
+ )
+ assert len(df_projected) == EXPECTED_ROWS
+ print(f"Projected columns: {list(df_projected.columns)}")
+
+ print("\n--- Projection by name ['name', 'score'] ---")
+ scanner_names = (
+ await table.new_scan()
+ .project_by_name(["name", "score"])
+ .create_record_batch_log_scanner()
+ )
+ scanner_names.subscribe_buckets(
+ {i: fluss.EARLIEST_OFFSET for i in range(num_buckets)}
+ )
+ df_named = await scanner_names.to_pandas()
+ assert list(df_named.columns) == ["name", "score"], (
+ f"Unexpected projected columns: {list(df_named.columns)}"
+ )
+ assert len(df_named) == EXPECTED_ROWS
+ print(f"Projected columns: {list(df_named.columns)}")
+
+
+async def _context_manager_demo(conn, table_path):
+ print("\n--- Async context manager (auto-flush on exit) ---")
+ table = await conn.get_table(table_path)
+ async with table.new_append().create_writer() as writer:
+ writer.append(
+ {
+ "id": 100,
+ "name": "demo",
+ "score": 1.0,
+ "age": 25,
+ "birth_date": date(2000, 1, 1),
+ "check_in_time": dt_time(12, 0, 0),
+ "created_at": datetime(2024, 1, 1, 12, 0, 0),
+ "updated_at": datetime(2024, 1, 1, 12, 0, 0),
+ "salary": Decimal("100.00"),
+ }
+ )
+ # auto-flushes on exit
+ print("Wrote one row via context manager")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/bindings/python/example/partitioned_kv_table.py
b/bindings/python/example/partitioned_kv_table.py
new file mode 100644
index 00000000..15cb8e30
--- /dev/null
+++ b/bindings/python/example/partitioned_kv_table.py
@@ -0,0 +1,138 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Partitioned primary-key (KV) table example.
+
+Covers upsert, lookup, update, and delete on a primary-key table whose primary
+key includes the partition key, across multiple partitions.
+
+Run standalone against a local cluster:
+
+ python example/partitioned_kv_table.py
+
+Or point it at a specific cluster:
+
+ FLUSS_BOOTSTRAP_SERVERS=host:port python example/partitioned_kv_table.py
+"""
+
+import asyncio
+import os
+from typing import Optional
+
+import pyarrow as pa
+
+import fluss
+
+DEFAULT_BOOTSTRAP_SERVERS = "127.0.0.1:9123"
+
+
+async def main(bootstrap_servers: Optional[str] = None):
+ bootstrap_servers = bootstrap_servers or os.environ.get(
+ "FLUSS_BOOTSTRAP_SERVERS", DEFAULT_BOOTSTRAP_SERVERS
+ )
+
+ config = fluss.Config({"bootstrap.servers": bootstrap_servers})
+ conn = await fluss.FlussConnection.create(config)
+ try:
+ await _run(conn)
+ finally:
+ await conn.close()
+ print("\nConnection closed")
+
+
+async def _run(conn):
+ fields = [
+ pa.field("region", pa.string()), # partition key + part of PK
+ pa.field("user_id", pa.int32()), # part of PK
+ pa.field("name", pa.string()),
+ pa.field("score", pa.int64()),
+ ]
+ schema = fluss.Schema(pa.schema(fields), primary_keys=["region",
"user_id"])
+ table_descriptor = fluss.TableDescriptor(schema, partition_keys=["region"])
+
+ admin = conn.get_admin()
+ table_path = fluss.TablePath("fluss", "example_partitioned_kv")
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ await admin.create_table(table_path, table_descriptor,
ignore_if_exists=True)
+ print(f"Created partitioned KV table: {table_path}")
+
+ for region in ("US", "EU", "APAC"):
+ await admin.create_partition(
+ table_path, {"region": region}, ignore_if_exists=True
+ )
+ print("Created partitions: US, EU, APAC")
+
+ table = await conn.get_table(table_path)
+ upsert_writer = table.new_upsert().create_writer()
+ lookuper = table.new_lookup().create_lookuper()
+
+ print("\n--- Upsert across partitions ---")
+ test_data = [
+ ("US", 1, "Gustave", 100),
+ ("US", 2, "Lune", 200),
+ ("EU", 1, "Sciel", 150),
+ ("EU", 2, "Maelle", 250),
+ ("APAC", 1, "Noco", 300),
+ ]
+ for region, user_id, name, score in test_data:
+ upsert_writer.upsert(
+ {"region": region, "user_id": user_id, "name": name, "score":
score}
+ )
+ await upsert_writer.flush()
+ print(f"Upserted {len(test_data)} rows across 3 partitions")
+
+ print("\n--- Lookup across partitions ---")
+ for region, user_id, name, score in test_data:
+ result = await lookuper.lookup({"region": region, "user_id": user_id})
+ assert result is not None, f"Expected region={region}
user_id={user_id}"
+ assert result["name"] == name, f"Name mismatch: {result['name']} !=
{name}"
+ assert result["score"] == score, f"Score mismatch: {result['score']}
!= {score}"
+ print(f"All {len(test_data)} rows verified")
+
+ print("\n--- Update within a partition ---")
+ handle = upsert_writer.upsert(
+ {"region": "US", "user_id": 1, "name": "Gustave Updated", "score": 999}
+ )
+ await handle.wait()
+ result = await lookuper.lookup({"region": "US", "user_id": 1})
+ assert result is not None and result["name"] == "Gustave Updated"
+ assert result["score"] == 999
+ print(f"Updated US/1: name={result['name']}, score={result['score']}")
+
+ print("\n--- Lookup in a non-existent partition ---")
+ result = await lookuper.lookup({"region": "UNKNOWN", "user_id": 1})
+ assert result is None, "Expected UNKNOWN partition lookup to return None"
+ print("UNKNOWN partition lookup: not found (expected)")
+
+ print("\n--- Delete within a partition ---")
+ handle = upsert_writer.delete({"region": "EU", "user_id": 1})
+ await handle.wait()
+ result = await lookuper.lookup({"region": "EU", "user_id": 1})
+ assert result is None, "Expected EU/1 to be deleted"
+ print("Deleted EU/1, confirmed absent")
+
+ result = await lookuper.lookup({"region": "EU", "user_id": 2})
+ assert result is not None and result["name"] == "Maelle"
+ print(f"Sibling EU/2 still present: name={result['name']}")
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ print(f"\nDropped partitioned KV table: {table_path}")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/bindings/python/example/partitioned_table.py
b/bindings/python/example/partitioned_table.py
new file mode 100644
index 00000000..e1593569
--- /dev/null
+++ b/bindings/python/example/partitioned_table.py
@@ -0,0 +1,176 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Partitioned log table example.
+
+Covers creating and listing partitions, writing across partitions, querying
+per-partition offsets, and the partition-aware scanner subscription APIs
+(subscribe_partition, subscribe_partition_buckets, unsubscribe_partition).
+
+Run standalone against a local cluster:
+
+ python example/partitioned_table.py
+
+Or point it at a specific cluster:
+
+ FLUSS_BOOTSTRAP_SERVERS=host:port python example/partitioned_table.py
+"""
+
+import asyncio
+import os
+from typing import Optional
+
+import pyarrow as pa
+
+import fluss
+
+DEFAULT_BOOTSTRAP_SERVERS = "127.0.0.1:9123"
+
+
+async def main(bootstrap_servers: Optional[str] = None):
+ bootstrap_servers = bootstrap_servers or os.environ.get(
+ "FLUSS_BOOTSTRAP_SERVERS", DEFAULT_BOOTSTRAP_SERVERS
+ )
+
+ config = fluss.Config({"bootstrap.servers": bootstrap_servers})
+ conn = await fluss.FlussConnection.create(config)
+ try:
+ await _run(conn)
+ finally:
+ await conn.close()
+ print("\nConnection closed")
+
+
+async def _run(conn):
+ fields = [
+ pa.field("id", pa.int32()),
+ pa.field("region", pa.string()), # partition key
+ pa.field("value", pa.int64()),
+ ]
+ schema = fluss.Schema(pa.schema(fields))
+ table_descriptor = fluss.TableDescriptor(
+ schema, partition_keys=["region"], bucket_count=1
+ )
+
+ admin = conn.get_admin()
+ table_path = fluss.TablePath("fluss", "example_partitioned_log")
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ await admin.create_table(table_path, table_descriptor,
ignore_if_exists=True)
+ print(f"Created partitioned table: {table_path}")
+
+ print("\n--- Creating partitions ---")
+ await admin.create_partition(table_path, {"region": "US"},
ignore_if_exists=True)
+ await admin.create_partition(table_path, {"region": "EU"},
ignore_if_exists=True)
+ print("Created partitions: region=US, region=EU")
+
+ partition_infos = await admin.list_partition_infos(table_path)
+ assert len(partition_infos) == 2, (
+ f"Expected 2 partitions, got {len(partition_infos)}"
+ )
+ print(f"Partitions: {[p.partition_name for p in partition_infos]}")
+
+ print("\n--- Writing across partitions ---")
+ table = await conn.get_table(table_path)
+ writer = table.new_append().create_writer()
+ writer.append({"id": 1, "region": "US", "value": 100})
+ writer.append({"id": 2, "region": "US", "value": 200})
+ writer.append({"id": 3, "region": "EU", "value": 300})
+ writer.append({"id": 4, "region": "EU", "value": 400})
+ await writer.flush()
+ print("Wrote 4 records (2 to US, 2 to EU)")
+
+ print("\n--- list_partition_infos() with a partition_spec filter ---")
+ us_partitions = await admin.list_partition_infos(
+ table_path, partition_spec={"region": "US"}
+ )
+ assert len(us_partitions) == 1, (
+ f"Expected 1 partition for region=US, got {len(us_partitions)}"
+ )
+ print(f"Filtered partitions (region=US): {us_partitions}")
+
+ print("\n--- list_partition_offsets() ---")
+ # partition_name is the value (e.g. "US"), not "region=US".
+ us_offsets = await admin.list_partition_offsets(
+ table_path,
+ partition_name="US",
+ bucket_ids=[0],
+ offset_spec=fluss.OffsetSpec.latest(),
+ )
+ print(f"US partition latest offsets: {us_offsets}")
+ eu_offsets = await admin.list_partition_offsets(
+ table_path,
+ partition_name="EU",
+ bucket_ids=[0],
+ offset_spec=fluss.OffsetSpec.latest(),
+ )
+ print(f"EU partition latest offsets: {eu_offsets}")
+
+ await _scan_partitions(table, partition_infos)
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ print(f"\nDropped partitioned table: {table_path}")
+
+
+async def _scan_partitions(table, partition_infos):
+ print("\n--- subscribe_partition() + to_arrow() ---")
+ scanner = await table.new_scan().create_record_batch_log_scanner()
+ for p in partition_infos:
+ scanner.subscribe_partition(
+ partition_id=p.partition_id,
+ bucket_id=0,
+ start_offset=fluss.EARLIEST_OFFSET,
+ )
+ arrow = await scanner.to_arrow()
+ assert arrow.num_rows == 4, f"Expected 4 records, got {arrow.num_rows}"
+ print(f"to_arrow() returned {arrow.num_rows} records across all
partitions")
+
+ print("\n--- subscribe_partition_buckets() + to_arrow() ---")
+ batch_scanner = await table.new_scan().create_record_batch_log_scanner()
+ partition_bucket_offsets = {
+ (p.partition_id, 0): fluss.EARLIEST_OFFSET for p in partition_infos
+ }
+ batch_scanner.subscribe_partition_buckets(partition_bucket_offsets)
+ batch_arrow = await batch_scanner.to_arrow()
+ assert batch_arrow.num_rows == 4, f"Expected 4 records, got
{batch_arrow.num_rows}"
+ print(f"to_arrow() returned {batch_arrow.num_rows} records")
+
+ print("\n--- unsubscribe_partition() ---")
+ scanner3 = await table.new_scan().create_record_batch_log_scanner()
+ for p in partition_infos:
+ scanner3.subscribe_partition(p.partition_id, 0, fluss.EARLIEST_OFFSET)
+ first = partition_infos[0]
+ scanner3.unsubscribe_partition(first.partition_id, 0)
+ remaining = await scanner3.to_arrow()
+ # Each partition holds 2 records, so dropping one leaves 2.
+ assert remaining.num_rows == 2, f"Expected 2 records, got
{remaining.num_rows}"
+ print(
+ f"After unsubscribing partition {first.partition_name}: "
+ f"{remaining.num_rows} records from the rest"
+ )
+
+ print("\n--- to_pandas() on a partitioned table ---")
+ scanner4 = await table.new_scan().create_record_batch_log_scanner()
+ for p in partition_infos:
+ scanner4.subscribe_partition(p.partition_id, 0, fluss.EARLIEST_OFFSET)
+ df = await scanner4.to_pandas()
+ assert len(df) == 4, f"Expected 4 records, got {len(df)}"
+ print(f"to_pandas() returned {len(df)} records")
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/bindings/python/example/pk_table.py
b/bindings/python/example/pk_table.py
new file mode 100644
index 00000000..68a7c9ea
--- /dev/null
+++ b/bindings/python/example/pk_table.py
@@ -0,0 +1,225 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Primary-key table example: upsert, lookup, delete, partial update.
+
+Covers fire-and-forget and per-record-acknowledged upserts, point lookups,
+deletes, and partial updates by column name and by column index.
+
+Run standalone against a local cluster:
+
+ python example/pk_table.py
+
+Or point it at a specific cluster:
+
+ FLUSS_BOOTSTRAP_SERVERS=host:port python example/pk_table.py
+"""
+
+import asyncio
+import os
+from datetime import date, datetime
+from datetime import time as dt_time
+from decimal import Decimal
+from typing import Optional
+
+import pyarrow as pa
+
+import fluss
+
+DEFAULT_BOOTSTRAP_SERVERS = "127.0.0.1:9123"
+
+
+async def main(bootstrap_servers: Optional[str] = None):
+ bootstrap_servers = bootstrap_servers or os.environ.get(
+ "FLUSS_BOOTSTRAP_SERVERS", DEFAULT_BOOTSTRAP_SERVERS
+ )
+
+ config = fluss.Config({"bootstrap.servers": bootstrap_servers})
+ conn = await fluss.FlussConnection.create(config)
+ try:
+ await _run(conn)
+ finally:
+ await conn.close()
+ print("\nConnection closed")
+
+
+async def _run(conn):
+ fields = [
+ pa.field("user_id", pa.int32()),
+ pa.field("name", pa.string()),
+ pa.field("email", pa.string()),
+ pa.field("age", pa.int32()),
+ pa.field("birth_date", pa.date32()),
+ pa.field("login_time", pa.time32("ms")),
+ pa.field("created_at", pa.timestamp("us")), # TIMESTAMP (NTZ)
+ pa.field("updated_at", pa.timestamp("us", tz="UTC")), # TIMESTAMP_LTZ
+ pa.field("balance", pa.decimal128(10, 2)),
+ ]
+ schema = fluss.Schema(pa.schema(fields), primary_keys=["user_id"])
+ table_descriptor = fluss.TableDescriptor(schema, bucket_count=3)
+
+ admin = conn.get_admin()
+ table_path = fluss.TablePath("fluss", "example_pk_table")
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ await admin.create_table(table_path, table_descriptor,
ignore_if_exists=True)
+ print(f"Created PK table: {table_path}")
+
+ table = await conn.get_table(table_path)
+ print(f"Has primary key: {table.has_primary_key()}")
+
+ await _upsert(table)
+ await _lookup(table)
+ await _delete(table)
+ await _partial_update(table)
+
+ await admin.drop_table(table_path, ignore_if_not_exists=True)
+ print(f"\nDropped PK table: {table_path}")
+
+
+async def _upsert(table):
+ print("\n--- Upsert (fire-and-forget) ---")
+ upsert_writer = table.new_upsert().create_writer()
+
+ rows = [
+ {
+ "user_id": 1,
+ "name": "Alice",
+ "email": "[email protected]",
+ "age": 25,
+ "birth_date": date(1999, 5, 15),
+ "login_time": dt_time(9, 30, 45, 123000),
+ "created_at": datetime(2024, 1, 15, 10, 30, 45, 123456),
+ "updated_at": datetime(2024, 1, 15, 10, 30, 45, 123456),
+ "balance": Decimal("1234.56"),
+ },
+ {
+ "user_id": 2,
+ "name": "Bob",
+ "email": "[email protected]",
+ "age": 30,
+ "birth_date": date(1994, 3, 20),
+ "login_time": dt_time(14, 15, 30, 500000),
+ "created_at": datetime(2024, 1, 16, 11, 22, 33, 444555),
+ "updated_at": datetime(2024, 1, 16, 11, 22, 33, 444555),
+ "balance": Decimal("5678.91"),
+ },
+ {
+ "user_id": 3,
+ "name": "Charlie",
+ "email": "[email protected]",
+ "age": 35,
+ "birth_date": date(1989, 11, 8),
+ "login_time": dt_time(16, 45, 59, 999000),
+ "created_at": datetime(2024, 1, 17, 23, 59, 59, 999999),
+ "updated_at": datetime(2024, 1, 17, 23, 59, 59, 999999),
+ "balance": Decimal("9876.54"),
+ },
+ ]
+ for row in rows:
+ upsert_writer.upsert(row)
+ await upsert_writer.flush()
+ print(f"Upserted {len(rows)} rows (flush waits for server acknowledgment)")
+
+ print("\n--- Upsert (per-record acknowledgment) ---")
+ handle = upsert_writer.upsert(
+ {
+ "user_id": 1,
+ "name": "Alice Updated",
+ "email": "[email protected]",
+ "age": 26,
+ "birth_date": date(1999, 5, 15),
+ "login_time": dt_time(10, 11, 12, 345000),
+ "created_at": datetime(2024, 1, 15, 10, 30, 45, 123456),
+ "updated_at": datetime(2024, 1, 20, 15, 45, 30, 678901),
+ "balance": Decimal("2345.67"),
+ }
+ )
+ await handle.wait()
+ print("Updated user_id=1 (Alice -> Alice Updated), server acknowledged")
+
+
+async def _lookup(table):
+ print("\n--- Lookup ---")
+ lookuper = table.new_lookup().create_lookuper()
+
+ result = await lookuper.lookup({"user_id": 1})
+ assert result is not None, "Expected to find user_id=1"
+ assert result["name"] == "Alice Updated", f"Unexpected name:
{result['name']}"
+ print(f"Lookup user_id=1: name={result['name']},
balance={result['balance']}")
+
+ result = await lookuper.lookup({"user_id": 2})
+ assert result is not None, "Expected to find user_id=2"
+ print(f"Lookup user_id=2: name={result['name']}")
+
+ # A missing key returns None (normal control flow, not an error).
+ result = await lookuper.lookup({"user_id": 999})
+ assert result is None, "Expected user_id=999 to be absent"
+ print("Lookup user_id=999: not found (expected)")
+
+
+async def _delete(table):
+ print("\n--- Delete ---")
+ upsert_writer = table.new_upsert().create_writer()
+ handle = upsert_writer.delete({"user_id": 3})
+ await handle.wait()
+ print("Deleted user_id=3, server acknowledged")
+
+ lookuper = table.new_lookup().create_lookuper()
+ result = await lookuper.lookup({"user_id": 3})
+ assert result is None, "Expected user_id=3 to be deleted"
+ print("Lookup user_id=3 after delete: not found (deletion confirmed)")
+
+
+async def _partial_update(table):
+ print("\n--- Partial update by column names ---")
+ by_name = (
+ table.new_upsert()
+ .partial_update_by_name(["user_id", "balance"])
+ .create_writer()
+ )
+ handle = by_name.upsert({"user_id": 1, "balance": Decimal("9999.99")})
+ await handle.wait()
+
+ lookuper = table.new_lookup().create_lookuper()
+ result = await lookuper.lookup({"user_id": 1})
+ assert result is not None, "Expected to find user_id=1"
+ assert result["balance"] == Decimal("9999.99")
+ assert result["name"] == "Alice Updated", "name should be unchanged"
+ print(
+ f"By name: balance={result['balance']} (updated), "
+ f"name={result['name']} (unchanged)"
+ )
+
+ print("\n--- Partial update by column indices ---")
+ # Columns: 0=user_id (PK), 1=name. Update name only.
+ by_index = table.new_upsert().partial_update_by_index([0,
1]).create_writer()
+ handle = by_index.upsert([1, "Alice Renamed"])
+ await handle.wait()
+
+ result = await lookuper.lookup({"user_id": 1})
+ assert result is not None, "Expected to find user_id=1"
+ assert result["name"] == "Alice Renamed", "name should be updated"
+ assert result["balance"] == Decimal("9999.99"), "balance should be
unchanged"
+ print(
+ f"By index: name={result['name']} (updated), "
+ f"balance={result['balance']} (unchanged)"
+ )
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/bindings/python/fluss/__init__.pyi
b/bindings/python/fluss/__init__.pyi
index 4fbbc97e..5a8ecb5d 100644
--- a/bindings/python/fluss/__init__.pyi
+++ b/bindings/python/fluss/__init__.pyi
@@ -135,7 +135,6 @@ class ScanRecords:
def __getitem__(self, index: slice) -> List[ScanRecord]: ...
@overload
def __getitem__(self, bucket: TableBucket) -> List[ScanRecord]: ...
- def __getitem__(self, key: Union[int, slice, TableBucket]) ->
Union[ScanRecord, List[ScanRecord]]: ...
def __contains__(self, bucket: TableBucket) -> bool: ...
def __iter__(self) -> Iterator[ScanRecord]: ...
def __str__(self) -> str: ...
@@ -339,7 +338,7 @@ class FlussAdmin:
self,
table_path: TablePath,
table_descriptor: TableDescriptor,
- ignore_if_exists: Optional[bool] = False,
+ ignore_if_exists: Optional[bool] = None,
) -> None: ...
async def get_table_info(self, table_path: TablePath) -> TableInfo: ...
async def get_latest_lake_snapshot(self, table_path: TablePath) ->
LakeSnapshot: ...
diff --git a/bindings/python/test/test_examples.py
b/bindings/python/test/test_examples.py
new file mode 100644
index 00000000..b2a1d6bc
--- /dev/null
+++ b/bindings/python/test/test_examples.py
@@ -0,0 +1,66 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Executability check for the example scripts.
+
+Auto-discovers every ``example/*.py`` that exposes a callable ``main`` and runs
+it against the shared test cluster, so an example that stops working fails CI.
+Adding a new example file requires no changes here.
+"""
+
+import importlib.util
+import sys
+from pathlib import Path
+
+import pytest
+
+EXAMPLES_DIR = Path(__file__).resolve().parent.parent / "example"
+
+
+def _load_module(path: Path):
+ spec = importlib.util.spec_from_file_location(f"example_{path.stem}", path)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _discover_examples():
+ # Examples import each other's siblings only via the package dir, and may
+ # ship a shared "_"-prefixed helper; make the dir importable and skip
those.
+ if str(EXAMPLES_DIR) not in sys.path:
+ sys.path.insert(0, str(EXAMPLES_DIR))
+ params = []
+ for path in sorted(EXAMPLES_DIR.glob("*.py")):
+ if path.name.startswith("_"):
+ continue
+ module = _load_module(path)
+ main = getattr(module, "main", None)
+ if callable(main):
+ params.append(pytest.param(main, id=path.stem))
+ return params
+
+
+EXAMPLES = _discover_examples()
+
+
+def test_examples_discovered():
+ assert EXAMPLES, f"No runnable examples found in {EXAMPLES_DIR}"
+
+
[email protected]("example_main", EXAMPLES)
+async def test_example_runs(example_main, plaintext_bootstrap_servers):
+ await example_main(plaintext_bootstrap_servers)