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.
Files changed (38) hide show
  1. homecloud_sdk-0.3.2/PKG-INFO +128 -0
  2. homecloud_sdk-0.3.2/README.md +112 -0
  3. homecloud_sdk-0.3.2/homecloud_core/__init__.py +1 -0
  4. homecloud_sdk-0.3.2/homecloud_core/account.py +23 -0
  5. homecloud_sdk-0.3.2/homecloud_core/config.py +165 -0
  6. homecloud_sdk-0.3.2/homecloud_core/context.py +323 -0
  7. homecloud_sdk-0.3.2/homecloud_core/defaults.py +39 -0
  8. homecloud_sdk-0.3.2/homecloud_core/env.py +49 -0
  9. homecloud_sdk-0.3.2/homecloud_core/errors.py +56 -0
  10. homecloud_sdk-0.3.2/homecloud_core/mfa.py +120 -0
  11. homecloud_sdk-0.3.2/homecloud_core/progress_reader.py +27 -0
  12. homecloud_sdk-0.3.2/homecloud_core/py.typed +0 -0
  13. homecloud_sdk-0.3.2/homecloud_core/session.py +115 -0
  14. homecloud_sdk-0.3.2/homecloud_core/signing.py +50 -0
  15. homecloud_sdk-0.3.2/homecloud_core/so_paths.py +29 -0
  16. homecloud_sdk-0.3.2/homecloud_core/transfer_state.py +65 -0
  17. homecloud_sdk-0.3.2/homecloud_core/transport.py +388 -0
  18. homecloud_sdk-0.3.2/homecloud_sdk/__init__.py +24 -0
  19. homecloud_sdk-0.3.2/homecloud_sdk/client.py +177 -0
  20. homecloud_sdk-0.3.2/homecloud_sdk/py.typed +0 -0
  21. homecloud_sdk-0.3.2/homecloud_sdk/services.py +552 -0
  22. homecloud_sdk-0.3.2/homecloud_sdk/so_parallel.py +38 -0
  23. homecloud_sdk-0.3.2/homecloud_sdk.egg-info/PKG-INFO +128 -0
  24. homecloud_sdk-0.3.2/homecloud_sdk.egg-info/SOURCES.txt +36 -0
  25. homecloud_sdk-0.3.2/homecloud_sdk.egg-info/dependency_links.txt +1 -0
  26. homecloud_sdk-0.3.2/homecloud_sdk.egg-info/requires.txt +5 -0
  27. homecloud_sdk-0.3.2/homecloud_sdk.egg-info/top_level.txt +2 -0
  28. homecloud_sdk-0.3.2/pyproject.toml +41 -0
  29. homecloud_sdk-0.3.2/setup.cfg +4 -0
  30. homecloud_sdk-0.3.2/tests/test_auth_model.py +99 -0
  31. homecloud_sdk-0.3.2/tests/test_client.py +77 -0
  32. homecloud_sdk-0.3.2/tests/test_config.py +85 -0
  33. homecloud_sdk-0.3.2/tests/test_env_profile.py +171 -0
  34. homecloud_sdk-0.3.2/tests/test_head_object.py +68 -0
  35. homecloud_sdk-0.3.2/tests/test_live_integration.py +188 -0
  36. homecloud_sdk-0.3.2/tests/test_signing.py +25 -0
  37. homecloud_sdk-0.3.2/tests/test_so_paths.py +19 -0
  38. 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:]}"