Storage
Files
databaas_utils.generic
Blob storage under a Lakekeeper generic table, without S3 credentials.
Every request is signed by Lakekeeper's own signer: the client asks the catalog to sign a method/URI/header triple and then issues the signed request itself, so no long-lived S3 key ever reaches the task.
The credentialed fallback from the prior art is deliberately absent. A fallback that quietly works without signing is how a deployment ends up on it without anyone deciding to.
BlobStore
Bases: Protocol
Opaque bytes under a named table.
Implemented by :class:GenericTableStore (remote-signed storage) and
:class:LocalStore (a directory), so code written against this runs on
either.
put
put(table: str, key: str, source: Path) -> str
Store the local file source as key; return its URI.
get
get(table: str, key: str, dest: Path) -> Path
Download key to dest and return dest.
uri
uri(table: str, key: str) -> str
The URI key has (or would have) under table.
GenericTableStore
dataclass
GenericTableStore(
settings: LakehouseSettings,
client: CatalogClient | None = None,
_tables: dict[str, ResolvedTable] = dict(),
_stores: dict[tuple[str, str], Any] = dict(),
_lock: Lock = threading.Lock(),
)
Bases: BlobStore
Blob access under generic tables, signed rather than credentialed.
Caches are instance-scoped on purpose. settings varies by namespace across
call sites, so a module-level cache keyed only on table name would collide
across namespaces and branch deploys.
Needs the obstore extra. Tables are created on first write.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
LakehouseSettings
|
Which warehouse and namespace, as which identity. |
required |
client
|
CatalogClient | None
|
A |
None
|
Example
store = GenericTableStore(settings) store.put("grib", "2026/09/30/run.grib2", Path("run.grib2")) 's3://.../2026/09/30/run.grib2' for meta in store.list("grib", prefix="2026/09"): ... print(meta["path"], meta["size"])
table
table(
table: str,
*,
create: bool = True,
doc: str = "Generic table",
) -> ResolvedTable
The table's location and signing details. Cached per instance.
object_store
object_store(
table: str, *, prefix: str = "", create: bool = True
) -> RemoteSignedS3Store
The raw obstore handle, for callers that need more than put/get.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
table
|
str
|
Generic table name. |
required |
prefix
|
str
|
Restrict the handle to keys under this prefix. |
''
|
create
|
bool
|
Create the table if missing. |
True
|
uri
uri(table: str, key: str) -> str
The s3:// URI of key under table.
put
put(table: str, key: str, source: Path) -> str
Upload a local file under the table; returns its s3:// URI.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
table
|
str
|
Generic table name; created if missing. |
required |
key
|
str
|
Object key within the table. |
required |
source
|
Path
|
Local file to upload. |
required |
get
get(table: str, key: str, dest: Path) -> Path
Stream one object under the table to dest.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
table
|
str
|
Generic table name; it must already exist. |
required |
key
|
str
|
Object key within the table. |
required |
dest
|
Path
|
Local path to write; parent directories are created. |
required |
Returns:
| Type | Description |
|---|---|
Path
|
|
open
open(table: str, key: str) -> Any
A readable file object, without staging to disk first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
table
|
str
|
Generic table name; it must already exist. |
required |
key
|
str
|
Object key within the table. |
required |
list
list(table: str, *, prefix: str = '') -> Iterator[Any]
Object metadata under the table.
Lakekeeper records only a location for a generic table, so listing is the only way to learn what actually landed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
table
|
str
|
Generic table name; it must already exist. |
required |
prefix
|
str
|
Only list keys under this prefix. |
''
|
Yields:
| Type | Description |
|---|---|
Any
|
obstore object-metadata dicts ( |
delete
delete(table: str, key: str) -> None
Delete one object under the table.
LocalStore
dataclass
LocalStore(root: Path)
Bases: BlobStore
A filesystem stand-in for laptop runs and tests. Never a silent fallback.
Files live at <root>/<table>/<key>.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
root
|
Path
|
Directory that plays the role of the warehouse. |
required |
uri
uri(table: str, key: str) -> str
The filesystem path of key under table.
put
put(table: str, key: str, source: Path) -> str
Copy source to <root>/<table>/<key>; return that path.
get
get(table: str, key: str, dest: Path) -> Path
Copy <root>/<table>/<key> to dest and return dest.
ResolvedTable
dataclass
ResolvedTable(
location: str,
signer_uri: str,
region: str,
endpoint: str,
)
A generic table's location plus what is needed to sign requests under it.
Attributes:
| Name | Type | Description |
|---|---|---|
location |
str
|
The table's |
signer_uri |
str
|
The full URL of Lakekeeper's signing endpoint. |
region |
str
|
Storage region reported by Lakekeeper. |
endpoint |
str
|
S3 endpoint, or |
resolve
resolve(
settings: LakehouseSettings,
table: str,
*,
create: bool = True,
doc: str = "Generic table",
client: CatalogClient | None = None,
) -> ResolvedTable
Look up settings.namespace.table, creating it if asked.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
LakehouseSettings
|
Which warehouse and namespace, as which identity. |
required |
table
|
str
|
Generic table name. |
required |
create
|
bool
|
Create the table (and namespace) if missing. With |
True
|
doc
|
str
|
Description recorded on a newly created table. |
'Generic table'
|
client
|
CatalogClient | None
|
A |
None
|
Returns:
| Type | Description |
|---|---|
ResolvedTable
|
The table's location and signing details. |
Raises:
| Type | Description |
|---|---|
NotFoundError
|
The table is missing and |
LakehouseError
|
Lakekeeper did not advertise a signer for the table. |
signed_store
signed_store(
settings: LakehouseSettings,
resolved: ResolvedTable,
*,
prefix: str = "",
session: Session | None = None,
client_options: Any = None,
) -> RemoteSignedS3Store
An obstore store over resolved, signing each request as it goes out.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
LakehouseSettings
|
Supplies the bearer used to call Lakekeeper's signer. |
required |
resolved
|
ResolvedTable
|
The table to open, from :func: |
required |
prefix
|
str
|
Restrict the store to keys under this prefix. |
''
|
session
|
Session | None
|
Pin all signing calls to one session (tests only). |
None
|
client_options
|
Any
|
Extra obstore client options. |
None
|
Raises:
| Type | Description |
|---|---|
RemoteSigningUnavailable
|
If |
remote_signing_available
remote_signing_available() -> bool
Whether this environment can sign generic-table requests.
A capability probe, not a version check: it asks whether the class and method exist rather than which distribution or release provided them.
Zarr
databaas_utils.zarr
Zarr groups backed by a Lakekeeper generic table.
Iceberg models tables; numerical weather output is multidimensional gridded
arrays, so the payload itself is written as zarr into a generic table and the
Iceberg side only records what landed (see databaas_utils.index).
This is the same signed object store GenericTableStore uses — zarr just gets a
zarr.storage.ObjectStore wrapper around it instead of put/get. It lives in its
own module because zarr is the heaviest optional dependency here and nothing
else needs it.
Install plain zarr, never zarr[remote]. That extra depends on upstream
obstore, which shares a top-level module name with obstore-databaas and
would silently overwrite it — taking RemoteSignedS3Store, and every signed
write, with it. The object store is supplied here, so the extra buys nothing.
open_zarr_group
open_zarr_group(
settings: LakehouseSettings,
table: str,
*,
path: str = "",
read_only: bool = False,
create: bool = True,
doc: str = "Zarr store",
client: CatalogClient | None = None,
) -> Group
Open a zarr group stored under settings.namespace.table.
Reads and writes are remote-signed by Lakekeeper; no S3 credentials are
involved. create is ignored when read_only is set — opening a reader
should never create a table as a side effect. Needs the zarr extra.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
LakehouseSettings
|
Which warehouse and namespace, as which identity. |
required |
table
|
str
|
Generic table holding the zarr store; created if missing when
|
required |
path
|
str
|
Group path within the store. |
''
|
read_only
|
bool
|
Open for reading only. |
False
|
create
|
bool
|
Create the table and group if missing. |
True
|
doc
|
str
|
Description recorded on a newly created table. |
'Zarr store'
|
client
|
CatalogClient | None
|
A |
None
|
Returns:
| Type | Description |
|---|---|
Group
|
A |
Example
group = open_zarr_group(settings, "harmonie_zarr", read_only=True) list(group.keys())
local_zarr_group
local_zarr_group(
root: str | Path,
table: str,
*,
path: str = "",
read_only: bool = False,
create: bool = True,
) -> Group
A zarr group on the local filesystem, for laptop runs and tests.
Explicit, never a fallback: a missing catalog connection raises rather than silently redirecting a production write to an ephemeral pod disk.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
root
|
str | Path
|
Directory that plays the role of the warehouse. |
required |
table
|
str
|
Subdirectory holding the zarr store. |
required |
path
|
str
|
Group path within the store. |
''
|
read_only
|
bool
|
Open for reading only. |
False
|
create
|
bool
|
Create the directory and group if missing. |
True
|
File index
databaas_utils.index
An Iceberg index of what actually landed in the generic tables.
Lakekeeper records only a location for a generic table — no file list — so neither the catalog nor the console can say which objects exist under one. This table is that record.
The schema deliberately carries no source-format knowledge: product,
run_date, cycle and forecast_hour are all optional, and anything else goes
in attributes. The prior art required product and forecast_hour, which is
numerical-weather-prediction knowledge that a package indexing invoices should
not have to satisfy.
Index
Bases: Protocol
Something that records ingested files and knows the newest run.
Implemented by :class:FileIndex and :class:NullIndex.
record
record(*records: FileRecord) -> None
Record that these files landed.
latest
latest() -> str | None
The newest run_date + cycle recorded, or None.
FileIndex
FileIndex(
settings: LakehouseSettings,
table: str = DEFAULT_INDEX_TABLE,
*,
catalog: Any = None,
client: CatalogClient | None = None,
)
Bases: Index
Appends rows to <namespace>.<table>, creating the table on first use.
Needs the iceberg extra.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
LakehouseSettings
|
Which warehouse and namespace, as which identity. |
required |
table
|
str
|
Index table name. |
DEFAULT_INDEX_TABLE
|
catalog
|
Any
|
A pyiceberg catalog to use instead of building one. |
None
|
client
|
CatalogClient | None
|
A |
None
|
Example
index = FileIndex(settings) index.record(FileRecord(uri=uri, key="run.grib2", size_bytes=1024)) index.latest() '2026093000'
identifier
property
identifier: str
The Iceberg identifier, <namespace>.<table>.
catalog
property
catalog: Any
The pyiceberg catalog, built on first use.
record
record(*records: FileRecord) -> None
Append records as one commit, retrying if a parallel task wins.
Raises:
| Type | Description |
|---|---|
CommitFailedException
|
After five conflicting attempts. |
latest
latest() -> str | None
The newest run_date || cycle, or None if there is no index yet.
Three outcomes, kept distinct on purpose: no table means nothing was ever ingested (None); a table with no run identity means the same (None); a table that exists but cannot be read is a real failure and raises — it must not look like "nothing ingested" to a caller deciding whether to re-ingest.
scan
scan(**kwargs: Any) -> pa.Table
The index as an Arrow table. Raises if the table does not exist.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
**kwargs
|
Any
|
Passed to pyiceberg's |
{}
|
NullIndex
dataclass
NullIndex()
Bases: Index
No catalog to index into (laptop runs) — just say what would happen.
record
record(*records: FileRecord) -> None
Log what would have been indexed.
latest
latest() -> str | None
Always None.
FileRecord
dataclass
FileRecord(
uri: str,
key: str,
size_bytes: int,
product: str | None = None,
run_date: str | None = None,
cycle: str | None = None,
forecast_hour: int | None = None,
attributes: dict[str, str] = dict(),
ingested_at: datetime = (lambda: datetime.now(UTC))(),
)
One stored object, as the index describes it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
uri
|
str
|
The object's full URI. |
required |
key
|
str
|
Its key within the generic table. |
required |
size_bytes
|
int
|
Size in bytes. |
required |
product
|
str | None
|
Optional product/dataset name. |
None
|
run_date
|
str | None
|
Optional run date, |
None
|
cycle
|
str | None
|
Optional cycle within the run date, e.g. |
None
|
forecast_hour
|
int | None
|
Optional forecast lead time in hours. |
None
|
attributes
|
dict[str, str]
|
Free-form string metadata. |
dict()
|
ingested_at
|
datetime
|
When it landed; defaults to now (UTC). |
(lambda: now(UTC))()
|
as_row
as_row() -> dict[str, Any]
The record as a column-name to value mapping, matching :func:arrow_schema.
Deployment scoping
databaas_utils.scoping
Naming a thing per deployment, so a branch run cannot write production data.
CI prefixes dev dag_ids so two deployments coexist in one Airflow. That is not enough on its own: without scoping the data too, a branch run writes the production tables and moves the production cursor.
This module is deliberately Airflow-free and knows nothing about
databaas_target. The dependency runs the other way — databaas_utils.airflow.
scoping resolves the slug from the calling DAG file and passes it in. That
inversion is what lets this code live in site-packages at all: the prior art
called databaas_target from library code, and its caller-frame lookup then
resolved against the library's own directory rather than the DAG's.
scoped
scoped(
name: str, *, slug: str | _Unset | None = UNSET
) -> str
name in production, name_dev_<slug> on a branch deployment.
Suffixed rather than prefixed so the production name stays the sort-order
prefix of its branches, and so a bare name is always the real one.
In a DAG use databaas_utils.airflow.scoped, which finds the slug from
the deployment for you.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The production name, e.g. a table or namespace. |
required |
slug
|
str | _Unset | None
|
Override the slug; |
UNSET
|
Example
set_default_slug("add-login") scoped("daily_summary") 'daily_summary_dev_add_login'
set_default_slug
set_default_slug(value: str | None) -> None
Set the process-wide slug.
For containers and notebooks, where there is one deployment per process by
construction. Not for Airflow: the dag-processor parses production and
dev bundles in one process, so a process-wide slug would leak a branch's
suffix into production table names — the data-layer twin of the dag-id
incident that databaas_target exists to prevent.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
str | None
|
The slug, or |
required |
normalise
normalise(key: str) -> str
Lower-case key and squash anything unsafe to single underscores.
normalise("Feature/Add-Login") 'feature_add_login'