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/__init__.py +1 -0
- homecloud_core/account.py +23 -0
- homecloud_core/config.py +165 -0
- homecloud_core/context.py +323 -0
- homecloud_core/defaults.py +39 -0
- homecloud_core/env.py +49 -0
- homecloud_core/errors.py +56 -0
- homecloud_core/mfa.py +120 -0
- homecloud_core/progress_reader.py +27 -0
- homecloud_core/py.typed +0 -0
- homecloud_core/session.py +115 -0
- homecloud_core/signing.py +50 -0
- homecloud_core/so_paths.py +29 -0
- homecloud_core/transfer_state.py +65 -0
- homecloud_core/transport.py +388 -0
- homecloud_sdk/__init__.py +24 -0
- homecloud_sdk/client.py +177 -0
- homecloud_sdk/py.typed +0 -0
- homecloud_sdk/services.py +552 -0
- homecloud_sdk/so_parallel.py +38 -0
- homecloud_sdk-0.3.2.dist-info/METADATA +128 -0
- homecloud_sdk-0.3.2.dist-info/RECORD +24 -0
- homecloud_sdk-0.3.2.dist-info/WHEEL +5 -0
- homecloud_sdk-0.3.2.dist-info/top_level.txt +2 -0
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)
|
homecloud_core/py.typed
ADDED
|
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
|
+
)
|