This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new e357a660 [python] Construct native tables from resolved REST responses
(#922)
e357a660 is described below
commit e357a6602c36fca5f7d53f1f56644c90f18fdf85
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Sep 24 19:18:15 2026 +0800
[python] Construct native tables from resolved REST responses (#922)
---
bindings/python/README.md | 5 +
.../python/python/pypaimon_rust/datafusion.pyi | 7 ++
bindings/python/src/table.rs | 29 +++++
bindings/python/tests/test_resolved_table.py | 118 +++++++++++++++++++++
crates/paimon-rest-server/src/lib.rs | 1 +
crates/paimon/src/api/api_response.rs | 4 +
crates/paimon/src/table/rest_env.rs | 102 +++++++++++++++---
crates/paimon/tests/mock_server.rs | 4 +
8 files changed, 256 insertions(+), 14 deletions(-)
diff --git a/bindings/python/README.md b/bindings/python/README.md
index b6505d1d..496aecc9 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -197,6 +197,11 @@ results and branch-scoped requests. Permission and service
failures (including
HTTP 501) are propagated as in Java, and the
FileIO provider continues to refresh catalog credentials after schema
replacement.
+`Table.from_rest_response(response_json, database=..., table=...,
rest_options=...)`
+reuses the matching REST table response and merged catalog options, skipping
+config/get-table requests while preserving REST snapshots and token refresh.
+Use `copy_with_resolved_schema` to apply branch or dynamic options.
+
## Setup
Install [uv](https://docs.astral.sh/uv/getting-started/installation/):
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 4b0532ef..9500b25a 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -166,6 +166,13 @@ class Table:
REST authorization or credential refresh is required.
"""
...
+ @staticmethod
+ def from_rest_response(
+ response_json: str, *, database: str, table: str, rest_options:
Dict[str, str],
+ ) -> "Table":
+ """Reuse matching REST metadata and merged catalog options."""
+ ...
+
def copy_with_resolved_schema(self, schema_json: str, *, branch:
Optional[str] = None) -> "Table":
"""Replace all fields/options, preserving FileIO, REST context and
branch."""
...
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 47701b5c..799af4a5 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -21,6 +21,7 @@ use std::sync::Arc;
use paimon::catalog::Identifier;
use paimon::io::FileIO;
use paimon::spec::TableSchema;
+use paimon::Options;
use paimon_datafusion::runtime::runtime;
use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
@@ -83,6 +84,34 @@ impl PyTable {
Ok(Self::new(Arc::new(table)))
}
+ /// Reuse the matching REST table response and merged catalog options.
+ /// Skips config/get-table requests, preserving REST snapshots and token
refresh.
+ #[staticmethod]
+ #[pyo3(signature = (response_json, *, database, table, rest_options))]
+ fn from_rest_response(
+ py: Python<'_>,
+ response_json: &str,
+ database: &str,
+ table: &str,
+ rest_options: HashMap<String, String>,
+ ) -> PyResult<Self> {
+ let response: paimon::api::GetTableResponse =
+ serde_json::from_str(response_json).map_err(|err| {
+ PyValueError::new_err(format!("Invalid REST table response
JSON: {err}"))
+ })?;
+ let identifier = Identifier::new(database, table);
+ let table = py
+ .detach(|| {
+ runtime().block_on(paimon::table::Table::from_rest_response(
+ identifier,
+ response,
+ Options::from_map(rest_options),
+ ))
+ })
+ .map_err(to_py_err)?;
+ Ok(Self::new(Arc::new(table)))
+ }
+
/// Replace the complete schema while retaining FileIO, REST credentials
and branch.
/// The caller has already resolved fields and options; no schema is
reloaded.
#[pyo3(signature = (schema_json, *, branch=None))]
diff --git a/bindings/python/tests/test_resolved_table.py
b/bindings/python/tests/test_resolved_table.py
index 92937112..566885c7 100644
--- a/bindings/python/tests/test_resolved_table.py
+++ b/bindings/python/tests/test_resolved_table.py
@@ -194,3 +194,121 @@ def
test_catalog_schema_copy_validates_branch_and_structure(resolved_source):
schema["fields"][1]["id"] = schema["fields"][0]["id"]
with pytest.raises(ValueError):
table.copy_with_resolved_schema(json.dumps(schema))
+
+
[email protected]("external", [False, True])
[email protected]("object_name", ["t", "t$branch_main", "t$branch_dev"])
+def test_resolved_rest_response_keeps_snapshot_and_token_refresh(
+ resolved_source, external, object_name
+):
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+ from threading import Thread
+
+ root, schema = resolved_source
+ snapshot = json.loads((root / "snapshot" / "snapshot-1").read_text())
+ requests = []
+ token_requests = []
+
+ class Handler(BaseHTTPRequestHandler):
+ def do_GET(self):
+ requests.append(self.path)
+ if self.path.endswith('/token'):
+ token_requests.append(self.path)
+ # Expire the first token to verify that subsequent FileIO
refreshes it.
+ response = {"token": {}, "expiresAtMillis": (
+ 0 if len(token_requests) == 1 else 4102444800000)}
+ elif self.path.endswith('/snapshot'):
+ # Disk has snapshot 2; REST snapshot 1 must remain
authoritative.
+ response = {"snapshot": {"snapshot": snapshot}}
+ else:
+ self.send_error(500, "Unexpected metadata request")
+ return
+ body = json.dumps(response).encode()
+ self.send_response(200)
+ self.send_header('Content-Type', 'application/json')
+ self.send_header('Content-Length', str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def log_message(self, *args):
+ pass
+
+ server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
+ thread = Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ # PyPaimon and older REST servers do not include `database`.
+ response = {"id": "table-uuid", "name": object_name, "path": str(root),
+ "isExternal": external, "schemaId": schema['id'],
"schema": schema}
+ table = Table.from_rest_response(
+ json.dumps(response),
+ database='db',
+ table=object_name,
+ rest_options={
+ 'uri': 'http://127.0.0.1:%d' % server.server_port,
+ 'warehouse': 'test', 'token.provider': 'bear', 'token':
'test-token',
+ 'data-token.enabled': 'true',
+ },
+ )
+ expected_branch = (
+ object_name.split('$branch_', 1)[-1]
+ if '$branch_' in object_name else 'main'
+ )
+ assert table.branch() == expected_branch
+ if '$branch_' in object_name:
+ with pytest.raises(NotImplementedError, match='Writing to Paimon
branch'):
+ table.new_batch_write_builder().new_write()
+ assert len(token_requests) == (0 if external else 1)
+ assert all(path.endswith('/token') for path in requests)
+ assert _read(table) == (1, [{'id': 1, 'name': 'a'}])
+ assert all(path.endswith(('/token', '/snapshot')) for path in requests)
+ encoded_name = object_name.replace('$', '%24')
+ assert any(path.endswith(
+ f'/databases/db/tables/{encoded_name}/snapshot') for path in
requests)
+ assert len(token_requests) == (0 if external else 2)
+ finally:
+ server.shutdown()
+ server.server_close()
+ thread.join()
+
+
[email protected](("database", "table"), [("db", "wrong"), ("wrong",
"t")])
+def test_resolved_rest_response_rejects_identity_mismatch(resolved_source,
database, table):
+ root, schema = resolved_source
+ response = {"id": "table-uuid", "database": "db", "name": "t", "path":
str(root),
+ "isExternal": True, "schemaId": schema["id"], "schema": schema}
+ # Identity validation must run before REST auth/cache initialization.
+ with pytest.raises(ValueError, match="does not match requested
identifier"):
+ Table.from_rest_response(
+ json.dumps(response), database=database, table=table,
rest_options={})
+
+
[email protected](("change", "message"), [
+ ("duplicate_id", "duplicate field id"),
+ ("missing_primary_key", "primary key"),
+ ("missing_partition_key", "partition fields"),
+])
+def test_resolved_rest_response_validates_schema_structure(resolved_source,
change, message):
+ root, schema = resolved_source
+ if change == "duplicate_id":
+ schema["fields"][1]["id"] = schema["fields"][0]["id"]
+ elif change == "missing_primary_key":
+ schema["primaryKeys"] = ["missing"]
+ else:
+ schema["partitionKeys"] = ["missing"]
+ response = {"id": "table-uuid", "database": "db", "name": "t", "path":
str(root),
+ "isExternal": True, "schemaId": schema["id"], "schema": schema}
+ with pytest.raises(ValueError, match=message):
+ Table.from_rest_response(json.dumps(response), database="db",
table="t", rest_options={
+ "uri": "http://127.0.0.1:1", "warehouse": "test",
+ "token.provider": "bear", "token": "test-token",
+ })
+
+
[email protected]('response', ['{', '{}'])
+def test_resolved_rest_response_rejects_missing_metadata(response):
+ with pytest.raises(ValueError):
+ Table.from_rest_response(response, database='db', table='t',
rest_options={
+ 'uri': 'http://127.0.0.1:1', 'warehouse': 'test',
+ 'token.provider': 'bear', 'token': 'test-token',
+ })
diff --git a/crates/paimon-rest-server/src/lib.rs
b/crates/paimon-rest-server/src/lib.rs
index 7d2a0959..8cf89f04 100644
--- a/crates/paimon-rest-server/src/lib.rs
+++ b/crates/paimon-rest-server/src/lib.rs
@@ -461,6 +461,7 @@ async fn get_table(path: RestPath, Extension(state):
Extension<Arc<AppState>>) -
// FileSystemCatalog has no UUID concept; the full name is a stable id
// that satisfies the client's RESTEnv requirement.
Some(identifier.full_name()),
+ Some(identifier.database().to_string()),
Some(table),
Some(location),
Some(false),
diff --git a/crates/paimon/src/api/api_response.rs
b/crates/paimon/src/api/api_response.rs
index 5df155c2..943f861c 100644
--- a/crates/paimon/src/api/api_response.rs
+++ b/crates/paimon/src/api/api_response.rs
@@ -140,6 +140,8 @@ pub struct GetTableResponse {
pub audit: AuditRESTResponse,
/// The unique identifier of the table.
pub id: Option<String>,
+ /// The database containing the table.
+ pub database: Option<String>,
/// The name of the table.
pub name: Option<String>,
/// The path to the table.
@@ -242,6 +244,7 @@ impl GetTableResponse {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: Option<String>,
+ database: Option<String>,
name: Option<String>,
path: Option<String>,
is_external: Option<bool>,
@@ -252,6 +255,7 @@ impl GetTableResponse {
Self {
audit,
id,
+ database,
name,
path,
is_external,
diff --git a/crates/paimon/src/table/rest_env.rs
b/crates/paimon/src/table/rest_env.rs
index 6440536f..af810f08 100644
--- a/crates/paimon/src/table/rest_env.rs
+++ b/crates/paimon/src/table/rest_env.rs
@@ -20,9 +20,9 @@
use crate::api::rest_api::RESTApi;
use crate::api::rest_error::RestError;
use crate::catalog::{Identifier, RESTTokenFileIO};
-use crate::common::Options;
+use crate::common::{CatalogOptions, Options};
use crate::error::Error;
-use crate::io::cache::LocalCache;
+use crate::io::cache::{create_local_cache_with_namespace, LocalCache};
use crate::io::FileIO;
use crate::spec::{CoreOptions, TableSchema, PATH_OPTION};
use crate::table::snapshot_commit::{RESTSnapshotCommit, SnapshotCommit};
@@ -30,6 +30,41 @@ use crate::table::{ObjectTable, Table};
use crate::Result;
use std::sync::Arc;
+impl Table {
+ /// Reuse the matching REST response and merged catalog options without
config/get-table requests.
+ /// Preserves REST snapshots, credential refresh and local caching.
+ pub async fn from_rest_response(
+ identifier: Identifier,
+ response: crate::api::GetTableResponse,
+ rest_options: Options,
+ ) -> Result<Self> {
+ identifier.validate()?;
+ // Reject a stale or misrouted response before initializing auth or
local-cache resources.
+ response_identifier(&identifier, &response)?;
+ rest_options
+ .get(CatalogOptions::WAREHOUSE)
+ .ok_or_else(|| RestError::BadRequest {
+ message: format!("Missing required option: {}",
CatalogOptions::WAREHOUSE),
+ })?;
+ let api = Arc::new(RESTApi::new(rest_options.clone(), false).await?);
+ let data_token_enabled = api
+ .options()
+ .get(CatalogOptions::DATA_TOKEN_ENABLED)
+ .map(|v| v.eq_ignore_ascii_case("true"))
+ .unwrap_or(false);
+ let local_cache = create_local_cache_with_namespace(&rest_options,
api.options())?;
+ RESTEnv::build_table(
+ &identifier,
+ response,
+ api,
+ rest_options,
+ data_token_enabled,
+ local_cache,
+ )
+ .await
+ }
+}
+
/// REST environment that holds the REST API client, identifier, and uuid
/// needed to create a `RESTSnapshotCommit`.
#[derive(Clone)]
@@ -143,6 +178,7 @@ impl RESTEnv {
data_token_enabled: bool,
local_cache: Option<Arc<LocalCache>>,
) -> Result<Table> {
+ let identifier = response_identifier(identifier, &response)?;
let schema = response.schema.ok_or_else(|| Error::DataInvalid {
message: format!("Table {} response missing schema",
identifier.full_name()),
source: None,
@@ -180,6 +216,7 @@ impl RESTEnv {
table_path.clone(),
)]));
}
+ table_schema.validate_resolved_structure()?;
let is_external = response.is_external.ok_or_else(||
Error::DataInvalid {
message: format!(
@@ -188,7 +225,7 @@ impl RESTEnv {
),
source: None,
})?;
- validate_catalog_managed_format_table(identifier, &table_schema,
is_external)?;
+ validate_catalog_managed_format_table(&identifier, &table_schema,
is_external)?;
let uuid = response.id.ok_or_else(|| Error::DataInvalid {
message: format!(
@@ -199,7 +236,7 @@ impl RESTEnv {
})?;
let file_io = Self::build_file_io(
- identifier,
+ &identifier,
&table_path,
api.clone(),
&options,
@@ -217,14 +254,20 @@ impl RESTEnv {
data_token_enabled,
local_cache,
);
-
- Ok(Table::new(
+ let parsed_identifier = identifier.parsed_object_name()?;
+ let branch = parsed_identifier.branch_or_default().to_string();
+ let branch_reference = parsed_identifier.branch().is_some();
+ let table = Table::new(
file_io,
- identifier.clone(),
+ identifier,
table_path,
table_schema,
Some(rest_env),
- ))
+ );
+
+ let mut table =
table.copy_with_resolved_schema(table.schema().clone(), &branch)?;
+ table.branch_reference = branch_reference;
+ Ok(table)
}
pub(crate) async fn build_object_table(
@@ -235,6 +278,7 @@ impl RESTEnv {
data_token_enabled: bool,
local_cache: Option<Arc<LocalCache>>,
) -> Result<ObjectTable> {
+ let identifier = response_identifier(identifier, &response)?;
let schema = response.schema.ok_or_else(|| Error::DataInvalid {
message: format!("Table {} response missing schema",
identifier.full_name()),
source: None,
@@ -260,6 +304,7 @@ impl RESTEnv {
let mut schema_options = schema.options().clone();
schema_options.insert(PATH_OPTION.to_string(), object_path.clone());
let table_schema = TableSchema::new(schema_id,
&schema).copy_with_options(schema_options);
+ table_schema.validate_resolved_structure()?;
let is_external = response.is_external.ok_or_else(||
Error::DataInvalid {
message: format!(
"Table {} response missing is_external",
@@ -269,7 +314,7 @@ impl RESTEnv {
})?;
let file_io = Self::build_file_io(
- identifier,
+ &identifier,
&object_path,
api,
&options,
@@ -279,7 +324,7 @@ impl RESTEnv {
)
.await?;
- ObjectTable::try_new(file_io, identifier.clone(), &table_schema)
+ ObjectTable::try_new(file_io, identifier, &table_schema)
}
async fn build_file_io(
@@ -315,11 +360,13 @@ impl RESTEnv {
&self,
branch: &str,
) -> Result<Option<crate::spec::Snapshot>> {
- let object = self.identifier.parsed_object_name()?.table().to_string();
- let object = if branch == crate::catalog::DEFAULT_MAIN_BRANCH {
- object
+ let parsed_identifier = self.identifier.parsed_object_name()?;
+ let object = if parsed_identifier.branch() == Some(branch) {
+ format!("{}$branch_{branch}", parsed_identifier.table())
+ } else if branch == crate::catalog::DEFAULT_MAIN_BRANCH {
+ parsed_identifier.table().to_string()
} else {
- format!("{object}$branch_{branch}")
+ format!("{}$branch_{branch}", parsed_identifier.table())
};
let identifier = Identifier::new(self.identifier.database(), object);
match self.api.load_snapshot(&identifier).await {
@@ -341,6 +388,33 @@ impl RESTEnv {
}
}
+fn response_identifier(
+ requested: &Identifier,
+ response: &crate::api::GetTableResponse,
+) -> Result<Identifier> {
+ let database = response
+ .database
+ .as_deref()
+ .unwrap_or_else(|| requested.database());
+ let name = response.name.as_deref().ok_or_else(|| Error::DataInvalid {
+ message: format!("Table response for database '{database}' missing
name"),
+ source: None,
+ })?;
+ let identifier = Identifier::new(database, name);
+ identifier.validate()?;
+ if &identifier != requested {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Table response identifier '{}' does not match requested
identifier '{}'",
+ identifier.full_name(),
+ requested.full_name()
+ ),
+ source: None,
+ });
+ }
+ Ok(identifier)
+}
+
/// Refuse a Format Table that asks for catalog-managed partitions it cannot
have: an engine
/// implementation reads the table directory itself, and only an internal
table's partitions
/// belong to the catalog.
diff --git a/crates/paimon/tests/mock_server.rs
b/crates/paimon/tests/mock_server.rs
index ec593d13..3afa0ac0 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -758,6 +758,7 @@ impl RESTServer {
// Create table response
let response = GetTableResponse::new(
Some(table_name.clone()),
+ Some(db.clone()),
Some(table_name),
None,
Some(true),
@@ -1620,6 +1621,7 @@ impl RESTServer {
// Update the table name in response and insert at new location
let new_table_response = GetTableResponse::new(
Some(request.destination.object().to_string()),
+ Some(request.destination.database().to_string()),
Some(request.destination.object().to_string()),
table_response.path,
table_response.is_external,
@@ -1682,6 +1684,7 @@ impl RESTServer {
s.tables.entry(key).or_insert_with(|| {
GetTableResponse::new(
Some(table.to_string()),
+ Some(database.to_string()),
Some(table.to_string()),
None,
Some(true),
@@ -1818,6 +1821,7 @@ impl RESTServer {
key,
GetTableResponse::new(
Some(table.to_string()),
+ Some(database.to_string()),
Some(table.to_string()),
Some(path.to_string()),
Some(true),