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.
- cellismo_cli/__init__.py +0 -0
- cellismo_cli/api_client.py +103 -0
- cellismo_cli/commands/__init__.py +0 -0
- cellismo_cli/commands/login.py +29 -0
- cellismo_cli/commands/upload.py +111 -0
- cellismo_cli/config.py +25 -0
- cellismo_cli/main.py +13 -0
- cellismo_cli/uploader.py +227 -0
- cellismo_cli-0.1.0.dist-info/METADATA +7 -0
- cellismo_cli-0.1.0.dist-info/RECORD +13 -0
- cellismo_cli-0.1.0.dist-info/WHEEL +5 -0
- cellismo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- cellismo_cli-0.1.0.dist-info/top_level.txt +1 -0
cellismo_cli/__init__.py
ADDED
|
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)
|
cellismo_cli/uploader.py
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
cellismo_cli
|