@@ -240,24 +240,28 @@ def pending_lookups(self) -> list[SecretLookupEntry]:
240240 """Return the list of pending secret lookups."""
241241 return list (self ._pending_lookups )
242242
243- def to_dict (self ) -> dict [ str , dict [ str , pa . Scalar [ Any ]]] :
244- """Return all resolved secrets as a flat dict keyed by secret_type .
243+ def to_dict (self ) -> "ResolvedSecrets" :
244+ """Return all resolved secrets keyed by secret name .
245245
246- Combines unscoped entries (column name = secret_type) with scoped
247- entries (``secret_N`` columns, keyed by ``secret_type`` from Arrow
248- field metadata). Null/unresolved entries are omitted.
246+ Resolved secrets are keyed by their unique DuckDB secret name, so several
247+ secrets of the same type (e.g. one per S3 bucket) coexist. Each carries a
248+ ``type`` field (the DuckDB secret type) and a ``scope`` field
249+ (newline-joined scope prefixes). Scoped ``secret_N`` columns (keyed by
250+ ``secret_type`` from Arrow field metadata) are merged in. Null/unresolved
251+ entries are omitted.
249252
250253 Returns:
251- Mapping of secret_type to its resolved key/scalar dict.
254+ A :class:`ResolvedSecrets` (a dict keyed by secret name) with
255+ type- and scope-aware selection helpers.
252256
253257 """
254258 result = dict (self ._unscoped )
255259 for meta , secret_dict in self ._scoped :
256260 if secret_dict is not None :
257- key = meta .get ("secret_type" , "" )
261+ key = meta .get ("secret_name" ) or meta . get ( " secret_type" , "" )
258262 if key :
259263 result [key ] = secret_dict
260- return result
264+ return ResolvedSecrets ( result )
261265
262266 def _find_scoped (
263267 self ,
@@ -287,6 +291,74 @@ def _find_scoped(
287291 return None
288292
289293
294+ def _secret_scalar_str (v : Any ) -> str :
295+ """Render a resolved-secret field (a pyarrow Scalar or plain value) to str."""
296+ if v is None :
297+ return ""
298+ py = v .as_py () if hasattr (v , "as_py" ) else v
299+ return "" if py is None else str (py )
300+
301+
302+ class ResolvedSecrets (dict ):
303+ """Resolved secrets keyed by secret name, with type- and scope-aware lookup.
304+
305+ A plain ``dict`` (so ``secrets[name]`` and ``secrets.get(name)`` still work)
306+ plus selectors that read each secret's connector-serialized ``type`` and
307+ ``scope`` fields. Mirrors ``vgi::Secrets`` in the Rust SDK.
308+ """
309+
310+ def secret_type (self , name : str ) -> str | None :
311+ """The DuckDB secret type of the named secret (its ``type`` field)."""
312+ fields = self .get (name )
313+ if not fields or "type" not in fields :
314+ return None
315+ return _secret_scalar_str (fields ["type" ])
316+
317+ def of_type (self , secret_type : str ) -> list [dict [str , Any ]]:
318+ """Every resolved secret whose ``type`` field matches ``secret_type``."""
319+ return [
320+ f for f in self .values () if _secret_scalar_str (f .get ("type" )) == secret_type
321+ ]
322+
323+ def for_scope (self , path : str ) -> dict [str , Any ] | None :
324+ """The secret whose ``scope`` is the longest prefix of ``path``.
325+
326+ The connector serializes each secret's scope as a newline-joined list of
327+ prefixes; a secret with no (or empty) scope matches as a last-resort
328+ fallback. Returns ``None`` only when there are no candidate secrets.
329+ """
330+ return self ._select_for_scope (path , None )
331+
332+ def for_scope_of_type (self , path : str , secret_type : str ) -> dict [str , Any ] | None :
333+ """Like :meth:`for_scope` but only over secrets of ``secret_type``."""
334+ return self ._select_for_scope (path , secret_type )
335+
336+ def field_for (self , path : str , field : str ) -> Any | None :
337+ """A field of the best scope-matching secret for ``path``."""
338+ fields = self .for_scope (path )
339+ return None if fields is None else fields .get (field )
340+
341+ def _select_for_scope (
342+ self , path : str , secret_type : str | None
343+ ) -> dict [str , Any ] | None :
344+ best : dict [str , Any ] | None = None
345+ best_len = - 1
346+ fallback : dict [str , Any ] | None = None
347+ for fields in self .values ():
348+ if secret_type is not None and _secret_scalar_str (fields .get ("type" )) != secret_type :
349+ continue
350+ scope = _secret_scalar_str (fields .get ("scope" ))
351+ if not scope :
352+ if fallback is None :
353+ fallback = fields
354+ continue
355+ for prefix in scope .split ("\n " ):
356+ if prefix and path .startswith (prefix ) and len (prefix ) > best_len :
357+ best_len = len (prefix )
358+ best = fields
359+ return best if best is not None else fallback
360+
361+
290362def project_schema (projection_ids : list [int ] | None , schema : pa .Schema ) -> pa .Schema :
291363 """Create the projected schema if projection_ids are supplied.
292364
0 commit comments