opsai-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.
- opsai_cli/__init__.py +8 -0
- opsai_cli/__main__.py +5 -0
- opsai_cli/business.py +250 -0
- opsai_cli/cli.py +98 -0
- opsai_cli/client.py +120 -0
- opsai_cli/config.py +124 -0
- opsai_cli/contracts.py +203 -0
- opsai_cli/credentials.py +71 -0
- opsai_cli/errors.py +11 -0
- opsai_cli/output.py +62 -0
- opsai_cli/sse.py +65 -0
- opsai_cli/tasks.py +177 -0
- opsai_cli-0.1.0.dist-info/METADATA +189 -0
- opsai_cli-0.1.0.dist-info/RECORD +18 -0
- opsai_cli-0.1.0.dist-info/WHEEL +5 -0
- opsai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- opsai_cli-0.1.0.dist-info/licenses/LICENSE +9 -0
- opsai_cli-0.1.0.dist-info/top_level.txt +1 -0
opsai_cli/contracts.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Standalone API v1 snapshot (2026-10-31).
|
|
2
|
+
|
|
3
|
+
Mirrors api/routes/open_api/schemas.py and shared/button_guide.py.
|
|
4
|
+
Contract tests compare against those pure model definitions without importing
|
|
5
|
+
the backend. Never import backend modules in the distributed client.
|
|
6
|
+
"""
|
|
7
|
+
from typing import Annotated, Literal
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
import ipaddress
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, AfterValidator, model_validator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ButtonRegion(BaseModel):
|
|
16
|
+
model_config = ConfigDict(extra="forbid")
|
|
17
|
+
number: int = Field(ge=1, le=100)
|
|
18
|
+
x: float = Field(ge=0, le=1, allow_inf_nan=False)
|
|
19
|
+
y: float = Field(ge=0, le=1, allow_inf_nan=False)
|
|
20
|
+
text: str = Field(min_length=1, max_length=1000)
|
|
21
|
+
|
|
22
|
+
@model_validator(mode="after")
|
|
23
|
+
def nonblank(self):
|
|
24
|
+
if not self.text.strip():
|
|
25
|
+
raise ValueError("请填写每个标记的功能")
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
class ButtonGuide(BaseModel):
|
|
29
|
+
model_config = ConfigDict(extra="forbid")
|
|
30
|
+
original: str = Field(min_length=1, max_length=2048)
|
|
31
|
+
base: str | None = Field(default=None, max_length=2048)
|
|
32
|
+
annotated: str | None = Field(default=None, max_length=2048)
|
|
33
|
+
marker_size: float = Field(default=0.036, ge=0.000001, le=8, allow_inf_nan=False)
|
|
34
|
+
regions: list[ButtonRegion] = Field(default_factory=list, max_length=100)
|
|
35
|
+
|
|
36
|
+
@model_validator(mode="after")
|
|
37
|
+
def unique_numbers(self):
|
|
38
|
+
if len({r.number for r in self.regions}) != len(self.regions):
|
|
39
|
+
raise ValueError("功能标记序号不能重复")
|
|
40
|
+
if self.regions and not self.annotated:
|
|
41
|
+
raise ValueError("功能标记缺少已保存的标记图片")
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def public_url(value: str) -> str:
|
|
47
|
+
parsed = urlsplit(value)
|
|
48
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password:
|
|
49
|
+
raise ValueError("素材必须是公开的 HTTP/HTTPS URL")
|
|
50
|
+
host = parsed.hostname.lower().rstrip(".")
|
|
51
|
+
if host == "localhost" or host.endswith((".localhost", ".local", ".internal")):
|
|
52
|
+
raise ValueError("素材 URL 不可指向内网")
|
|
53
|
+
try:
|
|
54
|
+
address = ipaddress.ip_address(host)
|
|
55
|
+
except ValueError:
|
|
56
|
+
pass
|
|
57
|
+
else:
|
|
58
|
+
if not address.is_global:
|
|
59
|
+
raise ValueError("素材 URL 不可指向内网")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def nonblank(value: str) -> str:
|
|
64
|
+
if not value.strip():
|
|
65
|
+
raise ValueError("不能为空")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Text = Annotated[str, Field(min_length=1, max_length=128), AfterValidator(nonblank)]
|
|
70
|
+
Id = Annotated[int, Field(strict=True, gt=0)]
|
|
71
|
+
Money = Annotated[float, Field(gt=0, allow_inf_nan=False)]
|
|
72
|
+
URL = Annotated[str, Field(max_length=2000), AfterValidator(public_url)]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Contract(BaseModel):
|
|
76
|
+
model_config = ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Images(Contract):
|
|
80
|
+
main_img: URL
|
|
81
|
+
bg_img: list[URL] = Field(default_factory=list)
|
|
82
|
+
size_img: URL | None = None
|
|
83
|
+
sale_img: list[URL] = Field(default_factory=list)
|
|
84
|
+
scene_img: list[URL] = Field(default_factory=list)
|
|
85
|
+
compete_img: list[URL] = Field(default_factory=list)
|
|
86
|
+
family_img: URL | None = None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Dimensions(Contract):
|
|
90
|
+
product_length: float | None = Field(None, ge=0)
|
|
91
|
+
product_width: float | None = Field(None, ge=0)
|
|
92
|
+
product_height: float | None = Field(None, ge=0)
|
|
93
|
+
product_weight: float | None = Field(None, ge=0)
|
|
94
|
+
package_length: float | None = Field(None, ge=0)
|
|
95
|
+
package_width: float | None = Field(None, ge=0)
|
|
96
|
+
package_height: float | None = Field(None, ge=0)
|
|
97
|
+
package_weight: float | None = Field(None, ge=0)
|
|
98
|
+
length_unit: Literal["毫米", "厘米", "米", "英寸"] = "毫米"
|
|
99
|
+
weight_unit: Literal["克", "千克", "磅"] = "克"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Product(Contract):
|
|
103
|
+
sku: Annotated[str, Field(min_length=1, max_length=64)]
|
|
104
|
+
spu: Annotated[str, Field(min_length=1, max_length=64)] | None = None
|
|
105
|
+
title: Annotated[str, Field(min_length=1, max_length=255)]
|
|
106
|
+
description: str | None = None
|
|
107
|
+
keywords: list[str] = Field(default_factory=list)
|
|
108
|
+
sale_points: list[str] = Field(default_factory=list)
|
|
109
|
+
video_url: URL | None = None
|
|
110
|
+
price: float | None = Field(None, ge=0)
|
|
111
|
+
currency: Literal["RMB"] = "RMB"
|
|
112
|
+
inventory: int = Field(50, ge=0)
|
|
113
|
+
package_list: str | None = Field(None, max_length=255)
|
|
114
|
+
size: str | None = None
|
|
115
|
+
dimensions: Dimensions = Field(default_factory=Dimensions)
|
|
116
|
+
images: Images
|
|
117
|
+
button_guide: ButtonGuide | None = None
|
|
118
|
+
|
|
119
|
+
@model_validator(mode="after")
|
|
120
|
+
def validate_materials(self):
|
|
121
|
+
if not self.sku.strip() or not self.title.strip() or (self.spu is not None and not self.spu.strip()):
|
|
122
|
+
raise ValueError("SKU、SPU、标题不能为空白")
|
|
123
|
+
if len(self.images.main_img) > 512:
|
|
124
|
+
raise ValueError("主图 URL 长度不能超过 512")
|
|
125
|
+
if self.button_guide:
|
|
126
|
+
for value in (self.button_guide.original, self.button_guide.base, self.button_guide.annotated):
|
|
127
|
+
if value:
|
|
128
|
+
public_url(value)
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SitePrice(Contract):
|
|
133
|
+
site: Annotated[str, Field(pattern=r"^[A-Z]{2}$")]
|
|
134
|
+
price: Money
|
|
135
|
+
pre_promotion_price: Money | None = None
|
|
136
|
+
promotional_price: Money | None = None
|
|
137
|
+
currency: Annotated[str, Field(pattern=r"^[A-Z]{3}$")] | None = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class StorePrices(Contract):
|
|
141
|
+
store_id: Id
|
|
142
|
+
site_prices: list[SitePrice] = Field(min_length=1, max_length=100)
|
|
143
|
+
|
|
144
|
+
@model_validator(mode="after")
|
|
145
|
+
def unique_sites(self):
|
|
146
|
+
if len({row.site for row in self.site_prices}) != len(self.site_prices):
|
|
147
|
+
raise ValueError("同一店铺站点价格不可重复")
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ListingSubmit(Contract):
|
|
152
|
+
request_id: Text
|
|
153
|
+
store_ids: list[Id] = Field(min_length=1, max_length=100)
|
|
154
|
+
prices: list[StorePrices] = Field(min_length=1, max_length=100)
|
|
155
|
+
products: list[Product] = Field(min_length=1, max_length=100)
|
|
156
|
+
|
|
157
|
+
@model_validator(mode="after")
|
|
158
|
+
def complete_prices(self):
|
|
159
|
+
if len(set(self.store_ids)) != len(self.store_ids):
|
|
160
|
+
raise ValueError("店铺不可重复")
|
|
161
|
+
if len({p.store_id for p in self.prices}) != len(self.prices) or {p.store_id for p in self.prices} != set(self.store_ids):
|
|
162
|
+
raise ValueError("prices 必须与 store_ids 一一对应")
|
|
163
|
+
if len({p.sku for p in self.products}) != len(self.products):
|
|
164
|
+
raise ValueError("商品 SKU 不可重复")
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class AvailableActions(Contract):
|
|
169
|
+
store_id: Id
|
|
170
|
+
listing_sku: Text
|
|
171
|
+
platform: Literal[9] = 9
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ListingPrices(Contract):
|
|
175
|
+
store_id: Id
|
|
176
|
+
listing_skus: list[Text] = Field(min_length=1, max_length=100)
|
|
177
|
+
action_id: Id
|
|
178
|
+
platform: Literal[9]
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ActivityItem(Contract):
|
|
182
|
+
listing_sku: Text
|
|
183
|
+
action_price: Money
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ActivitySubmit(Contract):
|
|
187
|
+
request_id: Text
|
|
188
|
+
store_id: Id
|
|
189
|
+
action_id: Id
|
|
190
|
+
items: list[ActivityItem] = Field(min_length=1, max_length=100)
|
|
191
|
+
|
|
192
|
+
@model_validator(mode="after")
|
|
193
|
+
def unique_skus(self):
|
|
194
|
+
if len({item.listing_sku for item in self.items}) != len(self.items):
|
|
195
|
+
raise ValueError("listing_sku 不可重复")
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class OptimizationSubmit(Contract):
|
|
200
|
+
request_id: Text
|
|
201
|
+
store_id: Id
|
|
202
|
+
mode: Literal["update", "create"]
|
|
203
|
+
listing_sku: Text
|
opsai_cli/credentials.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Origin-bound OS credentials; no plaintext or plugin fallback."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from opsai_cli.config import normalize_base_url, profile_name
|
|
9
|
+
from opsai_cli.errors import CliError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
UNAVAILABLE = "System credential storage is unavailable or locked; use OPSAI_API_KEY in this process instead."
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_key(value: str) -> str:
|
|
16
|
+
if (not isinstance(value, str) or not value or len(value) > 72
|
|
17
|
+
or any(ord(c) < 33 or ord(c) > 126 for c in value)):
|
|
18
|
+
raise CliError("API Key must contain 1-72 printable ASCII characters without whitespace.")
|
|
19
|
+
return value
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def system_backend():
|
|
23
|
+
# Select built-in OS implementations explicitly. Do not honor third-party
|
|
24
|
+
# plaintext, null, or chained fallback backends configured via keyring.
|
|
25
|
+
try:
|
|
26
|
+
if sys.platform == "win32":
|
|
27
|
+
from keyring.backends.Windows import WinVaultKeyring
|
|
28
|
+
backend = WinVaultKeyring()
|
|
29
|
+
elif sys.platform == "darwin":
|
|
30
|
+
from keyring.backends.macOS import Keyring
|
|
31
|
+
backend = Keyring()
|
|
32
|
+
elif sys.platform.startswith("linux"):
|
|
33
|
+
from keyring.backends.SecretService import Keyring
|
|
34
|
+
backend = Keyring()
|
|
35
|
+
else:
|
|
36
|
+
raise CliError(UNAVAILABLE)
|
|
37
|
+
if backend.priority <= 0:
|
|
38
|
+
raise CliError(UNAVAILABLE)
|
|
39
|
+
return backend
|
|
40
|
+
except Exception:
|
|
41
|
+
raise CliError(UNAVAILABLE) from None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def credential_account(profile: str, base_url: str) -> str:
|
|
45
|
+
identity = [profile_name(profile), normalize_base_url(base_url)]
|
|
46
|
+
return hashlib.sha256(json.dumps(identity, separators=(",", ":")).encode()).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class CredentialStore:
|
|
50
|
+
def __init__(self, backend_factory=None):
|
|
51
|
+
self.backend_factory = backend_factory or system_backend
|
|
52
|
+
|
|
53
|
+
def save(self, profile: str, base_url: str, key: str) -> None:
|
|
54
|
+
key = validate_key(key)
|
|
55
|
+
account = credential_account(profile, base_url)
|
|
56
|
+
try:
|
|
57
|
+
self.backend_factory().set_password("opsai-cli", account, key)
|
|
58
|
+
except Exception:
|
|
59
|
+
raise CliError(UNAVAILABLE) from None
|
|
60
|
+
|
|
61
|
+
def resolve(self, profile: str, base_url: str) -> str:
|
|
62
|
+
account = credential_account(profile, base_url)
|
|
63
|
+
if "OPSAI_API_KEY" in os.environ:
|
|
64
|
+
return validate_key(os.environ["OPSAI_API_KEY"])
|
|
65
|
+
try:
|
|
66
|
+
key = self.backend_factory().get_password("opsai-cli", account)
|
|
67
|
+
except Exception:
|
|
68
|
+
raise CliError(UNAVAILABLE) from None
|
|
69
|
+
if key is None:
|
|
70
|
+
raise CliError("No API Key for this profile and service address; run opsai auth configure or set OPSAI_API_KEY.")
|
|
71
|
+
return validate_key(key)
|
opsai_cli/errors.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Safe user-facing errors; never render raw transport or backend exceptions."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CliError(Exception):
|
|
5
|
+
def __init__(self, message: str, exit_code: int = 2, *, status: int | None = None,
|
|
6
|
+
retryable: bool = False, retry_after: float | None = None):
|
|
7
|
+
super().__init__(message)
|
|
8
|
+
self.exit_code = exit_code
|
|
9
|
+
self.status = status
|
|
10
|
+
self.retryable = retryable
|
|
11
|
+
self.retry_after = retry_after
|
opsai_cli/output.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Stable stdout results and stderr diagnostics, with credential redaction."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from opsai_cli.errors import CliError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Output:
|
|
11
|
+
def __init__(self, mode=None, secrets=()):
|
|
12
|
+
self.mode = mode or ("table" if sys.stdout.isatty() else "json")
|
|
13
|
+
self.secrets = tuple(s for s in secrets if s)
|
|
14
|
+
|
|
15
|
+
def clean(self, value):
|
|
16
|
+
if isinstance(value, str):
|
|
17
|
+
for secret in self.secrets:
|
|
18
|
+
value = value.replace(secret, "[REDACTED]")
|
|
19
|
+
return re.sub(r"(?i)bearer\s+[^\s\"']+", "Bearer [REDACTED]", value)
|
|
20
|
+
if isinstance(value, list):
|
|
21
|
+
return [self.clean(v) for v in value]
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
return {self.clean(str(k)): self.clean(v) for k, v in value.items()}
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
def note(self, text):
|
|
27
|
+
print(self.clean(text), file=sys.stderr, flush=True)
|
|
28
|
+
|
|
29
|
+
def emit(self, value, event="result"):
|
|
30
|
+
value = self.clean(value)
|
|
31
|
+
try:
|
|
32
|
+
json.dumps(value, allow_nan=False)
|
|
33
|
+
except (ValueError, TypeError, RecursionError):
|
|
34
|
+
raise CliError("API returned non-serializable or non-finite result data.", 5) from None
|
|
35
|
+
if self.mode == "ndjson":
|
|
36
|
+
print(json.dumps({"event": event, "data": value}, ensure_ascii=True, allow_nan=False), flush=True)
|
|
37
|
+
elif self.mode == "json":
|
|
38
|
+
print(json.dumps(value, ensure_ascii=True, indent=2, allow_nan=False), flush=True)
|
|
39
|
+
else:
|
|
40
|
+
data = value.get("data", value) if isinstance(value, dict) else value
|
|
41
|
+
rows = data if isinstance(data, list) else [data]
|
|
42
|
+
if rows and all(isinstance(row, dict) for row in rows):
|
|
43
|
+
columns = list(dict.fromkeys(k for row in rows for k in row))
|
|
44
|
+
print("\t".join(columns))
|
|
45
|
+
for row in rows:
|
|
46
|
+
print("\t".join(json.dumps(row.get(k), ensure_ascii=True) for k in columns))
|
|
47
|
+
else:
|
|
48
|
+
print(json.dumps(data, ensure_ascii=True))
|
|
49
|
+
print("Business task times: Asia/Shanghai; platform timestamps retain their source timezone.")
|
|
50
|
+
|
|
51
|
+
def event(self, name, data):
|
|
52
|
+
if self.mode == "ndjson":
|
|
53
|
+
self.emit(data, name)
|
|
54
|
+
elif name == "log":
|
|
55
|
+
self.note(str(data.get("message", "")))
|
|
56
|
+
|
|
57
|
+
def error(self, exc):
|
|
58
|
+
error = {"source": "cli", "exit_code": exc.exit_code, "message": str(exc)}
|
|
59
|
+
if self.mode in {"json", "ndjson"}:
|
|
60
|
+
print(json.dumps(self.clean({"error": error}), ensure_ascii=True), file=sys.stderr, flush=True)
|
|
61
|
+
else:
|
|
62
|
+
self.note(f"opsai: {exc}")
|
opsai_cli/sse.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Bounded SSE framing over HTTPX's incremental UTF-8 line decoder."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import codecs
|
|
5
|
+
|
|
6
|
+
from opsai_cli.errors import CliError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def utf8_lines(chunks, check=lambda: None):
|
|
10
|
+
decoder = codecs.getincrementaldecoder("utf-8")("strict")
|
|
11
|
+
line, skip_lf = [], False
|
|
12
|
+
try:
|
|
13
|
+
for chunk in chunks:
|
|
14
|
+
check()
|
|
15
|
+
text = decoder.decode(chunk)
|
|
16
|
+
for char in text:
|
|
17
|
+
if skip_lf:
|
|
18
|
+
skip_lf = False
|
|
19
|
+
if char == "\n":
|
|
20
|
+
continue
|
|
21
|
+
if char in "\r\n":
|
|
22
|
+
yield "".join(line)
|
|
23
|
+
line = []
|
|
24
|
+
skip_lf = char == "\r"
|
|
25
|
+
else:
|
|
26
|
+
line.append(char)
|
|
27
|
+
if len(line) > 2 * 1024 * 1024:
|
|
28
|
+
raise CliError("SSE line exceeded the supported size.", 5)
|
|
29
|
+
tail = decoder.decode(b"", final=True)
|
|
30
|
+
if tail or line:
|
|
31
|
+
yield "".join(line) + tail
|
|
32
|
+
except UnicodeError:
|
|
33
|
+
raise CliError("SSE returned invalid UTF-8.", 5) from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_events(lines, check=lambda: None):
|
|
37
|
+
name, data, size = "message", [], 0
|
|
38
|
+
first = True
|
|
39
|
+
for line in lines:
|
|
40
|
+
check()
|
|
41
|
+
if first:
|
|
42
|
+
line = line.removeprefix("\ufeff")
|
|
43
|
+
first = False
|
|
44
|
+
size += len(line)
|
|
45
|
+
if size > 2 * 1024 * 1024:
|
|
46
|
+
raise CliError("SSE event exceeded the supported size.", 5)
|
|
47
|
+
if line == "":
|
|
48
|
+
if data:
|
|
49
|
+
try:
|
|
50
|
+
payload = json.loads("\n".join(data))
|
|
51
|
+
if not isinstance(payload, dict):
|
|
52
|
+
raise ValueError
|
|
53
|
+
except ValueError:
|
|
54
|
+
raise CliError("SSE returned invalid event JSON.", 5) from None
|
|
55
|
+
yield name, payload
|
|
56
|
+
name, data, size = "message", [], 0
|
|
57
|
+
elif not line.startswith(":"):
|
|
58
|
+
field, separator, value = line.partition(":")
|
|
59
|
+
if value.startswith(" "):
|
|
60
|
+
value = value[1:]
|
|
61
|
+
if field == "event":
|
|
62
|
+
name = value
|
|
63
|
+
elif field == "data":
|
|
64
|
+
data.append(value if separator else "")
|
|
65
|
+
# Unterminated events are not dispatched; no fabricated terminal result.
|
opsai_cli/tasks.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Bounded task orchestration. Never cancel a remote task on local exit."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from opsai_cli.errors import CliError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ACTIVE = {"pending", "running", "paused"}
|
|
10
|
+
TERMINAL = {
|
|
11
|
+
"listing": {"completed", "failed", "cancelled"},
|
|
12
|
+
"activity": {"completed", "partial_failed", "failed", "cancelled"},
|
|
13
|
+
"optimization": {"succeeded", "failed", "cancelled"},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def finite_positive(value, name):
|
|
18
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or value <= 0:
|
|
19
|
+
raise CliError(f"{name} must be a finite positive number.")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_options(request_timeout, wait_timeout, poll_interval, max_retries):
|
|
23
|
+
finite_positive(request_timeout, "--request-timeout")
|
|
24
|
+
finite_positive(wait_timeout, "--wait-timeout")
|
|
25
|
+
finite_positive(poll_interval, "--poll-interval")
|
|
26
|
+
if poll_interval < 1:
|
|
27
|
+
raise CliError("--poll-interval must be at least 1 second.")
|
|
28
|
+
if type(max_retries) is not int or not 0 <= max_retries <= 10:
|
|
29
|
+
raise CliError("--max-retries must be between 0 and 10.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def task_data(envelope, expected_id=None):
|
|
33
|
+
data = envelope.get("data")
|
|
34
|
+
if (not isinstance(data, dict) or type(data.get("task_id")) is not int or data["task_id"] <= 0
|
|
35
|
+
or not isinstance(data.get("status"), str)
|
|
36
|
+
or (expected_id is not None and data["task_id"] != expected_id)):
|
|
37
|
+
raise CliError("API returned invalid task details.", 5)
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def outcome(family, data):
|
|
42
|
+
if data["status"] in {"failed", "partial_failed", "cancelled"}:
|
|
43
|
+
return 1
|
|
44
|
+
if data.get("error") or data.get("error_message"):
|
|
45
|
+
return 1
|
|
46
|
+
if family == "listing":
|
|
47
|
+
items = data.get("items")
|
|
48
|
+
return 0 if isinstance(items, list) and items and all(isinstance(r, dict) and r.get("status") in {"PUBLISHED", "SUBMITTED"} for r in items) else 1
|
|
49
|
+
if family == "activity":
|
|
50
|
+
items = data.get("items")
|
|
51
|
+
return 0 if isinstance(items, list) and items and len(items) == data.get("total") and data.get("failed_count") == 0 and all(isinstance(r, dict) and r.get("status") == "success" for r in items) else 1
|
|
52
|
+
results = data.get("results")
|
|
53
|
+
return 0 if isinstance(results, list) and results and data.get("success_count") == data.get("total") == 1 and all(isinstance(r, dict) and r.get("success") is True for r in results) else 1
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TaskSession:
|
|
57
|
+
def __init__(self, client, output, *, family, prefix, request_timeout=30,
|
|
58
|
+
wait_timeout=1800, poll_interval=5, max_retries=2,
|
|
59
|
+
waiting=False, clock=None, sleep=None):
|
|
60
|
+
validate_options(request_timeout, wait_timeout, poll_interval, max_retries)
|
|
61
|
+
self.client, self.output = client, output
|
|
62
|
+
self.family, self.prefix = family, prefix
|
|
63
|
+
self.clock, self.sleep = clock or time.monotonic, sleep or time.sleep
|
|
64
|
+
self.deadline = self.clock() + (wait_timeout if waiting else 120)
|
|
65
|
+
self.timeout_code = 6 if waiting else 5
|
|
66
|
+
self.request_timeout, self.interval, self.retries = request_timeout, poll_interval, max_retries
|
|
67
|
+
self.task_id = None
|
|
68
|
+
self.request_id = None
|
|
69
|
+
|
|
70
|
+
def remaining(self):
|
|
71
|
+
remaining = self.deadline - self.clock()
|
|
72
|
+
if remaining <= 0:
|
|
73
|
+
raise CliError("Local deadline expired; the server task may still be running.", self.timeout_code)
|
|
74
|
+
return remaining
|
|
75
|
+
|
|
76
|
+
def pause(self, seconds):
|
|
77
|
+
remaining = self.remaining()
|
|
78
|
+
if seconds >= remaining:
|
|
79
|
+
raise CliError("Local deadline would expire before the next permitted request; server work may continue.", self.timeout_code)
|
|
80
|
+
self.sleep(seconds)
|
|
81
|
+
self.remaining()
|
|
82
|
+
|
|
83
|
+
def retry_pause(self, error, attempt):
|
|
84
|
+
if not error.retryable or attempt >= self.retries:
|
|
85
|
+
raise error
|
|
86
|
+
delay = error.retry_after if error.retry_after is not None else min(2 ** attempt, 30)
|
|
87
|
+
if error.status == 429 and error.retry_after is None:
|
|
88
|
+
delay = 60
|
|
89
|
+
self.output.note(f"Transient API error; retry {attempt + 1}/{self.retries} after {delay:g}s.")
|
|
90
|
+
self.pause(delay)
|
|
91
|
+
|
|
92
|
+
def request(self, method, path, *, body=None, params=None, retry=True):
|
|
93
|
+
for attempt in range(self.retries + 1):
|
|
94
|
+
remaining = self.remaining()
|
|
95
|
+
try:
|
|
96
|
+
result = self.client.request_json(method, path, json=body, params=params,
|
|
97
|
+
timeout=min(self.request_timeout, remaining))
|
|
98
|
+
self.remaining()
|
|
99
|
+
return result
|
|
100
|
+
except CliError as exc:
|
|
101
|
+
if not retry:
|
|
102
|
+
raise
|
|
103
|
+
self.retry_pause(exc, attempt)
|
|
104
|
+
|
|
105
|
+
def accepted(self, data):
|
|
106
|
+
self.task_id = data["task_id"]
|
|
107
|
+
self.output.note(f"Task accepted: task_id={self.task_id}. Resume: opsai {self.family} task watch {self.task_id} (use the same --profile).")
|
|
108
|
+
self.output.event("accepted", data)
|
|
109
|
+
|
|
110
|
+
def watch(self):
|
|
111
|
+
while True:
|
|
112
|
+
envelope = self.request("GET", f"{self.prefix}/tasks/{self.task_id}")
|
|
113
|
+
data = task_data(envelope, self.task_id)
|
|
114
|
+
if data["status"] in TERMINAL[self.family]:
|
|
115
|
+
return envelope
|
|
116
|
+
if data["status"] not in ACTIVE:
|
|
117
|
+
self.output.note("Unknown task status; waiting until the configured deadline.")
|
|
118
|
+
self.output.event("status", data)
|
|
119
|
+
self.pause(self.interval)
|
|
120
|
+
|
|
121
|
+
def submit_stream(self, body):
|
|
122
|
+
# Reconnect/re-submit only while no accepted event has been received.
|
|
123
|
+
# The exact same normalized request body and request_id are reused.
|
|
124
|
+
for attempt in range(self.retries + 1):
|
|
125
|
+
try:
|
|
126
|
+
events = self.client.stream_events(self.prefix + "/tasks/stream", body,
|
|
127
|
+
timeout=min(max(30, self.request_timeout), self.remaining()), check=self.remaining)
|
|
128
|
+
try:
|
|
129
|
+
for name, data in events:
|
|
130
|
+
self.remaining()
|
|
131
|
+
if name == "accepted":
|
|
132
|
+
details = task_data({"data": data}, self.task_id)
|
|
133
|
+
self.accepted(details)
|
|
134
|
+
elif name == "result":
|
|
135
|
+
if self.task_id is None:
|
|
136
|
+
raise CliError("SSE result arrived before task acceptance.", 5)
|
|
137
|
+
details = task_data({"data": data}, self.task_id)
|
|
138
|
+
if details["status"] not in TERMINAL[self.family]:
|
|
139
|
+
raise CliError("SSE result was not a terminal task state.", 5)
|
|
140
|
+
return {"code": 0, "message": "Task finished", "data": details}
|
|
141
|
+
elif name == "error":
|
|
142
|
+
raise CliError("SSE status reading failed.", 5)
|
|
143
|
+
elif name == "log":
|
|
144
|
+
self.output.event(name, data)
|
|
145
|
+
finally:
|
|
146
|
+
events.close()
|
|
147
|
+
raise CliError("SSE ended without a final result.", 5, retryable=True)
|
|
148
|
+
except CliError as exc:
|
|
149
|
+
if exc.exit_code == 6:
|
|
150
|
+
raise
|
|
151
|
+
if self.task_id is not None:
|
|
152
|
+
self.output.note("SSE unavailable; switching to task polling. Logs may be incomplete.")
|
|
153
|
+
return self.watch()
|
|
154
|
+
self.retry_pause(exc, attempt)
|
|
155
|
+
|
|
156
|
+
def stop(self):
|
|
157
|
+
for attempt in range(self.retries + 1):
|
|
158
|
+
try:
|
|
159
|
+
envelope = self.request("POST", f"{self.prefix}/tasks/{self.task_id}/stop", retry=False)
|
|
160
|
+
except CliError as exc:
|
|
161
|
+
if exc.status in {408, 425, 429}:
|
|
162
|
+
self.retry_pause(exc, attempt)
|
|
163
|
+
continue
|
|
164
|
+
if not exc.retryable or (exc.status is not None and exc.status < 500):
|
|
165
|
+
raise
|
|
166
|
+
self.output.note("Stop response was not confirmed; querying task state without repeating the stop request.")
|
|
167
|
+
return self.watch()
|
|
168
|
+
data = task_data(envelope, self.task_id)
|
|
169
|
+
if data["status"] in TERMINAL[self.family]:
|
|
170
|
+
return envelope
|
|
171
|
+
return self.watch()
|
|
172
|
+
|
|
173
|
+
def recovery_note(self):
|
|
174
|
+
if self.task_id is not None:
|
|
175
|
+
self.output.note(f"Server work is not rolled back. Query: opsai {self.family} task get {self.task_id} (use the same --profile).")
|
|
176
|
+
elif self.request_id is not None:
|
|
177
|
+
self.output.note(f"Submission outcome may be unknown. Retry the original body with request_id={self.request_id}; do not generate a new request_id.")
|