homecloud-sdk 0.3.2__py3-none-any.whl

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.
@@ -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:]}"
@@ -0,0 +1,323 @@
1
+ """Runtime context — wires credentials, session, transport, and account resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ import webbrowser
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ from homecloud_core.account import remember_account, resolve_account_id
11
+ from homecloud_core.config import (
12
+ ProfileConfig,
13
+ load_credentials,
14
+ upsert_profile,
15
+ )
16
+ from homecloud_core.defaults import DEFAULT_PROFILE, platform_apex
17
+ from homecloud_core.env import (
18
+ env_access_key_id,
19
+ env_account_id,
20
+ env_apex,
21
+ env_profile,
22
+ env_secret_access_key,
23
+ )
24
+ from homecloud_core.errors import HomeCloudError, NotConfiguredError, NotLoggedInError
25
+ from homecloud_core.mfa import MfaResolver
26
+ from homecloud_core.session import (
27
+ get_access_token,
28
+ load_session,
29
+ migrate_legacy_token_from_credentials,
30
+ set_access_token,
31
+ )
32
+ from homecloud_core.transport import Transport
33
+
34
+
35
+ class CoreContext:
36
+ """
37
+ Shared runtime for SDK and CLI.
38
+
39
+ Programmatic path (default): Access Key ID + Secret → data-plane SigV1.
40
+ Interactive path (CLI): optional console JWT via login / login_browser (+ MFA).
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ profile_name: str | None = None,
46
+ *,
47
+ access_key_id: str | None = None,
48
+ secret_access_key: str | None = None,
49
+ account_id: str | None = None,
50
+ apex: str | None = None,
51
+ mfa_code: str | None = None,
52
+ mfa_prompt: Callable[[str], str] | None = None,
53
+ interactive_mfa: bool = False,
54
+ mfa_choose_method: Callable[[list[str], list[dict] | None], str] | None = None,
55
+ ) -> None:
56
+ explicit = profile_name or env_profile()
57
+ try:
58
+ credentials = load_credentials()
59
+ self.profile_name = explicit or credentials.default_profile or DEFAULT_PROFILE
60
+ try:
61
+ self.profile = credentials.get_profile(self.profile_name)
62
+ except ValueError:
63
+ self.profile = ProfileConfig(name=self.profile_name)
64
+ except FileNotFoundError:
65
+ self.profile_name = explicit or DEFAULT_PROFILE
66
+ self.profile = ProfileConfig(name=self.profile_name)
67
+
68
+ self._session = load_session().get(self.profile_name)
69
+ self._apply_env_overrides()
70
+
71
+ # Constructor credentials win over file + env (AWS-style).
72
+ if access_key_id:
73
+ self.profile.access_key_id = access_key_id
74
+ if secret_access_key:
75
+ self.profile.secret_access_key = secret_access_key
76
+ if account_id:
77
+ self.profile.default_account_id = account_id
78
+ self._account_id = account_id
79
+ else:
80
+ self._account_id = None
81
+ if apex:
82
+ self.profile.apex = apex.strip().rstrip("/")
83
+
84
+ self._mfa_prompt = mfa_prompt
85
+ self._interactive_mfa = interactive_mfa
86
+ self._mfa_choose_method = mfa_choose_method
87
+ self._mfa_resolver = MfaResolver(
88
+ mfa_code=mfa_code,
89
+ prompt=mfa_prompt,
90
+ interactive=interactive_mfa,
91
+ choose_method=mfa_choose_method,
92
+ )
93
+ self._transport = Transport(
94
+ apex=self.profile.apex,
95
+ access_key_id=self.profile.access_key_id,
96
+ secret_access_key=self.profile.secret_access_key,
97
+ access_token=self._session.access_token,
98
+ # MFA only when interactive CLI/tools opt in — never on default SDK path.
99
+ mfa_resolver=self._mfa_resolver if interactive_mfa or mfa_code else None,
100
+ )
101
+
102
+ def _apply_env_overrides(self) -> None:
103
+ if apex := env_apex():
104
+ self.profile.apex = apex
105
+ if account := env_account_id():
106
+ self.profile.default_account_id = account
107
+ if key_id := env_access_key_id():
108
+ self.profile.access_key_id = key_id
109
+ if secret := env_secret_access_key():
110
+ self.profile.secret_access_key = secret
111
+
112
+ @property
113
+ def transport(self) -> Transport:
114
+ return self._transport
115
+
116
+ @property
117
+ def has_access_key(self) -> bool:
118
+ return bool(self.profile.access_key_id and self.profile.secret_access_key)
119
+
120
+ @property
121
+ def has_console_session(self) -> bool:
122
+ return bool(self._transport.access_token)
123
+
124
+ def close(self) -> None:
125
+ self._transport.close()
126
+
127
+ def require_access_key(self) -> None:
128
+ if not self.has_access_key:
129
+ raise NotConfiguredError(
130
+ "Access Key not configured. Pass access_key_id/secret_access_key, "
131
+ "set HOMECLOUD_ACCESS_KEY_ID / HC_ACCESS_KEY_ID, or run: homecloud configure"
132
+ )
133
+
134
+ def require_console_session(self) -> None:
135
+ if not self.has_console_session:
136
+ raise NotLoggedInError(
137
+ "This operation needs a console JWT (human session). "
138
+ "For automation use Access Key data-plane APIs instead. "
139
+ "Interactive: client.login(...) or homecloud login"
140
+ )
141
+
142
+ def account_id(self) -> str:
143
+ if self._account_id is not None:
144
+ return self._account_id
145
+ try:
146
+ account_id = resolve_account_id(self.profile, self._session)
147
+ except NotConfiguredError:
148
+ self.require_access_key()
149
+ account_id = self._transport.resolve_access_key_account_id()
150
+ remember_account(self.profile_name, account_id)
151
+ self._account_id = account_id
152
+ return account_id
153
+
154
+ def _apply_access_token(self, token: str) -> None:
155
+ set_access_token(self.profile_name, token)
156
+ self._transport.access_token = token
157
+ self._session = load_session().get(self.profile_name)
158
+ self._auto_select_account()
159
+
160
+ def login(self, username: str, password: str, *, mfa_code: str | None = None) -> None:
161
+ """Interactive console login — not for unattended SDK/automation."""
162
+ if mfa_code or self._interactive_mfa:
163
+ self._mfa_resolver = MfaResolver(
164
+ mfa_code=mfa_code,
165
+ prompt=self._mfa_prompt,
166
+ interactive=self._interactive_mfa,
167
+ choose_method=self._mfa_choose_method,
168
+ )
169
+ self._transport.set_mfa_resolver(self._mfa_resolver)
170
+
171
+ body: dict[str, Any] = {"username": username, "password": password}
172
+ if mfa_code:
173
+ body["mfa_code"] = mfa_code
174
+
175
+ data = self._transport.console_request(
176
+ "POST",
177
+ "auth/login",
178
+ json=body,
179
+ require_auth=False,
180
+ )
181
+ token = data.get("access_token")
182
+ if not token:
183
+ raise HomeCloudError("Login failed")
184
+ self._apply_access_token(str(token))
185
+
186
+ def login_browser(
187
+ self,
188
+ *,
189
+ open_browser: bool = True,
190
+ on_waiting: Callable[[str], None] | None = None,
191
+ ) -> None:
192
+ """Browser/passkey console login — interactive tools only."""
193
+ start = self._transport.console_request(
194
+ "POST",
195
+ "auth/cli/session",
196
+ require_auth=False,
197
+ )
198
+ session_id = start.get("session_id")
199
+ verification_uri = start.get("verification_uri")
200
+ if not session_id or not verification_uri:
201
+ raise HomeCloudError("Failed to start browser login session")
202
+
203
+ expires_in = int(start.get("expires_in") or 600)
204
+ interval = max(1, int(start.get("interval") or 2))
205
+ deadline = time.monotonic() + expires_in
206
+
207
+ if open_browser:
208
+ webbrowser.open(str(verification_uri))
209
+ if on_waiting:
210
+ on_waiting(str(verification_uri))
211
+
212
+ while time.monotonic() < deadline:
213
+ poll = self._transport.console_request(
214
+ "GET",
215
+ f"auth/cli/session/{session_id}",
216
+ require_auth=False,
217
+ _skip_mfa=True,
218
+ )
219
+ status = str(poll.get("status") or "")
220
+ if status == "complete":
221
+ token = poll.get("access_token")
222
+ if not token:
223
+ raise HomeCloudError("Browser login completed without access token")
224
+ self._apply_access_token(str(token))
225
+ return
226
+ if status == "expired":
227
+ raise HomeCloudError("Browser login session expired")
228
+ time.sleep(interval)
229
+
230
+ raise HomeCloudError("Browser login timed out")
231
+
232
+ def _auto_select_account(self) -> None:
233
+ if self._session.active_account_id or self.profile.default_account_id:
234
+ return
235
+ accounts = self.list_accounts()
236
+ if len(accounts) == 1:
237
+ remember_account(self.profile_name, str(accounts[0]["id"]))
238
+
239
+ def list_accounts(self) -> list[dict[str, Any]]:
240
+ self.require_console_session()
241
+ data = self._transport.console_request("GET", "accounts")
242
+ return data.get("items", data if isinstance(data, list) else [])
243
+
244
+ def switch_account(self, account_ref: str) -> None:
245
+ accounts = self.list_accounts()
246
+ match = next(
247
+ (
248
+ account
249
+ for account in accounts
250
+ if str(account.get("id")) == account_ref
251
+ or str(account.get("slug")) == account_ref
252
+ or str(account.get("name")) == account_ref
253
+ ),
254
+ None,
255
+ )
256
+ if not match:
257
+ raise HomeCloudError(f"Account not found: {account_ref}")
258
+ remember_account(self.profile_name, str(match["id"]))
259
+ self._account_id = str(match["id"])
260
+
261
+ @staticmethod
262
+ def configure_profile(
263
+ *,
264
+ profile_name: str,
265
+ access_key_id: str,
266
+ secret_access_key: str,
267
+ default_account_id: str | None = None,
268
+ apex: str | None = None,
269
+ make_default: bool = True,
270
+ ) -> None:
271
+ upsert_profile(
272
+ ProfileConfig(
273
+ name=profile_name,
274
+ apex=apex or platform_apex(),
275
+ access_key_id=access_key_id,
276
+ secret_access_key=secret_access_key,
277
+ default_account_id=default_account_id,
278
+ ),
279
+ make_default=make_default,
280
+ )
281
+
282
+ @staticmethod
283
+ def import_credentials_file(
284
+ raw: dict[str, Any],
285
+ *,
286
+ profile_name: str = DEFAULT_PROFILE,
287
+ ) -> None:
288
+ migrate_legacy_token_from_credentials(profile_name, raw.get("access_token"))
289
+ CoreContext.configure_profile(
290
+ profile_name=profile_name,
291
+ access_key_id=raw["access_key_id"],
292
+ secret_access_key=raw["secret_access_key"],
293
+ default_account_id=raw.get("default_account_id"),
294
+ apex=raw.get("apex"),
295
+ )
296
+
297
+ def config_summary(self) -> dict[str, Any]:
298
+ from homecloud_core.config import credentials_path, load_credentials, mask_secret
299
+ from homecloud_core.session import session_path
300
+
301
+ try:
302
+ load_credentials()
303
+ configured = True
304
+ except FileNotFoundError:
305
+ configured = False
306
+
307
+ return {
308
+ "profile": self.profile_name,
309
+ "apex": self.profile.apex,
310
+ "credentials_file": str(credentials_path()),
311
+ "session_file": str(session_path()),
312
+ "configured": configured,
313
+ "has_access_key": self.has_access_key,
314
+ "has_console_session": self.has_console_session,
315
+ "access_key_id": self.profile.access_key_id,
316
+ "secret_access_key": mask_secret(self.profile.secret_access_key),
317
+ "logged_in": bool(get_access_token(self.profile_name)),
318
+ "has_account": bool(
319
+ self._account_id
320
+ or self._session.active_account_id
321
+ or self.profile.default_account_id
322
+ ),
323
+ }
@@ -0,0 +1,39 @@
1
+ """Platform endpoint defaults — hidden from end users."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from homecloud_core.env import env_apex
6
+
7
+ DEFAULT_APEX = "holab.abrdns.com"
8
+ DEFAULT_PROFILE = "default"
9
+ WHOAMI_PATH = "/access-key/whoami"
10
+ WHOAMI_ACCOUNT_SENTINEL = "-"
11
+
12
+
13
+ def platform_apex() -> str:
14
+ return env_apex() or DEFAULT_APEX
15
+
16
+
17
+ def console_url(apex: str | None = None) -> str:
18
+ host = apex or platform_apex()
19
+ return f"https://console.{host}/api/v1"
20
+
21
+
22
+ def console_web_url(apex: str | None = None) -> str:
23
+ host = apex or platform_apex()
24
+ return f"https://console.{host}"
25
+
26
+
27
+ def mq_url(apex: str | None = None) -> str:
28
+ host = apex or platform_apex()
29
+ return f"https://mq.{host}"
30
+
31
+
32
+ def so_url(apex: str | None = None) -> str:
33
+ host = apex or platform_apex()
34
+ return f"https://so.{host}"
35
+
36
+
37
+ def secrets_url(apex: str | None = None) -> str:
38
+ host = apex or platform_apex()
39
+ return f"https://secrets.{host}"
homecloud_core/env.py ADDED
@@ -0,0 +1,49 @@
1
+ """Environment variable helpers — HOMECLOUD_* canonical, HC_* short aliases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+
8
+ def env_first(*names: str) -> str | None:
9
+ """Return the first non-empty env value among ``names`` (in order)."""
10
+ for name in names:
11
+ value = os.environ.get(name)
12
+ if value is not None and value.strip() != "":
13
+ return value
14
+ return None
15
+
16
+
17
+ def env_profile() -> str | None:
18
+ return env_first("HOMECLOUD_PROFILE", "HC_PROFILE")
19
+
20
+
21
+ def env_apex() -> str | None:
22
+ value = env_first("HOMECLOUD_APEX", "HC_APEX")
23
+ if value is None:
24
+ return None
25
+ return value.strip().rstrip("/")
26
+
27
+
28
+ def env_account_id() -> str | None:
29
+ return env_first("HOMECLOUD_ACCOUNT_ID", "HC_ACCOUNT_ID")
30
+
31
+
32
+ def env_access_key_id() -> str | None:
33
+ return env_first("HOMECLOUD_ACCESS_KEY_ID", "HC_ACCESS_KEY_ID")
34
+
35
+
36
+ def env_secret_access_key() -> str | None:
37
+ return env_first("HOMECLOUD_SECRET_ACCESS_KEY", "HC_SECRET_ACCESS_KEY")
38
+
39
+
40
+ def env_config_dir() -> str | None:
41
+ return env_first("HOMECLOUD_CONFIG_DIR", "HC_CONFIG_DIR")
42
+
43
+
44
+ def env_credentials_file() -> str | None:
45
+ return env_first("HOMECLOUD_CREDENTIALS_FILE", "HC_CREDENTIALS_FILE")
46
+
47
+
48
+ def env_session_file() -> str | None:
49
+ return env_first("HOMECLOUD_SESSION_FILE", "HC_SESSION_FILE")
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class HomeCloudError(Exception):
7
+ def __init__(self, message: str, *, status_code: int | None = None, detail: Any = None) -> None:
8
+ super().__init__(message)
9
+ self.status_code = status_code
10
+ self.detail = detail
11
+
12
+ @property
13
+ def error_payload(self) -> dict[str, Any] | None:
14
+ """Normalized `{code, message, details}` from either envelope or legacy detail dict."""
15
+ if not isinstance(self.detail, dict):
16
+ return None
17
+ error = self.detail.get("error")
18
+ if isinstance(error, dict) and error.get("code"):
19
+ details = error.get("details") if isinstance(error.get("details"), dict) else {}
20
+ return {
21
+ "code": str(error.get("code")),
22
+ "message": str(error.get("message") or error.get("code")),
23
+ "details": details,
24
+ }
25
+ if self.detail.get("code"):
26
+ details = {
27
+ key: value
28
+ for key, value in self.detail.items()
29
+ if key not in {"code", "message"}
30
+ }
31
+ return {
32
+ "code": str(self.detail.get("code")),
33
+ "message": str(self.detail.get("message") or self.detail.get("code")),
34
+ "details": details,
35
+ }
36
+ return None
37
+
38
+ @property
39
+ def error_code(self) -> str | None:
40
+ payload = self.error_payload
41
+ return payload["code"] if payload else None
42
+
43
+ @property
44
+ def error_details(self) -> dict[str, Any]:
45
+ payload = self.error_payload
46
+ if not payload:
47
+ return {}
48
+ return dict(payload.get("details") or {})
49
+
50
+
51
+ class NotConfiguredError(HomeCloudError):
52
+ pass
53
+
54
+
55
+ class NotLoggedInError(HomeCloudError):
56
+ pass