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 9cfeda78 [python] Add stub test to catch pyi drift in CI (#616)
9cfeda78 is described below

commit 9cfeda7876929257d0bf1d68911560c971350fbc
Author: Kaiqi Dong <[email protected]>
AuthorDate: Wed Jun 10 23:59:15 2026 +0200

    [python] Add stub test to catch pyi drift in CI (#616)
    
    * stub test to catch pyi drift
    
    * update development
---
 .github/workflows/build_and_test_python.yml |  37 ++++++++++
 bindings/python/DEVELOPMENT.md              |  19 ++++++
 bindings/python/fluss/__init__.pyi          | 100 +++++++++++++++++++---------
 bindings/python/stubtest-allowlist.txt      |  27 ++++++++
 4 files changed, 150 insertions(+), 33 deletions(-)

diff --git a/.github/workflows/build_and_test_python.yml 
b/.github/workflows/build_and_test_python.yml
index a8d3f05b..52c4d274 100644
--- a/.github/workflows/build_and_test_python.yml
+++ b/.github/workflows/build_and_test_python.yml
@@ -98,3 +98,40 @@ jobs:
           path: cluster-logs/
           if-no-files-found: ignore
           retention-days: 3
+
+  stubtest:
+    # For stub tests, we only needs the module importable, so no
+    # test cluster is started and a single Python version should be sufficient.
+    timeout-minutes: 30
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v6
+
+      - name: Set up Python
+        uses: actions/setup-python@v6
+        with:
+          python-version: "3.12"
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78
+
+      - name: Install protoc
+        uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # 
v3.0.0
+        with:
+          repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Rust Cache
+        uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # 
v2.9.1
+
+      - name: Build Python bindings
+        working-directory: bindings/python
+        run: |
+          uv sync --extra dev --no-install-project
+          uv run --no-sync maturin develop --uv
+
+      - name: Check stub drift (stubtest)
+        working-directory: bindings/python
+        run: |
+          uv run --no-sync python -m mypy.stubtest fluss \
+            --mypy-config-file pyproject.toml \
+            --allowlist stubtest-allowlist.txt
diff --git a/bindings/python/DEVELOPMENT.md b/bindings/python/DEVELOPMENT.md
index 65bb37bc..40e473c9 100644
--- a/bindings/python/DEVELOPMENT.md
+++ b/bindings/python/DEVELOPMENT.md
@@ -44,6 +44,25 @@ uv run ruff check python/
 uv run mypy python/
 ```
 
+## Stub Drift Check
+
+The public API is typed in `fluss/__init__.pyi`. Because that stub is
+hand-written while the runtime classes come from the compiled PyO3 module, the
+two can drift apart. `stubtest` (shipped with `mypy`) compares them and is run
+in CI. After changing the binding's public surface, rebuild the module and run:
+
+```bash
+uv sync --extra dev --no-install-project
+uv run --no-sync maturin develop --uv
+uv run --no-sync python -m mypy.stubtest fluss \
+  --mypy-config-file pyproject.toml \
+  --allowlist stubtest-allowlist.txt
+```
+
+`stubtest-allowlist.txt` holds the few differences that are inherent to PyO3 
and
+cannot be expressed in the stub. Keep it minimal — stubtest treats an unused
+allowlist entry as an error, so stale lines must be removed.
+
 ## Run Examples
 
 Each example is standalone and runnable on its own. They default to a local
diff --git a/bindings/python/fluss/__init__.pyi 
b/bindings/python/fluss/__init__.pyi
index 5a8ecb5d..7d5bfa73 100644
--- a/bindings/python/fluss/__init__.pyi
+++ b/bindings/python/fluss/__init__.pyi
@@ -20,7 +20,6 @@
 from enum import IntEnum
 from types import TracebackType
 from typing import (
-    Any,
     AsyncIterator,
     Dict,
     Iterator,
@@ -28,12 +27,14 @@ from typing import (
     Optional,
     Tuple,
     Union,
+    final,
     overload,
 )
 
 import pandas as pd
 import pyarrow as pa
 
+@final
 class ChangeType(IntEnum):
     """Represents the type of change for a record in a log."""
 
@@ -52,6 +53,7 @@ class ChangeType(IntEnum):
         """Returns a short string representation (+A, +I, -U, +U, -D)."""
         ...
 
+@final
 class ScanRecord:
     """Represents a single scan record with metadata.
 
@@ -78,6 +80,7 @@ class ScanRecord:
     def __str__(self) -> str: ...
     def __repr__(self) -> str: ...
 
+@final
 class RecordBatch:
     """Represents a batch of records with metadata."""
 
@@ -100,6 +103,7 @@ class RecordBatch:
     def __str__(self) -> str: ...
     def __repr__(self) -> str: ...
 
+@final
 class ScanRecords:
     """A collection of scan records grouped by bucket.
 
@@ -130,18 +134,19 @@ class ScanRecords:
         ...
     def __len__(self) -> int: ...
     @overload
-    def __getitem__(self, index: int) -> ScanRecord: ...
+    def __getitem__(self, index: int, /) -> ScanRecord: ...
     @overload
-    def __getitem__(self, index: slice) -> List[ScanRecord]: ...
+    def __getitem__(self, index: slice, /) -> List[ScanRecord]: ...
     @overload
-    def __getitem__(self, bucket: TableBucket) -> List[ScanRecord]: ...
-    def __contains__(self, bucket: TableBucket) -> bool: ...
+    def __getitem__(self, bucket: TableBucket, /) -> List[ScanRecord]: ...
+    def __contains__(self, bucket: TableBucket, /) -> bool: ...
     def __iter__(self) -> Iterator[ScanRecord]: ...
     def __str__(self) -> str: ...
     def __repr__(self) -> str: ...
 
+@final
 class Config:
-    def __init__(self, properties: Optional[Dict[str, str]] = None) -> None: 
...
+    def __new__(cls, properties: Optional[Dict[str, str]] = None) -> Config: 
...
     @property
     def bootstrap_servers(self) -> str: ...
     @bootstrap_servers.setter
@@ -247,6 +252,7 @@ class Config:
     @security_sasl_password.setter
     def security_sasl_password(self, password: str) -> None: ...
 
+@final
 class FlussConnection:
     @staticmethod
     async def create(config: Config) -> FlussConnection: ...
@@ -256,19 +262,20 @@ class FlussConnection:
     def __enter__(self) -> FlussConnection: ...
     def __exit__(
         self,
-        exc_type: Optional[type],
-        exc_value: Optional[BaseException],
-        traceback: Optional[TracebackType],
+        exc_type: Optional[type] = None,
+        exc_value: Optional[BaseException] = None,
+        traceback: Optional[TracebackType] = None,
     ) -> bool: ...
     async def __aenter__(self) -> FlussConnection: ...
     async def __aexit__(
         self,
-        exc_type: Optional[type],
-        exc_value: Optional[BaseException],
-        traceback: Optional[TracebackType],
+        exc_type: Optional[type] = None,
+        exc_value: Optional[BaseException] = None,
+        traceback: Optional[TracebackType] = None,
     ) -> bool: ...
     def __repr__(self) -> str: ...
 
+@final
 class ServerNode:
     """Information about a server node in the Fluss cluster."""
 
@@ -294,6 +301,7 @@ class ServerNode:
         ...
     def __repr__(self) -> str: ...
 
+@final
 class FlussAdmin:
     async def create_database(
         self,
@@ -426,20 +434,22 @@ class FlussAdmin:
     def __repr__(self) -> str: ...
 
 
+@final
 class DatabaseDescriptor:
     """Descriptor for a Fluss database (comment and custom properties)."""
 
-    def __init__(
-        self,
+    def __new__(
+        cls,
         comment: Optional[str] = None,
         custom_properties: Optional[Dict[str, str]] = None,
-    ) -> None: ...
+    ) -> DatabaseDescriptor: ...
     @property
     def comment(self) -> Optional[str]: ...
     def get_custom_properties(self) -> Dict[str, str]: ...
     def __repr__(self) -> str: ...
 
 
+@final
 class DatabaseInfo:
     """Information about a Fluss database."""
 
@@ -452,6 +462,7 @@ class DatabaseInfo:
     def modified_time(self) -> int: ...
     def __repr__(self) -> str: ...
 
+@final
 class TableScan:
     """Builder for creating log scanners with flexible configuration.
 
@@ -514,6 +525,7 @@ class TableScan:
         ...
     def __repr__(self) -> str: ...
 
+@final
 class FlussTable:
     def new_scan(self) -> TableScan:
         """Create a new table scan builder for configuring and creating log 
scanners.
@@ -545,6 +557,7 @@ class FlussTable:
     def has_primary_key(self) -> bool: ...
     def __repr__(self) -> str: ...
 
+@final
 class TableAppend:
     """Builder for creating an AppendWriter.
 
@@ -557,6 +570,7 @@ class TableAppend:
     def create_writer(self) -> AppendWriter: ...
     def __repr__(self) -> str: ...
 
+@final
 class TableUpsert:
     """Builder for creating an UpsertWriter, with optional partial update.
 
@@ -580,6 +594,7 @@ class TableUpsert:
     def create_writer(self) -> UpsertWriter: ...
     def __repr__(self) -> str: ...
 
+@final
 class TableLookup:
     """Builder for creating a Lookuper or PrefixLookuper.
 
@@ -608,6 +623,7 @@ class TableLookup:
         ...
     def __repr__(self) -> str: ...
 
+@final
 class TablePrefixLookup:
     """Builder for creating a PrefixLookuper.
 
@@ -620,6 +636,7 @@ class TablePrefixLookup:
     def create_lookuper(self) -> "PrefixLookuper": ...
     def __repr__(self) -> str: ...
 
+@final
 class AppendWriter:
     def append(self, row: dict | list | tuple) -> WriteResultHandle:
         """Append a single row to the table.
@@ -674,9 +691,9 @@ class AppendWriter:
         ...
     async def __aexit__(
         self,
-        exc_type: Optional[type],
-        exc_value: Optional[BaseException],
-        traceback: Optional[TracebackType],
+        exc_type: Optional[type] = None,
+        exc_value: Optional[BaseException] = None,
+        traceback: Optional[TracebackType] = None,
     ) -> bool:
         """
         Exit the async context manager.
@@ -687,6 +704,7 @@ class AppendWriter:
         ...
     def __repr__(self) -> str: ...
 
+@final
 class UpsertWriter:
     """Writer for upserting and deleting data in a Fluss primary key table."""
 
@@ -728,9 +746,9 @@ class UpsertWriter:
         ...
     async def __aexit__(
         self,
-        exc_type: Optional[type],
-        exc_value: Optional[BaseException],
-        traceback: Optional[TracebackType],
+        exc_type: Optional[type] = None,
+        exc_value: Optional[BaseException] = None,
+        traceback: Optional[TracebackType] = None,
     ) -> bool:
         """
         Exit the async context manager.
@@ -742,6 +760,7 @@ class UpsertWriter:
     def __repr__(self) -> str: ...
 
 
+@final
 class WriteResultHandle:
     """Handle for a pending write (append/upsert/delete). Ignore for 
fire-and-forget, or await handle.wait() for ack."""
 
@@ -751,6 +770,7 @@ class WriteResultHandle:
     def __repr__(self) -> str: ...
 
 
+@final
 class Lookuper:
     """Lookuper for performing primary key lookups on a Fluss table."""
 
@@ -767,6 +787,7 @@ class Lookuper:
         ...
     def __repr__(self) -> str: ...
 
+@final
 class PrefixLookuper:
     """Lookuper for performing prefix key lookups on a Fluss table.
 
@@ -790,6 +811,7 @@ class PrefixLookuper:
         ...
     def __repr__(self) -> str: ...
 
+@final
 class LogScanner:
     """Scanner for reading log data from a Fluss table.
 
@@ -954,19 +976,23 @@ class LogScanner:
     def __repr__(self) -> str: ...
     def __aiter__(self) -> AsyncIterator[Union[ScanRecord, RecordBatch]]: ...
 
+@final
 class Schema:
-    def __init__(
-        self, schema: pa.Schema, primary_keys: Optional[List[str]] = None
-    ) -> None: ...
+    def __new__(
+        cls, schema: pa.Schema, primary_keys: Optional[List[str]] = None
+    ) -> Schema: ...
     def get_column_names(self) -> List[str]: ...
     def get_column_types(self) -> List[str]: ...
     def get_columns(self) -> List[Tuple[str, str]]: ...
     def get_primary_keys(self) -> List[str]: ...
     def __str__(self) -> str: ...
 
+@final
 class TableDescriptor:
-    def __init__(
-        self,
+    # Runtime accepts ``schema`` plus ``**kwargs``; the keyword-only parameters
+    # below document the supported options for type checkers and IDEs.
+    def __new__(
+        cls,
         schema: Schema,
         *,
         partition_keys: Optional[List[str]] = None,
@@ -977,11 +1003,12 @@ class TableDescriptor:
         kv_format: Optional[str] = None,
         properties: Optional[Dict[str, str]] = None,
         custom_properties: Optional[Dict[str, str]] = None,
-    ) -> None: ...
+    ) -> TableDescriptor: ...
     def get_schema(self) -> Schema: ...
 
+@final
 class TablePath:
-    def __init__(self, database: str, table: str) -> None: ...
+    def __new__(cls, database_name: str, table_name: str) -> TablePath: ...
     @property
     def database_name(self) -> str: ...
     @property
@@ -990,8 +1017,9 @@ class TablePath:
     def __str__(self) -> str: ...
     def __repr__(self) -> str: ...
     def __hash__(self) -> int: ...
-    def __eq__(self, other: object) -> bool: ...
+    def __eq__(self, other: object, /) -> bool: ...
 
+@final
 class TableInfo:
     @property
     def table_id(self) -> int: ...
@@ -1018,16 +1046,18 @@ class TableInfo:
     def get_column_names(self) -> List[str]: ...
     def get_column_count(self) -> int: ...
 
+@final
 class FlussError(Exception):
     message: str
     error_code: int
-    def __init__(self, message: str, error_code: int = -2) -> None: ...
+    def __new__(cls, message: str, error_code: int = -2) -> FlussError: ...
     def __str__(self) -> str: ...
     @property
     def is_retriable(self) -> bool: ...
 
+@final
 class LakeSnapshot:
-    def __init__(self, snapshot_id: int) -> None: ...
+    def __new__(cls, snapshot_id: int) -> LakeSnapshot: ...
     @property
     def snapshot_id(self) -> int: ...
     @property
@@ -1037,8 +1067,9 @@ class LakeSnapshot:
     def __str__(self) -> str: ...
     def __repr__(self) -> str: ...
 
+@final
 class TableBucket:
-    def __init__(self, table_id: int, bucket: int) -> None: ...
+    def __new__(cls, table_id: int, bucket: int) -> TableBucket: ...
     @staticmethod
     def with_partition(
         table_id: int, partition_id: int, bucket: int
@@ -1050,10 +1081,11 @@ class TableBucket:
     @property
     def partition_id(self) -> Optional[int]: ...
     def __hash__(self) -> int: ...
-    def __eq__(self, other: object) -> bool: ...
+    def __eq__(self, other: object, /) -> bool: ...
     def __str__(self) -> str: ...
     def __repr__(self) -> str: ...
 
+@final
 class PartitionInfo:
     """Information about a partition."""
 
@@ -1067,6 +1099,7 @@ class PartitionInfo:
         ...
     def __repr__(self) -> str: ...
 
+@final
 class ErrorCode:
     """Named constants for Fluss API error codes.
 
@@ -1137,6 +1170,7 @@ class ErrorCode:
     INVALID_ALTER_TABLE_EXCEPTION: int
     DELETION_DISABLED_EXCEPTION: int
 
+@final
 class OffsetSpec:
     """Offset specification for list_offsets(), matching Java's OffsetSpec.
 
diff --git a/bindings/python/stubtest-allowlist.txt 
b/bindings/python/stubtest-allowlist.txt
new file mode 100644
index 00000000..19de24dd
--- /dev/null
+++ b/bindings/python/stubtest-allowlist.txt
@@ -0,0 +1,27 @@
+# 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.
+
+# Allowlist for `python -m mypy.stubtest fluss`. Each entry is a regex matched
+# against the fully-qualified symbol and suppresses a stub/runtime difference
+# that is inherent to PyO3 and cannot be expressed in the stub. Keep this list
+# minimal: stubtest errors on unused entries, so a stale line fails CI.
+
+# PyO3 cannot embed a literal default value in the generated text signature, so
+# the real `error_code=-2` default is introspected as `Ellipsis`. We keep the
+# accurate `-2` default in the stub for documentation and ignore the resulting
+# default-value mismatch on the constructor.
+fluss.FlussError.__new__

Reply via email to