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 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())