pardesline 0.1.0__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.
- pardesline/__init__.py +173 -0
- pardesline/_auth.py +89 -0
- pardesline/_catalog.py +136 -0
- pardesline/_http.py +126 -0
- pardesline/cli.py +84 -0
- pardesline/client.py +308 -0
- pardesline/errors.py +113 -0
- pardesline/jobs.py +80 -0
- pardesline/models.py +118 -0
- pardesline/objects.py +220 -0
- pardesline/py.typed +0 -0
- pardesline/remote.py +294 -0
- pardesline/resources.py +201 -0
- pardesline-0.1.0.dist-info/METADATA +119 -0
- pardesline-0.1.0.dist-info/RECORD +18 -0
- pardesline-0.1.0.dist-info/WHEEL +4 -0
- pardesline-0.1.0.dist-info/entry_points.txt +2 -0
- pardesline-0.1.0.dist-info/licenses/LICENSE +23 -0
pardesline/__init__.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""PardesLine — transfer files between accounts in a few lines.
|
|
2
|
+
|
|
3
|
+
import pardesline as pl
|
|
4
|
+
pl.login()
|
|
5
|
+
obj = pl.upload("scan.ply")
|
|
6
|
+
pl.share(obj, to="bob@lab.org")
|
|
7
|
+
|
|
8
|
+
# bob:
|
|
9
|
+
pl.read("alice@lab.org/scan.ply").download("scan.ply")
|
|
10
|
+
|
|
11
|
+
The module-level helpers use a default client built lazily from the stored
|
|
12
|
+
credentials (PARDESLINE_API_KEY or ~/.pardesline/token). For explicit control,
|
|
13
|
+
use the Client class.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
|
|
22
|
+
from .client import Client
|
|
23
|
+
from .errors import (
|
|
24
|
+
AuthError,
|
|
25
|
+
Conflict,
|
|
26
|
+
Forbidden,
|
|
27
|
+
GpuAddonRequired,
|
|
28
|
+
NotFound,
|
|
29
|
+
PardesLineError,
|
|
30
|
+
PlatformLocked,
|
|
31
|
+
QuotaExceeded,
|
|
32
|
+
RateLimited,
|
|
33
|
+
TooLarge,
|
|
34
|
+
TransferError,
|
|
35
|
+
)
|
|
36
|
+
from .objects import RemoteObject
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"AuthError",
|
|
40
|
+
"Client",
|
|
41
|
+
"Conflict",
|
|
42
|
+
"Forbidden",
|
|
43
|
+
"GpuAddonRequired",
|
|
44
|
+
"NotFound",
|
|
45
|
+
"PardesLineError",
|
|
46
|
+
"PlatformLocked",
|
|
47
|
+
"QuotaExceeded",
|
|
48
|
+
"RateLimited",
|
|
49
|
+
"RemoteObject",
|
|
50
|
+
"TooLarge",
|
|
51
|
+
"TransferError",
|
|
52
|
+
"delete",
|
|
53
|
+
"download",
|
|
54
|
+
"gpu",
|
|
55
|
+
"inbox",
|
|
56
|
+
"list_files",
|
|
57
|
+
"login",
|
|
58
|
+
"logout",
|
|
59
|
+
"me",
|
|
60
|
+
"np",
|
|
61
|
+
"o3d",
|
|
62
|
+
"pv",
|
|
63
|
+
"read",
|
|
64
|
+
"send",
|
|
65
|
+
"share",
|
|
66
|
+
"sitk",
|
|
67
|
+
"upload",
|
|
68
|
+
"upload_bytes",
|
|
69
|
+
"view",
|
|
70
|
+
"view_registration",
|
|
71
|
+
"whoami",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
_default: Optional[Client] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _client() -> Client:
|
|
78
|
+
global _default
|
|
79
|
+
if _default is None:
|
|
80
|
+
_default = Client()
|
|
81
|
+
return _default
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def login(token: Optional[str] = None, *, email: Optional[str] = None, save: bool = True) -> Client:
|
|
85
|
+
"""Log in (terminal email+code flow, or with a pl_ key). Returns the client."""
|
|
86
|
+
global _default
|
|
87
|
+
_default = Client(token=token) if token else Client()
|
|
88
|
+
return _default.login(token=token, email=email, save=save)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def logout() -> None:
|
|
92
|
+
_client().logout()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def whoami() -> Optional[str]:
|
|
96
|
+
return _client().whoami()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def me():
|
|
100
|
+
return _client().me()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def upload(path, *, name=None, project=None, workspace=None) -> RemoteObject:
|
|
104
|
+
return _client().upload(path, name=name, project=project, workspace=workspace)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def upload_bytes(data: bytes, name: str, *, project=None) -> RemoteObject:
|
|
108
|
+
return _client().upload_bytes(data, name, project=project)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def send(path, to: str, *, name=None, message=None) -> RemoteObject:
|
|
112
|
+
"""Upload a local file and hand it to another account in one call."""
|
|
113
|
+
return _client().send(path, to=to, name=name, message=message)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def read(ref: str) -> RemoteObject:
|
|
117
|
+
return _client().read(ref)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def download(ref: str, path=None, *, extract: bool = False):
|
|
121
|
+
return _client().download(ref, path, extract=extract)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def share(obj, *, to: str, message: Optional[str] = None):
|
|
125
|
+
return _client().share(obj, to=to, message=message)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def inbox(*, limit: int = 50):
|
|
129
|
+
return _client().inbox(limit=limit)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def list_files(scope: str = "mine", *, limit: int = 50):
|
|
133
|
+
return _client().list(scope, limit=limit)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def delete(obj) -> None:
|
|
137
|
+
_client().delete(obj)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def view(*objs):
|
|
141
|
+
return _client().view(*objs)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def view_registration(fixed, moving=None, *, method: str = "rigid"):
|
|
145
|
+
return _client().view_registration(fixed, moving, method=method)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class _GpuProxy:
|
|
149
|
+
def __getattr__(self, name):
|
|
150
|
+
return getattr(_client().gpu, name)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
gpu = _GpuProxy()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _LibProxy:
|
|
157
|
+
"""Module-level pl.o3d / pl.pv / pl.sitk / pl.np — the faithful, server-side
|
|
158
|
+
mirrors of open3d / pyvista / SimpleITK / numpy (bound to the default client)."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, attr: str):
|
|
161
|
+
self._attr = attr
|
|
162
|
+
|
|
163
|
+
def __getattr__(self, name: str):
|
|
164
|
+
return getattr(getattr(_client(), self._attr), name)
|
|
165
|
+
|
|
166
|
+
def __call__(self, *args, **kwargs):
|
|
167
|
+
return getattr(_client(), self._attr)(*args, **kwargs)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
o3d = _LibProxy("o3d")
|
|
171
|
+
pv = _LibProxy("pv")
|
|
172
|
+
sitk = _LibProxy("sitk")
|
|
173
|
+
np = _LibProxy("np")
|
pardesline/_auth.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Credential resolution and the terminal login flow.
|
|
2
|
+
|
|
3
|
+
Precedence (highest first):
|
|
4
|
+
1. an explicit token passed to Client(token=...) / login(token=...)
|
|
5
|
+
2. the PARDESLINE_API_KEY environment variable (CI, notebooks)
|
|
6
|
+
3. the key saved at ~/.pardesline/token (mode 0600) by a previous login()
|
|
7
|
+
|
|
8
|
+
login() with no argument is the Hugging-Face-style terminal flow: email →
|
|
9
|
+
6-digit code → the SDK mints a long-lived pl_ key with that short session and
|
|
10
|
+
stores it; the session token is discarded.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import socket
|
|
17
|
+
import stat
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
ENV_VAR = "PARDESLINE_API_KEY"
|
|
22
|
+
ENV_BASE_URL = "PARDESLINE_BASE_URL"
|
|
23
|
+
DEFAULT_BASE_URL = "https://appbeta.pardesline.com"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def token_path() -> Path:
|
|
27
|
+
override = os.environ.get("PARDESLINE_TOKEN_PATH")
|
|
28
|
+
if override:
|
|
29
|
+
return Path(override)
|
|
30
|
+
return Path.home() / ".pardesline" / "token"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_saved_token() -> Optional[str]:
|
|
34
|
+
p = token_path()
|
|
35
|
+
try:
|
|
36
|
+
if p.exists():
|
|
37
|
+
tok = p.read_text(encoding="utf-8").strip()
|
|
38
|
+
return tok or None
|
|
39
|
+
except OSError:
|
|
40
|
+
pass
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def save_token(token: str) -> Path:
|
|
45
|
+
p = token_path()
|
|
46
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
p.write_text(token.strip() + "\n", encoding="utf-8")
|
|
48
|
+
try:
|
|
49
|
+
os.chmod(p, stat.S_IRUSR | stat.S_IWUSR) # 0600 (best-effort on Windows)
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
return p
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def clear_saved_token() -> None:
|
|
56
|
+
try:
|
|
57
|
+
token_path().unlink(missing_ok=True) # type: ignore[call-arg]
|
|
58
|
+
except TypeError: # Python 3.7/3.8 have no missing_ok
|
|
59
|
+
try:
|
|
60
|
+
token_path().unlink()
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
except OSError:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def resolve_token(explicit: Optional[str] = None) -> Optional[str]:
|
|
68
|
+
if explicit:
|
|
69
|
+
return explicit.strip()
|
|
70
|
+
env = os.environ.get(ENV_VAR)
|
|
71
|
+
if env:
|
|
72
|
+
return env.strip()
|
|
73
|
+
return load_saved_token()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_base_url(explicit: Optional[str] = None) -> str:
|
|
77
|
+
return (explicit or os.environ.get(ENV_BASE_URL) or DEFAULT_BASE_URL).rstrip("/")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def default_key_name() -> str:
|
|
81
|
+
try:
|
|
82
|
+
host = socket.gethostname() or "device"
|
|
83
|
+
except Exception:
|
|
84
|
+
host = "device"
|
|
85
|
+
return f"{host} (pardesline-python)"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def prompt(msg: str) -> str:
|
|
89
|
+
return input(msg).strip()
|
pardesline/_catalog.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Bind module methods onto the remote object classes from the live catalog.
|
|
2
|
+
|
|
3
|
+
The platform's /api/v1/modules lists every module, its methods and which input
|
|
4
|
+
geometry it accepts. We expose each method_id as a Python method on the object
|
|
5
|
+
type it applies to (a point-cloud method on PointCloud, a volume method on
|
|
6
|
+
Volume, …) plus a set of curated friendly aliases. Everything ultimately calls
|
|
7
|
+
obj.run(module_id, method_id, **params).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .client import Client
|
|
16
|
+
|
|
17
|
+
# Curated, human-friendly aliases → (module_id, method_id). Keep small and
|
|
18
|
+
# obvious; anything else is reachable via obj.run(...) or the generated names.
|
|
19
|
+
ALIASES: Dict[str, Tuple[str, str]] = {
|
|
20
|
+
# point cloud
|
|
21
|
+
"downsample": ("point_cloud_processing", "voxel_downsample"),
|
|
22
|
+
"remove_outliers": ("point_cloud_processing", "statistical_outlier_removal"),
|
|
23
|
+
"poisson": ("point_cloud_reconstruction", "poisson"),
|
|
24
|
+
"ball_pivoting": ("point_cloud_reconstruction", "ball_pivoting"),
|
|
25
|
+
"normals": ("features_extraction", "normal_estimation"),
|
|
26
|
+
"icp": ("registration", "icp_refinement"),
|
|
27
|
+
"ransac": ("registration", "ransac_global"),
|
|
28
|
+
# mesh
|
|
29
|
+
"decimate": ("pyvista_mesh_processing", "pv_decimate"),
|
|
30
|
+
"smooth": ("pyvista_mesh_processing", "pv_smooth_laplacian"),
|
|
31
|
+
"to_points": ("mesh_processing", "mesh_to_pcd_poisson_disk"),
|
|
32
|
+
# volume / medical (dual-input handled positionally)
|
|
33
|
+
"register": ("simpleitk_registration", "rigid"),
|
|
34
|
+
"segment": ("simpleitk_segmentation", "otsu_threshold"),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Catalog:
|
|
39
|
+
"""Loaded once per client from /api/v1/modules."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, modules: List[dict]):
|
|
42
|
+
self.modules = modules
|
|
43
|
+
# method_id -> list of (module_id, accepted_extensions, dual_input)
|
|
44
|
+
# We gate by the file EXTENSION against the module's input_formats (like
|
|
45
|
+
# the server), NOT by coarse geometry: a .ply may be a mesh or a point
|
|
46
|
+
# cloud, and a module accepting .ply applies to both.
|
|
47
|
+
self._by_method: Dict[str, List[Tuple[str, frozenset, bool]]] = {}
|
|
48
|
+
# (module_id, method_id) -> [param schema dicts] and -> display name
|
|
49
|
+
self._params: Dict[Tuple[str, str], List[dict]] = {}
|
|
50
|
+
self._method_name: Dict[Tuple[str, str], str] = {}
|
|
51
|
+
for m in modules:
|
|
52
|
+
exts = frozenset((e or "").lower() for e in (m.get("input_formats") or []))
|
|
53
|
+
for meth in m.get("methods", []):
|
|
54
|
+
self._by_method.setdefault(meth["id"], []).append(
|
|
55
|
+
(m["id"], exts, bool(meth.get("dual_input") or m.get("dual_input"))))
|
|
56
|
+
self._params[(m["id"], meth["id"])] = list(meth.get("parameters") or [])
|
|
57
|
+
self._method_name[(m["id"], meth["id"])] = meth.get("name") or meth["id"]
|
|
58
|
+
|
|
59
|
+
def params(self, module_id: str, method_id: str) -> List[dict]:
|
|
60
|
+
"""The parameter schema (name/type/default/min/max/description) for a
|
|
61
|
+
method — every knob the algorithm accepts, all optional."""
|
|
62
|
+
return self._params.get((module_id, method_id), [])
|
|
63
|
+
|
|
64
|
+
def validate_params(self, module_id: str, method_id: str, params: dict) -> None:
|
|
65
|
+
"""Raise a helpful ValueError if a param name isn't accepted by this
|
|
66
|
+
method. No-op when the method is unknown to the catalog (forward-compat)."""
|
|
67
|
+
key = (module_id, method_id)
|
|
68
|
+
if key not in self._params:
|
|
69
|
+
return
|
|
70
|
+
known = {p.get("name") for p in self._params[key]}
|
|
71
|
+
unknown = [k for k in params if k not in known]
|
|
72
|
+
if not unknown:
|
|
73
|
+
return
|
|
74
|
+
if known:
|
|
75
|
+
hint = "Accepted: " + ", ".join(sorted(n for n in known if n)) + "."
|
|
76
|
+
else:
|
|
77
|
+
hint = "This method takes no parameters."
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"{module_id}.{method_id}: unknown parameter(s) {', '.join(unknown)}. {hint} "
|
|
80
|
+
f"See obj.describe({method_id!r}).")
|
|
81
|
+
|
|
82
|
+
def describe(self, module_id: str, method_id: str) -> str:
|
|
83
|
+
"""A human-readable listing of every parameter + default + range."""
|
|
84
|
+
rows = self._params.get((module_id, method_id))
|
|
85
|
+
title = f"{module_id}.{method_id} ({self._method_name.get((module_id, method_id), method_id)})"
|
|
86
|
+
if not rows:
|
|
87
|
+
return f"{title}\n (no tunable parameters)"
|
|
88
|
+
lines = [title]
|
|
89
|
+
for p in rows:
|
|
90
|
+
name = p.get("name", "?")
|
|
91
|
+
typ = p.get("type", "")
|
|
92
|
+
dflt = p.get("default")
|
|
93
|
+
rng = ""
|
|
94
|
+
if p.get("min") is not None or p.get("max") is not None:
|
|
95
|
+
rng = f" [{p.get('min')}..{p.get('max')}]"
|
|
96
|
+
opts = f" {{{', '.join(map(str, p['options']))}}}" if p.get("options") else ""
|
|
97
|
+
desc = p.get("description", "")
|
|
98
|
+
lines.append(f" {name}: {typ}={dflt!r}{rng}{opts} {desc}".rstrip())
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
def resolve(self, method_id: str, extension: Optional[str]) -> Optional[Tuple[str, bool]]:
|
|
102
|
+
"""(module_id, dual_input) for a method whose module accepts `extension`,
|
|
103
|
+
else None."""
|
|
104
|
+
candidates = self._by_method.get(method_id)
|
|
105
|
+
if not candidates:
|
|
106
|
+
return None
|
|
107
|
+
ext = (extension or "").lower()
|
|
108
|
+
if ext:
|
|
109
|
+
for module_id, exts, dual in candidates:
|
|
110
|
+
if not exts or ext in exts:
|
|
111
|
+
return module_id, dual
|
|
112
|
+
return None
|
|
113
|
+
module_id, _exts, dual = candidates[0]
|
|
114
|
+
return module_id, dual
|
|
115
|
+
|
|
116
|
+
def methods_for(self, extension: Optional[str]) -> List[str]:
|
|
117
|
+
ext = (extension or "").lower()
|
|
118
|
+
names = set()
|
|
119
|
+
for method_id, entries in self._by_method.items():
|
|
120
|
+
for _module_id, exts, _dual in entries:
|
|
121
|
+
if not ext or not exts or ext in exts:
|
|
122
|
+
names.add(method_id)
|
|
123
|
+
break
|
|
124
|
+
return sorted(names)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_cache: Dict[str, Catalog] = {}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_catalog(client: Client) -> Catalog:
|
|
131
|
+
key = client.base_url
|
|
132
|
+
cat = _cache.get(key)
|
|
133
|
+
if cat is None:
|
|
134
|
+
cat = Catalog([m.model_dump() if hasattr(m, "model_dump") else m for m in client.modules.list()])
|
|
135
|
+
_cache[key] = cat
|
|
136
|
+
return cat
|
pardesline/_http.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Thin httpx transport: auth header, retries with backoff, error mapping.
|
|
2
|
+
|
|
3
|
+
A single place that turns HTTP failures into the typed errors in errors.py and
|
|
4
|
+
retries idempotent calls on 429/5xx. Used by every resource.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .errors import PardesLineError, TransferError, from_response
|
|
16
|
+
|
|
17
|
+
USER_AGENT = f"pardesline-python/{__version__}"
|
|
18
|
+
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Transport:
|
|
22
|
+
def __init__(self, base_url: str, token: Optional[str], *, timeout: float = 60.0,
|
|
23
|
+
max_retries: int = 3):
|
|
24
|
+
self.base_url = base_url.rstrip("/")
|
|
25
|
+
self.token = token
|
|
26
|
+
self.max_retries = max_retries
|
|
27
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
28
|
+
if token:
|
|
29
|
+
headers["X-API-Key"] = token
|
|
30
|
+
self._client = httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout,
|
|
31
|
+
follow_redirects=True)
|
|
32
|
+
|
|
33
|
+
def close(self) -> None:
|
|
34
|
+
self._client.close()
|
|
35
|
+
|
|
36
|
+
def set_token(self, token: Optional[str]) -> None:
|
|
37
|
+
self.token = token
|
|
38
|
+
if token:
|
|
39
|
+
self._client.headers["X-API-Key"] = token
|
|
40
|
+
else:
|
|
41
|
+
self._client.headers.pop("X-API-Key", None)
|
|
42
|
+
|
|
43
|
+
def request(self, method: str, path: str, *, json: Any = None, params: Any = None,
|
|
44
|
+
auth: bool = True, timeout: Optional[float] = None) -> Any:
|
|
45
|
+
"""Call an /api endpoint, return the parsed JSON, raise a typed error.
|
|
46
|
+
`timeout` (seconds) overrides the default for this call (GPU calls need
|
|
47
|
+
a long read timeout for cold starts)."""
|
|
48
|
+
headers = {}
|
|
49
|
+
if not auth:
|
|
50
|
+
headers["X-API-Key"] = "" # override the client default for public routes
|
|
51
|
+
kwargs = {"json": json, "params": params, "headers": headers or None}
|
|
52
|
+
if timeout is not None:
|
|
53
|
+
kwargs["timeout"] = httpx.Timeout(connect=30.0, read=float(timeout), write=60.0, pool=30.0)
|
|
54
|
+
# GPU calls are long single attempts — don't retry a partial compute.
|
|
55
|
+
retries = 0 if timeout and timeout > 120 else self.max_retries
|
|
56
|
+
for attempt in range(retries + 1):
|
|
57
|
+
try:
|
|
58
|
+
resp = self._client.request(method, path, **kwargs)
|
|
59
|
+
except httpx.HTTPError as e:
|
|
60
|
+
if attempt < retries:
|
|
61
|
+
time.sleep(0.5 * (2 ** attempt))
|
|
62
|
+
continue
|
|
63
|
+
raise TransferError(f"Network error calling {path}: {e}") from e
|
|
64
|
+
if resp.status_code in _RETRY_STATUS and attempt < retries:
|
|
65
|
+
time.sleep(float(resp.headers.get("Retry-After", 0)) or 0.5 * (2 ** attempt))
|
|
66
|
+
continue
|
|
67
|
+
if resp.status_code >= 400:
|
|
68
|
+
try:
|
|
69
|
+
body = resp.json()
|
|
70
|
+
except Exception:
|
|
71
|
+
body = {"detail": resp.text}
|
|
72
|
+
raise from_response(resp.status_code, body)
|
|
73
|
+
if resp.status_code == 204 or not resp.content:
|
|
74
|
+
return None
|
|
75
|
+
return resp.json()
|
|
76
|
+
raise PardesLineError("request failed after retries")
|
|
77
|
+
|
|
78
|
+
# ── raw bytes to/from a storage/API URL (no auth header for signed URLs) ──
|
|
79
|
+
|
|
80
|
+
def put_bytes(self, url: str, data, *, headers: Optional[dict] = None, timeout: float = 900.0) -> None:
|
|
81
|
+
absolute = url if url.startswith("http") else f"{self.base_url}{url}"
|
|
82
|
+
h = dict(headers or {})
|
|
83
|
+
# A signed bucket URL must NOT receive our API key; the API fallback
|
|
84
|
+
# route (relative path) needs it.
|
|
85
|
+
if not url.startswith("http") and self.token:
|
|
86
|
+
h["X-API-Key"] = self.token
|
|
87
|
+
try:
|
|
88
|
+
with httpx.Client(timeout=timeout) as c:
|
|
89
|
+
resp = c.put(absolute, content=data, headers=h)
|
|
90
|
+
except httpx.HTTPError as e:
|
|
91
|
+
raise TransferError(f"Upload failed: {e}") from e
|
|
92
|
+
if resp.status_code >= 400:
|
|
93
|
+
raise TransferError(f"Upload rejected by storage ({resp.status_code}): {resp.text[:200]}")
|
|
94
|
+
|
|
95
|
+
def get_bytes(self, url: str, *, timeout: float = 900.0):
|
|
96
|
+
absolute = url if url.startswith("http") else f"{self.base_url}{url}"
|
|
97
|
+
h = {}
|
|
98
|
+
if not url.startswith("http") and self.token:
|
|
99
|
+
h["X-API-Key"] = self.token
|
|
100
|
+
try:
|
|
101
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as c:
|
|
102
|
+
resp = c.get(absolute, headers=h)
|
|
103
|
+
except httpx.HTTPError as e:
|
|
104
|
+
raise TransferError(f"Download failed: {e}") from e
|
|
105
|
+
if resp.status_code >= 400:
|
|
106
|
+
raise TransferError(f"Download failed ({resp.status_code}): {resp.text[:200]}")
|
|
107
|
+
return resp.content
|
|
108
|
+
|
|
109
|
+
def stream_to_file(self, url: str, dest, *, timeout: float = 900.0) -> int:
|
|
110
|
+
absolute = url if url.startswith("http") else f"{self.base_url}{url}"
|
|
111
|
+
h = {}
|
|
112
|
+
if not url.startswith("http") and self.token:
|
|
113
|
+
h["X-API-Key"] = self.token
|
|
114
|
+
total = 0
|
|
115
|
+
try:
|
|
116
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as c:
|
|
117
|
+
with c.stream("GET", absolute, headers=h) as resp:
|
|
118
|
+
if resp.status_code >= 400:
|
|
119
|
+
raise TransferError(f"Download failed ({resp.status_code})")
|
|
120
|
+
with open(dest, "wb") as f:
|
|
121
|
+
for chunk in resp.iter_bytes(chunk_size=1024 * 1024):
|
|
122
|
+
f.write(chunk)
|
|
123
|
+
total += len(chunk)
|
|
124
|
+
except httpx.HTTPError as e:
|
|
125
|
+
raise TransferError(f"Download failed: {e}") from e
|
|
126
|
+
return total
|
pardesline/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""`pardesline` command-line interface — thin wrapper over the client.
|
|
2
|
+
|
|
3
|
+
pardesline login
|
|
4
|
+
pardesline whoami
|
|
5
|
+
pardesline upload scan.ply
|
|
6
|
+
pardesline send scan.ply bob@lab.org
|
|
7
|
+
pardesline inbox
|
|
8
|
+
pardesline ls
|
|
9
|
+
pardesline download "alice@lab.org/scan.ply" out.ply
|
|
10
|
+
pardesline share <file_id> bob@lab.org
|
|
11
|
+
pardesline logout
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .client import Client
|
|
21
|
+
from .errors import PardesLineError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fmt_size(n: int) -> str:
|
|
25
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
26
|
+
if n < 1024 or unit == "GB":
|
|
27
|
+
return f"{n:.0f}{unit}" if unit == "B" else f"{n/1:.0f}{unit}" if False else f"{n:.1f}{unit}"
|
|
28
|
+
n /= 1024
|
|
29
|
+
return f"{n:.1f}GB"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv=None) -> int:
|
|
33
|
+
p = argparse.ArgumentParser(prog="pardesline", description="Transfer files between PardesLine accounts.")
|
|
34
|
+
p.add_argument("--version", action="version", version=f"pardesline {__version__}")
|
|
35
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
36
|
+
|
|
37
|
+
sub.add_parser("login", help="Sign in (email + code) and store a key")
|
|
38
|
+
sub.add_parser("logout", help="Revoke and forget the stored key")
|
|
39
|
+
sub.add_parser("whoami", help="Show the logged-in account")
|
|
40
|
+
up = sub.add_parser("upload", help="Upload a file or folder"); up.add_argument("path")
|
|
41
|
+
se = sub.add_parser("send", help="Upload and share with an email"); se.add_argument("path"); se.add_argument("to")
|
|
42
|
+
sub.add_parser("inbox", help="Files shared with you")
|
|
43
|
+
lsp = sub.add_parser("ls", help="Your files"); lsp.add_argument("--scope", default="mine", choices=["mine", "shared", "all"])
|
|
44
|
+
dl = sub.add_parser("download", help="Download by id or owner@email/name")
|
|
45
|
+
dl.add_argument("ref"); dl.add_argument("out", nargs="?"); dl.add_argument("--extract", action="store_true")
|
|
46
|
+
sh = sub.add_parser("share", help="Share one of your files"); sh.add_argument("file_id"); sh.add_argument("to")
|
|
47
|
+
|
|
48
|
+
args = p.parse_args(argv)
|
|
49
|
+
client = Client()
|
|
50
|
+
try:
|
|
51
|
+
if args.cmd == "login":
|
|
52
|
+
client.login(); return 0
|
|
53
|
+
if args.cmd == "logout":
|
|
54
|
+
client.logout(); print("Logged out."); return 0
|
|
55
|
+
if args.cmd == "whoami":
|
|
56
|
+
print(client.whoami() or "(not logged in)"); return 0
|
|
57
|
+
if args.cmd == "upload":
|
|
58
|
+
print(client.upload(args.path)); return 0
|
|
59
|
+
if args.cmd == "send":
|
|
60
|
+
obj = client.send(args.path, to=args.to); print(f"Sent {obj.name} to {args.to} (id {obj.id})"); return 0
|
|
61
|
+
if args.cmd == "inbox":
|
|
62
|
+
for o in client.inbox():
|
|
63
|
+
print(f"{o.id} {o.name:<32} {_fmt_size(o.size_bytes):>9} from {o.shared_by}")
|
|
64
|
+
return 0
|
|
65
|
+
if args.cmd == "ls":
|
|
66
|
+
for o in client.list(args.scope):
|
|
67
|
+
print(f"{o.id} {o.name:<32} {_fmt_size(o.size_bytes):>9}")
|
|
68
|
+
return 0
|
|
69
|
+
if args.cmd == "download":
|
|
70
|
+
dest = client.download(args.ref, args.out, extract=args.extract); print(f"Saved {dest}"); return 0
|
|
71
|
+
if args.cmd == "share":
|
|
72
|
+
s = client.share(args.file_id, to=args.to); print(f"Shared with {args.to} ({s.status})"); return 0
|
|
73
|
+
except PardesLineError as e:
|
|
74
|
+
print(f"error: {e.message}" + (f" [{e.code}]" if e.code else ""), file=sys.stderr)
|
|
75
|
+
if getattr(e, "upgrade_url", None):
|
|
76
|
+
print(f" upgrade: {e.upgrade_url}", file=sys.stderr)
|
|
77
|
+
return 1
|
|
78
|
+
except (FileNotFoundError, KeyboardInterrupt) as e:
|
|
79
|
+
print(f"error: {e}", file=sys.stderr); return 1
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
raise SystemExit(main())
|