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.
homecloud_core/mfa.py ADDED
@@ -0,0 +1,120 @@
1
+ """Central MFA challenge handling for console API calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from homecloud_core.errors import HomeCloudError
9
+
10
+ PromptFn = Callable[[str], str]
11
+ MfaMethodChooser = Callable[[list[str], list[dict] | None], str]
12
+
13
+
14
+ class PreferBrowserLogin(Exception):
15
+ """User chose passkey/browser to finish authentication."""
16
+
17
+
18
+ def is_mfa_required(exc: HomeCloudError) -> bool:
19
+ return exc.status_code == 403 and exc.error_code == "MFA_REQUIRED"
20
+
21
+
22
+ def prompt_verification_code(prompt: PromptFn | None = None) -> str:
23
+ """Prompt for TOTP or backup code. Never cache the result."""
24
+ ask = prompt or (lambda msg: input(f"{msg}: ").strip())
25
+ code = ask("Verification code")
26
+ if not code:
27
+ raise HomeCloudError("Verification code is required")
28
+ return code.strip()
29
+
30
+
31
+ class MfaResolver:
32
+ """
33
+ Completes MFA_REQUIRED challenges for console requests.
34
+
35
+ - Login challenge (``mfa_token`` in details): POST auth/login with mfa_token + mfa_code
36
+ - Step-up (no mfa_token): retry original JSON body with mfa_code injected
37
+
38
+ Passkeys raise PreferBrowserLogin so the CLI can open browser login.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ mfa_code: str | None = None,
45
+ prompt: PromptFn | None = None,
46
+ interactive: bool = True,
47
+ choose_method: MfaMethodChooser | None = None,
48
+ ) -> None:
49
+ self._mfa_code = mfa_code
50
+ self._prompt = prompt
51
+ self._interactive = interactive
52
+ self._choose_method = choose_method
53
+
54
+ def obtain_code(
55
+ self,
56
+ *,
57
+ methods: list[str] | None = None,
58
+ passkeys: list[dict] | None = None,
59
+ allow_browser_switch: bool = True,
60
+ ) -> str:
61
+ if self._mfa_code:
62
+ code = self._mfa_code
63
+ self._mfa_code = None # one-shot — never reuse across challenges
64
+ return code
65
+ if not self._interactive:
66
+ raise HomeCloudError(
67
+ "MFA required. Re-run with --mfa-code, or use: homecloud login --browser"
68
+ )
69
+
70
+ methods = methods or []
71
+ choice = "totp"
72
+ if self._choose_method and methods:
73
+ choice = self._choose_method(methods, passkeys)
74
+ elif methods and "totp" not in methods and "passkey" in methods:
75
+ choice = "browser"
76
+
77
+ if choice == "browser":
78
+ if not allow_browser_switch:
79
+ raise HomeCloudError(
80
+ "Passkey confirmation is only available in the Console UI for this action."
81
+ )
82
+ raise PreferBrowserLogin()
83
+
84
+ return prompt_verification_code(self._prompt)
85
+
86
+ def resolve(
87
+ self,
88
+ exc: HomeCloudError,
89
+ *,
90
+ method: str,
91
+ path: str,
92
+ json_body: Any | None,
93
+ retry: Callable[..., Any],
94
+ ) -> Any:
95
+ details = exc.error_details
96
+ mfa_token = details.get("mfa_token")
97
+ methods_raw = details.get("methods") if isinstance(details.get("methods"), list) else None
98
+ methods = [str(m) for m in methods_raw] if methods_raw else None
99
+ passkeys_raw = details.get("passkeys")
100
+ passkeys = passkeys_raw if isinstance(passkeys_raw, list) else None
101
+ # Login challenge can switch to browser; step-up cannot.
102
+ allow_browser = bool(mfa_token) or path.rstrip("/").endswith("auth/login")
103
+ code = self.obtain_code(
104
+ methods=methods,
105
+ passkeys=[p for p in passkeys if isinstance(p, dict)] if passkeys else None,
106
+ allow_browser_switch=allow_browser,
107
+ )
108
+
109
+ if mfa_token:
110
+ return retry(
111
+ "POST",
112
+ "auth/login",
113
+ json={"mfa_token": mfa_token, "mfa_code": code},
114
+ require_auth=False,
115
+ _skip_mfa=True,
116
+ )
117
+
118
+ body = dict(json_body) if isinstance(json_body, dict) else {}
119
+ body["mfa_code"] = code
120
+ return retry(method, path, json=body, _skip_mfa=True)
@@ -0,0 +1,27 @@
1
+ """File-like wrapper that reports bytes read for upload progress."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+
9
+ class ProgressReader:
10
+ """Wrap a binary file handle; call on_bytes with each read() length."""
11
+
12
+ def __init__(
13
+ self,
14
+ wrapped: Any,
15
+ on_bytes: Callable[[int], None] | None,
16
+ ) -> None:
17
+ self._wrapped = wrapped
18
+ self._on_bytes = on_bytes
19
+
20
+ def read(self, size: int = -1) -> bytes:
21
+ data = self._wrapped.read(size)
22
+ if data and self._on_bytes is not None:
23
+ self._on_bytes(len(data))
24
+ return data
25
+
26
+ def __getattr__(self, name: str) -> Any:
27
+ return getattr(self._wrapped, name)
File without changes
@@ -0,0 +1,115 @@
1
+ """Temporary session state — JWT and active account selection."""
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.config import homecloud_dir
11
+ from homecloud_core.env import env_session_file
12
+
13
+
14
+ def session_path() -> Path:
15
+ override = env_session_file()
16
+ if override:
17
+ return Path(override).expanduser()
18
+ return homecloud_dir() / "session"
19
+
20
+
21
+ @dataclass
22
+ class ProfileSession:
23
+ access_token: str | None = None
24
+ active_account_id: str | None = None
25
+ last_used_account_id: str | None = None
26
+
27
+
28
+ @dataclass
29
+ class SessionFile:
30
+ version: int
31
+ profiles: dict[str, ProfileSession]
32
+
33
+ def get(self, profile_name: str) -> ProfileSession:
34
+ return self.profiles.setdefault(profile_name, ProfileSession())
35
+
36
+
37
+ def load_session(path: Path | None = None) -> SessionFile:
38
+ session_file = path or session_path()
39
+ if not session_file.exists():
40
+ return SessionFile(version=1, profiles={})
41
+
42
+ raw = json.loads(session_file.read_text(encoding="utf-8"))
43
+ profiles = {
44
+ name: ProfileSession(
45
+ access_token=data.get("access_token"),
46
+ active_account_id=data.get("active_account_id"),
47
+ last_used_account_id=data.get("last_used_account_id"),
48
+ )
49
+ for name, data in raw.get("profiles", {}).items()
50
+ }
51
+ return SessionFile(version=int(raw.get("version", 1)), profiles=profiles)
52
+
53
+
54
+ def save_session(session: SessionFile, path: Path | None = None) -> Path:
55
+ session_file = path or session_path()
56
+ session_file.parent.mkdir(parents=True, exist_ok=True)
57
+
58
+ payload: dict[str, Any] = {
59
+ "version": session.version,
60
+ "profiles": {
61
+ name: {
62
+ **({"access_token": profile.access_token} if profile.access_token else {}),
63
+ **({"active_account_id": profile.active_account_id} if profile.active_account_id else {}),
64
+ **(
65
+ {"last_used_account_id": profile.last_used_account_id}
66
+ if profile.last_used_account_id
67
+ else {}
68
+ ),
69
+ }
70
+ for name, profile in session.profiles.items()
71
+ if profile.access_token or profile.active_account_id or profile.last_used_account_id
72
+ },
73
+ }
74
+
75
+ session_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
76
+ try:
77
+ session_file.chmod(0o600)
78
+ except OSError:
79
+ pass
80
+ return session_file
81
+
82
+
83
+ def set_access_token(profile_name: str, token: str) -> Path:
84
+ session = load_session()
85
+ profile_session = session.get(profile_name)
86
+ profile_session.access_token = token
87
+ session.profiles[profile_name] = profile_session
88
+ return save_session(session)
89
+
90
+
91
+ def set_active_account(profile_name: str, account_id: str) -> Path:
92
+ session = load_session()
93
+ profile_session = session.get(profile_name)
94
+ profile_session.active_account_id = account_id
95
+ profile_session.last_used_account_id = account_id
96
+ session.profiles[profile_name] = profile_session
97
+ return save_session(session)
98
+
99
+
100
+ def get_access_token(profile_name: str) -> str | None:
101
+ return load_session().get(profile_name).access_token
102
+
103
+
104
+ def migrate_legacy_token_from_credentials(
105
+ profile_name: str,
106
+ access_token: str | None,
107
+ ) -> None:
108
+ if not access_token:
109
+ return
110
+ session = load_session()
111
+ profile_session = session.get(profile_name)
112
+ if not profile_session.access_token:
113
+ profile_session.access_token = access_token
114
+ session.profiles[profile_name] = profile_session
115
+ save_session(session)
@@ -0,0 +1,50 @@
1
+ """HomeCloud request signing (SigV1) — internal only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ from datetime import datetime, timezone
8
+
9
+
10
+ def build_string_to_sign(
11
+ *,
12
+ method: str,
13
+ path: str,
14
+ timestamp: str,
15
+ account_id: str,
16
+ ) -> str:
17
+ return f"{method.upper()}\n{path}\n{timestamp}\n{account_id}"
18
+
19
+
20
+ def compute_signature(*, secret: str, string_to_sign: str) -> str:
21
+ return hmac.new(
22
+ secret.encode("utf-8"),
23
+ string_to_sign.encode("utf-8"),
24
+ hashlib.sha256,
25
+ ).hexdigest()
26
+
27
+
28
+ def sign_request_headers(
29
+ *,
30
+ access_key_id: str,
31
+ secret: str,
32
+ method: str,
33
+ path: str,
34
+ account_id: str,
35
+ timestamp: datetime | None = None,
36
+ ) -> dict[str, str]:
37
+ ts = (timestamp or datetime.now(timezone.utc)).replace(microsecond=0)
38
+ ts_str = ts.isoformat().replace("+00:00", "Z")
39
+ string_to_sign = build_string_to_sign(
40
+ method=method,
41
+ path=path,
42
+ timestamp=ts_str,
43
+ account_id=account_id,
44
+ )
45
+ signature = compute_signature(secret=secret, string_to_sign=string_to_sign)
46
+ return {
47
+ "X-Homecloud-Access-Key-Id": access_key_id,
48
+ "X-Homecloud-Date": ts_str,
49
+ "X-Homecloud-Signature": signature,
50
+ }
@@ -0,0 +1,29 @@
1
+ """SO object path helpers — canonical sign path vs URL-encoded request path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote
6
+
7
+
8
+ def encode_object_key_path(key: str) -> str:
9
+ """Encode each object key segment for the HTTP path (spaces → %20, etc.)."""
10
+ return "/".join(quote(part, safe="") for part in key.lstrip("/").split("/"))
11
+
12
+
13
+ def so_object_paths(account_id: str, bucket_name: str, object_key: str) -> tuple[str, str]:
14
+ """Return (sign_path, url_path) for object GET/DELETE."""
15
+ key = object_key.lstrip("/")
16
+ sign_path = f"/{account_id}/{bucket_name}/objects/{key}"
17
+ url_path = f"/{account_id}/{bucket_name}/objects/{encode_object_key_path(key)}"
18
+ return sign_path, url_path
19
+
20
+
21
+ def sync_relative_local_path(key: str, prefix_clean: str) -> str:
22
+ """Map remote object key to a relative path under the sync destination directory."""
23
+ if not prefix_clean:
24
+ return key
25
+ if key == prefix_clean:
26
+ return key.rsplit("/", 1)[-1]
27
+ if key.startswith(f"{prefix_clean}/"):
28
+ return key[len(prefix_clean) + 1 :]
29
+ return key
@@ -0,0 +1,65 @@
1
+ """Thread-safe transfer metrics for upload/download progress."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class TransferSnapshot:
12
+ total_bytes: int
13
+ completed_bytes: int
14
+ files_total: int
15
+ files_completed: int
16
+ active_files: tuple[str, ...]
17
+ bytes_per_second: float
18
+ eta_seconds: float | None
19
+
20
+
21
+ class TransferState:
22
+ """Mutable transfer counters — workers update via add_bytes / file_* only."""
23
+
24
+ def __init__(self, *, total_bytes: int, files_total: int) -> None:
25
+ self.total_bytes = max(total_bytes, 0)
26
+ self.files_total = max(files_total, 0)
27
+ self._completed_bytes = 0
28
+ self._files_completed = 0
29
+ self._active_files: list[str] = []
30
+ self._lock = threading.Lock()
31
+ self._started_at = time.monotonic()
32
+
33
+ def add_bytes(self, nbytes: int) -> None:
34
+ if nbytes <= 0:
35
+ return
36
+ with self._lock:
37
+ self._completed_bytes += nbytes
38
+
39
+ def file_begin(self, key: str) -> None:
40
+ with self._lock:
41
+ if key not in self._active_files:
42
+ self._active_files.append(key)
43
+
44
+ def file_complete(self, key: str) -> None:
45
+ with self._lock:
46
+ if key in self._active_files:
47
+ self._active_files.remove(key)
48
+ self._files_completed += 1
49
+
50
+ def snapshot(self) -> TransferSnapshot:
51
+ with self._lock:
52
+ elapsed = max(time.monotonic() - self._started_at, 0.001)
53
+ completed = self._completed_bytes
54
+ speed = completed / elapsed
55
+ remaining = max(self.total_bytes - completed, 0)
56
+ eta = remaining / speed if speed > 0 and remaining > 0 else None
57
+ return TransferSnapshot(
58
+ total_bytes=self.total_bytes,
59
+ completed_bytes=completed,
60
+ files_total=self.files_total,
61
+ files_completed=self._files_completed,
62
+ active_files=tuple(self._active_files),
63
+ bytes_per_second=speed,
64
+ eta_seconds=eta,
65
+ )