powertech-cli 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- powertech_cli-0.1.0/PKG-INFO +107 -0
- powertech_cli-0.1.0/README.md +94 -0
- powertech_cli-0.1.0/pyproject.toml +36 -0
- powertech_cli-0.1.0/setup.cfg +4 -0
- powertech_cli-0.1.0/src/powertech_cli/__init__.py +3 -0
- powertech_cli-0.1.0/src/powertech_cli/__main__.py +4 -0
- powertech_cli-0.1.0/src/powertech_cli/api.py +370 -0
- powertech_cli-0.1.0/src/powertech_cli/checkpoint.py +63 -0
- powertech_cli-0.1.0/src/powertech_cli/config.py +68 -0
- powertech_cli-0.1.0/src/powertech_cli/errors.py +2 -0
- powertech_cli-0.1.0/src/powertech_cli/main.py +306 -0
- powertech_cli-0.1.0/src/powertech_cli/storage/__init__.py +16 -0
- powertech_cli-0.1.0/src/powertech_cli/storage/local.py +80 -0
- powertech_cli-0.1.0/src/powertech_cli/storage/oss.py +152 -0
- powertech_cli-0.1.0/src/powertech_cli/utils.py +129 -0
- powertech_cli-0.1.0/src/powertech_cli.egg-info/PKG-INFO +107 -0
- powertech_cli-0.1.0/src/powertech_cli.egg-info/SOURCES.txt +22 -0
- powertech_cli-0.1.0/src/powertech_cli.egg-info/dependency_links.txt +1 -0
- powertech_cli-0.1.0/src/powertech_cli.egg-info/entry_points.txt +2 -0
- powertech_cli-0.1.0/src/powertech_cli.egg-info/requires.txt +6 -0
- powertech_cli-0.1.0/src/powertech_cli.egg-info/top_level.txt +1 -0
- powertech_cli-0.1.0/tests/test_cli_flow.py +97 -0
- powertech_cli-0.1.0/tests/test_downloads.py +78 -0
- powertech_cli-0.1.0/tests/test_video_metadata.py +34 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: powertech-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PowerTech CLI for batch video upload and result download.
|
|
5
|
+
Author: PowerTech
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: alibabacloud-oss-v2<2,>=1.3.2
|
|
9
|
+
Requires-Dist: httpx<1,>=0.27
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: build<2,>=1.2; extra == "dev"
|
|
12
|
+
Requires-Dist: ruff<1,>=0.9; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# PowerTech CLI
|
|
15
|
+
|
|
16
|
+
Command-line client for uploading video batches and downloading processing results.
|
|
17
|
+
|
|
18
|
+
## Development install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
python3 -m venv .venv
|
|
22
|
+
source .venv/bin/activate
|
|
23
|
+
pip install -e .
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The same package is used in every environment. Production is the default;
|
|
27
|
+
development and staging select another API URL during login and keep their local
|
|
28
|
+
CLI state in separate directories.
|
|
29
|
+
|
|
30
|
+
## Environments
|
|
31
|
+
|
|
32
|
+
### Development
|
|
33
|
+
|
|
34
|
+
Start `powertech-api` at `http://127.0.0.1:8000`, then create a CLI token in the
|
|
35
|
+
development SaaS web app:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
POWERTECH_CLI_HOME=~/.powertech/dev \
|
|
39
|
+
powertech login --token <development_token> --api-url http://127.0.0.1:8000
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Staging
|
|
43
|
+
|
|
44
|
+
Use the same editable installation and save staging credentials separately:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
48
|
+
powertech login \
|
|
49
|
+
--token <staging_token> \
|
|
50
|
+
--api-url https://api-staging.powertech-ai.com
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Set the same `POWERTECH_CLI_HOME` when running later commands. The saved
|
|
54
|
+
configuration is private to the current operating-system user.
|
|
55
|
+
|
|
56
|
+
### Production
|
|
57
|
+
|
|
58
|
+
PyPI releases default to the production API:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
powertech login --token <production_token>
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Use `--api-url` only for development, staging, or a private deployment. The URL
|
|
65
|
+
is saved with the token, so later upload and download commands do not repeat it.
|
|
66
|
+
|
|
67
|
+
## Upload
|
|
68
|
+
|
|
69
|
+
Each command creates one upload batch. Directory uploads preserve relative paths,
|
|
70
|
+
and `--resume` retains the batch and completed OSS parts after interruption.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./video.mp4 --resume
|
|
74
|
+
POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./videos --recursive --resume
|
|
75
|
+
POWERTECH_CLI_HOME=~/.powertech/staging powertech status <upload_batch_id>
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Supported input formats: MP4 and MOV.
|
|
79
|
+
|
|
80
|
+
## Download
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
84
|
+
powertech download --upload-batch <upload_batch_id> --output ./results
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The output always contains `manifest.json`. Result files are downloaded into the
|
|
88
|
+
paths returned by the API.
|
|
89
|
+
|
|
90
|
+
## Local Test Mode
|
|
91
|
+
|
|
92
|
+
The isolated mock adapter does not use RDS or OSS:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
powertech login --token dev_token --api-url local
|
|
96
|
+
powertech upload ./videos --recursive --resume
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
CLI state is stored under `~/.powertech/`. Set `POWERTECH_CLI_HOME` to use another
|
|
100
|
+
directory during tests.
|
|
101
|
+
|
|
102
|
+
## Continuous integration
|
|
103
|
+
|
|
104
|
+
Pull requests and pushes to `main` run Ruff, the unit tests, and a packaging smoke
|
|
105
|
+
test. The packaging check is release preparation; staging does not deploy or
|
|
106
|
+
require a separate CLI package. PyPI publication is a separate, manually
|
|
107
|
+
triggered GitHub Actions workflow.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# PowerTech CLI
|
|
2
|
+
|
|
3
|
+
Command-line client for uploading video batches and downloading processing results.
|
|
4
|
+
|
|
5
|
+
## Development install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
python3 -m venv .venv
|
|
9
|
+
source .venv/bin/activate
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The same package is used in every environment. Production is the default;
|
|
14
|
+
development and staging select another API URL during login and keep their local
|
|
15
|
+
CLI state in separate directories.
|
|
16
|
+
|
|
17
|
+
## Environments
|
|
18
|
+
|
|
19
|
+
### Development
|
|
20
|
+
|
|
21
|
+
Start `powertech-api` at `http://127.0.0.1:8000`, then create a CLI token in the
|
|
22
|
+
development SaaS web app:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
POWERTECH_CLI_HOME=~/.powertech/dev \
|
|
26
|
+
powertech login --token <development_token> --api-url http://127.0.0.1:8000
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### Staging
|
|
30
|
+
|
|
31
|
+
Use the same editable installation and save staging credentials separately:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
35
|
+
powertech login \
|
|
36
|
+
--token <staging_token> \
|
|
37
|
+
--api-url https://api-staging.powertech-ai.com
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Set the same `POWERTECH_CLI_HOME` when running later commands. The saved
|
|
41
|
+
configuration is private to the current operating-system user.
|
|
42
|
+
|
|
43
|
+
### Production
|
|
44
|
+
|
|
45
|
+
PyPI releases default to the production API:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
powertech login --token <production_token>
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Use `--api-url` only for development, staging, or a private deployment. The URL
|
|
52
|
+
is saved with the token, so later upload and download commands do not repeat it.
|
|
53
|
+
|
|
54
|
+
## Upload
|
|
55
|
+
|
|
56
|
+
Each command creates one upload batch. Directory uploads preserve relative paths,
|
|
57
|
+
and `--resume` retains the batch and completed OSS parts after interruption.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./video.mp4 --resume
|
|
61
|
+
POWERTECH_CLI_HOME=~/.powertech/staging powertech upload ./videos --recursive --resume
|
|
62
|
+
POWERTECH_CLI_HOME=~/.powertech/staging powertech status <upload_batch_id>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Supported input formats: MP4 and MOV.
|
|
66
|
+
|
|
67
|
+
## Download
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
POWERTECH_CLI_HOME=~/.powertech/staging \
|
|
71
|
+
powertech download --upload-batch <upload_batch_id> --output ./results
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The output always contains `manifest.json`. Result files are downloaded into the
|
|
75
|
+
paths returned by the API.
|
|
76
|
+
|
|
77
|
+
## Local Test Mode
|
|
78
|
+
|
|
79
|
+
The isolated mock adapter does not use RDS or OSS:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
powertech login --token dev_token --api-url local
|
|
83
|
+
powertech upload ./videos --recursive --resume
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
CLI state is stored under `~/.powertech/`. Set `POWERTECH_CLI_HOME` to use another
|
|
87
|
+
directory during tests.
|
|
88
|
+
|
|
89
|
+
## Continuous integration
|
|
90
|
+
|
|
91
|
+
Pull requests and pushes to `main` run Ruff, the unit tests, and a packaging smoke
|
|
92
|
+
test. The packaging check is release preparation; staging does not deploy or
|
|
93
|
+
require a separate CLI package. PyPI publication is a separate, manually
|
|
94
|
+
triggered GitHub Actions workflow.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "powertech-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "PowerTech CLI for batch video upload and result download."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "PowerTech" }
|
|
13
|
+
]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"alibabacloud-oss-v2>=1.3.2,<2",
|
|
16
|
+
"httpx>=0.27,<1",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"build>=1.2,<2",
|
|
22
|
+
"ruff>=0.9,<1",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
powertech = "powertech_cli.main:main"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
line-length = 120
|
|
33
|
+
target-version = "py311"
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -0,0 +1,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()
|