powertech-cli 0.1.0__tar.gz

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.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.4
2
+ Name: powertech-cli
3
+ Version: 0.1.0
4
+ Summary: PowerTech CLI for batch video upload and result download.
5
+ Author: PowerTech
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: alibabacloud-oss-v2<2,>=1.3.2
9
+ Requires-Dist: httpx<1,>=0.27
10
+ Provides-Extra: dev
11
+ Requires-Dist: build<2,>=1.2; extra == "dev"
12
+ Requires-Dist: ruff<1,>=0.9; extra == "dev"
13
+
14
+ # PowerTech CLI
15
+
16
+ Command-line client for uploading video batches and downloading processing results.
17
+
18
+ ## Development install
19
+
20
+ ```bash
21
+ python3 -m venv .venv
22
+ source .venv/bin/activate
23
+ pip install -e .
24
+ ```
25
+
26
+ The same package is used in every environment. Production is the default;
27
+ development and staging select another API URL during login and keep their local
28
+ CLI state in separate directories.
29
+
30
+ ## Environments
31
+
32
+ ### Development
33
+
34
+ Start `powertech-api` at `http://127.0.0.1:8000`, then create a CLI token in the
35
+ development SaaS web app:
36
+
37
+ ```bash
38
+ POWERTECH_CLI_HOME=~/.powertech/dev \
39
+ powertech login --token <development_token> --api-url http://127.0.0.1:8000
40
+ ```
41
+
42
+ ### Staging
43
+
44
+ Use the same editable installation and save staging credentials separately:
45
+
46
+ ```bash
47
+ POWERTECH_CLI_HOME=~/.powertech/staging \
48
+ powertech login \
49
+ --token <staging_token> \
50
+ --api-url https://api-staging.powertech-ai.com
51
+ ```
52
+
53
+ Set the same `POWERTECH_CLI_HOME` when running later commands. The saved
54
+ configuration is private to the current operating-system user.
55
+
56
+ ### Production
57
+
58
+ PyPI releases default to the production API:
59
+
60
+ ```bash
61
+ powertech login --token <production_token>
62
+ ```
63
+
64
+ Use `--api-url` only for development, staging, or a private deployment. The URL
65
+ is saved with the token, so later upload and download commands do not repeat it.
66
+
67
+ ## Upload
68
+
69
+ Each command creates one upload batch. Directory uploads preserve relative paths,
70
+ and `--resume` retains the batch and completed OSS parts after interruption.
71
+
72
+ ```bash
73
+ POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./video.mp4 --resume
74
+ POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./videos --recursive --resume
75
+ POWERTECH_CLI_HOME=~/.powertech/staging powertech status <upload_batch_id>
76
+ ```
77
+
78
+ Supported input formats: MP4 and MOV.
79
+
80
+ ## Download
81
+
82
+ ```bash
83
+ POWERTECH_CLI_HOME=~/.powertech/staging \
84
+ powertech download --upload-batch <upload_batch_id> --output ./results
85
+ ```
86
+
87
+ The output always contains `manifest.json`. Result files are downloaded into the
88
+ paths returned by the API.
89
+
90
+ ## Local Test Mode
91
+
92
+ The isolated mock adapter does not use RDS or OSS:
93
+
94
+ ```bash
95
+ powertech login --token dev_token --api-url local
96
+ powertech upload ./videos --recursive --resume
97
+ ```
98
+
99
+ CLI state is stored under `~/.powertech/`. Set `POWERTECH_CLI_HOME` to use another
100
+ directory during tests.
101
+
102
+ ## Continuous integration
103
+
104
+ Pull requests and pushes to `main` run Ruff, the unit tests, and a packaging smoke
105
+ test. The packaging check is release preparation; staging does not deploy or
106
+ require a separate CLI package. PyPI publication is a separate, manually
107
+ triggered GitHub Actions workflow.
@@ -0,0 +1,94 @@
1
+ # PowerTech CLI
2
+
3
+ Command-line client for uploading video batches and downloading processing results.
4
+
5
+ ## Development install
6
+
7
+ ```bash
8
+ python3 -m venv .venv
9
+ source .venv/bin/activate
10
+ pip install -e .
11
+ ```
12
+
13
+ The same package is used in every environment. Production is the default;
14
+ development and staging select another API URL during login and keep their local
15
+ CLI state in separate directories.
16
+
17
+ ## Environments
18
+
19
+ ### Development
20
+
21
+ Start `powertech-api` at `http://127.0.0.1:8000`, then create a CLI token in the
22
+ development SaaS web app:
23
+
24
+ ```bash
25
+ POWERTECH_CLI_HOME=~/.powertech/dev \
26
+ powertech login --token <development_token> --api-url http://127.0.0.1:8000
27
+ ```
28
+
29
+ ### Staging
30
+
31
+ Use the same editable installation and save staging credentials separately:
32
+
33
+ ```bash
34
+ POWERTECH_CLI_HOME=~/.powertech/staging \
35
+ powertech login \
36
+ --token <staging_token> \
37
+ --api-url https://api-staging.powertech-ai.com
38
+ ```
39
+
40
+ Set the same `POWERTECH_CLI_HOME` when running later commands. The saved
41
+ configuration is private to the current operating-system user.
42
+
43
+ ### Production
44
+
45
+ PyPI releases default to the production API:
46
+
47
+ ```bash
48
+ powertech login --token <production_token>
49
+ ```
50
+
51
+ Use `--api-url` only for development, staging, or a private deployment. The URL
52
+ is saved with the token, so later upload and download commands do not repeat it.
53
+
54
+ ## Upload
55
+
56
+ Each command creates one upload batch. Directory uploads preserve relative paths,
57
+ and `--resume` retains the batch and completed OSS parts after interruption.
58
+
59
+ ```bash
60
+ POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./video.mp4 --resume
61
+ POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./videos --recursive --resume
62
+ POWERTECH_CLI_HOME=~/.powertech/staging powertech status <upload_batch_id>
63
+ ```
64
+
65
+ Supported input formats: MP4 and MOV.
66
+
67
+ ## Download
68
+
69
+ ```bash
70
+ POWERTECH_CLI_HOME=~/.powertech/staging \
71
+ powertech download --upload-batch <upload_batch_id> --output ./results
72
+ ```
73
+
74
+ The output always contains `manifest.json`. Result files are downloaded into the
75
+ paths returned by the API.
76
+
77
+ ## Local Test Mode
78
+
79
+ The isolated mock adapter does not use RDS or OSS:
80
+
81
+ ```bash
82
+ powertech login --token dev_token --api-url local
83
+ powertech upload ./videos --recursive --resume
84
+ ```
85
+
86
+ CLI state is stored under `~/.powertech/`. Set `POWERTECH_CLI_HOME` to use another
87
+ directory during tests.
88
+
89
+ ## Continuous integration
90
+
91
+ Pull requests and pushes to `main` run Ruff, the unit tests, and a packaging smoke
92
+ test. The packaging check is release preparation; staging does not deploy or
93
+ require a separate CLI package. PyPI publication is a separate, manually
94
+ triggered GitHub Actions workflow.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "powertech-cli"
7
+ version = "0.1.0"
8
+ description = "PowerTech CLI for batch video upload and result download."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [
12
+ { name = "PowerTech" }
13
+ ]
14
+ dependencies = [
15
+ "alibabacloud-oss-v2>=1.3.2,<2",
16
+ "httpx>=0.27,<1",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "build>=1.2,<2",
22
+ "ruff>=0.9,<1",
23
+ ]
24
+
25
+ [project.scripts]
26
+ powertech = "powertech_cli.main:main"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
30
+
31
+ [tool.ruff]
32
+ line-length = 120
33
+ target-version = "py311"
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """PowerTech CLI upload tool."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,370 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import uuid
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ import httpx
9
+
10
+ from . import __version__
11
+ from .config import CliConfig, get_home
12
+ from .errors import CliError
13
+ from .utils import content_type_for_path, utc_now
14
+
15
+
16
+ class HttpBackendClient:
17
+ def __init__(self, config: CliConfig) -> None:
18
+ self.config = config
19
+ self.base_url = f"{config.api_url.rstrip('/')}/cli/v1"
20
+
21
+ def ensure_authenticated(self) -> None:
22
+ if not self.config.is_logged_in:
23
+ raise CliError("Not logged in. Run: powertech login --token <cli_token>")
24
+
25
+ def create_upload_batch(self) -> dict:
26
+ return self._request("POST", "/upload-batches", {"client_version": __version__})
27
+
28
+ def get_current_context(self) -> dict:
29
+ return self._request("GET", "/me")
30
+
31
+ def get_upload_batch(self, upload_batch_id: str) -> dict:
32
+ return self._request("GET", f"/upload-batches/{upload_batch_id}")
33
+
34
+ def create_upload_credential(
35
+ self, *, upload_batch_id: str, file_path: Path, relative_path: str
36
+ ) -> dict:
37
+ return self._request(
38
+ "POST",
39
+ "/upload-credentials",
40
+ {
41
+ "upload_batch_id": upload_batch_id,
42
+ "file_name": file_path.name,
43
+ "file_size_bytes": file_path.stat().st_size,
44
+ "content_type": content_type_for_path(file_path),
45
+ "relative_path": relative_path,
46
+ "client_version": __version__,
47
+ },
48
+ )
49
+
50
+ def complete_upload(
51
+ self,
52
+ *,
53
+ upload_batch_id: str,
54
+ relative_path: str,
55
+ file_path: Path,
56
+ object_info: dict,
57
+ duration_ms: int | None,
58
+ ) -> dict:
59
+ return self._request(
60
+ "POST",
61
+ "/uploads/complete",
62
+ {
63
+ "upload_batch_id": upload_batch_id,
64
+ "object_key": object_info["object_key"],
65
+ "relative_path": relative_path,
66
+ "file_size_bytes": file_path.stat().st_size,
67
+ "content_type": content_type_for_path(file_path),
68
+ "duration_ms": duration_ms,
69
+ "etag": object_info.get("etag"),
70
+ "checksum_sha256": object_info.get("checksum_sha256"),
71
+ },
72
+ )
73
+
74
+ def finish_upload_batch(self, upload_batch_id: str) -> dict:
75
+ return self._request("POST", f"/upload-batches/{upload_batch_id}/finish")
76
+
77
+ def get_results(self, upload_batch_id: str) -> dict:
78
+ return self._request("GET", f"/upload-batches/{upload_batch_id}/results")
79
+
80
+ def list_uploaded_files(self) -> list[dict]:
81
+ raise CliError("The files command is only available in mock mode. Use the Web video data page.")
82
+
83
+ def _request(self, method: str, path: str, payload: dict | None = None) -> dict:
84
+ self.ensure_authenticated()
85
+ try:
86
+ with httpx.Client(timeout=30, trust_env=False) as client:
87
+ response = client.request(
88
+ method,
89
+ f"{self.base_url}{path}",
90
+ headers={"Authorization": f"Bearer {self.config.token}"},
91
+ json=payload,
92
+ )
93
+ except httpx.HTTPError as exc:
94
+ raise CliError(f"Could not reach PowerTech API at {self.config.api_url}: {exc}") from exc
95
+
96
+ if response.is_error:
97
+ raise CliError(_api_error_message(response))
98
+ try:
99
+ return response.json()
100
+ except ValueError as exc:
101
+ raise CliError("PowerTech API returned an invalid JSON response.") from exc
102
+
103
+
104
+ class LocalBackendClient:
105
+ """Local JSON-backed API simulator retained for isolated CLI tests."""
106
+
107
+ def __init__(self, config: CliConfig) -> None:
108
+ self.config = config
109
+ self.state_path = get_home() / "backend-state.json"
110
+
111
+ def ensure_authenticated(self) -> None:
112
+ if not self.config.is_logged_in:
113
+ raise CliError("Not logged in. Run: powertech login --token <cli_token>")
114
+
115
+ def create_upload_batch(self) -> dict:
116
+ self.ensure_authenticated()
117
+ state = self._load_state()
118
+ batch_id = f"ub_{uuid.uuid4().hex[:12]}"
119
+ batch = {
120
+ "id": batch_id,
121
+ "upload_source": "cli",
122
+ "status": "created",
123
+ "client_version": __version__,
124
+ "created_at": utc_now(),
125
+ "completed_at": None,
126
+ }
127
+ state["upload_batches"][batch_id] = batch
128
+ self._save_state(state)
129
+ return batch
130
+
131
+ def get_current_context(self) -> dict:
132
+ self.ensure_authenticated()
133
+ return {
134
+ "user_id": self.config.user_id,
135
+ "organization_id": self.config.org_id,
136
+ "role": "owner",
137
+ "organization_name": self.config.org_id,
138
+ "user_name": self.config.user_id,
139
+ }
140
+
141
+ def get_upload_batch(self, upload_batch_id: str) -> dict:
142
+ state = self._load_state()
143
+ batch = _require_batch(state, upload_batch_id)
144
+ files = [item for item in state["uploaded_files"].values() if item["upload_batch_id"] == upload_batch_id]
145
+ return {
146
+ "upload_batch": batch,
147
+ "file_count": len(files),
148
+ "total_size_bytes": sum(item["file_size_bytes"] for item in files),
149
+ }
150
+
151
+ def create_upload_credential(
152
+ self, *, upload_batch_id: str, file_path: Path, relative_path: str
153
+ ) -> dict:
154
+ state = self._load_state()
155
+ batch = _require_batch(state, upload_batch_id)
156
+ safe_path = _safe_object_name(relative_path)
157
+ object_key = f"uploads/{self.config.org_id}/{batch['id']}/{safe_path}"
158
+ batch["status"] = "uploading"
159
+ self._save_state(state)
160
+ return {
161
+ "upload_batch_id": batch["id"],
162
+ "object_key": object_key,
163
+ "storage_provider": "mock",
164
+ "upload_method": "mock_multipart",
165
+ "credential": {"type": "mock", "file_size_bytes": file_path.stat().st_size},
166
+ }
167
+
168
+ def complete_upload(
169
+ self,
170
+ *,
171
+ upload_batch_id: str,
172
+ relative_path: str,
173
+ file_path: Path,
174
+ object_info: dict,
175
+ duration_ms: int | None,
176
+ ) -> dict:
177
+ state = self._load_state()
178
+ batch = _require_batch(state, upload_batch_id)
179
+ existing = next(
180
+ (
181
+ item
182
+ for item in state["uploaded_files"].values()
183
+ if item["upload_batch_id"] == upload_batch_id and item["object_key"] == object_info["object_key"]
184
+ ),
185
+ None,
186
+ )
187
+ if existing:
188
+ return {"upload_batch_id": upload_batch_id, "uploaded_file": existing}
189
+
190
+ uploaded_file_id = f"uf_{uuid.uuid4().hex[:12]}"
191
+ uploaded_file = {
192
+ "id": uploaded_file_id,
193
+ "upload_batch_id": upload_batch_id,
194
+ "original_file_name": file_path.name,
195
+ "relative_path": relative_path,
196
+ "file_size_bytes": file_path.stat().st_size,
197
+ "duration_ms": duration_ms,
198
+ "content_type": content_type_for_path(file_path),
199
+ "object_key": object_info["object_key"],
200
+ "storage_provider": "mock",
201
+ "status": "uploaded",
202
+ "upload_source": "cli",
203
+ "created_at": utc_now(),
204
+ }
205
+ batch["status"] = "uploading"
206
+ state["uploaded_files"][uploaded_file_id] = uploaded_file
207
+ self._save_state(state)
208
+ return {"upload_batch_id": upload_batch_id, "uploaded_file": uploaded_file}
209
+
210
+ def finish_upload_batch(self, upload_batch_id: str) -> dict:
211
+ state = self._load_state()
212
+ batch = _require_batch(state, upload_batch_id)
213
+ batch["status"] = "completed"
214
+ batch["completed_at"] = utc_now()
215
+ self._save_state(state)
216
+ return {"upload_batch": batch}
217
+
218
+ def get_results(self, upload_batch_id: str) -> dict:
219
+ status = self.get_upload_batch(upload_batch_id)
220
+ items = [
221
+ {
222
+ "upload_batch_id": upload_batch_id,
223
+ "uploaded_file_id": item["id"],
224
+ "task_id": None,
225
+ "service_sku": None,
226
+ "original_file_name": item["original_file_name"],
227
+ "relative_path": item["relative_path"],
228
+ "task_status": "not_created",
229
+ "result_files": [],
230
+ "skipped_reason": "no_task_created",
231
+ }
232
+ for item in self.list_uploaded_files()
233
+ if item["upload_batch_id"] == upload_batch_id
234
+ ]
235
+ return {
236
+ "upload_batch_id": upload_batch_id,
237
+ "status": "ready",
238
+ "manifest": {
239
+ "schema_version": "result-manifest-v1",
240
+ "generated_at": utc_now(),
241
+ "download_scope": "upload_batch",
242
+ "upload_batch_id": upload_batch_id,
243
+ "items": items,
244
+ },
245
+ "downloads": [],
246
+ "file_count": status["file_count"],
247
+ }
248
+
249
+ def list_uploaded_files(self) -> list[dict]:
250
+ state = self._load_state()
251
+ return sorted(state["uploaded_files"].values(), key=lambda item: item["created_at"], reverse=True)
252
+
253
+ def _load_state(self) -> dict:
254
+ if not self.state_path.exists():
255
+ return {"upload_batches": {}, "uploaded_files": {}}
256
+ state = json.loads(self.state_path.read_text(encoding="utf-8"))
257
+ state.setdefault("upload_batches", {})
258
+ state.setdefault("uploaded_files", {})
259
+ return state
260
+
261
+ def _save_state(self, state: dict) -> None:
262
+ self.state_path.parent.mkdir(parents=True, exist_ok=True)
263
+ tmp_path = self.state_path.with_suffix(".json.tmp")
264
+ tmp_path.write_text(json.dumps(state, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
265
+ tmp_path.replace(self.state_path)
266
+
267
+
268
+ def build_backend_client(config: CliConfig) -> HttpBackendClient | LocalBackendClient:
269
+ if config.is_mock_mode:
270
+ return LocalBackendClient(config)
271
+ return HttpBackendClient(config)
272
+
273
+
274
+ def download_file(
275
+ url: str,
276
+ destination: Path,
277
+ *,
278
+ expected_size: int | None = None,
279
+ expected_sha256: str | None = None,
280
+ client: httpx.Client | None = None,
281
+ ) -> bool:
282
+ if destination.exists() and _download_matches(
283
+ destination,
284
+ expected_size=expected_size,
285
+ expected_sha256=expected_sha256,
286
+ ):
287
+ return False
288
+
289
+ destination.parent.mkdir(parents=True, exist_ok=True)
290
+ partial = destination.with_name(f"{destination.name}.part")
291
+ if expected_size is not None and partial.exists() and partial.stat().st_size > expected_size:
292
+ partial.unlink()
293
+
294
+ own_client = client is None
295
+ http_client = client or httpx.Client(timeout=120, follow_redirects=True, trust_env=False)
296
+ try:
297
+ downloaded_bytes = partial.stat().st_size if partial.exists() else 0
298
+ headers = {"Range": f"bytes={downloaded_bytes}-"} if downloaded_bytes else {}
299
+ with http_client.stream("GET", url, headers=headers) as response:
300
+ if response.status_code == 416 and expected_size == downloaded_bytes:
301
+ pass
302
+ elif response.is_error:
303
+ raise CliError(f"Result download failed with HTTP {response.status_code}.")
304
+ else:
305
+ append = downloaded_bytes > 0 and response.status_code == 206
306
+ with partial.open("ab" if append else "wb") as handle:
307
+ for chunk in response.iter_bytes():
308
+ handle.write(chunk)
309
+ except httpx.HTTPError as exc:
310
+ raise CliError(f"Result download failed: {exc}") from exc
311
+ finally:
312
+ if own_client:
313
+ http_client.close()
314
+
315
+ has_validation = expected_size is not None or expected_sha256 is not None
316
+ if has_validation and not _download_matches(
317
+ partial,
318
+ expected_size=expected_size,
319
+ expected_sha256=expected_sha256,
320
+ ):
321
+ raise CliError(f"Downloaded result failed validation: {destination.name}")
322
+ partial.replace(destination)
323
+ return True
324
+
325
+
326
+ def _download_matches(
327
+ path: Path,
328
+ *,
329
+ expected_size: int | None,
330
+ expected_sha256: str | None,
331
+ ) -> bool:
332
+ if not path.exists():
333
+ return False
334
+ if expected_size is not None and path.stat().st_size != expected_size:
335
+ return False
336
+ if expected_sha256:
337
+ digest = hashlib.sha256()
338
+ with path.open("rb") as handle:
339
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
340
+ digest.update(chunk)
341
+ if digest.hexdigest().lower() != expected_sha256.lower():
342
+ return False
343
+ return expected_size is not None or expected_sha256 is not None
344
+
345
+
346
+ def _api_error_message(response: httpx.Response) -> str:
347
+ try:
348
+ payload = response.json()
349
+ except ValueError:
350
+ return f"PowerTech API request failed with HTTP {response.status_code}."
351
+ error = payload.get("error", {}) if isinstance(payload, dict) else {}
352
+ message = error.get("message")
353
+ code = error.get("code")
354
+ if message and code:
355
+ return f"{message} ({code})"
356
+ return message or f"PowerTech API request failed with HTTP {response.status_code}."
357
+
358
+
359
+ def _require_batch(state: dict, upload_batch_id: str) -> dict:
360
+ batch = state["upload_batches"].get(upload_batch_id)
361
+ if not batch:
362
+ raise CliError(f"Upload batch not found: {upload_batch_id}")
363
+ return batch
364
+
365
+
366
+ def _safe_object_name(relative_name: str) -> str:
367
+ parts = [part for part in PurePosixPath(relative_name).parts if part not in {"", ".", "..", "/"}]
368
+ if not parts:
369
+ raise CliError("Invalid upload file name.")
370
+ return "/".join(parts)
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import asdict, dataclass, field
6
+ from pathlib import Path
7
+
8
+ from .config import get_home
9
+ from .utils import utc_now
10
+
11
+
12
+ def compute_upload_fingerprint(path: Path, org_id: str) -> str:
13
+ stat = path.stat()
14
+ source = f"{org_id}|{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}"
15
+ return hashlib.sha256(source.encode("utf-8")).hexdigest()[:32]
16
+
17
+
18
+ @dataclass
19
+ class UploadCheckpoint:
20
+ fingerprint: str
21
+ source_path: str
22
+ file_size_bytes: int
23
+ upload_batch_id: str
24
+ object_key: str
25
+ uploaded_bytes: int
26
+ chunk_size_bytes: int
27
+ updated_at: str
28
+ completed: bool = False
29
+ oss_upload_id: str | None = None
30
+ completed_parts: dict[str, str] = field(default_factory=dict)
31
+ oss_completed: bool = False
32
+ oss_etag: str | None = None
33
+
34
+
35
+ class CheckpointStore:
36
+ def __init__(self, root: Path | None = None) -> None:
37
+ self.root = root or get_home() / "checkpoints"
38
+
39
+ def _path(self, fingerprint: str) -> Path:
40
+ return self.root / f"{fingerprint}.json"
41
+
42
+ def load(self, fingerprint: str) -> UploadCheckpoint | None:
43
+ path = self._path(fingerprint)
44
+ if not path.exists():
45
+ return None
46
+ try:
47
+ data = json.loads(path.read_text(encoding="utf-8"))
48
+ return UploadCheckpoint(**data)
49
+ except (json.JSONDecodeError, OSError, TypeError):
50
+ return None
51
+
52
+ def save(self, checkpoint: UploadCheckpoint) -> None:
53
+ checkpoint.updated_at = utc_now()
54
+ self.root.mkdir(parents=True, exist_ok=True)
55
+ path = self._path(checkpoint.fingerprint)
56
+ tmp_path = path.with_suffix(".json.tmp")
57
+ tmp_path.write_text(json.dumps(asdict(checkpoint), indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
58
+ tmp_path.replace(path)
59
+
60
+ def delete(self, fingerprint: str) -> None:
61
+ path = self._path(fingerprint)
62
+ if path.exists():
63
+ path.unlink()