powertech-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.
@@ -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())
powertech_cli/api.py ADDED
@@ -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()
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import asdict, dataclass
6
+ from pathlib import Path
7
+
8
+ APP_HOME_ENV = "POWERTECH_CLI_HOME"
9
+ CONFIG_FILENAME = "config.json"
10
+ DEFAULT_API_URL = os.environ.get("POWERTECH_API_URL", "https://api.powertech-ai.com")
11
+ DEFAULT_ORG_ID = "org_demo"
12
+ DEFAULT_USER_ID = "user_demo"
13
+ DEFAULT_STORAGE = "local"
14
+ DEFAULT_CHUNK_SIZE_BYTES = 8 * 1024 * 1024
15
+
16
+
17
+ def get_home() -> Path:
18
+ raw_home = os.environ.get(APP_HOME_ENV)
19
+ if raw_home:
20
+ return Path(raw_home).expanduser()
21
+ return Path.home() / ".powertech"
22
+
23
+
24
+ @dataclass
25
+ class CliConfig:
26
+ token: str | None = None
27
+ api_url: str = DEFAULT_API_URL
28
+ org_id: str = DEFAULT_ORG_ID
29
+ user_id: str = DEFAULT_USER_ID
30
+ storage: str = DEFAULT_STORAGE
31
+ chunk_size_bytes: int = DEFAULT_CHUNK_SIZE_BYTES
32
+ local_store_dir: str | None = None
33
+
34
+ @property
35
+ def is_logged_in(self) -> bool:
36
+ return bool(self.token)
37
+
38
+ @property
39
+ def is_mock_mode(self) -> bool:
40
+ return self.api_url in {"local", "mock"}
41
+
42
+ def resolve_local_store_dir(self) -> Path:
43
+ if self.local_store_dir:
44
+ return Path(self.local_store_dir).expanduser()
45
+ return get_home() / "local-oss"
46
+
47
+
48
+ def config_path() -> Path:
49
+ return get_home() / CONFIG_FILENAME
50
+
51
+
52
+ def load_config() -> CliConfig:
53
+ path = config_path()
54
+ if not path.exists():
55
+ return CliConfig()
56
+ data = json.loads(path.read_text(encoding="utf-8"))
57
+ allowed = CliConfig.__dataclass_fields__.keys()
58
+ filtered = {key: value for key, value in data.items() if key in allowed}
59
+ return CliConfig(**filtered)
60
+
61
+
62
+ def save_config(config: CliConfig) -> Path:
63
+ path = config_path()
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ path.parent.chmod(0o700)
66
+ path.write_text(json.dumps(asdict(config), indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
67
+ path.chmod(0o600)
68
+ return path
@@ -0,0 +1,2 @@
1
+ class CliError(Exception):
2
+ """Expected command-line error that should be shown without a traceback."""
powertech_cli/main.py ADDED
@@ -0,0 +1,306 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from . import __version__
9
+ from .api import build_backend_client, download_file
10
+ from .checkpoint import CheckpointStore, UploadCheckpoint, compute_upload_fingerprint
11
+ from .config import DEFAULT_API_URL, DEFAULT_ORG_ID, DEFAULT_STORAGE, CliConfig, load_config, save_config
12
+ from .errors import CliError
13
+ from .storage import build_storage_client
14
+ from .utils import collect_video_files, format_bytes, read_video_duration_ms, utc_now
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ parser = build_parser()
19
+ args = parser.parse_args(argv)
20
+ if not hasattr(args, "handler"):
21
+ parser.print_help()
22
+ return 0
23
+
24
+ try:
25
+ return args.handler(args)
26
+ except CliError as exc:
27
+ print(f"Error: {exc}", file=sys.stderr)
28
+ return 1
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ parser = argparse.ArgumentParser(prog="powertech", description="PowerTech CLI upload and result download tool")
33
+ parser.add_argument("--version", action="version", version=f"powertech-cli {__version__}")
34
+ subparsers = parser.add_subparsers(dest="command")
35
+
36
+ login = subparsers.add_parser("login", help="Save a CLI token")
37
+ login.add_argument("--token", required=True, help="CLI token generated by the SaaS web app")
38
+ login.add_argument("--api-url", default=DEFAULT_API_URL, help="PowerTech API URL")
39
+ login.add_argument("--org-id", default=DEFAULT_ORG_ID, help=argparse.SUPPRESS)
40
+ login.add_argument("--user-id", default="user_demo", help=argparse.SUPPRESS)
41
+ login.add_argument("--storage", choices=["local", "oss"], default=DEFAULT_STORAGE, help=argparse.SUPPRESS)
42
+ login.add_argument("--chunk-size-mb", type=float, default=8, help="Upload chunk size in MiB")
43
+ login.set_defaults(handler=handle_login)
44
+
45
+ upload = subparsers.add_parser("upload", help="Upload a video file or directory")
46
+ upload.add_argument("paths", nargs="+", help="Video file or directory paths")
47
+ upload.add_argument("--recursive", action="store_true", help="Upload supported videos in nested directories")
48
+ upload.add_argument("--resume", action="store_true", help="Reuse checkpoint for interrupted uploads")
49
+ upload.add_argument("--dry-run", action="store_true", help="Print matched files without uploading")
50
+ upload.add_argument("--chunk-size-mb", type=float, default=None, help="Override upload chunk size in MiB")
51
+ upload.set_defaults(handler=handle_upload)
52
+
53
+ status = subparsers.add_parser("status", help="Show upload batch status")
54
+ status.add_argument("upload_batch_id", help="Upload batch id, for example ub_...")
55
+ status.add_argument("--json", action="store_true", help="Print raw JSON status")
56
+ status.set_defaults(handler=handle_status)
57
+
58
+ files = subparsers.add_parser("files", help="List locally recorded uploaded files")
59
+ files.add_argument("--json", action="store_true", help="Print raw JSON list")
60
+ files.set_defaults(handler=handle_files)
61
+
62
+ download = subparsers.add_parser("download", help="Download results for an upload batch")
63
+ download.add_argument("--upload-batch", required=True, dest="upload_batch_id", help="Upload batch id")
64
+ download.add_argument("--output", "-o", default="./results", help="Output directory")
65
+ download.set_defaults(handler=handle_download)
66
+
67
+ return parser
68
+
69
+
70
+ def handle_login(args: argparse.Namespace) -> int:
71
+ config = load_config()
72
+ config.token = args.token
73
+ config.api_url = args.api_url
74
+ config.org_id = args.org_id
75
+ config.user_id = args.user_id
76
+ config.storage = args.storage
77
+ config.chunk_size_bytes = _chunk_size_bytes(args.chunk_size_mb)
78
+ if not config.is_mock_mode:
79
+ context = build_backend_client(config).get_current_context()
80
+ config.org_id = context["organization_id"]
81
+ config.user_id = context["user_id"]
82
+ path = save_config(config)
83
+ print(f"Logged in to organization {config.org_id}. Config saved to {path}")
84
+ return 0
85
+
86
+
87
+ def handle_upload(args: argparse.Namespace) -> int:
88
+ config = load_config()
89
+ if args.chunk_size_mb is not None:
90
+ config.chunk_size_bytes = _chunk_size_bytes(args.chunk_size_mb)
91
+
92
+ backend = build_backend_client(config)
93
+ checkpoint_store = CheckpointStore()
94
+
95
+ upload_items: list[tuple[Path, str]] = []
96
+ for raw_path in args.paths:
97
+ upload_items.extend(collect_video_files(Path(raw_path), recursive=args.recursive))
98
+
99
+ if not upload_items:
100
+ raise CliError("No supported MP4 or MOV files were found.")
101
+
102
+ if args.dry_run:
103
+ print(f"Matched {len(upload_items)} video file(s):")
104
+ for source_path, relative_name in upload_items:
105
+ print(f"- {source_path} -> {relative_name} ({format_bytes(source_path.stat().st_size)})")
106
+ return 0
107
+
108
+ checkpoint_identity = _checkpoint_identity(config)
109
+ checkpoints = {
110
+ fingerprint: checkpoint_store.load(fingerprint) if args.resume else None
111
+ for fingerprint in [compute_upload_fingerprint(path, checkpoint_identity) for path, _ in upload_items]
112
+ }
113
+ resumable_batch_ids = {item.upload_batch_id for item in checkpoints.values() if item is not None}
114
+ if len(resumable_batch_ids) > 1:
115
+ raise CliError("Matched files belong to multiple interrupted upload batches. Resume them separately.")
116
+
117
+ if resumable_batch_ids:
118
+ upload_batch_id = resumable_batch_ids.pop()
119
+ backend.get_upload_batch(upload_batch_id)
120
+ print(f"Resuming upload batch {upload_batch_id}")
121
+ else:
122
+ upload_batch_id = backend.create_upload_batch()["id"]
123
+ print(f"Created upload batch {upload_batch_id}")
124
+
125
+ print(f"Uploading {len(upload_items)} video file(s) to {config.api_url}")
126
+ for source_path, relative_name in upload_items:
127
+ _upload_one(
128
+ config=config,
129
+ backend=backend,
130
+ checkpoint_store=checkpoint_store,
131
+ upload_batch_id=upload_batch_id,
132
+ source_path=source_path,
133
+ relative_name=relative_name,
134
+ resume=args.resume,
135
+ )
136
+ backend.finish_upload_batch(upload_batch_id)
137
+ for source_path, _ in upload_items:
138
+ checkpoint_store.delete(compute_upload_fingerprint(source_path, checkpoint_identity))
139
+ print(f"Upload batch completed: {upload_batch_id}")
140
+ return 0
141
+
142
+
143
+ def handle_status(args: argparse.Namespace) -> int:
144
+ config = load_config()
145
+ backend = build_backend_client(config)
146
+ status = backend.get_upload_batch(args.upload_batch_id)
147
+ if args.json:
148
+ print(json.dumps(status, indent=2, ensure_ascii=False))
149
+ return 0
150
+
151
+ batch = status["upload_batch"]
152
+ print(f"Upload batch: {batch['id']}")
153
+ print(f"Status: {batch['status']}")
154
+ print(f"Files: {status['file_count']}")
155
+ print(f"Total size: {format_bytes(status['total_size_bytes'])}")
156
+ return 0
157
+
158
+
159
+ def handle_download(args: argparse.Namespace) -> int:
160
+ config = load_config()
161
+ backend = build_backend_client(config)
162
+ result = backend.get_results(args.upload_batch_id)
163
+ output_dir = Path(args.output).expanduser()
164
+ output_dir.mkdir(parents=True, exist_ok=True)
165
+ (output_dir / "manifest.json").write_text(
166
+ json.dumps(result["manifest"], indent=2, ensure_ascii=False) + "\n",
167
+ encoding="utf-8",
168
+ )
169
+
170
+ result_metadata = {
171
+ result_file["path"]: result_file
172
+ for item in result["manifest"]["items"]
173
+ for result_file in item["result_files"]
174
+ }
175
+ downloaded = 0
176
+ existing = 0
177
+ for entry in result["downloads"]:
178
+ url = entry["download_url"]
179
+ if url.startswith("mock://"):
180
+ continue
181
+ destination = _safe_download_destination(output_dir, entry["path"])
182
+ metadata = result_metadata.get(entry["path"], {})
183
+ did_download = download_file(
184
+ url,
185
+ destination,
186
+ expected_size=metadata.get("size_bytes"),
187
+ expected_sha256=metadata.get("sha256"),
188
+ )
189
+ if did_download:
190
+ downloaded += 1
191
+ print(f"Downloaded: {entry['path']}")
192
+ else:
193
+ existing += 1
194
+ print(f"Already complete: {entry['path']}")
195
+
196
+ print(f"Manifest: {output_dir / 'manifest.json'}")
197
+ print(f"Downloaded {downloaded} result file(s).")
198
+ if existing:
199
+ print(f"Reused {existing} verified local file(s).")
200
+ return 0
201
+
202
+
203
+ def handle_files(args: argparse.Namespace) -> int:
204
+ config = load_config()
205
+ backend = build_backend_client(config)
206
+ files = backend.list_uploaded_files()
207
+ if args.json:
208
+ print(json.dumps(files, indent=2, ensure_ascii=False))
209
+ return 0
210
+
211
+ if not files:
212
+ print("No uploaded files.")
213
+ return 0
214
+
215
+ for item in files:
216
+ print(
217
+ f"{item['id']} {item['original_file_name']} "
218
+ f"{format_bytes(item['file_size_bytes'])} {item['created_at']}"
219
+ )
220
+ return 0
221
+
222
+
223
+ def _upload_one(
224
+ *,
225
+ config: CliConfig,
226
+ backend,
227
+ checkpoint_store: CheckpointStore,
228
+ upload_batch_id: str,
229
+ source_path: Path,
230
+ relative_name: str,
231
+ resume: bool,
232
+ ) -> None:
233
+ fingerprint = compute_upload_fingerprint(source_path, _checkpoint_identity(config))
234
+ checkpoint = checkpoint_store.load(fingerprint) if resume else None
235
+ if checkpoint:
236
+ if checkpoint.completed:
237
+ print(f"Already uploaded in this batch: {source_path}")
238
+ return
239
+ if checkpoint.upload_batch_id != upload_batch_id:
240
+ checkpoint = None
241
+
242
+ if not checkpoint:
243
+ authorization = backend.create_upload_credential(
244
+ upload_batch_id=upload_batch_id,
245
+ file_path=source_path,
246
+ relative_path=relative_name,
247
+ )
248
+ checkpoint = UploadCheckpoint(
249
+ fingerprint=fingerprint,
250
+ source_path=str(source_path.resolve()),
251
+ file_size_bytes=source_path.stat().st_size,
252
+ upload_batch_id=upload_batch_id,
253
+ object_key=authorization["object_key"],
254
+ uploaded_bytes=0,
255
+ chunk_size_bytes=config.chunk_size_bytes,
256
+ updated_at=utc_now(),
257
+ )
258
+ checkpoint_store.save(checkpoint)
259
+ else:
260
+ authorization = backend.create_upload_credential(
261
+ upload_batch_id=upload_batch_id,
262
+ file_path=source_path,
263
+ relative_path=relative_name,
264
+ )
265
+ if authorization["object_key"] != checkpoint.object_key:
266
+ raise CliError("The resumed upload object key no longer matches the API authorization.")
267
+
268
+ storage = build_storage_client(config, authorization)
269
+ duration_ms = read_video_duration_ms(source_path)
270
+ duration_text = f", duration={duration_ms / 1000:g}s" if duration_ms is not None else ""
271
+ print(f"- {relative_name}: size={format_bytes(source_path.stat().st_size)}{duration_text}")
272
+ object_info = storage.upload_file(
273
+ source_path=source_path,
274
+ object_key=checkpoint.object_key,
275
+ checkpoint=checkpoint,
276
+ checkpoint_store=checkpoint_store,
277
+ resume=resume,
278
+ )
279
+ completed = backend.complete_upload(
280
+ upload_batch_id=upload_batch_id,
281
+ relative_path=relative_name,
282
+ file_path=source_path,
283
+ object_info=object_info,
284
+ duration_ms=duration_ms,
285
+ )
286
+ checkpoint.completed = True
287
+ checkpoint_store.save(checkpoint)
288
+ uploaded_file = completed["uploaded_file"]
289
+ print(f" UploadedFile={uploaded_file['id']} relative_path={uploaded_file['relative_path']}")
290
+
291
+
292
+ def _safe_download_destination(output_dir: Path, relative_path: str) -> Path:
293
+ parts = PurePosixPath(relative_path).parts
294
+ if not parts or any(part in {"", ".", "..", "/"} for part in parts):
295
+ raise CliError(f"Unsafe result path returned by API: {relative_path}")
296
+ return output_dir.joinpath(*parts)
297
+
298
+
299
+ def _checkpoint_identity(config: CliConfig) -> str:
300
+ return f"{config.api_url}|{config.org_id}"
301
+
302
+
303
+ def _chunk_size_bytes(value_mb: float) -> int:
304
+ if value_mb <= 0:
305
+ raise CliError("--chunk-size-mb must be greater than 0")
306
+ return max(1, int(value_mb * 1024 * 1024))
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from powertech_cli.config import CliConfig
4
+ from powertech_cli.errors import CliError
5
+
6
+ from .local import LocalStorageClient
7
+ from .oss import OssStorageClient
8
+
9
+
10
+ def build_storage_client(config: CliConfig, authorization: dict):
11
+ upload_method = authorization.get("upload_method")
12
+ if upload_method == "mock_multipart" and config.storage == "local":
13
+ return LocalStorageClient(config)
14
+ if upload_method == "oss_multipart":
15
+ return OssStorageClient(authorization)
16
+ raise CliError(f"Unsupported upload method: {upload_method or 'unknown'}")
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from pathlib import Path, PurePosixPath
5
+
6
+ from powertech_cli.checkpoint import CheckpointStore, UploadCheckpoint
7
+ from powertech_cli.config import CliConfig
8
+ from powertech_cli.errors import CliError
9
+
10
+
11
+ class LocalStorageClient:
12
+ """Local filesystem storage that simulates object storage multipart upload."""
13
+
14
+ def __init__(self, config: CliConfig) -> None:
15
+ self.root = config.resolve_local_store_dir()
16
+
17
+ def upload_file(
18
+ self,
19
+ *,
20
+ source_path: Path,
21
+ object_key: str,
22
+ checkpoint: UploadCheckpoint,
23
+ checkpoint_store: CheckpointStore,
24
+ resume: bool,
25
+ ) -> dict:
26
+ destination = self._destination_for_key(object_key)
27
+ destination.parent.mkdir(parents=True, exist_ok=True)
28
+
29
+ source_size = source_path.stat().st_size
30
+ start_offset = 0
31
+ if resume and destination.exists():
32
+ existing_size = destination.stat().st_size
33
+ if existing_size <= source_size:
34
+ start_offset = existing_size
35
+ else:
36
+ destination.unlink()
37
+ elif destination.exists():
38
+ destination.unlink()
39
+
40
+ mode = "ab" if start_offset else "wb"
41
+ checkpoint.uploaded_bytes = start_offset
42
+ checkpoint_store.save(checkpoint)
43
+
44
+ with source_path.open("rb") as source, destination.open(mode) as target:
45
+ source.seek(start_offset)
46
+ uploaded = start_offset
47
+ while True:
48
+ chunk = source.read(checkpoint.chunk_size_bytes)
49
+ if not chunk:
50
+ break
51
+ target.write(chunk)
52
+ uploaded += len(chunk)
53
+ checkpoint.uploaded_bytes = uploaded
54
+ checkpoint_store.save(checkpoint)
55
+
56
+ if destination.stat().st_size != source_size:
57
+ raise CliError(f"Local upload failed for {source_path.name}: incomplete destination file.")
58
+
59
+ checksum = _sha256_file(destination)
60
+ return {
61
+ "object_key": object_key,
62
+ "size": destination.stat().st_size,
63
+ "etag": checksum[:32],
64
+ "checksum_sha256": checksum,
65
+ "local_path": str(destination),
66
+ }
67
+
68
+ def _destination_for_key(self, object_key: str) -> Path:
69
+ parts = [part for part in PurePosixPath(object_key).parts if part not in {"", ".", "..", "/"}]
70
+ if not parts:
71
+ raise CliError("Invalid object key.")
72
+ return self.root.joinpath(*parts)
73
+
74
+
75
+ def _sha256_file(path: Path) -> str:
76
+ digest = hashlib.sha256()
77
+ with path.open("rb") as handle:
78
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
79
+ digest.update(chunk)
80
+ return digest.hexdigest()
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import alibabacloud_oss_v2 as oss
6
+
7
+ from powertech_cli.checkpoint import CheckpointStore, UploadCheckpoint
8
+ from powertech_cli.errors import CliError
9
+
10
+
11
+ class OssStorageClient:
12
+ def __init__(self, authorization: dict) -> None:
13
+ credential = authorization.get("credential", {})
14
+ required = {
15
+ "access_key_id",
16
+ "access_key_secret",
17
+ "security_token",
18
+ "region",
19
+ "bucket",
20
+ "object_key",
21
+ }
22
+ missing = sorted(required.difference(credential))
23
+ if authorization.get("upload_method") != "oss_multipart" or missing:
24
+ raise CliError(f"Invalid OSS upload authorization. Missing: {', '.join(missing)}")
25
+
26
+ provider = oss.credentials.StaticCredentialsProvider(
27
+ credential["access_key_id"],
28
+ credential["access_key_secret"],
29
+ credential["security_token"],
30
+ )
31
+ config = oss.config.Config(
32
+ region=credential["region"],
33
+ endpoint=credential.get("endpoint"),
34
+ credentials_provider=provider,
35
+ )
36
+ self.client = oss.Client(config)
37
+ self.bucket = credential["bucket"]
38
+ self.authorized_object_key = credential["object_key"]
39
+
40
+ def upload_file(
41
+ self,
42
+ *,
43
+ source_path: Path,
44
+ object_key: str,
45
+ checkpoint: UploadCheckpoint,
46
+ checkpoint_store: CheckpointStore,
47
+ resume: bool,
48
+ ) -> dict:
49
+ if object_key != self.authorized_object_key:
50
+ raise CliError("OSS credential does not authorize the requested object key.")
51
+
52
+ if checkpoint.oss_completed:
53
+ return {
54
+ "object_key": object_key,
55
+ "size": source_path.stat().st_size,
56
+ "etag": checkpoint.oss_etag,
57
+ "checksum_sha256": None,
58
+ }
59
+
60
+ upload_id = checkpoint.oss_upload_id
61
+ if not upload_id:
62
+ try:
63
+ initiated = self.client.initiate_multipart_upload(
64
+ oss.InitiateMultipartUploadRequest(bucket=self.bucket, key=object_key)
65
+ )
66
+ except Exception as exc:
67
+ raise CliError(f"Could not initiate OSS upload for {source_path.name}: {exc}") from exc
68
+ upload_id = initiated.upload_id
69
+ checkpoint.oss_upload_id = upload_id
70
+ checkpoint.completed_parts = {}
71
+ checkpoint_store.save(checkpoint)
72
+
73
+ try:
74
+ self._upload_parts(source_path, object_key, upload_id, checkpoint, checkpoint_store)
75
+ parts = [
76
+ oss.UploadPart(part_number=int(number), etag=etag)
77
+ for number, etag in sorted(checkpoint.completed_parts.items(), key=lambda item: int(item[0]))
78
+ ]
79
+ completed = self.client.complete_multipart_upload(
80
+ oss.CompleteMultipartUploadRequest(
81
+ bucket=self.bucket,
82
+ key=object_key,
83
+ upload_id=upload_id,
84
+ complete_multipart_upload=oss.CompleteMultipartUpload(parts=parts),
85
+ )
86
+ )
87
+ except Exception as exc:
88
+ if not resume:
89
+ self._abort_quietly(object_key, upload_id)
90
+ checkpoint.oss_upload_id = None
91
+ checkpoint.completed_parts = {}
92
+ checkpoint_store.save(checkpoint)
93
+ raise CliError(f"OSS upload failed for {source_path.name}: {exc}") from exc
94
+
95
+ checkpoint.uploaded_bytes = source_path.stat().st_size
96
+ checkpoint.oss_completed = True
97
+ checkpoint.oss_etag = _normalize_etag(completed.etag)
98
+ checkpoint_store.save(checkpoint)
99
+ return {
100
+ "object_key": object_key,
101
+ "size": source_path.stat().st_size,
102
+ "etag": checkpoint.oss_etag,
103
+ "checksum_sha256": None,
104
+ }
105
+
106
+ def _upload_parts(
107
+ self,
108
+ source_path: Path,
109
+ object_key: str,
110
+ upload_id: str,
111
+ checkpoint: UploadCheckpoint,
112
+ checkpoint_store: CheckpointStore,
113
+ ) -> None:
114
+ part_size = max(checkpoint.chunk_size_bytes, 100 * 1024)
115
+ uploaded_bytes = 0
116
+ with source_path.open("rb") as source:
117
+ part_number = 1
118
+ while True:
119
+ body = source.read(part_size)
120
+ if not body:
121
+ break
122
+ part_key = str(part_number)
123
+ if part_key not in checkpoint.completed_parts:
124
+ result = self.client.upload_part(
125
+ oss.UploadPartRequest(
126
+ bucket=self.bucket,
127
+ key=object_key,
128
+ upload_id=upload_id,
129
+ part_number=part_number,
130
+ content_length=len(body),
131
+ body=body,
132
+ )
133
+ )
134
+ if not result.etag:
135
+ raise CliError(f"OSS did not return an ETag for part {part_number}.")
136
+ checkpoint.completed_parts[part_key] = result.etag
137
+ uploaded_bytes += len(body)
138
+ checkpoint.uploaded_bytes = uploaded_bytes
139
+ checkpoint_store.save(checkpoint)
140
+ part_number += 1
141
+
142
+ def _abort_quietly(self, object_key: str, upload_id: str) -> None:
143
+ try:
144
+ self.client.abort_multipart_upload(
145
+ oss.AbortMultipartUploadRequest(bucket=self.bucket, key=object_key, upload_id=upload_id)
146
+ )
147
+ except Exception:
148
+ pass
149
+
150
+
151
+ def _normalize_etag(etag: str | None) -> str | None:
152
+ return etag.strip('"') if etag else None
powertech_cli/utils.py ADDED
@@ -0,0 +1,129 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ import struct
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+ from typing import BinaryIO
8
+
9
+ from .errors import CliError
10
+
11
+ SUPPORTED_VIDEO_EXTENSIONS = {".mp4", ".mov"}
12
+
13
+
14
+ def utc_now() -> str:
15
+ return datetime.now(UTC).isoformat(timespec="seconds")
16
+
17
+
18
+ def content_type_for_path(path: Path) -> str:
19
+ guessed, _ = mimetypes.guess_type(path.name)
20
+ return guessed or "application/octet-stream"
21
+
22
+
23
+ def read_video_duration_ms(path: Path) -> int | None:
24
+ try:
25
+ with path.open("rb") as handle:
26
+ moov = _find_iso_bmff_box(handle, 0, path.stat().st_size, b"moov")
27
+ if moov is None:
28
+ return None
29
+ mvhd = _find_iso_bmff_box(handle, moov[0], moov[1], b"mvhd")
30
+ if mvhd is None:
31
+ return None
32
+ handle.seek(mvhd[0])
33
+ version_and_flags = handle.read(4)
34
+ if len(version_and_flags) != 4:
35
+ return None
36
+ version = version_and_flags[0]
37
+ if version == 0:
38
+ payload = handle.read(16)
39
+ if len(payload) != 16:
40
+ return None
41
+ timescale, duration = struct.unpack(">II", payload[8:])
42
+ elif version == 1:
43
+ payload = handle.read(28)
44
+ if len(payload) != 28:
45
+ return None
46
+ timescale = struct.unpack(">I", payload[16:20])[0]
47
+ duration = struct.unpack(">Q", payload[20:28])[0]
48
+ else:
49
+ return None
50
+ except (OSError, ValueError, struct.error):
51
+ return None
52
+ if timescale <= 0 or duration <= 0:
53
+ return None
54
+ return round(duration * 1000 / timescale)
55
+
56
+
57
+ def _find_iso_bmff_box(handle: BinaryIO, start: int, end: int, target: bytes) -> tuple[int, int] | None:
58
+ position = start
59
+ while position + 8 <= end:
60
+ handle.seek(position)
61
+ header = handle.read(8)
62
+ if len(header) != 8:
63
+ return None
64
+ size, box_type = struct.unpack(">I4s", header)
65
+ header_size = 8
66
+ if size == 1:
67
+ extended_size = handle.read(8)
68
+ if len(extended_size) != 8:
69
+ return None
70
+ size = struct.unpack(">Q", extended_size)[0]
71
+ header_size = 16
72
+ elif size == 0:
73
+ size = end - position
74
+ if size < header_size or position + size > end:
75
+ return None
76
+ data_start = position + header_size
77
+ box_end = position + size
78
+ if box_type == target:
79
+ return data_start, box_end
80
+ position = box_end
81
+ return None
82
+
83
+
84
+ def ensure_supported_video(path: Path) -> None:
85
+ if path.suffix.lower() not in SUPPORTED_VIDEO_EXTENSIONS:
86
+ supported = ", ".join(sorted(SUPPORTED_VIDEO_EXTENSIONS))
87
+ raise CliError(f"Unsupported file type: {path.name}. Supported video types: {supported}")
88
+
89
+
90
+ def collect_video_files(input_path: Path, recursive: bool) -> list[tuple[Path, str]]:
91
+ path = input_path.expanduser()
92
+ if not path.exists():
93
+ raise CliError(f"Path does not exist: {path}")
94
+
95
+ if path.is_file():
96
+ ensure_supported_video(path)
97
+ return [(path, path.name)]
98
+
99
+ if not path.is_dir():
100
+ raise CliError(f"Path is not a regular file or directory: {path}")
101
+
102
+ candidates = path.rglob("*") if recursive else path.iterdir()
103
+ files: list[tuple[Path, str]] = []
104
+ for candidate in sorted(candidates):
105
+ if not candidate.is_file():
106
+ continue
107
+ if candidate.suffix.lower() not in SUPPORTED_VIDEO_EXTENSIONS:
108
+ continue
109
+ relative_name = candidate.relative_to(path).as_posix()
110
+ files.append((candidate, relative_name))
111
+
112
+ if not files:
113
+ scope = "recursively " if recursive else ""
114
+ raise CliError(f"No supported video files found {scope}under: {path}")
115
+
116
+ return files
117
+
118
+
119
+ def format_bytes(size: int) -> str:
120
+ units = ["B", "KB", "MB", "GB", "TB"]
121
+ value = float(size)
122
+ for unit in units:
123
+ if value < 1024 or unit == units[-1]:
124
+ if unit == "B":
125
+ return f"{int(value)} {unit}"
126
+ return f"{value:.1f} {unit}"
127
+ value /= 1024
128
+
129
+ return f"{size} B"
@@ -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,16 @@
1
+ powertech_cli/__init__.py,sha256=GfxO8tiBXgwvyfTdTDJaoYiLsUUeBYN-khv6Q-eRahM,56
2
+ powertech_cli/__main__.py,sha256=fdA1QWBe5LNX0fbtaJXa1-dwEhrg_4sS_5UnjaajcMo,80
3
+ powertech_cli/api.py,sha256=HCFa0SgZVLQy3MQ7CCPruDQjSWvpOLzidhbWWw_dsr8,13674
4
+ powertech_cli/checkpoint.py,sha256=agUMKS-X8eCLv_MPsaBejuX70AQJxdyNAsz8ftiHXZI,1977
5
+ powertech_cli/config.py,sha256=evij4EoRbTx89FtMca-iPjkZ7doTKdke24ewsGkUdlA,1918
6
+ powertech_cli/errors.py,sha256=jFz6TgbLjuEZHRN-iE_gtCeGFkgYn8NALVBrzUPalTU,107
7
+ powertech_cli/main.py,sha256=dinx_VOtVIawj2t72EAqJ9D8U1i1BrbwoAsBoxnUvik,12073
8
+ powertech_cli/utils.py,sha256=BuXtI3X-8VfZAP4RWW4dRow7BukMgW4j1gf5HMh1bfw,4227
9
+ powertech_cli/storage/__init__.py,sha256=JYwQEgIPauDRVscUiP_iZxpYr5H7qpMgKMJ_Ta8cRYM,598
10
+ powertech_cli/storage/local.py,sha256=Jrx0AUCbBJ5Nq0TakzFcr0Np6lGeOq837nPIw9LOEmc,2758
11
+ powertech_cli/storage/oss.py,sha256=r0onQEzuCyIHKNlPoRb6Fnca7MoEcZmF7r5AYfhudwU,5756
12
+ powertech_cli-0.1.0.dist-info/METADATA,sha256=M50wKJOVVhQ-tlhx0MbMR19FwTtKGXFjuxeyqXg9Qx0,3008
13
+ powertech_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ powertech_cli-0.1.0.dist-info/entry_points.txt,sha256=Kg6mhZXY-Jc-ZwIh-UmA2nP5zq1oNSeR2P4o8X9Th8U,54
15
+ powertech_cli-0.1.0.dist-info/top_level.txt,sha256=nXGg3NMin97FoWvr3mGlxrRBypUbRh640XBc2J8AkoE,14
16
+ powertech_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
+ powertech = powertech_cli.main:main
@@ -0,0 +1 @@
1
+ powertech_cli