containerio 2.4.0__tar.gz → 2.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: containerio
3
- Version: 2.4.0
3
+ Version: 2.5.0
4
4
  Summary: Azure Blob Storage handler with unified local/cloud interface
5
5
  Author: Alliance SwissPass
6
6
  Requires-Dist: polars>=1.30.0
@@ -20,7 +20,7 @@ Provides a unified interface for reading and writing data to both local filesyst
20
20
  ## Features
21
21
 
22
22
  - **Unified API**: Same code works for local files and Azure Blob Storage
23
- - **Flexible configuration**: Load Azure config from a dotenv file, from existing environment variables, or directly from strings (e.g. values from a TOML file)
23
+ - **Flexible configuration**: Load Azure config from a dotenv file, environment variables, strings, or a `container-config.toml` with direnv-populated secrets (see [TOML + direnv configuration](#toml--direnv-configuration))
24
24
  - **[Polars](https://pola.rs/) integration**: Native support for Polars DataFrames and LazyFrames
25
25
  - **Multiple auth methods**: Device code flow (default), client credentials (CI/CD), or SAS tokens
26
26
  - **CLI tools**: Unified `io` command for listing, downloading, uploading, deleting blobs and generating SAS tokens
@@ -81,7 +81,9 @@ containerio = { git = "https://codefloe.com/Alliance-SwissPass/py-containerio.gi
81
81
  When installed as a dependency, containerio integrates seamlessly with your project:
82
82
 
83
83
  - **CLI tools are available** - Run `uv run io` directly from your project
84
- - **Dotenv is the default convention** - The CLI best-effort-loads `.env.<name>` matching `--env <name>` from your current working directory. Other formats are supported via a small wrapper script — see [Providing config to the CLI](#providing-config-to-the-cli).
84
+ - **Dotenv is the default convention** - The CLI best-effort-loads `.env.<name>` matching `--env <name>` from your current working directory.
85
+ - **Native TOML + direnv support** - Pass `--container-config` to resolve `--container` as a logical key from a `container-config.toml` with direnv-populated secrets — see [TOML + direnv configuration](#toml--direnv-configuration).
86
+ - **Other formats via a small wrapper script** - See [Providing config to the CLI](#providing-config-to-the-cli).
85
87
  - **No configuration in containerio needed** - Just add the dependency and configure in your project
86
88
 
87
89
  Example workflow in your project:
@@ -218,7 +220,109 @@ AZURE_CLIENT_SECRET_CONTAINER=<container_app_client_secret>
218
220
  AZURE_STORAGE_SAS_TOKEN_RAW_DATA_STG=<sas_token_for_staging_container>
219
221
  ```
220
222
 
221
- > **Note:** There is no registry of containers — you simply pass `--container <name>` (or `container="..."` in code) and the call is forwarded to Azure. Whether the container exists and what you can do with it is decided by Azure RBAC on your identity.
223
+ > **Note:** There is no registry of containers — you simply pass `--container <name>` (or `container="..."` in code) and the call is forwarded to Azure.
224
+ > Whether the container exists and what you can do with it is decided by Azure RBAC on your identity.
225
+
226
+ ### TOML + direnv configuration
227
+
228
+ For projects with several logical containers across environments, py-containerio supports a combined TOML + direnv approach (the Python-native equivalent of r-containerio's `containerio_config()`):
229
+
230
+ - Non-sensitive fields (storage account, container names, and identity IDs) live in a project-local `container-config.toml`, which is safe to commit.
231
+ - Sensitive values (client secrets) never appear in the TOML: `client_secret_env` names the environment variable that holds the secret, resolved from `os.environ` at load time.
232
+ - `tenant_id`, `client_id`, and `device_code_client_id` are resolved environment-first: their `*_env` keys name environment variables that win when set at runtime, and the TOML value is only the fallback.
233
+ - The environment is populated through direnv: a commit-safe `.envrc` that pulls values at runtime, e.g. from Azure Key Vault via the authenticated `az` CLI.
234
+ direnv only fills gaps (`${VAR:-...}`), so a value injected by CI (Crow `from_secret` becomes a plain environment variable) or an existing shell export stays authoritative.
235
+
236
+ Copy `container-config_example.toml` from this repository to `container-config.toml` and adapt it.
237
+ Each container maps an environment (`staging`, `prod`) to a concrete container name and an Azure auth configuration. Auth can be referenced in two ways:
238
+
239
+ - **Tag indirection** (shared auth across containers): `azure_auth.<env>` is a string referencing a shared `[azure_auth.<tag>]` section.
240
+ - **Inline nested auth** (per-container): `azure_auth.<env>` is a nested table defining the auth fields directly under `[containers.<key>.azure_auth.<env>]`.
241
+
242
+ If the resolved auth spec defines no `storage_account`, the full configuration is read from environment variables via `AzureStorageConfig.from_env()` instead.
243
+
244
+ #### Example `.envrc` (direnv)
245
+
246
+ The `.envrc` is committed; the secrets it pulls are not. Secrets are fetched at shell-entry time with the already-authenticated `az` CLI:
247
+
248
+ ```bash
249
+ # Load the base tool environment (optional, e.g. uv/mise).
250
+ use flake
251
+
252
+ # Fetch a Key Vault secret into an env var at direnv load time.
253
+ # Requires a logged-in 'az' CLI with access to the vault.
254
+ azure_kv_secret() {
255
+ az keyvault secret show \
256
+ --vault-name "$1" \
257
+ --name "$2" \
258
+ --query value \
259
+ --output tsv
260
+ }
261
+
262
+ # Identity values live in a distinct vault from the application secrets.
263
+ # Env-first: only fetch when the variable is not already set, so CI
264
+ # (Crow from_secret -> plain env var) and existing exports win.
265
+ export AZURE_TENANT_ID="${AZURE_TENANT_ID:-$(azure_kv_secret <identity-vault-name> <tenant-id-secret-name>)}"
266
+ export AZURE_CLIENT_ID_CONTAINER="${AZURE_CLIENT_ID_CONTAINER:-$(azure_kv_secret <identity-vault-name> <client-id-secret-name>)}"
267
+ export AZURE_CLIENT_ID="${AZURE_CLIENT_ID:-$(azure_kv_secret <identity-vault-name> <device-code-client-id-secret-name>)}"
268
+
269
+ export AZURE_CLIENT_SECRET_CONTAINER_RAW_STAGING="${AZURE_CLIENT_SECRET_CONTAINER_RAW_STAGING:-$(azure_kv_secret <app-vault-name> <secret-name-staging>)}"
270
+ export AZURE_CLIENT_SECRET_CONTAINER_RAW_PROD="${AZURE_CLIENT_SECRET_CONTAINER_RAW_PROD:-$(azure_kv_secret <app-vault-name> <secret-name-prod>)}"
271
+
272
+ # SAS tokens for containers are read by the handler from env vars named
273
+ # AZURE_STORAGE_SAS_TOKEN_<CONTAINER> (dashes -> underscores) and can be
274
+ # populated the same way:
275
+ export AZURE_STORAGE_SAS_TOKEN_RAW_DATA="${AZURE_STORAGE_SAS_TOKEN_RAW_DATA:-$(azure_kv_secret <app-vault-name> <sas-secret-name>)}"
276
+
277
+ # ARM_SUBSCRIPTION_ID is not used by py-containerio; never hardcode it here.
278
+ ```
279
+
280
+ Allow the file once with `direnv allow`.
281
+ See `examples/toml-direnv-demo/envrc.example` for a full template that fetches several values in parallel, classifies failures, and skips variables that are already set.
282
+
283
+ #### Library usage
284
+
285
+ ```python
286
+ from containerio import resolve_container_config, StorageHandler
287
+
288
+ # Resolve a logical key for an environment.
289
+ # Returns (container_name, AzureStorageConfig); both None for env="local".
290
+ container, config = resolve_container_config(
291
+ container_key="input1",
292
+ env="staging",
293
+ )
294
+
295
+ handler = StorageHandler(container=container, config=config)
296
+ ```
297
+
298
+ Errors mirror the R wrapper: unknown container key, missing auth for the selected env, unknown auth tag, missing container name, and a malformed `name`/`azure_auth` table all raise `ValueError`.
299
+ A missing TOML file raises `FileNotFoundError`.
300
+ An unset `client_secret_env` does not raise at resolve time: the secret resolves to `None` and only client-credentials auth reports it, when that method is actually used, so device-code login still works.
301
+
302
+ #### CLI usage
303
+
304
+ The `io` command consumes the combined TOML + direnv config directly.
305
+ Pass `--container-config` (optionally with a path, default `container-config.toml` in the current directory) and select the container by its logical key.
306
+ `--env` is required and selects the `name.<env>` / `azure_auth.<env>` entries:
307
+
308
+ ```bash
309
+ # List blobs in the container mapped from logical key 'input1' for staging
310
+ uv run io ls --env staging --container input1 --container-config
311
+
312
+ # Explicit TOML path
313
+ uv run io ls --env prod --container input1 --container-config path/to/container-config.toml
314
+
315
+ # All other commands work the same way, e.g.
316
+ uv run io download data/file.csv --env prod --container output --container-config
317
+ uv run io auth --env staging --container input1 --container-config
318
+ ```
319
+
320
+ With `--container-config`, the `--container` value is always interpreted as a logical key under `[containers]`.
321
+ The `io session` command stores both the logical key and the TOML path, so a saved session keeps resolving through the TOML on later runs.
322
+
323
+ > **Note:** When using `--container-config`, pass `--env` explicitly.
324
+ >
325
+ > The dotenv preload of the default CLI flow is unaffected and keeps working; secrets reach the process through direnv or plain exports.
222
326
 
223
327
  ### Authentication
224
328
 
@@ -272,14 +376,18 @@ The chosen auth method is saved to the session. Subsequent commands (`ls`, `down
272
376
 
273
377
  ## CLI Tools
274
378
 
275
- The package provides a unified `io` command with subcommands for all storage operations. All commands accept `--env prod|staging` (selects the env file) and `--container <name>` (selects the Azure container).
379
+ The package provides a unified `io` command with subcommands for all storage operations.
380
+ All commands accept `--env prod|staging` (selects the env file) and `--container <name>` (selects the Azure container).
381
+ `--container-config` resolves `--container` as a logical key from a `container-config.toml` (see [TOML + direnv configuration](#toml--direnv-configuration)).
276
382
 
277
383
  | Command | Usage | Description |
278
384
  |---------|-------|-------------|
279
385
  | `auth` | `uv run io auth [login\|status\|clear] [--method M] [--force]` | Manage authentication |
280
386
  | `ls` | `uv run io ls --env <env> --container <name>` | List blobs in a container |
281
387
  | `download` | `uv run io download <blob_name> --env <env> --container <name>` | Download a blob to local `data/` directory |
388
+ | `download-dir` | `uv run io download-dir [<prefix>] [--local-dir <path>] --env <env> --container <name>` | Recursively download all blobs under a prefix to a local directory |
282
389
  | `upload` | `uv run io upload <blob_name> <file_path> --env <env> --container <name>` | Upload a local file to a container |
390
+ | `upload-dir` | `uv run io upload-dir <local_dir> [--blob-prefix <prefix>] --env <env> --container <name>` | Upload all files from a local directory to storage |
283
391
  | `rm` | `uv run io rm <path> --env <env> --container <name>` | Delete a blob or folder from a container |
284
392
  | `mv` | `uv run io mv <source> <dest> [--overwrite] --env <env> --container <name>` | Move (rename) a blob within a container |
285
393
  | `generate-sas` | `uv run io generate-sas --env <env> --container <name> [--days N] [--update-env] [--write]` | Generate an Azure Storage SAS token for a container (read+list by default; add `--write` for write+delete) |
@@ -369,23 +477,29 @@ The session is also available programmatically via the `Session` class:
369
477
  from containerio.session import Session
370
478
 
371
479
  session = Session()
372
- print(session.auth_method) # e.g. 'client-credentials'
373
- print(session.env) # e.g. 'staging'
374
- print(session.container) # e.g. 'raw-data'
375
- print(session.is_active) # True if any session data exists
480
+ print(session.auth_method) # e.g. 'client-credentials'
481
+ print(session.env) # e.g. 'staging'
482
+ print(session.container) # e.g. 'raw-data'
483
+ print(session.container_config) # e.g. 'container-config.toml', or None
484
+ print(session.is_active) # True if any session data exists
376
485
  ```
377
486
 
378
487
  CLI options:
379
488
 
380
- - `--env <name>`: selects the environment. The CLI best-effort-preloads `.env.<name>` (no-op if missing) and uses the name as a label in prints, sessions, and SAS file write-back. Any name works (`prod`, `staging`, `dev`, `eu-prod`, …); `local` means the local filesystem.
381
- - `--container`: the Azure container name to target
489
+ - `--env <name>`: selects the environment. The CLI best-effort-preloads `.env.<name>` (no-op if missing) and uses the name as a label in prints, sessions, and SAS file write-back.
490
+ Any name works (`prod`, `staging`, `dev`, `eu-prod`, …); `local` means the local filesystem.
491
+ - `--container`: the Azure container name to target. With `--container-config`, this is a logical key under `[containers]` in the TOML file.
492
+ - `--container-config [path]`: resolve `--container` as a logical key from a `container-config.toml` (default: `container-config.toml` in the current directory).
493
+ Secrets come from the environment (e.g. via direnv). See [TOML + direnv configuration](#toml--direnv-configuration).
382
494
  - `--days`: SAS token validity in days, max 7 (default: 1, `generate-sas` only)
383
495
  - `--update-env`: write SAS token to env file instead of printing (`generate-sas` only)
384
496
  - `--write`: opt in to write+delete permissions (`generate-sas` only). Default is read+list.
385
497
 
386
498
  **Note:** SAS tokens are generated using a *user delegation key* which has a maximum validity of **7 days** (Azure limitation). See [Microsoft documentation](https://learn.microsoft.com/en-us/rest/api/storageservices/get-user-delegation-key#request-body) for details.
387
499
 
388
- The token's effective permissions are the **intersection** of the permissions requested here and the Azure RBAC permissions held by your identity (the user-delegation-key requester) at generation time — so requesting `--write` only produces a write-capable token if your RBAC role allows it. See [Microsoft documentation on user delegation SAS](https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas#permissions-for-a-user-delegation-sas) for details.
500
+ The token's effective permissions are the **intersection** of the permissions requested here and the Azure RBAC permissions held by your identity (the user-delegation-key requester) at generation time.
501
+ This means requesting `--write` only produces a write-capable token if your RBAC role allows it.
502
+ See [Microsoft documentation on user delegation SAS](https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas#permissions-for-a-user-delegation-sas) for details.
389
503
 
390
504
  ## API Reference
391
505
 
@@ -445,9 +559,9 @@ StorageHandler(
445
559
  ### AzureStorageConfig
446
560
 
447
561
  Configuration dataclass for Azure storage settings.
448
- Fields: `storage_account`, `tenant_id` (optional), `dotenv_path` (optional — the file the config was loaded from; useful in logs and `repr`), `client_id` (optional), `client_secret` (optional) and `device_code_client_id` (optional).
562
+ Fields: `storage_account`, `tenant_id` (optional), `dotenv_path` (optional), `client_id` (optional), `client_secret` (optional), `device_code_client_id` (optional).
563
+ `dotenv_path` is the file the config was loaded from; it is useful in logs and `repr`.
449
564
  `storage_endpoint` is a derived property — `https://<storage_account>.blob.core.windows.net`.
450
-
451
565
  The config is the single carrier of account and identity data: the handler reads these fields and never reads environment variables for them itself.
452
566
  `from_env()` is the one seam that maps env vars onto the config (`AZURE_CLIENT_ID_CONTAINER` → `client_id`, `AZURE_CLIENT_SECRET_CONTAINER` → `client_secret`, `AZURE_CLIENT_ID` → `device_code_client_id`).
453
567
  The lone exception is the SAS token, which is container-scoped and therefore resolved by the handler from `AZURE_STORAGE_SAS_TOKEN_<NAME>` at use time.
@@ -509,8 +623,8 @@ Errors:
509
623
 
510
624
  ### Providing config to the CLI
511
625
 
512
- The CLI reads required Azure variables from `os.environ` — it does not parse any specific config format itself.
513
- This keeps the CLI format-agnostic and lets each project choose how to manage config.
626
+ The CLI reads required Azure variables from `os.environ` — by default it does not parse any specific config format itself, keeping it format-agnostic so each project can choose how to manage config.
627
+ For the native combined TOML + direnv approach, pass `--container-config` (see [TOML + direnv configuration](#toml--direnv-configuration)); the rest of this section covers the default dotenv / env-var flow.
514
628
 
515
629
  **Dotenv is the default.**
516
630
  If a `.env.<name>` file matching `--env <name>` exists, the CLI auto-loads it via `python-dotenv` before reading `os.environ`.
@@ -623,6 +737,7 @@ The `.gitsv/config.yaml` file defines how versions are bumped based on commit me
623
737
  4. **Release**: The CI pipeline automatically handles tagging and publishing
624
738
 
625
739
  The CI pipeline will automatically:
740
+
626
741
  - Bump the version based on commit history
627
742
  - Generate and pin the changelog as a Git issue
628
743
  - Build and publish the package to Forgejo
@@ -641,6 +756,7 @@ The repository includes a Crow CI pipeline that automatically builds, tests, and
641
756
  1. `git sv tag`: generate tag with version and push it to remote in one go, based on git commit parsing.
642
757
 
643
758
  The pipeline will:
759
+
644
760
  - Run linting and tests
645
761
  - Update the version in `pyproject.toml` automatically
646
762
  - Generate changelog from git history using `git-sv`
@@ -6,7 +6,7 @@ Provides a unified interface for reading and writing data to both local filesyst
6
6
  ## Features
7
7
 
8
8
  - **Unified API**: Same code works for local files and Azure Blob Storage
9
- - **Flexible configuration**: Load Azure config from a dotenv file, from existing environment variables, or directly from strings (e.g. values from a TOML file)
9
+ - **Flexible configuration**: Load Azure config from a dotenv file, environment variables, strings, or a `container-config.toml` with direnv-populated secrets (see [TOML + direnv configuration](#toml--direnv-configuration))
10
10
  - **[Polars](https://pola.rs/) integration**: Native support for Polars DataFrames and LazyFrames
11
11
  - **Multiple auth methods**: Device code flow (default), client credentials (CI/CD), or SAS tokens
12
12
  - **CLI tools**: Unified `io` command for listing, downloading, uploading, deleting blobs and generating SAS tokens
@@ -67,7 +67,9 @@ containerio = { git = "https://codefloe.com/Alliance-SwissPass/py-containerio.gi
67
67
  When installed as a dependency, containerio integrates seamlessly with your project:
68
68
 
69
69
  - **CLI tools are available** - Run `uv run io` directly from your project
70
- - **Dotenv is the default convention** - The CLI best-effort-loads `.env.<name>` matching `--env <name>` from your current working directory. Other formats are supported via a small wrapper script — see [Providing config to the CLI](#providing-config-to-the-cli).
70
+ - **Dotenv is the default convention** - The CLI best-effort-loads `.env.<name>` matching `--env <name>` from your current working directory.
71
+ - **Native TOML + direnv support** - Pass `--container-config` to resolve `--container` as a logical key from a `container-config.toml` with direnv-populated secrets — see [TOML + direnv configuration](#toml--direnv-configuration).
72
+ - **Other formats via a small wrapper script** - See [Providing config to the CLI](#providing-config-to-the-cli).
71
73
  - **No configuration in containerio needed** - Just add the dependency and configure in your project
72
74
 
73
75
  Example workflow in your project:
@@ -204,7 +206,109 @@ AZURE_CLIENT_SECRET_CONTAINER=<container_app_client_secret>
204
206
  AZURE_STORAGE_SAS_TOKEN_RAW_DATA_STG=<sas_token_for_staging_container>
205
207
  ```
206
208
 
207
- > **Note:** There is no registry of containers — you simply pass `--container <name>` (or `container="..."` in code) and the call is forwarded to Azure. Whether the container exists and what you can do with it is decided by Azure RBAC on your identity.
209
+ > **Note:** There is no registry of containers — you simply pass `--container <name>` (or `container="..."` in code) and the call is forwarded to Azure.
210
+ > Whether the container exists and what you can do with it is decided by Azure RBAC on your identity.
211
+
212
+ ### TOML + direnv configuration
213
+
214
+ For projects with several logical containers across environments, py-containerio supports a combined TOML + direnv approach (the Python-native equivalent of r-containerio's `containerio_config()`):
215
+
216
+ - Non-sensitive fields (storage account, container names, and identity IDs) live in a project-local `container-config.toml`, which is safe to commit.
217
+ - Sensitive values (client secrets) never appear in the TOML: `client_secret_env` names the environment variable that holds the secret, resolved from `os.environ` at load time.
218
+ - `tenant_id`, `client_id`, and `device_code_client_id` are resolved environment-first: their `*_env` keys name environment variables that win when set at runtime, and the TOML value is only the fallback.
219
+ - The environment is populated through direnv: a commit-safe `.envrc` that pulls values at runtime, e.g. from Azure Key Vault via the authenticated `az` CLI.
220
+ direnv only fills gaps (`${VAR:-...}`), so a value injected by CI (Crow `from_secret` becomes a plain environment variable) or an existing shell export stays authoritative.
221
+
222
+ Copy `container-config_example.toml` from this repository to `container-config.toml` and adapt it.
223
+ Each container maps an environment (`staging`, `prod`) to a concrete container name and an Azure auth configuration. Auth can be referenced in two ways:
224
+
225
+ - **Tag indirection** (shared auth across containers): `azure_auth.<env>` is a string referencing a shared `[azure_auth.<tag>]` section.
226
+ - **Inline nested auth** (per-container): `azure_auth.<env>` is a nested table defining the auth fields directly under `[containers.<key>.azure_auth.<env>]`.
227
+
228
+ If the resolved auth spec defines no `storage_account`, the full configuration is read from environment variables via `AzureStorageConfig.from_env()` instead.
229
+
230
+ #### Example `.envrc` (direnv)
231
+
232
+ The `.envrc` is committed; the secrets it pulls are not. Secrets are fetched at shell-entry time with the already-authenticated `az` CLI:
233
+
234
+ ```bash
235
+ # Load the base tool environment (optional, e.g. uv/mise).
236
+ use flake
237
+
238
+ # Fetch a Key Vault secret into an env var at direnv load time.
239
+ # Requires a logged-in 'az' CLI with access to the vault.
240
+ azure_kv_secret() {
241
+ az keyvault secret show \
242
+ --vault-name "$1" \
243
+ --name "$2" \
244
+ --query value \
245
+ --output tsv
246
+ }
247
+
248
+ # Identity values live in a distinct vault from the application secrets.
249
+ # Env-first: only fetch when the variable is not already set, so CI
250
+ # (Crow from_secret -> plain env var) and existing exports win.
251
+ export AZURE_TENANT_ID="${AZURE_TENANT_ID:-$(azure_kv_secret <identity-vault-name> <tenant-id-secret-name>)}"
252
+ export AZURE_CLIENT_ID_CONTAINER="${AZURE_CLIENT_ID_CONTAINER:-$(azure_kv_secret <identity-vault-name> <client-id-secret-name>)}"
253
+ export AZURE_CLIENT_ID="${AZURE_CLIENT_ID:-$(azure_kv_secret <identity-vault-name> <device-code-client-id-secret-name>)}"
254
+
255
+ export AZURE_CLIENT_SECRET_CONTAINER_RAW_STAGING="${AZURE_CLIENT_SECRET_CONTAINER_RAW_STAGING:-$(azure_kv_secret <app-vault-name> <secret-name-staging>)}"
256
+ export AZURE_CLIENT_SECRET_CONTAINER_RAW_PROD="${AZURE_CLIENT_SECRET_CONTAINER_RAW_PROD:-$(azure_kv_secret <app-vault-name> <secret-name-prod>)}"
257
+
258
+ # SAS tokens for containers are read by the handler from env vars named
259
+ # AZURE_STORAGE_SAS_TOKEN_<CONTAINER> (dashes -> underscores) and can be
260
+ # populated the same way:
261
+ export AZURE_STORAGE_SAS_TOKEN_RAW_DATA="${AZURE_STORAGE_SAS_TOKEN_RAW_DATA:-$(azure_kv_secret <app-vault-name> <sas-secret-name>)}"
262
+
263
+ # ARM_SUBSCRIPTION_ID is not used by py-containerio; never hardcode it here.
264
+ ```
265
+
266
+ Allow the file once with `direnv allow`.
267
+ See `examples/toml-direnv-demo/envrc.example` for a full template that fetches several values in parallel, classifies failures, and skips variables that are already set.
268
+
269
+ #### Library usage
270
+
271
+ ```python
272
+ from containerio import resolve_container_config, StorageHandler
273
+
274
+ # Resolve a logical key for an environment.
275
+ # Returns (container_name, AzureStorageConfig); both None for env="local".
276
+ container, config = resolve_container_config(
277
+ container_key="input1",
278
+ env="staging",
279
+ )
280
+
281
+ handler = StorageHandler(container=container, config=config)
282
+ ```
283
+
284
+ Errors mirror the R wrapper: unknown container key, missing auth for the selected env, unknown auth tag, missing container name, and a malformed `name`/`azure_auth` table all raise `ValueError`.
285
+ A missing TOML file raises `FileNotFoundError`.
286
+ An unset `client_secret_env` does not raise at resolve time: the secret resolves to `None` and only client-credentials auth reports it, when that method is actually used, so device-code login still works.
287
+
288
+ #### CLI usage
289
+
290
+ The `io` command consumes the combined TOML + direnv config directly.
291
+ Pass `--container-config` (optionally with a path, default `container-config.toml` in the current directory) and select the container by its logical key.
292
+ `--env` is required and selects the `name.<env>` / `azure_auth.<env>` entries:
293
+
294
+ ```bash
295
+ # List blobs in the container mapped from logical key 'input1' for staging
296
+ uv run io ls --env staging --container input1 --container-config
297
+
298
+ # Explicit TOML path
299
+ uv run io ls --env prod --container input1 --container-config path/to/container-config.toml
300
+
301
+ # All other commands work the same way, e.g.
302
+ uv run io download data/file.csv --env prod --container output --container-config
303
+ uv run io auth --env staging --container input1 --container-config
304
+ ```
305
+
306
+ With `--container-config`, the `--container` value is always interpreted as a logical key under `[containers]`.
307
+ The `io session` command stores both the logical key and the TOML path, so a saved session keeps resolving through the TOML on later runs.
308
+
309
+ > **Note:** When using `--container-config`, pass `--env` explicitly.
310
+ >
311
+ > The dotenv preload of the default CLI flow is unaffected and keeps working; secrets reach the process through direnv or plain exports.
208
312
 
209
313
  ### Authentication
210
314
 
@@ -258,14 +362,18 @@ The chosen auth method is saved to the session. Subsequent commands (`ls`, `down
258
362
 
259
363
  ## CLI Tools
260
364
 
261
- The package provides a unified `io` command with subcommands for all storage operations. All commands accept `--env prod|staging` (selects the env file) and `--container <name>` (selects the Azure container).
365
+ The package provides a unified `io` command with subcommands for all storage operations.
366
+ All commands accept `--env prod|staging` (selects the env file) and `--container <name>` (selects the Azure container).
367
+ `--container-config` resolves `--container` as a logical key from a `container-config.toml` (see [TOML + direnv configuration](#toml--direnv-configuration)).
262
368
 
263
369
  | Command | Usage | Description |
264
370
  |---------|-------|-------------|
265
371
  | `auth` | `uv run io auth [login\|status\|clear] [--method M] [--force]` | Manage authentication |
266
372
  | `ls` | `uv run io ls --env <env> --container <name>` | List blobs in a container |
267
373
  | `download` | `uv run io download <blob_name> --env <env> --container <name>` | Download a blob to local `data/` directory |
374
+ | `download-dir` | `uv run io download-dir [<prefix>] [--local-dir <path>] --env <env> --container <name>` | Recursively download all blobs under a prefix to a local directory |
268
375
  | `upload` | `uv run io upload <blob_name> <file_path> --env <env> --container <name>` | Upload a local file to a container |
376
+ | `upload-dir` | `uv run io upload-dir <local_dir> [--blob-prefix <prefix>] --env <env> --container <name>` | Upload all files from a local directory to storage |
269
377
  | `rm` | `uv run io rm <path> --env <env> --container <name>` | Delete a blob or folder from a container |
270
378
  | `mv` | `uv run io mv <source> <dest> [--overwrite] --env <env> --container <name>` | Move (rename) a blob within a container |
271
379
  | `generate-sas` | `uv run io generate-sas --env <env> --container <name> [--days N] [--update-env] [--write]` | Generate an Azure Storage SAS token for a container (read+list by default; add `--write` for write+delete) |
@@ -355,23 +463,29 @@ The session is also available programmatically via the `Session` class:
355
463
  from containerio.session import Session
356
464
 
357
465
  session = Session()
358
- print(session.auth_method) # e.g. 'client-credentials'
359
- print(session.env) # e.g. 'staging'
360
- print(session.container) # e.g. 'raw-data'
361
- print(session.is_active) # True if any session data exists
466
+ print(session.auth_method) # e.g. 'client-credentials'
467
+ print(session.env) # e.g. 'staging'
468
+ print(session.container) # e.g. 'raw-data'
469
+ print(session.container_config) # e.g. 'container-config.toml', or None
470
+ print(session.is_active) # True if any session data exists
362
471
  ```
363
472
 
364
473
  CLI options:
365
474
 
366
- - `--env <name>`: selects the environment. The CLI best-effort-preloads `.env.<name>` (no-op if missing) and uses the name as a label in prints, sessions, and SAS file write-back. Any name works (`prod`, `staging`, `dev`, `eu-prod`, …); `local` means the local filesystem.
367
- - `--container`: the Azure container name to target
475
+ - `--env <name>`: selects the environment. The CLI best-effort-preloads `.env.<name>` (no-op if missing) and uses the name as a label in prints, sessions, and SAS file write-back.
476
+ Any name works (`prod`, `staging`, `dev`, `eu-prod`, …); `local` means the local filesystem.
477
+ - `--container`: the Azure container name to target. With `--container-config`, this is a logical key under `[containers]` in the TOML file.
478
+ - `--container-config [path]`: resolve `--container` as a logical key from a `container-config.toml` (default: `container-config.toml` in the current directory).
479
+ Secrets come from the environment (e.g. via direnv). See [TOML + direnv configuration](#toml--direnv-configuration).
368
480
  - `--days`: SAS token validity in days, max 7 (default: 1, `generate-sas` only)
369
481
  - `--update-env`: write SAS token to env file instead of printing (`generate-sas` only)
370
482
  - `--write`: opt in to write+delete permissions (`generate-sas` only). Default is read+list.
371
483
 
372
484
  **Note:** SAS tokens are generated using a *user delegation key* which has a maximum validity of **7 days** (Azure limitation). See [Microsoft documentation](https://learn.microsoft.com/en-us/rest/api/storageservices/get-user-delegation-key#request-body) for details.
373
485
 
374
- The token's effective permissions are the **intersection** of the permissions requested here and the Azure RBAC permissions held by your identity (the user-delegation-key requester) at generation time — so requesting `--write` only produces a write-capable token if your RBAC role allows it. See [Microsoft documentation on user delegation SAS](https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas#permissions-for-a-user-delegation-sas) for details.
486
+ The token's effective permissions are the **intersection** of the permissions requested here and the Azure RBAC permissions held by your identity (the user-delegation-key requester) at generation time.
487
+ This means requesting `--write` only produces a write-capable token if your RBAC role allows it.
488
+ See [Microsoft documentation on user delegation SAS](https://learn.microsoft.com/en-us/rest/api/storageservices/create-user-delegation-sas#permissions-for-a-user-delegation-sas) for details.
375
489
 
376
490
  ## API Reference
377
491
 
@@ -431,9 +545,9 @@ StorageHandler(
431
545
  ### AzureStorageConfig
432
546
 
433
547
  Configuration dataclass for Azure storage settings.
434
- Fields: `storage_account`, `tenant_id` (optional), `dotenv_path` (optional — the file the config was loaded from; useful in logs and `repr`), `client_id` (optional), `client_secret` (optional) and `device_code_client_id` (optional).
548
+ Fields: `storage_account`, `tenant_id` (optional), `dotenv_path` (optional), `client_id` (optional), `client_secret` (optional), `device_code_client_id` (optional).
549
+ `dotenv_path` is the file the config was loaded from; it is useful in logs and `repr`.
435
550
  `storage_endpoint` is a derived property — `https://<storage_account>.blob.core.windows.net`.
436
-
437
551
  The config is the single carrier of account and identity data: the handler reads these fields and never reads environment variables for them itself.
438
552
  `from_env()` is the one seam that maps env vars onto the config (`AZURE_CLIENT_ID_CONTAINER` → `client_id`, `AZURE_CLIENT_SECRET_CONTAINER` → `client_secret`, `AZURE_CLIENT_ID` → `device_code_client_id`).
439
553
  The lone exception is the SAS token, which is container-scoped and therefore resolved by the handler from `AZURE_STORAGE_SAS_TOKEN_<NAME>` at use time.
@@ -495,8 +609,8 @@ Errors:
495
609
 
496
610
  ### Providing config to the CLI
497
611
 
498
- The CLI reads required Azure variables from `os.environ` — it does not parse any specific config format itself.
499
- This keeps the CLI format-agnostic and lets each project choose how to manage config.
612
+ The CLI reads required Azure variables from `os.environ` — by default it does not parse any specific config format itself, keeping it format-agnostic so each project can choose how to manage config.
613
+ For the native combined TOML + direnv approach, pass `--container-config` (see [TOML + direnv configuration](#toml--direnv-configuration)); the rest of this section covers the default dotenv / env-var flow.
500
614
 
501
615
  **Dotenv is the default.**
502
616
  If a `.env.<name>` file matching `--env <name>` exists, the CLI auto-loads it via `python-dotenv` before reading `os.environ`.
@@ -609,6 +723,7 @@ The `.gitsv/config.yaml` file defines how versions are bumped based on commit me
609
723
  4. **Release**: The CI pipeline automatically handles tagging and publishing
610
724
 
611
725
  The CI pipeline will automatically:
726
+
612
727
  - Bump the version based on commit history
613
728
  - Generate and pin the changelog as a Git issue
614
729
  - Build and publish the package to Forgejo
@@ -627,6 +742,7 @@ The repository includes a Crow CI pipeline that automatically builds, tests, and
627
742
  1. `git sv tag`: generate tag with version and push it to remote in one go, based on git commit parsing.
628
743
 
629
744
  The pipeline will:
745
+
630
746
  - Run linting and tests
631
747
  - Update the version in `pyproject.toml` automatically
632
748
  - Generate changelog from git history using `git-sv`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "containerio"
3
- version = "2.4.0"
3
+ version = "2.5.0"
4
4
  description = "Azure Blob Storage handler with unified local/cloud interface"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "containerio"
3
- version = "2.4.0"
3
+ version = "2.5.0"
4
4
  description = "Azure Blob Storage handler with unified local/cloud interface"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,4 +1,5 @@
1
1
  from containerio.azure_config import AzureStorageConfig
2
+ from containerio.config import resolve_container_config
2
3
  from containerio.handler import AzureAuthType, BlobInfo, StorageHandler
3
4
 
4
5
  __all__ = [
@@ -6,4 +7,5 @@ __all__ = [
6
7
  "AzureStorageConfig",
7
8
  "BlobInfo",
8
9
  "StorageHandler",
10
+ "resolve_container_config",
9
11
  ]
@@ -0,0 +1,209 @@
1
+ """Resolve container configuration from a TOML file + environment.
2
+
3
+ This module implements the Python-native equivalent of r-containerio's
4
+ `containerio_config()`: a project-local `container-config.toml` maps
5
+ logical container keys and environments (`staging`, `prod`) to concrete
6
+ Azure container names plus auth specs.
7
+
8
+ Non-sensitive fields (storage account, container names) come from the
9
+ TOML. Identity values (`tenant_id`, `client_id`,
10
+ `device_code_client_id`) and the client secret are resolved
11
+ environment-first: their `*_env` keys name an environment variable that
12
+ wins when it is set at runtime, and the TOML value is only the
13
+ fallback. That way a CI-injected value (Crow `from_secret` becomes a
14
+ plain environment variable) or an existing shell export is never
15
+ overwritten by the local direnv fetcher. The client secret has no TOML
16
+ fallback: it is resolved from `os.environ` by name only. Combined with
17
+ direnv (a commit-safe `.envrc` that pulls values at runtime, e.g. from
18
+ Azure Key Vault via the authenticated `az` CLI), the TOML file is safe
19
+ to commit.
20
+
21
+ If the resolved auth spec defines no `storage_account`, the module
22
+ falls back to `AzureStorageConfig.from_env()`, which reads the full
23
+ configuration from environment variables.
24
+ """
25
+
26
+ import os
27
+ import tomllib
28
+ from pathlib import Path
29
+ from typing import Optional, Tuple, Union
30
+
31
+ from containerio.azure_config import AzureStorageConfig, is_local
32
+
33
+ DEFAULT_CONTAINER_CONFIG_FILE = "container-config.toml"
34
+
35
+
36
+ def _resolve_env_or_direct(
37
+ auth: dict,
38
+ value_key: str,
39
+ env_key: str,
40
+ ) -> Optional[str]:
41
+ """Resolve an auth value environment-first, else TOML.
42
+
43
+ The environment variable named by `env_key` wins when it is set at
44
+ runtime; otherwise the direct `value_key` from the TOML is used.
45
+ This lets a CI-injected or pre-exported value take precedence over
46
+ the local direnv fallback.
47
+
48
+ Args:
49
+ auth (dict): The resolved auth table.
50
+ value_key (str): TOML key holding the direct fallback value.
51
+ env_key (str): TOML key naming the environment variable.
52
+
53
+ Returns:
54
+ Optional[str]: The resolved value, or `None` when neither the
55
+ named environment variable nor the direct value is set.
56
+ """
57
+ env_var = auth.get(env_key)
58
+
59
+ if env_var:
60
+ value = os.getenv(env_var)
61
+
62
+ if value:
63
+ return value
64
+
65
+ return auth.get(value_key)
66
+
67
+
68
+ def resolve_container_config(
69
+ container_key: str,
70
+ env: str,
71
+ toml_file: Union[str, Path] = DEFAULT_CONTAINER_CONFIG_FILE,
72
+ ) -> Tuple[Optional[str], Optional[AzureStorageConfig]]:
73
+ """Resolve a logical container key to a name + Azure config.
74
+
75
+ The config is resolved from a TOML file plus the environment.
76
+
77
+ Looks up the container definition under `[containers]` in the TOML
78
+ file and maps it to an Azure auth configuration for the selected
79
+ `env`. Auth can be referenced in two ways:
80
+
81
+ - **Tag indirection** (shared auth across containers):
82
+ `azure_auth.<env>` is a string referencing a
83
+ `[azure_auth.<tag>]` section.
84
+ - **Inline nested auth** (per-container): `azure_auth.<env>` is a
85
+ nested table defining the auth fields directly under
86
+ `[containers.<key>.azure_auth.<env>]`.
87
+
88
+ `tenant_id_env`, `client_id_env`, and `device_code_client_id_env`
89
+ name the environment variables that hold those identity values. An
90
+ environment variable that is set at runtime wins; otherwise the
91
+ direct `tenant_id`, `client_id`, or `device_code_client_id` value
92
+ from the TOML is used. This keeps CI-provided or pre-exported
93
+ values authoritative over the local direnv fallback.
94
+
95
+ `client_secret_env` is the name of the environment variable that
96
+ holds the client secret; it is resolved from `os.environ` at load
97
+ time so no secret is stored in the TOML. An unset variable yields
98
+ `None` rather than an error: only client-credentials auth needs the
99
+ secret, and the storage handler reports the missing value when that
100
+ method is actually used. This keeps device-code login working when
101
+ only `device_code_client_id` is available. SAS tokens are not part
102
+ of this file: the storage handler resolves them from the
103
+ environment via `sas_token_env_var()` when connecting.
104
+
105
+ If the resolved auth spec defines no `storage_account`, the full
106
+ configuration is read from environment variables via
107
+ `AzureStorageConfig.from_env()` instead.
108
+
109
+ Args:
110
+ container_key (str): Logical container key under `[containers]`.
111
+ env (str): Environment name (e.g. 'staging', 'prod'). 'local'
112
+ short-circuits to the local filesystem.
113
+ toml_file (Union[str, Path]): Path to the TOML config file.
114
+ Defaults to `container-config.toml` in the current working
115
+ directory.
116
+
117
+ Returns:
118
+ Tuple[Optional[str], Optional[AzureStorageConfig]]: The resolved
119
+ concrete container name and an `AzureStorageConfig` suitable
120
+ for `StorageHandler`. Both are `None` for the local
121
+ filesystem (`env='local'`).
122
+
123
+ Raises:
124
+ FileNotFoundError: If `toml_file` does not exist.
125
+ ValueError: If the container key is unknown, the TOML is
126
+ missing the auth or name for the selected env, `name` or
127
+ `azure_auth` is not a table keyed by environment, or the
128
+ referenced auth tag does not exist.
129
+ """
130
+ if is_local(env):
131
+ return None, None
132
+
133
+ path = Path(toml_file)
134
+
135
+ if not path.is_file():
136
+ raise FileNotFoundError(f"container config file not found: {path}")
137
+
138
+ with path.open("rb") as handle:
139
+ cfg = tomllib.load(handle)
140
+
141
+ containers = cfg.get("containers")
142
+ container = containers.get(container_key) if isinstance(containers, dict) else None
143
+
144
+ if container is None:
145
+ raise ValueError(f"Unknown container: {container_key}")
146
+
147
+ name_map = container.get("name") or {}
148
+
149
+ if not isinstance(name_map, dict):
150
+ raise ValueError(
151
+ f"Invalid 'name' for container '{container_key}': expected a "
152
+ f'table keyed by environment (e.g. name.{env} = "..."), '
153
+ f"got {type(name_map).__name__}."
154
+ )
155
+
156
+ container_name = name_map.get(env)
157
+
158
+ if not container_name:
159
+ raise ValueError(
160
+ f"No container name defined for env '{env}' in container '{container_key}'"
161
+ )
162
+
163
+ auth_map = container.get("azure_auth") or {}
164
+
165
+ if not isinstance(auth_map, dict):
166
+ raise ValueError(
167
+ f"Invalid 'azure_auth' for container '{container_key}': expected "
168
+ f"a table keyed by environment (e.g. azure_auth.{env} = ...), "
169
+ f"got {type(auth_map).__name__}."
170
+ )
171
+
172
+ auth_spec = auth_map.get(env)
173
+
174
+ if auth_spec is None:
175
+ raise ValueError(
176
+ f"Container '{container_key}' has no azure_auth defined for env '{env}'."
177
+ )
178
+
179
+ # `azure_auth.<env>` may be either:
180
+ # - a string referencing a shared `[azure_auth.<tag>]` section, or
181
+ # - an inline (nested) table defining the auth fields directly
182
+ # under `[containers.<key>.azure_auth.<env>]`.
183
+ if isinstance(auth_spec, dict):
184
+ auth = auth_spec
185
+ else:
186
+ tag = auth_spec
187
+ shared = cfg.get("azure_auth") or {}
188
+ auth = shared.get(tag) if isinstance(shared, dict) else None
189
+
190
+ if auth is None:
191
+ raise ValueError(f"Unknown azure_auth: {tag}")
192
+
193
+ if auth.get("storage_account"):
194
+ client_secret_env = auth.get("client_secret_env")
195
+ client_secret = os.getenv(client_secret_env) if client_secret_env else None
196
+
197
+ config = AzureStorageConfig(
198
+ storage_account=auth["storage_account"],
199
+ tenant_id=_resolve_env_or_direct(auth, "tenant_id", "tenant_id_env"),
200
+ client_id=_resolve_env_or_direct(auth, "client_id", "client_id_env"),
201
+ client_secret=client_secret,
202
+ device_code_client_id=_resolve_env_or_direct(
203
+ auth, "device_code_client_id", "device_code_client_id_env"
204
+ ),
205
+ )
206
+ else:
207
+ config = AzureStorageConfig.from_env()
208
+
209
+ return container_name, config
@@ -33,6 +33,15 @@ class Session:
33
33
  """The Azure container name (e.g. 'raw-data')."""
34
34
  return self._data.get("container")
35
35
 
36
+ @property
37
+ def container_config(self) -> Optional[str]:
38
+ """The container-config TOML path saved with the session.
39
+
40
+ When set, `container` holds a logical key that is resolved
41
+ through this TOML file on later runs.
42
+ """
43
+ return self._data.get("container_config")
44
+
36
45
  @property
37
46
  def is_active(self) -> bool:
38
47
  """Whether a session has any data."""
@@ -43,6 +52,8 @@ class Session:
43
52
  auth_method: Optional[str] = None,
44
53
  env: Optional[str] = None,
45
54
  container: Optional[str] = None,
55
+ container_config: Optional[str] = None,
56
+ clear_container_config: bool = False,
46
57
  ) -> None:
47
58
  """Update session fields and persist to disk.
48
59
 
@@ -50,6 +61,11 @@ class Session:
50
61
  auth_method (Optional[str]): Azure auth method.
51
62
  env (Optional[str]): Storage environment.
52
63
  container (Optional[str]): Azure container name.
64
+ container_config (Optional[str]): Path to the TOML file used
65
+ to resolve `container` as a logical key.
66
+ clear_container_config (bool): Remove any stored
67
+ container-config path. Takes precedence over
68
+ `container_config`.
53
69
  """
54
70
  if auth_method is not None:
55
71
  self._data["auth_method"] = auth_method
@@ -60,6 +76,11 @@ class Session:
60
76
  if container is not None:
61
77
  self._data["container"] = container
62
78
 
79
+ if clear_container_config:
80
+ self._data.pop("container_config", None)
81
+ elif container_config is not None:
82
+ self._data["container_config"] = container_config
83
+
63
84
  self._save()
64
85
 
65
86
  def clear(self) -> None:
@@ -29,6 +29,10 @@ from containerio import (
29
29
  StorageHandler,
30
30
  )
31
31
  from containerio.azure_config import env_file, is_local, sas_token_env_var
32
+ from containerio.config import (
33
+ DEFAULT_CONTAINER_CONFIG_FILE,
34
+ resolve_container_config,
35
+ )
32
36
  from containerio.session import Session
33
37
 
34
38
  MAX_SAS_DAYS_VALID = 7
@@ -86,6 +90,54 @@ def _require_container(args: argparse.Namespace) -> None:
86
90
  )
87
91
 
88
92
 
93
+ def _apply_container_config(args: argparse.Namespace) -> None:
94
+ """Resolve --container-config selection onto args, in place.
95
+
96
+ When `--container-config` is set and a cloud env is selected,
97
+ `--container` is interpreted as a logical key under `[containers]`
98
+ in the TOML file. It is replaced by the resolved concrete container
99
+ name and the resolved `AzureStorageConfig` is stashed on the
100
+ namespace as `_toml_config` for `_build_config` to pick up.
101
+
102
+ The 'session' command is skipped so logical keys — not concrete
103
+ names — persist in the session. `_cmd_session` additionally stores
104
+ the TOML path, and `_apply_session_defaults` restores it on later
105
+ runs so the key keeps resolving.
106
+
107
+ Args:
108
+ args (argparse.Namespace): Parsed CLI arguments.
109
+ """
110
+ toml_file = getattr(args, "container_config", None)
111
+
112
+ if not toml_file or args.command == "session":
113
+ return
114
+
115
+ if not args.env:
116
+ _fail(
117
+ "Error: --env <name> is required when --container-config is used. "
118
+ "Pass a cloud env (e.g. --env staging) or drop --container-config."
119
+ )
120
+
121
+ if is_local(args.env):
122
+ return
123
+
124
+ if not args.container:
125
+ _fail(
126
+ "Error: --container <logical-key> is required when "
127
+ "--container-config is used. Pass a key defined under "
128
+ "[containers] in the TOML file."
129
+ )
130
+
131
+ container_name, config = resolve_container_config(
132
+ container_key=args.container,
133
+ env=args.env,
134
+ toml_file=toml_file,
135
+ )
136
+
137
+ args.container = container_name
138
+ args._toml_config = config
139
+
140
+
89
141
  def _build_config(args: argparse.Namespace) -> Optional[AzureStorageConfig]:
90
142
  """Build an AzureStorageConfig from CLI args, or None for local.
91
143
 
@@ -106,6 +158,12 @@ def _build_config(args: argparse.Namespace) -> Optional[AzureStorageConfig]:
106
158
  """
107
159
  if not args.env or is_local(args.env):
108
160
  return None
161
+
162
+ toml_config = getattr(args, "_toml_config", None)
163
+
164
+ if toml_config is not None:
165
+ return toml_config
166
+
109
167
  return AzureStorageConfig.from_env()
110
168
 
111
169
 
@@ -127,6 +185,38 @@ def _require_config(args: argparse.Namespace) -> AzureStorageConfig:
127
185
  return config
128
186
 
129
187
 
188
+ def _apply_session_defaults(
189
+ args: argparse.Namespace,
190
+ session: Session,
191
+ ) -> None:
192
+ """Fill missing env/container/container-config from a session.
193
+
194
+ Explicit flags always win. When the container is taken from the
195
+ session and that session was started with `--container-config`, the
196
+ stored TOML path is restored too, so the logical key keeps
197
+ resolving on later runs instead of being treated as a concrete
198
+ container name.
199
+
200
+ Args:
201
+ args (argparse.Namespace): Parsed CLI arguments, updated in
202
+ place.
203
+ session (Session): The active session.
204
+ """
205
+ # The 'session' command reads the session directly and must see the
206
+ # user's explicit flags, so it never inherits defaults.
207
+ if getattr(args, "command", None) == "session":
208
+ return
209
+
210
+ if not args.env and session.env:
211
+ args.env = session.env
212
+
213
+ if getattr(args, "container", None) is None and session.container:
214
+ args.container = session.container
215
+
216
+ if session.container_config and not getattr(args, "container_config", None):
217
+ args.container_config = session.container_config
218
+
219
+
130
220
  def _get_handler(args: argparse.Namespace) -> StorageHandler:
131
221
  """Validate --container and construct a StorageHandler.
132
222
 
@@ -153,7 +243,13 @@ def _cmd_session(args: argparse.Namespace) -> None:
153
243
  return
154
244
 
155
245
  if args.env and args.container:
156
- session.update(env=args.env, container=args.container)
246
+ container_config = getattr(args, "container_config", None)
247
+ session.update(
248
+ env=args.env,
249
+ container=args.container,
250
+ container_config=container_config,
251
+ clear_container_config=not container_config,
252
+ )
157
253
  print(f"Session started: env={args.env}, container={args.container}")
158
254
  return
159
255
 
@@ -856,7 +952,20 @@ def main() -> None:
856
952
  parent.add_argument(
857
953
  "--container",
858
954
  default=None,
859
- help="Azure container name (required for Azure operations)",
955
+ help="Azure container name (required for Azure operations). "
956
+ "With --container-config, this is a logical key under "
957
+ "[containers] in the TOML file.",
958
+ )
959
+ parent.add_argument(
960
+ "--container-config",
961
+ nargs="?",
962
+ const=DEFAULT_CONTAINER_CONFIG_FILE,
963
+ default=None,
964
+ help="Resolve --container as a logical key from a "
965
+ "container-config.toml file (default name: "
966
+ "container-config.toml in the current directory). Secrets "
967
+ "are resolved from the environment (e.g. via direnv). "
968
+ "Requires --env.",
860
969
  )
861
970
 
862
971
  parser = argparse.ArgumentParser(
@@ -1043,20 +1152,20 @@ def main() -> None:
1043
1152
  sys.exit(1)
1044
1153
 
1045
1154
  session = Session()
1046
-
1047
- if not args.env and session.env:
1048
- args.env = session.env
1049
- if getattr(args, "container", None) is None and session.container:
1050
- args.container = session.container
1155
+ _apply_session_defaults(args=args, session=session)
1051
1156
 
1052
1157
  if args.env:
1053
1158
  os.environ["STORAGE_ENV"] = args.env
1054
1159
  if not is_local(args.env):
1055
- load_dotenv(dotenv_path=env_file(args.env), override=True)
1160
+ load_dotenv(
1161
+ dotenv_path=env_file(args.env),
1162
+ override=not getattr(args, "container_config", None),
1163
+ )
1056
1164
 
1057
1165
  try:
1166
+ _apply_container_config(args=args)
1058
1167
  args.func(args)
1059
- except ValueError as error:
1168
+ except (ValueError, FileNotFoundError) as error:
1060
1169
  _fail(f"Error: {error}")
1061
1170
 
1062
1171