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.
- powertech_cli/__init__.py +3 -0
- powertech_cli/__main__.py +4 -0
- powertech_cli/api.py +370 -0
- powertech_cli/checkpoint.py +63 -0
- powertech_cli/config.py +68 -0
- powertech_cli/errors.py +2 -0
- powertech_cli/main.py +306 -0
- powertech_cli/storage/__init__.py +16 -0
- powertech_cli/storage/local.py +80 -0
- powertech_cli/storage/oss.py +152 -0
- powertech_cli/utils.py +129 -0
- powertech_cli-0.1.0.dist-info/METADATA +107 -0
- powertech_cli-0.1.0.dist-info/RECORD +16 -0
- powertech_cli-0.1.0.dist-info/WHEEL +5 -0
- powertech_cli-0.1.0.dist-info/entry_points.txt +2 -0
- powertech_cli-0.1.0.dist-info/top_level.txt +1 -0
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()
|
powertech_cli/config.py
ADDED
|
@@ -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
|
powertech_cli/errors.py
ADDED
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 @@
|
|
|
1
|
+
powertech_cli
|