2020 Any ,
2121 Union ,
2222)
23+ from urllib .parse import quote , unquote
2324
2425from pydantic import ConfigDict , Field , field_validator
2526from requests import HTTPError , Session
@@ -227,7 +228,8 @@ class IdentifierKind(Enum):
227228VIEW_ENDPOINTS_SUPPORTED = "view-endpoints-supported"
228229VIEW_ENDPOINTS_SUPPORTED_DEFAULT = False
229230
230- NAMESPACE_SEPARATOR = b"\x1f " .decode (UTF8 )
231+ NAMESPACE_SEPARATOR_PROPERTY = "namespace-separator"
232+ DEFAULT_NAMESPACE_SEPARATOR = b"\x1f " .decode (UTF8 )
231233
232234
233235def _retry_hook (retry_state : RetryCallState ) -> None :
@@ -319,6 +321,7 @@ class RestCatalog(Catalog):
319321 uri : str
320322 _session : Session
321323 _supported_endpoints : set [Endpoint ]
324+ _namespace_separator : str
322325
323326 def __init__ (self , name : str , ** properties : str ):
324327 """Rest Catalog.
@@ -333,6 +336,8 @@ def __init__(self, name: str, **properties: str):
333336 self .uri = properties [URI ]
334337 self ._fetch_config ()
335338 self ._session = self ._create_session ()
339+ separator_from_properties = self .properties .get (NAMESPACE_SEPARATOR_PROPERTY , DEFAULT_NAMESPACE_SEPARATOR )
340+ self ._namespace_separator = unquote (separator_from_properties )
336341
337342 def _create_session (self ) -> Session :
338343 """Create a request session with provided catalog configuration."""
@@ -478,6 +483,16 @@ def _extract_optional_oauth_params(self) -> dict[str, str]:
478483
479484 return optional_oauth_param
480485
486+ def _encode_namespace_path (self , namespace : Identifier ) -> str :
487+ """
488+ Encode a namespace for use as a path parameter in a URL.
489+
490+ Each part of the namespace is URL-encoded using `urllib.parse.quote`
491+ (ensuring characters like '/' are encoded) and then joined by the
492+ configured namespace separator.
493+ """
494+ return self ._namespace_separator .join (quote (part , safe = "" ) for part in namespace )
495+
481496 def _fetch_config (self ) -> None :
482497 params = {}
483498 if warehouse_location := self .properties .get (WAREHOUSE_LOCATION ):
@@ -519,11 +534,19 @@ def _identifier_to_validated_tuple(self, identifier: str | Identifier) -> Identi
519534 def _split_identifier_for_path (
520535 self , identifier : str | Identifier | TableIdentifier , kind : IdentifierKind = IdentifierKind .TABLE
521536 ) -> Properties :
537+ from urllib .parse import quote
538+
522539 if isinstance (identifier , TableIdentifier ):
523- return {"namespace" : NAMESPACE_SEPARATOR .join (identifier .namespace .root ), kind .value : identifier .name }
540+ return {
541+ "namespace" : self ._encode_namespace_path (tuple (identifier .namespace .root )),
542+ kind .value : quote (identifier .name , safe = "" ),
543+ }
524544 identifier_tuple = self ._identifier_to_validated_tuple (identifier )
525545
526- return {"namespace" : NAMESPACE_SEPARATOR .join (identifier_tuple [:- 1 ]), kind .value : identifier_tuple [- 1 ]}
546+ return {
547+ "namespace" : self ._encode_namespace_path (identifier_tuple [:- 1 ]),
548+ kind .value : quote (identifier_tuple [- 1 ], safe = "" ),
549+ }
527550
528551 def _split_identifier_for_json (self , identifier : str | Identifier ) -> dict [str , Identifier | str ]:
529552 identifier_tuple = self ._identifier_to_validated_tuple (identifier )
@@ -741,7 +764,7 @@ def register_table(self, identifier: str | Identifier, metadata_location: str) -
741764 def list_tables (self , namespace : str | Identifier ) -> list [Identifier ]:
742765 self ._check_endpoint (Capability .V1_LIST_TABLES )
743766 namespace_tuple = self ._check_valid_namespace_identifier (namespace )
744- namespace_concat = NAMESPACE_SEPARATOR . join (namespace_tuple )
767+ namespace_concat = self . _encode_namespace_path (namespace_tuple )
745768 response = self ._session .get (self .url (Endpoints .list_tables , namespace = namespace_concat ))
746769 try :
747770 response .raise_for_status ()
@@ -827,7 +850,7 @@ def list_views(self, namespace: str | Identifier) -> list[Identifier]:
827850 if Capability .V1_LIST_VIEWS not in self ._supported_endpoints :
828851 return []
829852 namespace_tuple = self ._check_valid_namespace_identifier (namespace )
830- namespace_concat = NAMESPACE_SEPARATOR . join (namespace_tuple )
853+ namespace_concat = self . _encode_namespace_path (namespace_tuple )
831854 response = self ._session .get (self .url (Endpoints .list_views , namespace = namespace_concat ))
832855 try :
833856 response .raise_for_status ()
@@ -897,7 +920,7 @@ def create_namespace(self, namespace: str | Identifier, properties: Properties =
897920 def drop_namespace (self , namespace : str | Identifier ) -> None :
898921 self ._check_endpoint (Capability .V1_DELETE_NAMESPACE )
899922 namespace_tuple = self ._check_valid_namespace_identifier (namespace )
900- namespace = NAMESPACE_SEPARATOR . join (namespace_tuple )
923+ namespace = self . _encode_namespace_path (namespace_tuple )
901924 response = self ._session .delete (self .url (Endpoints .drop_namespace , namespace = namespace ))
902925 try :
903926 response .raise_for_status ()
@@ -910,7 +933,7 @@ def list_namespaces(self, namespace: str | Identifier = ()) -> list[Identifier]:
910933 namespace_tuple = self .identifier_to_tuple (namespace )
911934 response = self ._session .get (
912935 self .url (
913- f"{ Endpoints .list_namespaces } ?parent={ NAMESPACE_SEPARATOR .join (namespace_tuple )} "
936+ f"{ Endpoints .list_namespaces } ?parent={ self . _namespace_separator .join (namespace_tuple )} "
914937 if namespace_tuple
915938 else Endpoints .list_namespaces
916939 ),
@@ -926,7 +949,7 @@ def list_namespaces(self, namespace: str | Identifier = ()) -> list[Identifier]:
926949 def load_namespace_properties (self , namespace : str | Identifier ) -> Properties :
927950 self ._check_endpoint (Capability .V1_LOAD_NAMESPACE )
928951 namespace_tuple = self ._check_valid_namespace_identifier (namespace )
929- namespace = NAMESPACE_SEPARATOR . join (namespace_tuple )
952+ namespace = self . _encode_namespace_path (namespace_tuple )
930953 response = self ._session .get (self .url (Endpoints .load_namespace_metadata , namespace = namespace ))
931954 try :
932955 response .raise_for_status ()
@@ -941,7 +964,7 @@ def update_namespace_properties(
941964 ) -> PropertiesUpdateSummary :
942965 self ._check_endpoint (Capability .V1_UPDATE_NAMESPACE )
943966 namespace_tuple = self ._check_valid_namespace_identifier (namespace )
944- namespace = NAMESPACE_SEPARATOR . join (namespace_tuple )
967+ namespace = self . _encode_namespace_path (namespace_tuple )
945968 payload = {"removals" : list (removals or []), "updates" : updates }
946969 response = self ._session .post (self .url (Endpoints .update_namespace_properties , namespace = namespace ), json = payload )
947970 try :
@@ -958,7 +981,8 @@ def update_namespace_properties(
958981 @retry (** _RETRY_ARGS )
959982 def namespace_exists (self , namespace : str | Identifier ) -> bool :
960983 namespace_tuple = self ._check_valid_namespace_identifier (namespace )
961- namespace = NAMESPACE_SEPARATOR .join (namespace_tuple )
984+ namespace = self ._encode_namespace_path (namespace_tuple )
985+
962986 # fallback in order to work with older rest catalog implementations
963987 if Capability .V1_NAMESPACE_EXISTS not in self ._supported_endpoints :
964988 try :
0 commit comments