homecloud-sdk 0.3.2__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.
- homecloud_sdk-0.3.2/PKG-INFO +128 -0
- homecloud_sdk-0.3.2/README.md +112 -0
- homecloud_sdk-0.3.2/homecloud_core/__init__.py +1 -0
- homecloud_sdk-0.3.2/homecloud_core/account.py +23 -0
- homecloud_sdk-0.3.2/homecloud_core/config.py +165 -0
- homecloud_sdk-0.3.2/homecloud_core/context.py +323 -0
- homecloud_sdk-0.3.2/homecloud_core/defaults.py +39 -0
- homecloud_sdk-0.3.2/homecloud_core/env.py +49 -0
- homecloud_sdk-0.3.2/homecloud_core/errors.py +56 -0
- homecloud_sdk-0.3.2/homecloud_core/mfa.py +120 -0
- homecloud_sdk-0.3.2/homecloud_core/progress_reader.py +27 -0
- homecloud_sdk-0.3.2/homecloud_core/py.typed +0 -0
- homecloud_sdk-0.3.2/homecloud_core/session.py +115 -0
- homecloud_sdk-0.3.2/homecloud_core/signing.py +50 -0
- homecloud_sdk-0.3.2/homecloud_core/so_paths.py +29 -0
- homecloud_sdk-0.3.2/homecloud_core/transfer_state.py +65 -0
- homecloud_sdk-0.3.2/homecloud_core/transport.py +388 -0
- homecloud_sdk-0.3.2/homecloud_sdk/__init__.py +24 -0
- homecloud_sdk-0.3.2/homecloud_sdk/client.py +177 -0
- homecloud_sdk-0.3.2/homecloud_sdk/py.typed +0 -0
- homecloud_sdk-0.3.2/homecloud_sdk/services.py +552 -0
- homecloud_sdk-0.3.2/homecloud_sdk/so_parallel.py +38 -0
- homecloud_sdk-0.3.2/homecloud_sdk.egg-info/PKG-INFO +128 -0
- homecloud_sdk-0.3.2/homecloud_sdk.egg-info/SOURCES.txt +36 -0
- homecloud_sdk-0.3.2/homecloud_sdk.egg-info/dependency_links.txt +1 -0
- homecloud_sdk-0.3.2/homecloud_sdk.egg-info/requires.txt +5 -0
- homecloud_sdk-0.3.2/homecloud_sdk.egg-info/top_level.txt +2 -0
- homecloud_sdk-0.3.2/pyproject.toml +41 -0
- homecloud_sdk-0.3.2/setup.cfg +4 -0
- homecloud_sdk-0.3.2/tests/test_auth_model.py +99 -0
- homecloud_sdk-0.3.2/tests/test_client.py +77 -0
- homecloud_sdk-0.3.2/tests/test_config.py +85 -0
- homecloud_sdk-0.3.2/tests/test_env_profile.py +171 -0
- homecloud_sdk-0.3.2/tests/test_head_object.py +68 -0
- homecloud_sdk-0.3.2/tests/test_live_integration.py +188 -0
- homecloud_sdk-0.3.2/tests/test_signing.py +25 -0
- homecloud_sdk-0.3.2/tests/test_so_paths.py +19 -0
- homecloud_sdk-0.3.2/tests/test_transfer_state.py +33 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: homecloud-sdk
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: HomeCloud Python SDK and core client library
|
|
5
|
+
Author: HomeCloud
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/HomeCloudLab/homecloud-sdk
|
|
8
|
+
Project-URL: Documentation, https://docs.holab.abrdns.com/sdk/
|
|
9
|
+
Keywords: homecloud,sdk,object-storage,mq
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: httpx>=0.28.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.3.0; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff>=0.8.0; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# HomeCloud SDK
|
|
18
|
+
|
|
19
|
+
Python SDK for HomeCloud (`pip install homecloud-sdk`).
|
|
20
|
+
|
|
21
|
+
## Auth model (cloud-style)
|
|
22
|
+
|
|
23
|
+
| Who | How | MFA |
|
|
24
|
+
|-----|-----|-----|
|
|
25
|
+
| **SDK / automation** | Access Key ID + Secret (SigV1 data plane) | Never on requests |
|
|
26
|
+
| **CLI / humans** | `homecloud login` → console JWT | At login / step-up only |
|
|
27
|
+
|
|
28
|
+
Access Keys are created once in the Console (human + MFA there). Runtime SDK calls do **not** re-prompt MFA.
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from homecloud_sdk import HomeCloud
|
|
32
|
+
|
|
33
|
+
# Recommended — explicit credentials (CI / servers)
|
|
34
|
+
client = HomeCloud(
|
|
35
|
+
access_key="HCAK...",
|
|
36
|
+
secret_key="...",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Or environment (HOMECLOUD_* / HC_*)
|
|
40
|
+
client = HomeCloud.from_env()
|
|
41
|
+
|
|
42
|
+
# Or ~/.homecloud/credentials (+ optional HC_PROFILE)
|
|
43
|
+
client = HomeCloud()
|
|
44
|
+
|
|
45
|
+
# Data plane — Access Key only, no login
|
|
46
|
+
client.so.upload("docs", "./file.txt", key="a.txt")
|
|
47
|
+
client.mq.send("orders", {"id": 1})
|
|
48
|
+
|
|
49
|
+
# Interactive helpers only (CLI/tools) — may involve MFA
|
|
50
|
+
# client.login("alice", "…")
|
|
51
|
+
# client.login_browser()
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Architecture
|
|
55
|
+
|
|
56
|
+
```text
|
|
57
|
+
homecloud_core/ ← auth, routing, signing, sessions, MFA helpers
|
|
58
|
+
homecloud_sdk/ ← public API (HomeCloud / HomeCloudClient)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
CLI (`homecloud-cli`) is a Typer/Rich wrapper; it opts into `interactive_mfa=True`.
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install homecloud-sdk
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Until PyPI is configured, use a Git checkout or sibling editable install:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install -e "../homecloud-sdk"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
PyPI publish is wired via `.github/workflows/publish-pypi.yml` (tag `v*` + `PYPI_API_TOKEN` or Trusted Publishing).
|
|
76
|
+
|
|
77
|
+
## Operations by plane
|
|
78
|
+
|
|
79
|
+
| API | Auth | Notes |
|
|
80
|
+
|-----|------|-------|
|
|
81
|
+
| `so.upload` / `download` / `sync_*` / `list_objects` / `delete` | Access Key | Primary SDK path |
|
|
82
|
+
| `so.head_object` (`object_metadata`) | Access Key | Metadata only — no object body (AWS HeadObject) |
|
|
83
|
+
| `mq.send` / `receive` | Access Key | Primary SDK path |
|
|
84
|
+
| `account_id()` | Access Key whoami | No JWT |
|
|
85
|
+
| `so.list_buckets` / `create_bucket` | Console JWT | Management helper |
|
|
86
|
+
| `queues.list` / `apps.list` / `accounts.*` | Console JWT | Management helper |
|
|
87
|
+
|
|
88
|
+
## Configuration
|
|
89
|
+
|
|
90
|
+
| File | Purpose |
|
|
91
|
+
|------|---------|
|
|
92
|
+
| `~/.homecloud/credentials` | Access Keys (multi-profile JSON) |
|
|
93
|
+
| `~/.homecloud/session` | Console JWT (interactive only) |
|
|
94
|
+
|
|
95
|
+
### Profile / env
|
|
96
|
+
|
|
97
|
+
1. Constructor `profile=` / `access_key_id=` …
|
|
98
|
+
2. `HOMECLOUD_PROFILE` / `HC_PROFILE`
|
|
99
|
+
3. credentials `default_profile`
|
|
100
|
+
4. `default`
|
|
101
|
+
|
|
102
|
+
| Variable | Short | Effect |
|
|
103
|
+
|----------|-------|--------|
|
|
104
|
+
| `HOMECLOUD_PROFILE` | `HC_PROFILE` | Active profile |
|
|
105
|
+
| `HOMECLOUD_ACCESS_KEY_ID` | `HC_ACCESS_KEY_ID` | Access key |
|
|
106
|
+
| `HOMECLOUD_SECRET_ACCESS_KEY` | `HC_SECRET_ACCESS_KEY` | Secret |
|
|
107
|
+
| `HOMECLOUD_ACCOUNT_ID` | `HC_ACCOUNT_ID` | Optional account |
|
|
108
|
+
| `HOMECLOUD_APEX` | `HC_APEX` | Platform domain |
|
|
109
|
+
|
|
110
|
+
## Development
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
pip install -e ".[dev]"
|
|
114
|
+
pytest tests/ -q
|
|
115
|
+
pytest tests/test_live_integration.py -q # needs Access Keys in ~/.homecloud
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Service-account verification (pre-PyPI)
|
|
119
|
+
|
|
120
|
+
Mints a dedicated Access Key named `homecloud-sdk-test` (requires a one-time
|
|
121
|
+
`homecloud login` to create the key), then runs a **clean subprocess** with
|
|
122
|
+
`HomeCloud.from_env()` — no JWT session, no MFA, no browser:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python scripts/verify_service_account_flow.py
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Expected: `PASS: service-account flow works without login/JWT/MFA/browser`.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# HomeCloud SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for HomeCloud (`pip install homecloud-sdk`).
|
|
4
|
+
|
|
5
|
+
## Auth model (cloud-style)
|
|
6
|
+
|
|
7
|
+
| Who | How | MFA |
|
|
8
|
+
|-----|-----|-----|
|
|
9
|
+
| **SDK / automation** | Access Key ID + Secret (SigV1 data plane) | Never on requests |
|
|
10
|
+
| **CLI / humans** | `homecloud login` → console JWT | At login / step-up only |
|
|
11
|
+
|
|
12
|
+
Access Keys are created once in the Console (human + MFA there). Runtime SDK calls do **not** re-prompt MFA.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from homecloud_sdk import HomeCloud
|
|
16
|
+
|
|
17
|
+
# Recommended — explicit credentials (CI / servers)
|
|
18
|
+
client = HomeCloud(
|
|
19
|
+
access_key="HCAK...",
|
|
20
|
+
secret_key="...",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Or environment (HOMECLOUD_* / HC_*)
|
|
24
|
+
client = HomeCloud.from_env()
|
|
25
|
+
|
|
26
|
+
# Or ~/.homecloud/credentials (+ optional HC_PROFILE)
|
|
27
|
+
client = HomeCloud()
|
|
28
|
+
|
|
29
|
+
# Data plane — Access Key only, no login
|
|
30
|
+
client.so.upload("docs", "./file.txt", key="a.txt")
|
|
31
|
+
client.mq.send("orders", {"id": 1})
|
|
32
|
+
|
|
33
|
+
# Interactive helpers only (CLI/tools) — may involve MFA
|
|
34
|
+
# client.login("alice", "…")
|
|
35
|
+
# client.login_browser()
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Architecture
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
homecloud_core/ ← auth, routing, signing, sessions, MFA helpers
|
|
42
|
+
homecloud_sdk/ ← public API (HomeCloud / HomeCloudClient)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
CLI (`homecloud-cli`) is a Typer/Rich wrapper; it opts into `interactive_mfa=True`.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install homecloud-sdk
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Until PyPI is configured, use a Git checkout or sibling editable install:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install -e "../homecloud-sdk"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
PyPI publish is wired via `.github/workflows/publish-pypi.yml` (tag `v*` + `PYPI_API_TOKEN` or Trusted Publishing).
|
|
60
|
+
|
|
61
|
+
## Operations by plane
|
|
62
|
+
|
|
63
|
+
| API | Auth | Notes |
|
|
64
|
+
|-----|------|-------|
|
|
65
|
+
| `so.upload` / `download` / `sync_*` / `list_objects` / `delete` | Access Key | Primary SDK path |
|
|
66
|
+
| `so.head_object` (`object_metadata`) | Access Key | Metadata only — no object body (AWS HeadObject) |
|
|
67
|
+
| `mq.send` / `receive` | Access Key | Primary SDK path |
|
|
68
|
+
| `account_id()` | Access Key whoami | No JWT |
|
|
69
|
+
| `so.list_buckets` / `create_bucket` | Console JWT | Management helper |
|
|
70
|
+
| `queues.list` / `apps.list` / `accounts.*` | Console JWT | Management helper |
|
|
71
|
+
|
|
72
|
+
## Configuration
|
|
73
|
+
|
|
74
|
+
| File | Purpose |
|
|
75
|
+
|------|---------|
|
|
76
|
+
| `~/.homecloud/credentials` | Access Keys (multi-profile JSON) |
|
|
77
|
+
| `~/.homecloud/session` | Console JWT (interactive only) |
|
|
78
|
+
|
|
79
|
+
### Profile / env
|
|
80
|
+
|
|
81
|
+
1. Constructor `profile=` / `access_key_id=` …
|
|
82
|
+
2. `HOMECLOUD_PROFILE` / `HC_PROFILE`
|
|
83
|
+
3. credentials `default_profile`
|
|
84
|
+
4. `default`
|
|
85
|
+
|
|
86
|
+
| Variable | Short | Effect |
|
|
87
|
+
|----------|-------|--------|
|
|
88
|
+
| `HOMECLOUD_PROFILE` | `HC_PROFILE` | Active profile |
|
|
89
|
+
| `HOMECLOUD_ACCESS_KEY_ID` | `HC_ACCESS_KEY_ID` | Access key |
|
|
90
|
+
| `HOMECLOUD_SECRET_ACCESS_KEY` | `HC_SECRET_ACCESS_KEY` | Secret |
|
|
91
|
+
| `HOMECLOUD_ACCOUNT_ID` | `HC_ACCOUNT_ID` | Optional account |
|
|
92
|
+
| `HOMECLOUD_APEX` | `HC_APEX` | Platform domain |
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install -e ".[dev]"
|
|
98
|
+
pytest tests/ -q
|
|
99
|
+
pytest tests/test_live_integration.py -q # needs Access Keys in ~/.homecloud
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Service-account verification (pre-PyPI)
|
|
103
|
+
|
|
104
|
+
Mints a dedicated Access Key named `homecloud-sdk-test` (requires a one-time
|
|
105
|
+
`homecloud login` to create the key), then runs a **clean subprocess** with
|
|
106
|
+
`HomeCloud.from_env()` — no JWT session, no MFA, no browser:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
python scripts/verify_service_account_flow.py
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Expected: `PASS: service-account flow works without login/JWT/MFA/browser`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Internal core — not part of the public SDK surface."""
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Automatic account resolution — users never pass account IDs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from homecloud_core.config import ProfileConfig
|
|
6
|
+
from homecloud_core.errors import NotConfiguredError
|
|
7
|
+
from homecloud_core.session import ProfileSession, set_active_account
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_account_id(profile: ProfileConfig, session: ProfileSession) -> str:
|
|
11
|
+
if session.active_account_id:
|
|
12
|
+
return session.active_account_id
|
|
13
|
+
if profile.default_account_id:
|
|
14
|
+
return profile.default_account_id
|
|
15
|
+
if session.last_used_account_id:
|
|
16
|
+
return session.last_used_account_id
|
|
17
|
+
raise NotConfiguredError(
|
|
18
|
+
"No active account for this profile. Run: homecloud configure or homecloud accounts switch"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def remember_account(profile_name: str, account_id: str) -> None:
|
|
23
|
+
set_active_account(profile_name, account_id)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Persistent credentials — Access Keys only, no session tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from homecloud_core.defaults import DEFAULT_PROFILE, platform_apex
|
|
11
|
+
from homecloud_core.env import env_config_dir, env_credentials_file
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def homecloud_dir() -> Path:
|
|
15
|
+
override = env_config_dir()
|
|
16
|
+
if override:
|
|
17
|
+
return Path(override).expanduser()
|
|
18
|
+
return Path.home() / ".homecloud"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def credentials_path() -> Path:
|
|
22
|
+
override = env_credentials_file()
|
|
23
|
+
if override:
|
|
24
|
+
return Path(override).expanduser()
|
|
25
|
+
return homecloud_dir() / "credentials"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ProfileConfig:
|
|
30
|
+
name: str
|
|
31
|
+
apex: str = platform_apex()
|
|
32
|
+
default_account_id: str | None = None
|
|
33
|
+
access_key_id: str | None = None
|
|
34
|
+
secret_access_key: str | None = None
|
|
35
|
+
|
|
36
|
+
def require_access_key(self) -> tuple[str, str, str]:
|
|
37
|
+
if not self.default_account_id:
|
|
38
|
+
raise ValueError("No account configured for this profile. Run: homecloud configure")
|
|
39
|
+
if not self.access_key_id or not self.secret_access_key:
|
|
40
|
+
raise ValueError("Access Key not configured. Run: homecloud configure")
|
|
41
|
+
return self.default_account_id, self.access_key_id, self.secret_access_key
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class CredentialsFile:
|
|
46
|
+
version: int
|
|
47
|
+
default_profile: str
|
|
48
|
+
profiles: dict[str, ProfileConfig]
|
|
49
|
+
|
|
50
|
+
def get_profile(self, name: str | None = None) -> ProfileConfig:
|
|
51
|
+
profile_name = name or self.default_profile
|
|
52
|
+
if profile_name not in self.profiles:
|
|
53
|
+
raise ValueError(f"Profile not found: {profile_name}")
|
|
54
|
+
return self.profiles[profile_name]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _profile_from_dict(name: str, data: dict[str, Any]) -> ProfileConfig:
|
|
58
|
+
return ProfileConfig(
|
|
59
|
+
name=name,
|
|
60
|
+
apex=data.get("apex", platform_apex()),
|
|
61
|
+
default_account_id=data.get("default_account_id"),
|
|
62
|
+
access_key_id=data.get("access_key_id"),
|
|
63
|
+
secret_access_key=data.get("secret_access_key"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _strip_legacy_session_fields(data: dict[str, Any]) -> dict[str, Any]:
|
|
68
|
+
cleaned = dict(data)
|
|
69
|
+
cleaned.pop("access_token", None)
|
|
70
|
+
cleaned.pop("console_url", None)
|
|
71
|
+
cleaned.pop("mq_url", None)
|
|
72
|
+
cleaned.pop("so_url", None)
|
|
73
|
+
cleaned.pop("secrets_url", None)
|
|
74
|
+
return cleaned
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _normalize_raw(data: dict[str, Any]) -> dict[str, Any]:
|
|
78
|
+
if "profiles" in data:
|
|
79
|
+
return {
|
|
80
|
+
"version": data.get("version", 2),
|
|
81
|
+
"default_profile": data.get("default_profile", DEFAULT_PROFILE),
|
|
82
|
+
"profiles": {
|
|
83
|
+
name: _strip_legacy_session_fields(profile_data)
|
|
84
|
+
for name, profile_data in data["profiles"].items()
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
profile = _strip_legacy_session_fields(data)
|
|
89
|
+
return {
|
|
90
|
+
"version": data.get("version", 2),
|
|
91
|
+
"default_profile": data.get("default_profile", DEFAULT_PROFILE),
|
|
92
|
+
"profiles": {DEFAULT_PROFILE: profile},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_credentials(path: Path | None = None) -> CredentialsFile:
|
|
97
|
+
cred_path = path or credentials_path()
|
|
98
|
+
if not cred_path.exists():
|
|
99
|
+
raise FileNotFoundError(
|
|
100
|
+
f"Credentials file not found: {cred_path}. Run: homecloud configure"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
raw = json.loads(cred_path.read_text(encoding="utf-8"))
|
|
104
|
+
if not isinstance(raw, dict):
|
|
105
|
+
raise ValueError("Invalid credentials file: expected JSON object")
|
|
106
|
+
|
|
107
|
+
normalized = _normalize_raw(raw)
|
|
108
|
+
profiles = {
|
|
109
|
+
name: _profile_from_dict(name, profile_data)
|
|
110
|
+
for name, profile_data in normalized.get("profiles", {}).items()
|
|
111
|
+
}
|
|
112
|
+
if not profiles:
|
|
113
|
+
raise ValueError("No profiles found in credentials file")
|
|
114
|
+
|
|
115
|
+
return CredentialsFile(
|
|
116
|
+
version=int(normalized.get("version", 2)),
|
|
117
|
+
default_profile=normalized.get("default_profile", DEFAULT_PROFILE),
|
|
118
|
+
profiles=profiles,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def save_credentials(credentials: CredentialsFile, path: Path | None = None) -> Path:
|
|
123
|
+
cred_path = path or credentials_path()
|
|
124
|
+
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
|
|
126
|
+
payload = {
|
|
127
|
+
"version": credentials.version,
|
|
128
|
+
"default_profile": credentials.default_profile,
|
|
129
|
+
"profiles": {
|
|
130
|
+
name: {
|
|
131
|
+
"apex": profile.apex,
|
|
132
|
+
"default_account_id": profile.default_account_id,
|
|
133
|
+
"access_key_id": profile.access_key_id,
|
|
134
|
+
"secret_access_key": profile.secret_access_key,
|
|
135
|
+
}
|
|
136
|
+
for name, profile in credentials.profiles.items()
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
cred_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
141
|
+
try:
|
|
142
|
+
cred_path.chmod(0o600)
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
return cred_path
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def upsert_profile(profile: ProfileConfig, *, make_default: bool = True) -> Path:
|
|
149
|
+
try:
|
|
150
|
+
credentials = load_credentials()
|
|
151
|
+
except FileNotFoundError:
|
|
152
|
+
credentials = CredentialsFile(version=2, default_profile=profile.name, profiles={})
|
|
153
|
+
|
|
154
|
+
credentials.profiles[profile.name] = profile
|
|
155
|
+
if make_default:
|
|
156
|
+
credentials.default_profile = profile.name
|
|
157
|
+
return save_credentials(credentials)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def mask_secret(value: str | None) -> str:
|
|
161
|
+
if not value:
|
|
162
|
+
return ""
|
|
163
|
+
if len(value) <= 8:
|
|
164
|
+
return "****"
|
|
165
|
+
return f"{value[:4]}...{value[-4:]}"
|