cellismo-cli 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.
File without changes
@@ -0,0 +1,103 @@
1
+ import requests
2
+
3
+
4
+ class ApiError(Exception):
5
+ def __init__(self, status: int, code: str, message: str) -> None:
6
+ super().__init__(message)
7
+ self.status = status
8
+ self.code = code
9
+ self.message = message
10
+
11
+
12
+ class ApiClient:
13
+ def __init__(self, base_url: str, token: str | None) -> None:
14
+ self._base = base_url.rstrip("/")
15
+ self._token = token
16
+
17
+ def _headers(self) -> dict[str, str]:
18
+ h: dict[str, str] = {"Content-Type": "application/json"}
19
+ if self._token:
20
+ h["Authorization"] = f"Bearer {self._token}"
21
+ return h
22
+
23
+ def _raise_for(self, r: requests.Response) -> None:
24
+ if r.ok:
25
+ return
26
+ try:
27
+ err = r.json().get("error", {})
28
+ if isinstance(err, dict):
29
+ code = err.get("code", "unknown")
30
+ message = err.get("message", r.text)
31
+ else:
32
+ code = "unknown"
33
+ message = r.text
34
+ except Exception:
35
+ code = "unknown"
36
+ message = r.text
37
+ raise ApiError(r.status_code, code, message)
38
+
39
+ def issue_token(self, email: str, password: str) -> str:
40
+ r = requests.post(f"{self._base}/api/cli/auth/token",
41
+ json={"email": email, "password": password},
42
+ headers=self._headers())
43
+ self._raise_for(r)
44
+ return r.json()["token"]
45
+
46
+ def register_file(self, *, project_id: str, filename: str, size_bytes: int) -> str:
47
+ r = requests.post(f"{self._base}/api/cli/files/register",
48
+ json={"project_id": project_id, "filename": filename,
49
+ "size_bytes": size_bytes},
50
+ headers=self._headers())
51
+ self._raise_for(r)
52
+ return r.json()["file_id"]
53
+
54
+ def upload_start(self, file_id: str, total_parts: int) -> str:
55
+ r = requests.post(f"{self._base}/api/cli/files/{file_id}/upload/start",
56
+ json={"total_parts": total_parts},
57
+ headers=self._headers())
58
+ self._raise_for(r)
59
+ return r.json()["upload_id"]
60
+
61
+ def get_presigned_urls(self, file_id: str, parts_info: list[dict], url_expire_time: int = 600) -> tuple[dict, float]:
62
+ r = requests.post(f"{self._base}/api/cli/files/{file_id}/upload/presigned-urls",
63
+ json={"parts_info": parts_info, "url_expire_time": url_expire_time},
64
+ headers=self._headers())
65
+ self._raise_for(r)
66
+ data = r.json()
67
+ return data["urls"], float(data["expires_at"])
68
+
69
+ def report_progress(self, file_id: str, parts_completed: int) -> bool:
70
+ """Returns True if the server reports the upload was cancelled."""
71
+ r = requests.post(f"{self._base}/api/cli/files/{file_id}/progress",
72
+ json={"parts_completed": parts_completed},
73
+ headers=self._headers())
74
+ self._raise_for(r)
75
+ return bool(r.json().get("cancelled", False))
76
+
77
+ def complete_upload(self, file_id: str, parts: list[dict]) -> None:
78
+ r = requests.post(f"{self._base}/api/cli/files/{file_id}/upload/complete",
79
+ json={"parts": parts},
80
+ headers=self._headers())
81
+ self._raise_for(r)
82
+
83
+ def verify_upload(self, file_id: str) -> str:
84
+ """Returns the S3 composite SHA-256 checksum for the completed upload."""
85
+ r = requests.get(f"{self._base}/api/cli/files/{file_id}/upload/verify",
86
+ headers=self._headers())
87
+ self._raise_for(r)
88
+ return r.json()["checksum_sha256"]
89
+
90
+ def abort_upload(self, file_id: str) -> None:
91
+ try:
92
+ r = requests.post(f"{self._base}/api/cli/files/{file_id}/upload/abort",
93
+ headers=self._headers())
94
+ self._raise_for(r)
95
+ except Exception:
96
+ pass # best-effort abort
97
+
98
+ def list_project_files(self, project_id: str) -> list[dict]:
99
+ r = requests.get(f"{self._base}/api/cli/projects/{project_id}/files",
100
+ headers=self._headers())
101
+ self._raise_for(r)
102
+ return r.json()["files"]
103
+
File without changes
@@ -0,0 +1,29 @@
1
+ import click
2
+
3
+ from cellismo_cli import config
4
+ from cellismo_cli.api_client import ApiClient, ApiError
5
+
6
+
7
+ @click.command()
8
+ @click.option("--new", "force_new", is_flag=True, default=False, help="Force re-authentication even if already logged in.")
9
+ def login(force_new):
10
+ """Authenticate with a Cellismo server and save credentials locally."""
11
+ if not force_new:
12
+ cfg = config.load()
13
+ if cfg and cfg.get("token") and cfg.get("base_url"):
14
+ click.echo("Already logged in. Use --new to authenticate again.")
15
+ return
16
+
17
+ # TODO: should be replaced with the real backend URL
18
+ base_url = "http://localhost:8000"
19
+ email = click.prompt("Email")
20
+ password = click.prompt("Password", hide_input=True)
21
+
22
+ client = ApiClient(base_url=base_url, token=None)
23
+ try:
24
+ token = client.issue_token(email, password)
25
+ except ApiError as e:
26
+ raise click.ClickException(e.message)
27
+
28
+ config.save(token, base_url)
29
+ click.echo(f"Logged in as {email}. Credentials saved to ~/.cellismo/config.json")
@@ -0,0 +1,111 @@
1
+ import os
2
+ import signal
3
+
4
+ import click
5
+
6
+ from cellismo_cli import config
7
+ from cellismo_cli.api_client import ApiClient, ApiError
8
+ from cellismo_cli.uploader import upload_file, MAX_FILE_SIZE, UploadCancelledError
9
+
10
+ MIN_THREADS = 10
11
+ MAX_THREADS = 30
12
+
13
+
14
+ @click.command()
15
+ @click.option("--project", required=True, help="Project ID to upload files into.")
16
+ @click.option("--threads", type=int, default=None, help="Number of upload threads.")
17
+ @click.argument("files", nargs=-1, required=True)
18
+ def upload(project, threads, files):
19
+ """Upload files to the given project in Cellismo."""
20
+ cfg = config.require()
21
+ client = ApiClient(base_url=cfg["base_url"], token=cfg["token"])
22
+
23
+ if threads is None:
24
+ threads = max((os.cpu_count() or 4) * 3, MIN_THREADS)
25
+ threads = min(threads, MAX_THREADS)
26
+
27
+ try:
28
+ existing_files = client.list_project_files(project)
29
+ except ApiError as e:
30
+ if e.status == 401:
31
+ raise click.ClickException(
32
+ "Session expired or invalid. Run `cellismo login --new` to create a new authentication."
33
+ )
34
+ raise click.ClickException(f"Failed to fetch project files: {e.message}")
35
+
36
+ existing_by_name: dict[str, str] = {f["filename"]: f["file_id"] for f in existing_files}
37
+
38
+ for file_path in files:
39
+ if not os.path.exists(file_path):
40
+ click.echo(f"✗ Skipping {file_path} — file not found locally")
41
+ continue
42
+
43
+ file_size = os.path.getsize(file_path)
44
+ if file_size > MAX_FILE_SIZE:
45
+ click.echo(f"✗ Skipping {file_path} — file too large (max 48.8 TB)")
46
+ continue
47
+
48
+ filename = os.path.basename(file_path)
49
+
50
+ if filename in existing_by_name:
51
+ overwrite = click.confirm(
52
+ f" '{filename}' already exists in this project. Overwrite?", default=False
53
+ )
54
+ if overwrite:
55
+ # Delete the existing file
56
+ client.abort_upload(existing_by_name[filename])
57
+ else:
58
+ click.echo(f"✗ Skipping {filename} — not overwriting")
59
+ continue
60
+
61
+ try:
62
+ # Register the file information into database
63
+ file_id = client.register_file(
64
+ project_id=project, filename=filename, size_bytes=file_size
65
+ )
66
+ except ApiError as e:
67
+ if e.status == 403:
68
+ click.echo(f"✗ Skipping {filename} — you don't have access to project {project}")
69
+ elif e.status == 401:
70
+ raise click.ClickException(
71
+ "Session expired or invalid. Run `cellismo login --new` to create a new authentication."
72
+ )
73
+ else:
74
+ click.echo(f"✗ Skipping {filename} — {e.message}")
75
+ continue
76
+
77
+ click.echo(f"\nUploading {filename} (file ID: {file_id})")
78
+
79
+ def on_progress(parts_done: int, total_parts: int) -> bool:
80
+ try:
81
+ return client.report_progress(file_id, parts_done)
82
+ except Exception:
83
+ return False
84
+
85
+ try:
86
+ success = upload_file(
87
+ file_path=file_path,
88
+ file_id=file_id,
89
+ api_client=client,
90
+ num_threads=threads,
91
+ on_progress=on_progress,
92
+ )
93
+ if success:
94
+ click.echo(f"✓ {filename} uploaded successfully")
95
+ else:
96
+ click.echo(f"✗ {filename} upload failed")
97
+ except UploadCancelledError:
98
+ client.abort_upload(file_id)
99
+ click.echo(f"\n✗ {filename} upload cancelled from the web UI")
100
+ except KeyboardInterrupt:
101
+ # to make pressing any more ctrl+c do nothing
102
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
103
+ click.echo(f"\n✗ Upload interrupted — aborting {filename}")
104
+ try:
105
+ client.abort_upload(file_id)
106
+ except Exception:
107
+ pass
108
+ raise SystemExit(1)
109
+ except Exception as e:
110
+ click.echo(f"✗ {filename} upload failed: {e}")
111
+ client.abort_upload(file_id)
cellismo_cli/config.py ADDED
@@ -0,0 +1,25 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ _CONFIG_PATH = Path.home() / ".cellismo" / "config.json"
5
+
6
+
7
+ def load() -> dict | None:
8
+ if not _CONFIG_PATH.exists():
9
+ return None
10
+ try:
11
+ return json.loads(_CONFIG_PATH.read_text())
12
+ except (json.JSONDecodeError, OSError):
13
+ return None
14
+
15
+
16
+ def save(token: str, base_url: str) -> None:
17
+ _CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
18
+ _CONFIG_PATH.write_text(json.dumps({"token": token, "base_url": base_url}))
19
+
20
+
21
+ def require() -> dict:
22
+ cfg = load()
23
+ if cfg is None:
24
+ raise SystemExit("Not logged in. Run `cellismo login` first.")
25
+ return cfg
cellismo_cli/main.py ADDED
@@ -0,0 +1,13 @@
1
+ import click
2
+
3
+ from cellismo_cli.commands.login import login
4
+ from cellismo_cli.commands.upload import upload
5
+
6
+
7
+ @click.group()
8
+ def cli():
9
+ """Cellismo CLI — upload files to your projects."""
10
+
11
+
12
+ cli.add_command(login)
13
+ cli.add_command(upload)
@@ -0,0 +1,227 @@
1
+ import base64
2
+ import hashlib
3
+ import math
4
+ import os
5
+ import time
6
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
+ from threading import Event, Thread
8
+
9
+ import requests
10
+
11
+ from cellismo_cli.api_client import ApiError
12
+
13
+ class UploadCancelledError(Exception):
14
+ pass
15
+
16
+
17
+ CANCEL_POLL_INTERVAL = 2 # seconds between background cancellation checks
18
+ UPLOAD_CHUNK_SIZE = 256 * 1024 # 256 KiB — abort is checked between chunks
19
+
20
+ PART_SIZE_MIN = 5 * 1024 * 1024 # 5 MiB
21
+ PART_SIZE_MAX = 5 * 1024 * 1024 * 1024 # 5 GiB
22
+ PART_COUNT_MAX = 10_000
23
+ MAX_FILE_SIZE = int(48.8 * 1024**4)
24
+ MAX_RETRY = 10
25
+ URL_EXPIRE_TIME = 600
26
+ URL_EXPIRE_BUFFER = 30 # seconds before expiry to refresh URLs
27
+
28
+
29
+ def get_part_num_size(file_size: int) -> tuple[int, int]:
30
+ minimum = math.ceil(file_size / PART_COUNT_MAX)
31
+ part_size = max(minimum, PART_SIZE_MIN)
32
+ part_size = min(part_size, PART_SIZE_MAX)
33
+ number_of_parts = math.ceil(file_size / part_size)
34
+ return number_of_parts, part_size
35
+
36
+
37
+ def compute_checksums(file_path: str, part_size: int, number_of_parts: int) -> dict[int, tuple[bytes, str]]:
38
+ checksums: dict[int, tuple[bytes, str]] = {}
39
+ with open(file_path, "rb") as f:
40
+ for pn in range(1, number_of_parts + 1):
41
+ f.seek((pn - 1) * part_size)
42
+ data = f.read(part_size)
43
+ raw = hashlib.sha256(data).digest()
44
+ checksums[pn] = (raw, base64.b64encode(raw).decode())
45
+ return checksums
46
+
47
+
48
+ def _upload_part(
49
+ part_number: int,
50
+ file_path: str,
51
+ part_size: int,
52
+ presigned_url: str,
53
+ checksum_b64: str,
54
+ urls_expire_at: float,
55
+ abort_event: Event,
56
+ url_expire_event: Event,
57
+ ) -> dict:
58
+ if abort_event.is_set():
59
+ return {"PartNumber": part_number, "Failed": True}
60
+ if url_expire_event.is_set() or time.time() >= urls_expire_at - URL_EXPIRE_BUFFER:
61
+ url_expire_event.set()
62
+ return {"Expired": True, "PartNumber": part_number}
63
+ try:
64
+ with open(file_path, "rb") as f:
65
+ f.seek((part_number - 1) * part_size)
66
+ data = f.read(part_size)
67
+
68
+ r = requests.put(
69
+ presigned_url,
70
+ data=data,
71
+ headers={
72
+ "x-amz-checksum-sha256": checksum_b64,
73
+ "x-amz-sdk-checksum-algorithm": "SHA256",
74
+ },
75
+ )
76
+ if abort_event.is_set():
77
+ return {"PartNumber": part_number, "Failed": True}
78
+ if r.status_code == 403:
79
+ url_expire_event.set()
80
+ return {"Expired": True, "PartNumber": part_number}
81
+ if not r.ok:
82
+ r.raise_for_status()
83
+ except Exception as e:
84
+ print(f"\n [part {part_number} error] {type(e).__name__}: {e}")
85
+ return {"PartNumber": part_number, "Failed": True}
86
+ return {"PartNumber": part_number, "ETag": r.headers["ETag"], "ChecksumSHA256": checksum_b64}
87
+
88
+
89
+ def upload_file(
90
+ file_path: str,
91
+ file_id: str,
92
+ api_client,
93
+ num_threads: int,
94
+ on_progress=None,
95
+ ) -> bool:
96
+ """Upload a single file. Returns True on success, False on failure."""
97
+ file_size = os.path.getsize(file_path)
98
+ if file_size > MAX_FILE_SIZE:
99
+ print(f" ✗ Skipping {file_path} — file too large (max 48.8 TB)")
100
+ return False
101
+
102
+ number_of_parts, part_size = get_part_num_size(file_size)
103
+ try:
104
+ api_client.upload_start(file_id, total_parts=number_of_parts)
105
+ except ApiError as e:
106
+ if e.code == "cancelled":
107
+ raise UploadCancelledError()
108
+ raise
109
+ checksums = compute_checksums(file_path, part_size, number_of_parts)
110
+
111
+ # check whether file upload is cancelled on the frontend before going into the while loop
112
+ try:
113
+ if api_client.report_progress(file_id, 0):
114
+ raise UploadCancelledError()
115
+ except ApiError as e:
116
+ if e.code == "cancelled":
117
+ raise UploadCancelledError()
118
+ raise
119
+
120
+ unsorted: dict[int, dict] = {}
121
+ remaining = set(range(1, number_of_parts + 1))
122
+ completed: set[int] = set()
123
+ failed_counts: dict[int, int] = {}
124
+ num_attempt = 1
125
+
126
+ print(" Preparing...", end="", flush=True)
127
+ abort_event = Event()
128
+ stop_poll = Event()
129
+
130
+ def _poll_cancel():
131
+ while not stop_poll.wait(CANCEL_POLL_INTERVAL):
132
+ try:
133
+ cancelled = api_client.report_progress(file_id, len(completed))
134
+ except Exception:
135
+ continue
136
+ if cancelled:
137
+ abort_event.set()
138
+ break
139
+
140
+ poll_thread = Thread(target=_poll_cancel, daemon=True)
141
+ poll_thread.start()
142
+
143
+ try:
144
+ while remaining:
145
+ url_expire_event = Event()
146
+
147
+ parts_info = [
148
+ {"part_number": pn, "checksum_sha256": checksums[pn][1]}
149
+ for pn in sorted(remaining)
150
+ ]
151
+ try:
152
+ urls_dict, expires_at = api_client.get_presigned_urls(
153
+ file_id, parts_info, url_expire_time=URL_EXPIRE_TIME * num_attempt
154
+ )
155
+ except ApiError as e:
156
+ if e.code == "cancelled":
157
+ raise UploadCancelledError()
158
+ raise
159
+ urls = {int(k): v for k, v in urls_dict.items()}
160
+
161
+ pool = ThreadPoolExecutor(max_workers=num_threads)
162
+ try:
163
+ futures = [
164
+ pool.submit(
165
+ _upload_part, pn, file_path, part_size,
166
+ urls[pn], checksums[pn][1], expires_at, abort_event, url_expire_event,
167
+ )
168
+ for pn in sorted(remaining)
169
+ ]
170
+ for future in as_completed(futures):
171
+ if abort_event.is_set():
172
+ pool.shutdown(wait=False, cancel_futures=True)
173
+ raise UploadCancelledError()
174
+
175
+ result = future.result()
176
+ if result.get("Failed"):
177
+ pn = result["PartNumber"]
178
+ failed_counts[pn] = failed_counts.get(pn, 0) + 1
179
+ if max(failed_counts.values()) >= MAX_RETRY:
180
+ abort_event.set()
181
+ pool.shutdown(wait=False, cancel_futures=True)
182
+ api_client.abort_upload(file_id)
183
+ return False
184
+ elif result.get("Expired"):
185
+ continue
186
+ else:
187
+ pn = result["PartNumber"]
188
+ unsorted[pn] = result
189
+ remaining.discard(pn)
190
+ completed.add(pn)
191
+ pct = len(completed) * 100 // number_of_parts
192
+ if on_progress:
193
+ cancelled = on_progress(len(completed), number_of_parts)
194
+ if cancelled:
195
+ abort_event.set()
196
+ pool.shutdown(wait=False, cancel_futures=True)
197
+ raise UploadCancelledError()
198
+ filled = pct * 50 // 100
199
+ bar = "█" * filled + "░" * (50 - filled)
200
+ print(f"\r |{bar}| {pct}%", end="", flush=True)
201
+ except KeyboardInterrupt:
202
+ abort_event.set()
203
+ pool.shutdown(wait=False, cancel_futures=True)
204
+ raise
205
+ else:
206
+ pool.shutdown(wait=True)
207
+
208
+ if abort_event.is_set():
209
+ raise UploadCancelledError()
210
+
211
+ num_attempt += 1
212
+ finally:
213
+ stop_poll.set()
214
+ poll_thread.join()
215
+
216
+ parts = [unsorted[pn] for pn in range(1, number_of_parts + 1)]
217
+ api_client.complete_upload(file_id, parts)
218
+
219
+ part_raw_digests = b"".join(checksums[pn][0] for pn in range(1, number_of_parts + 1))
220
+ composite = base64.b64encode(hashlib.sha256(part_raw_digests).digest()).decode()
221
+ local_checksum = f"{composite}-{number_of_parts}"
222
+ s3_checksum = api_client.verify_upload(file_id)
223
+ if local_checksum != s3_checksum:
224
+ raise RuntimeError(f"Checksum mismatch: local={local_checksum} s3={s3_checksum}")
225
+ print(f"\n ✓ Integrity OK: {local_checksum}")
226
+
227
+ return True
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: cellismo-cli
3
+ Version: 0.1.0
4
+ Summary: Cellismo file upload CLI
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: click>=8.1
7
+ Requires-Dist: requests>=2.32
@@ -0,0 +1,13 @@
1
+ cellismo_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ cellismo_cli/api_client.py,sha256=cL2F2bAUzFArbi6PrMF8hWreJfj8wmsgaaTdkzIlPTY,4205
3
+ cellismo_cli/config.py,sha256=lGyswZzzqwif-Vn5H3jOhTec107xFaO0lBqiunFWR_0,637
4
+ cellismo_cli/main.py,sha256=YhqFrGe5wX4qu6_5JQhbylwbPxkgn0hUOIhopG2iMYA,243
5
+ cellismo_cli/uploader.py,sha256=S6KfnHLRASLeHW3g3RMoptLDiQRRLoWWkTCKoKt2aSs,8187
6
+ cellismo_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ cellismo_cli/commands/login.py,sha256=lNLcv4x91PDDgABjdec3_oUQmogbz7MNlWHz8Ek1AsM,1047
8
+ cellismo_cli/commands/upload.py,sha256=pZgxt6AT40OQsrexWmkGk1YLQ8DdMv3wwQIA34T1aX8,4154
9
+ cellismo_cli-0.1.0.dist-info/METADATA,sha256=UisU2y8EO_ySaWclu-yNX2AxSVkgExUmv9Og3e_40oc,170
10
+ cellismo_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ cellismo_cli-0.1.0.dist-info/entry_points.txt,sha256=RxCGc2xpV1KW83nmjkHr-GIsJWoteA86yUTfV8iUPsg,51
12
+ cellismo_cli-0.1.0.dist-info/top_level.txt,sha256=xAudVMyMor2VwwrGShFmilPtCtiRp0MN1dsxn74G1GE,13
13
+ cellismo_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cellismo = cellismo_cli.main:cli
@@ -0,0 +1 @@
1
+ cellismo_cli