This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 90e0a2a5bd [python] Fall back to table metadata when REST partition 
listing is unimplemented (#10176)
90e0a2a5bd is described below

commit 90e0a2a5bd87b99f9c3a1ceef185a1147d9dc8c1
Author: jackylee <[email protected]>
AuthorDate: Sat Oct 3 17:12:34 2026 +0800

    [python] Fall back to table metadata when REST partition listing is 
unimplemented (#10176)
---
 paimon-python/pypaimon/catalog/catalog_utils.py    | 121 +++++++++++++++++++++
 .../pypaimon/catalog/filesystem_catalog.py         |  87 +--------------
 .../pypaimon/catalog/rest/rest_catalog.py          |  21 ++++
 .../rest/rest_list_partitions_fallback_test.py     | 104 ++++++++++++++++++
 4 files changed, 249 insertions(+), 84 deletions(-)

diff --git a/paimon-python/pypaimon/catalog/catalog_utils.py 
b/paimon-python/pypaimon/catalog/catalog_utils.py
new file mode 100644
index 0000000000..a3f70ab5b7
--- /dev/null
+++ b/paimon-python/pypaimon/catalog/catalog_utils.py
@@ -0,0 +1,121 @@
+# 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.
+
+"""Helpers shared across catalog implementations."""
+
+import re
+from typing import Optional
+
+from pypaimon.api.api_response import PagedList, Partition
+from pypaimon.table.table import Table
+
+
+def list_partitions_from_file_system(
+        table: Table,
+        max_results: Optional[int] = None,
+        page_token: Optional[str] = None,
+        partition_name_pattern: Optional[str] = None,
+) -> PagedList[Partition]:
+    """Compute partitions from a table's own metadata (manifest entries).
+
+    Mirrors Java ``CatalogUtils.listPartitionsFromFileSystem``: the aggregation
+    is shared by the filesystem catalog and by the REST catalog, which falls
+    back to it when the server does not implement the partition-listing
+    endpoint.
+    """
+    from pypaimon.manifest.manifest_file_manager import ManifestFileManager
+    from pypaimon.manifest.manifest_list_manager import ManifestListManager
+
+    snapshot = table.snapshot_manager().get_latest_snapshot()
+    if snapshot is None:
+        return PagedList(elements=[])
+
+    # Read all manifest entries (ADD - DELETE merged) and group by partition.
+    manifest_list_manager = ManifestListManager(table)
+    manifest_file_manager = ManifestFileManager(table)
+    manifest_files = manifest_list_manager.read_all(snapshot)
+    entries = manifest_file_manager.read_entries_parallel(manifest_files, 
drop_stats=True)
+
+    partition_map = {}  # spec_key -> aggregated stats
+    for entry in entries:
+        spec = {field.name: str(v) for field, v in
+                zip(entry.partition.fields, entry.partition.values)}
+        spec_key = tuple(sorted(spec.items()))
+
+        if spec_key not in partition_map:
+            partition_map[spec_key] = {
+                'spec': spec,
+                'record_count': 0,
+                'file_size_in_bytes': 0,
+                'file_count': 0,
+                'last_file_creation_time': 0,
+                'buckets': set(),
+            }
+        stats = partition_map[spec_key]
+        stats['record_count'] += entry.file.row_count
+        stats['file_size_in_bytes'] += entry.file.file_size
+        stats['file_count'] += 1
+        if entry.file.creation_time is not None:
+            ct = entry.file.creation_time.get_millisecond()
+            if ct > stats['last_file_creation_time']:
+                stats['last_file_creation_time'] = ct
+        stats['buckets'].add(entry.bucket)
+
+    partitions = [
+        Partition(
+            spec=stats['spec'],
+            record_count=stats['record_count'],
+            file_size_in_bytes=stats['file_size_in_bytes'],
+            file_count=stats['file_count'],
+            last_file_creation_time=stats['last_file_creation_time'],
+            total_buckets=len(stats['buckets']),
+        )
+        for stats in partition_map.values()
+    ]
+
+    # Apply pattern filter with proper regex escaping.
+    if partition_name_pattern:
+        # Escape special regex chars except '*', then replace '*' with '.*'.
+        escaped_pattern = re.escape(partition_name_pattern).replace(r'\*', 
'.*')
+        regex = re.compile(escaped_pattern)
+        partitions = [
+            p for p in partitions
+            if regex.fullmatch(','.join(f'{k}={v}' for k, v in p.spec.items()))
+        ]
+
+    # Sort partitions by name (partition spec string).
+    partitions.sort(key=lambda p: ','.join(f'{k}={v}' for k, v in 
sorted(p.spec.items())))
+
+    # Apply pagination.
+    start_index = 0
+    if page_token is not None:
+        try:
+            start_index = int(page_token)
+        except ValueError:
+            # Invalid token, start from beginning.
+            start_index = 0
+
+    end_index = len(partitions)
+    if max_results is not None and max_results > 0:
+        end_index = min(start_index + max_results, len(partitions))
+
+    result_partitions = partitions[start_index:end_index]
+    next_page_token = None
+    if max_results is not None and end_index < len(partitions):
+        next_page_token = str(end_index)
+
+    return PagedList(elements=result_partitions, 
next_page_token=next_page_token)
diff --git a/paimon-python/pypaimon/catalog/filesystem_catalog.py 
b/paimon-python/pypaimon/catalog/filesystem_catalog.py
index e71f9cc18a..f2aad5b442 100644
--- a/paimon-python/pypaimon/catalog/filesystem_catalog.py
+++ b/paimon-python/pypaimon/catalog/filesystem_catalog.py
@@ -307,95 +307,14 @@ class FileSystemCatalog(Catalog):
             page_token: Optional[str] = None,
             partition_name_pattern: Optional[str] = None,
     ) -> PagedList[Partition]:
-        from pypaimon.manifest.manifest_file_manager import ManifestFileManager
-        from pypaimon.manifest.manifest_list_manager import ManifestListManager
+        from pypaimon.catalog.catalog_utils import 
list_partitions_from_file_system
 
         if not isinstance(identifier, Identifier):
             identifier = Identifier.from_string(identifier)
 
         table = self.get_table(identifier)
-        snapshot = table.snapshot_manager().get_latest_snapshot()
-        if snapshot is None:
-            return PagedList(elements=[])
-
-        # Read all manifest entries (ADD - DELETE merged)
-        manifest_list_manager = ManifestListManager(table)
-        manifest_file_manager = ManifestFileManager(table)
-        manifest_files = manifest_list_manager.read_all(snapshot)
-        entries = manifest_file_manager.read_entries_parallel(manifest_files, 
drop_stats=True)
-
-        # Group entries by partition spec
-        partition_map = {}  # spec_key -> aggregated stats
-        for entry in entries:
-            spec = {field.name: str(v) for field, v in
-                    zip(entry.partition.fields, entry.partition.values)}
-            spec_key = tuple(sorted(spec.items()))
-
-            if spec_key not in partition_map:
-                partition_map[spec_key] = {
-                    'spec': spec,
-                    'record_count': 0,
-                    'file_size_in_bytes': 0,
-                    'file_count': 0,
-                    'last_file_creation_time': 0,
-                    'buckets': set(),
-                }
-            stats = partition_map[spec_key]
-            stats['record_count'] += entry.file.row_count
-            stats['file_size_in_bytes'] += entry.file.file_size
-            stats['file_count'] += 1
-            if entry.file.creation_time is not None:
-                ct = entry.file.creation_time.get_millisecond()
-                if ct > stats['last_file_creation_time']:
-                    stats['last_file_creation_time'] = ct
-            stats['buckets'].add(entry.bucket)
-
-        # Convert to Partition objects
-        partitions = []
-        for stats in partition_map.values():
-            partitions.append(Partition(
-                spec=stats['spec'],
-                record_count=stats['record_count'],
-                file_size_in_bytes=stats['file_size_in_bytes'],
-                file_count=stats['file_count'],
-                last_file_creation_time=stats['last_file_creation_time'],
-                total_buckets=len(stats['buckets']),
-            ))
-
-        # Apply pattern filter with proper regex escaping
-        if partition_name_pattern:
-            import re
-
-            # Escape special regex chars except '*', then replace '*' with '.*'
-            escaped_pattern = re.escape(partition_name_pattern).replace(r'\*', 
'.*')
-            regex = re.compile(escaped_pattern)
-            partitions = [
-                p for p in partitions
-                if regex.fullmatch(','.join(f'{k}={v}' for k, v in 
p.spec.items()))
-            ]
-
-        # Sort partitions by name (partition spec string)
-        partitions.sort(key=lambda p: ','.join(f'{k}={v}' for k, v in 
sorted(p.spec.items())))
-
-        # Apply pagination
-        start_index = 0
-        if page_token is not None:
-            try:
-                start_index = int(page_token)
-            except ValueError:
-                # Invalid token, start from beginning
-                start_index = 0
-
-        end_index = len(partitions)
-        if max_results is not None and max_results > 0:
-            end_index = min(start_index + max_results, len(partitions))
-
-        result_partitions = partitions[start_index:end_index]
-        next_page_token = None
-        if max_results is not None and end_index < len(partitions):
-            next_page_token = str(end_index)
-
-        return PagedList(elements=result_partitions, 
next_page_token=next_page_token)
+        return list_partitions_from_file_system(
+            table, max_results, page_token, partition_name_pattern)
 
     def drop_partitions(
             self,
diff --git a/paimon-python/pypaimon/catalog/rest/rest_catalog.py 
b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
index c8ebbfa10e..12fc512c29 100644
--- a/paimon-python/pypaimon/catalog/rest/rest_catalog.py
+++ b/paimon-python/pypaimon/catalog/rest/rest_catalog.py
@@ -368,6 +368,27 @@ class RESTCatalog(Catalog):
             raise TableNotExistException(identifier) from e
         except ForbiddenException as e:
             raise TableNoPermissionException(identifier) from e
+        except NotImplementedException:
+            # The server does not implement the partition-listing endpoint.
+            # Mirror Java RESTCatalog#listPartitionsPaged and fall back to
+            # computing partitions from the table's own metadata.
+            #
+            # Scope: this Python fallback reads Paimon manifests, so it only
+            # supports data tables (FileStoreTable). Java re-raises solely for
+            # catalog-managed-partition tables (a FormatTable whose
+            # partitionManager is set) and otherwise scans the table's file
+            # system, including an unmanaged format table's directory layout
+            # (CatalogUtils.listPartitionsFromFileSystem). PyPaimon does not
+            # scan format/object tables here yet, so those still surface the
+            # server's NotImplemented error rather than being listed.
+            from pypaimon.catalog.catalog_utils import (
+                list_partitions_from_file_system)
+
+            table = self.get_table(identifier)
+            if not isinstance(table, FileStoreTable):
+                raise
+            return list_partitions_from_file_system(
+                table, max_results, page_token, partition_name_pattern)
 
     def alter_table(
         self,
diff --git 
a/paimon-python/pypaimon/tests/rest/rest_list_partitions_fallback_test.py 
b/paimon-python/pypaimon/tests/rest/rest_list_partitions_fallback_test.py
new file mode 100644
index 0000000000..c0e2de6089
--- /dev/null
+++ b/paimon-python/pypaimon/tests/rest/rest_list_partitions_fallback_test.py
@@ -0,0 +1,104 @@
+# 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.
+
+from pypaimon import Schema
+from pypaimon.api.rest_exception import NotImplementedException
+from pypaimon.common.identifier import Identifier
+from pypaimon.tests.rest.rest_base_test import RESTBaseTest
+
+
+class RESTListPartitionsFallbackTest(RESTBaseTest):
+    """When the REST server does not implement partition listing, the catalog
+    must fall back to computing partitions from the table's own metadata,
+    mirroring the Java RESTCatalog."""
+
+    def _create_partitioned_table(self, name):
+        schema = Schema.from_pyarrow_schema(self.pa_schema, 
partition_keys=['dt'])
+        self.rest_catalog.create_table(name, schema, False)
+        table = self.rest_catalog.get_table(name)
+        write_builder = table.new_batch_write_builder()
+        table_write = write_builder.new_write()
+        table_commit = write_builder.new_commit()
+        table_write.write_arrow(self.expected)
+        table_commit.commit(table_write.prepare_commit())
+        table_write.close()
+        table_commit.close()
+        return Identifier.from_string(name)
+
+    def _make_endpoint_not_implemented(self):
+        def _raise(*args, **kwargs):
+            raise NotImplementedException("partition listing not implemented")
+        self.rest_catalog.rest_api.list_partitions_paged = _raise
+
+    def _create_format_table(self, name):
+        schema = Schema.from_pyarrow_schema(
+            self.pa_schema,
+            partition_keys=['dt'],
+            options={"type": "format-table", "file.format": "parquet"})
+        self.rest_catalog.create_table(name, schema, False)
+        table = self.rest_catalog.get_table(name)
+        write_builder = table.new_batch_write_builder()
+        table_write = write_builder.new_write()
+        table_commit = write_builder.new_commit()
+        table_write.write_arrow(self.expected)
+        table_commit.commit(table_write.prepare_commit())
+        table_write.close()
+        table_commit.close()
+        return Identifier.from_string(name)
+
+    def test_falls_back_to_metadata(self):
+        identifier = self._create_partitioned_table('default.parts_fallback')
+        self._make_endpoint_not_implemented()
+
+        result = self.rest_catalog.list_partitions_paged(identifier)
+
+        specs = sorted(tuple(sorted(p.spec.items())) for p in result.elements)
+        self.assertEqual(specs, [(('dt', 'p1'),), (('dt', 'p2'),)])
+        # Stats come from the manifests, not zero placeholders.
+        by_dt = {p.spec['dt']: p for p in result.elements}
+        self.assertEqual(by_dt['p1'].record_count, 4)
+        self.assertEqual(by_dt['p2'].record_count, 4)
+        for p in result.elements:
+            self.assertGreater(p.file_count, 0)
+
+    def test_format_table_still_raises(self):
+        # The manifest-based fallback only supports data tables. Unlike Java
+        # (which scans an unmanaged format table's directory), PyPaimon does
+        # not list format-table partitions here, so the server's
+        # NotImplemented error must surface rather than be silently swallowed.
+        identifier = self._create_format_table('default.parts_fallback_format')
+        self._make_endpoint_not_implemented()
+
+        with self.assertRaises(NotImplementedException):
+            self.rest_catalog.list_partitions_paged(identifier)
+
+    def test_fallback_honors_pattern_and_pagination(self):
+        identifier = 
self._create_partitioned_table('default.parts_fallback_paged')
+        self._make_endpoint_not_implemented()
+
+        only_p1 = self.rest_catalog.list_partitions_paged(
+            identifier, partition_name_pattern="dt=p1")
+        self.assertEqual([p.spec for p in only_p1.elements], [{"dt": "p1"}])
+
+        first = self.rest_catalog.list_partitions_paged(identifier, 
max_results=1)
+        self.assertEqual(len(first.elements), 1)
+        self.assertIsNotNone(first.next_page_token)
+        second = self.rest_catalog.list_partitions_paged(
+            identifier, max_results=1, page_token=first.next_page_token)
+        self.assertEqual(len(second.elements), 1)
+        self.assertIsNone(second.next_page_token)
+        self.assertNotEqual(first.elements[0].spec, second.elements[0].spec)

Reply via email to