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.
- {containerio-2.4.0 → containerio-2.5.0}/PKG-INFO +132 -16
- {containerio-2.4.0 → containerio-2.5.0}/README.md +131 -15
- {containerio-2.4.0 → containerio-2.5.0}/pyproject.toml +1 -1
- {containerio-2.4.0 → containerio-2.5.0}/pyproject.toml.orig +1 -1
- {containerio-2.4.0 → containerio-2.5.0}/src/containerio/__init__.py +2 -0
- containerio-2.5.0/src/containerio/config.py +209 -0
- {containerio-2.4.0 → containerio-2.5.0}/src/containerio/session.py +21 -0
- {containerio-2.4.0 → containerio-2.5.0}/src/containerio/tools/cli.py +118 -9
- {containerio-2.4.0 → containerio-2.5.0}/src/containerio/azure_config.py +0 -0
- {containerio-2.4.0 → containerio-2.5.0}/src/containerio/handler.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: containerio
|
|
3
|
-
Version: 2.
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
373
|
-
print(session.env)
|
|
374
|
-
print(session.container)
|
|
375
|
-
print(session.
|
|
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.
|
|
381
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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)
|
|
359
|
-
print(session.env)
|
|
360
|
-
print(session.container)
|
|
361
|
-
print(session.
|
|
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.
|
|
367
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,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
|
-
|
|
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(
|
|
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
|
|
|
File without changes
|
|
File without changes
|