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/__init__.py
ADDED
opsai_cli/__main__.py
ADDED
opsai_cli/business.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Business command registration and API v1 request admission."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from pydantic import ValidationError
|
|
9
|
+
|
|
10
|
+
from opsai_cli.client import ApiClient
|
|
11
|
+
from opsai_cli.contracts import (
|
|
12
|
+
ListingSubmit, ActivitySubmit, OptimizationSubmit, AvailableActions, ListingPrices,
|
|
13
|
+
)
|
|
14
|
+
from opsai_cli.errors import CliError
|
|
15
|
+
from opsai_cli.output import Output
|
|
16
|
+
from opsai_cli.tasks import TaskSession, task_data, outcome, validate_options
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
FAMILIES = {"listing": "listing", "activity": "activity", "optimization": "listing-optimization"}
|
|
20
|
+
MODELS = {"listing": {"submit": ListingSubmit}, "activity": {
|
|
21
|
+
"submit": ActivitySubmit, "actions": AvailableActions, "prices": ListingPrices,
|
|
22
|
+
}, "optimization": {"submit": OptimizationSubmit}}
|
|
23
|
+
LIMIT = 10 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def positive(value):
|
|
27
|
+
try:
|
|
28
|
+
number = int(value)
|
|
29
|
+
if number <= 0:
|
|
30
|
+
raise ValueError
|
|
31
|
+
return number
|
|
32
|
+
except ValueError:
|
|
33
|
+
raise CliError("IDs and platform numbers must be positive integers.") from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def register(groups):
|
|
37
|
+
for family in FAMILIES:
|
|
38
|
+
group = groups.add_parser(family, allow_abbrev=False)
|
|
39
|
+
commands = group.add_subparsers(dest="command", required=True)
|
|
40
|
+
stores = commands.add_parser("stores", allow_abbrev=False)
|
|
41
|
+
stores.add_argument("--platform", type=int)
|
|
42
|
+
submit = commands.add_parser("submit", allow_abbrev=False)
|
|
43
|
+
submit.add_argument("--file")
|
|
44
|
+
modes = submit.add_mutually_exclusive_group()
|
|
45
|
+
modes.add_argument("--wait", action="store_true")
|
|
46
|
+
if family != "activity":
|
|
47
|
+
modes.add_argument("--stream", action="store_true")
|
|
48
|
+
if family == "optimization":
|
|
49
|
+
submit.add_argument("--store-id", type=int)
|
|
50
|
+
submit.add_argument("--listing-sku")
|
|
51
|
+
submit.add_argument("--mode", choices=("update", "create"))
|
|
52
|
+
submit.add_argument("--request-id")
|
|
53
|
+
task = commands.add_parser("task", allow_abbrev=False)
|
|
54
|
+
task_commands = task.add_subparsers(dest="task_command", required=True)
|
|
55
|
+
get = task_commands.add_parser("get", allow_abbrev=False)
|
|
56
|
+
get.add_argument("task_id", type=int)
|
|
57
|
+
leaves = [stores, submit, get]
|
|
58
|
+
watch = task_commands.add_parser("watch", allow_abbrev=False)
|
|
59
|
+
watch.add_argument("task_id", type=int)
|
|
60
|
+
leaves.append(watch)
|
|
61
|
+
if family != "activity":
|
|
62
|
+
stop = task_commands.add_parser("stop", allow_abbrev=False)
|
|
63
|
+
stop.add_argument("task_id", type=int)
|
|
64
|
+
leaves.append(stop)
|
|
65
|
+
if family == "activity":
|
|
66
|
+
actions = commands.add_parser("actions", allow_abbrev=False)
|
|
67
|
+
actions.add_argument("--file")
|
|
68
|
+
actions.add_argument("--store-id", type=int)
|
|
69
|
+
actions.add_argument("--listing-sku")
|
|
70
|
+
actions.add_argument("--platform", type=int)
|
|
71
|
+
prices = commands.add_parser("prices", allow_abbrev=False)
|
|
72
|
+
prices.add_argument("--file", required=True)
|
|
73
|
+
leaves.extend([actions, prices])
|
|
74
|
+
for command in ("template", "validate"):
|
|
75
|
+
local = commands.add_parser(command, allow_abbrev=False)
|
|
76
|
+
local.add_argument("--kind", choices=tuple(MODELS[family]), default="submit")
|
|
77
|
+
if command == "template":
|
|
78
|
+
local.add_argument("--out", required=True)
|
|
79
|
+
local.add_argument("--force", action="store_true")
|
|
80
|
+
else:
|
|
81
|
+
local.add_argument("--file", required=True)
|
|
82
|
+
leaves.append(local)
|
|
83
|
+
for leaf in leaves:
|
|
84
|
+
# SUPPRESS preserves a global --profile passed before the group.
|
|
85
|
+
leaf.add_argument("--profile", default=argparse.SUPPRESS)
|
|
86
|
+
leaf.add_argument("--output", choices=("table", "json", "ndjson"), default=argparse.SUPPRESS)
|
|
87
|
+
if leaf.get_default("kind") is None:
|
|
88
|
+
leaf.add_argument("--request-timeout", type=float, default=30)
|
|
89
|
+
leaf.add_argument("--wait-timeout", type=float, default=1800)
|
|
90
|
+
leaf.add_argument("--poll-interval", type=float, default=5)
|
|
91
|
+
leaf.add_argument("--max-retries", type=int, default=2)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_json(filename):
|
|
95
|
+
def reject_constant(value):
|
|
96
|
+
raise ValueError
|
|
97
|
+
|
|
98
|
+
def unique_pairs(pairs):
|
|
99
|
+
data = {}
|
|
100
|
+
for key, value in pairs:
|
|
101
|
+
if key in data:
|
|
102
|
+
raise ValueError
|
|
103
|
+
data[key] = value
|
|
104
|
+
return data
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
if filename == "-":
|
|
108
|
+
binary = getattr(sys.stdin, "buffer", None)
|
|
109
|
+
content = binary.read(LIMIT + 1).decode("utf-8-sig") if binary is not None else sys.stdin.read(LIMIT + 1)
|
|
110
|
+
if len(content.encode("utf-8")) > LIMIT:
|
|
111
|
+
raise ValueError
|
|
112
|
+
else:
|
|
113
|
+
with Path(filename).open("rb") as stream:
|
|
114
|
+
content = stream.read(LIMIT + 1)
|
|
115
|
+
if len(content) > LIMIT:
|
|
116
|
+
raise ValueError
|
|
117
|
+
content = content.decode("utf-8-sig")
|
|
118
|
+
return json.loads(content, parse_constant=reject_constant, object_pairs_hook=unique_pairs)
|
|
119
|
+
except (OSError, ValueError, UnicodeError, RecursionError):
|
|
120
|
+
raise CliError("Cannot read request JSON: use UTF-8, unique fields, finite numbers and at most 10 MiB.") from None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def validate(family, kind, body):
|
|
124
|
+
try:
|
|
125
|
+
return MODELS[family][kind].model_validate(body).model_dump(mode="json")
|
|
126
|
+
except (ValidationError, ValueError, RecursionError) as exc:
|
|
127
|
+
if isinstance(exc, ValidationError):
|
|
128
|
+
issues = []
|
|
129
|
+
for error in exc.errors(include_input=False, include_context=False)[:8]:
|
|
130
|
+
loc = list(error["loc"])
|
|
131
|
+
if error["type"] == "extra_forbidden" and loc:
|
|
132
|
+
loc[-1] = "<unknown-field>"
|
|
133
|
+
path = ".".join(str(x) for x in loc) or "request"
|
|
134
|
+
issues.append(f"{path}: {error['type']}")
|
|
135
|
+
raise CliError("Request validation failed: " + "; ".join(issues)) from None
|
|
136
|
+
raise CliError("Request validation failed.") from None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def template(family, kind="submit"):
|
|
140
|
+
if kind == "actions":
|
|
141
|
+
return {"store_id": 1001, "listing_sku": "L-0001", "platform": 9}
|
|
142
|
+
if kind == "prices":
|
|
143
|
+
return {"store_id": 1001, "listing_skus": ["L-0001"], "action_id": 1977747, "platform": 9}
|
|
144
|
+
if family == "listing":
|
|
145
|
+
return {"request_id": "replace-with-stable-listing-request-id", "store_ids": [1001],
|
|
146
|
+
"prices": [{"store_id": 1001, "site_prices": [{"site": "RU", "price": 1999, "currency": "RUB"}]}],
|
|
147
|
+
"products": [{"sku": "YOUR-SKU-001", "title": "Your product title",
|
|
148
|
+
"images": {"main_img": "https://cdn.example.com/product.jpg"}}]}
|
|
149
|
+
if family == "activity":
|
|
150
|
+
return {"request_id": "replace-with-stable-activity-request-id", "store_id": 1001,
|
|
151
|
+
"action_id": 1977747, "items": [{"listing_sku": "L-0001", "action_price": 1899}]}
|
|
152
|
+
return {"request_id": "replace-with-stable-optimization-request-id", "store_id": 1001,
|
|
153
|
+
"listing_sku": "L-0001", "mode": "update"}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def request_body(args):
|
|
157
|
+
fields = ("store_id", "listing_sku", "mode", "request_id") if args.group == "optimization" else ("store_id", "listing_sku", "platform")
|
|
158
|
+
flags = {field: getattr(args, field) for field in fields if getattr(args, field, None) is not None}
|
|
159
|
+
filename = getattr(args, "file", None)
|
|
160
|
+
if filename is not None:
|
|
161
|
+
if flags:
|
|
162
|
+
raise CliError("Do not mix --file with business parameter flags.")
|
|
163
|
+
body = read_json(filename)
|
|
164
|
+
elif flags:
|
|
165
|
+
body = flags
|
|
166
|
+
else:
|
|
167
|
+
raise CliError("Provide --file (use - for stdin), or all required shortcut parameters.")
|
|
168
|
+
return validate(args.group, args.command, body)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def run(args):
|
|
172
|
+
family, command = args.group, args.command
|
|
173
|
+
output = Output(getattr(args, "output", None))
|
|
174
|
+
if command == "template":
|
|
175
|
+
# Exclusive creation protects user data unless overwrite is explicit.
|
|
176
|
+
content = template(family, args.kind)
|
|
177
|
+
validate(family, args.kind, content)
|
|
178
|
+
try:
|
|
179
|
+
with Path(args.out).open("w" if args.force else "x", encoding="utf-8") as stream:
|
|
180
|
+
json.dump(content, stream, ensure_ascii=False, indent=2)
|
|
181
|
+
stream.write("\n")
|
|
182
|
+
except FileExistsError:
|
|
183
|
+
raise CliError("Template target exists; use --force to overwrite it.") from None
|
|
184
|
+
except OSError:
|
|
185
|
+
raise CliError("Cannot write template file; check the directory and permissions.") from None
|
|
186
|
+
output.emit({"template_written": True, "kind": args.kind, "message": "Replace examples and use a stable request_id before submission."})
|
|
187
|
+
return 0
|
|
188
|
+
if command == "validate":
|
|
189
|
+
validate(family, args.kind, read_json(args.file))
|
|
190
|
+
output.emit({"valid": True, "message": "Server permissions and platform acceptance have not been checked."})
|
|
191
|
+
return 0
|
|
192
|
+
path = f"/{FAMILIES[family]}/v1"
|
|
193
|
+
method, body, params = "GET", None, None
|
|
194
|
+
if command == "stores":
|
|
195
|
+
platform = args.platform
|
|
196
|
+
if platform is not None:
|
|
197
|
+
positive(str(platform))
|
|
198
|
+
allowed = {9} if family == "activity" else {1, 2, 3, 9, 10, 11, 12}
|
|
199
|
+
if platform not in allowed:
|
|
200
|
+
raise CliError("Platform is not supported by this API family.")
|
|
201
|
+
params = {"platform": platform}
|
|
202
|
+
path += "/stores"
|
|
203
|
+
elif command == "task":
|
|
204
|
+
path += f"/tasks/{positive(str(args.task_id))}"
|
|
205
|
+
else:
|
|
206
|
+
body = request_body(args) # Validate before reading credentials or sending.
|
|
207
|
+
method = "POST"
|
|
208
|
+
path += {"submit": "/tasks", "actions": "/available-actions", "prices": "/listing-prices"}[command]
|
|
209
|
+
validate_options(args.request_timeout, args.wait_timeout, args.poll_interval, args.max_retries)
|
|
210
|
+
with ApiClient.from_profile(getattr(args, "profile", None)) as client:
|
|
211
|
+
output.secrets += (client._key,)
|
|
212
|
+
waiting = (getattr(args, "wait", False) or getattr(args, "stream", False)
|
|
213
|
+
or (command == "task" and args.task_command in {"watch", "stop"}))
|
|
214
|
+
session = TaskSession(client, output, family=family, prefix=f"/{FAMILIES[family]}/v1",
|
|
215
|
+
request_timeout=args.request_timeout, wait_timeout=args.wait_timeout,
|
|
216
|
+
poll_interval=args.poll_interval, max_retries=args.max_retries, waiting=waiting)
|
|
217
|
+
if command == "submit":
|
|
218
|
+
session.request_id = body["request_id"]
|
|
219
|
+
if command == "task":
|
|
220
|
+
session.task_id = args.task_id
|
|
221
|
+
try:
|
|
222
|
+
if command == "submit" and getattr(args, "stream", False):
|
|
223
|
+
result = session.submit_stream(body)
|
|
224
|
+
elif command == "task" and args.task_command == "watch":
|
|
225
|
+
result = session.watch()
|
|
226
|
+
elif command == "task" and args.task_command == "stop":
|
|
227
|
+
result = session.stop()
|
|
228
|
+
else:
|
|
229
|
+
result = session.request(method, path, body=body, params=params)
|
|
230
|
+
if command == "submit":
|
|
231
|
+
data = task_data(result)
|
|
232
|
+
session.accepted(data)
|
|
233
|
+
if args.wait:
|
|
234
|
+
result = session.watch()
|
|
235
|
+
if not (command == "submit" and not waiting):
|
|
236
|
+
output.emit(result["data"] if output.mode == "ndjson" and waiting else result)
|
|
237
|
+
elif output.mode != "ndjson":
|
|
238
|
+
output.emit(result)
|
|
239
|
+
# Submit-only and task get report request success, not business success.
|
|
240
|
+
if waiting and not (command == "task" and args.task_command == "stop"):
|
|
241
|
+
return outcome(family, task_data(result, session.task_id))
|
|
242
|
+
return 0
|
|
243
|
+
except CliError as exc:
|
|
244
|
+
session.recovery_note()
|
|
245
|
+
output.error(exc)
|
|
246
|
+
return exc.exit_code
|
|
247
|
+
except KeyboardInterrupt:
|
|
248
|
+
session.recovery_note()
|
|
249
|
+
output.error(CliError("Local command interrupted; no stop request was sent automatically.", 130))
|
|
250
|
+
return 130
|
opsai_cli/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Console entry point. Business commands are added in later milestones."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
import getpass
|
|
6
|
+
import sys
|
|
7
|
+
import warnings
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from opsai_cli import get_version
|
|
11
|
+
from opsai_cli.config import ConfigStore, select_profile
|
|
12
|
+
from opsai_cli.credentials import CredentialStore
|
|
13
|
+
from opsai_cli.errors import CliError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SafeArgumentParser(argparse.ArgumentParser):
|
|
17
|
+
def error(self, message):
|
|
18
|
+
# argparse normally repeats invalid input, potentially including a key
|
|
19
|
+
# mistakenly provided as a positional argument or unknown option.
|
|
20
|
+
from opsai_cli.output import Output
|
|
21
|
+
Output().error(CliError("Invalid command arguments; run --help for usage."))
|
|
22
|
+
self.exit(2)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
26
|
+
parser = SafeArgumentParser(
|
|
27
|
+
prog="opsai",
|
|
28
|
+
description="OpsAI Open API command-line client.",
|
|
29
|
+
epilog="M4/M5 provide task waiting, bounded retries, SSE and explicit task stop.",
|
|
30
|
+
allow_abbrev=False,
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {get_version()}")
|
|
33
|
+
parser.add_argument("--profile", default=argparse.SUPPRESS, help="Profile name (otherwise OPSAI_PROFILE or default).")
|
|
34
|
+
parser.add_argument("--output", choices=("table", "json", "ndjson"), default=argparse.SUPPRESS)
|
|
35
|
+
groups = parser.add_subparsers(dest="group")
|
|
36
|
+
config = groups.add_parser("config", allow_abbrev=False)
|
|
37
|
+
config_commands = config.add_subparsers(dest="command", required=True)
|
|
38
|
+
set_profile = config_commands.add_parser("set-profile", allow_abbrev=False)
|
|
39
|
+
set_profile.add_argument("name")
|
|
40
|
+
config_commands.add_parser("profiles", allow_abbrev=False)
|
|
41
|
+
auth = groups.add_parser("auth", allow_abbrev=False)
|
|
42
|
+
auth_commands = auth.add_subparsers(dest="command", required=True)
|
|
43
|
+
configure = auth_commands.add_parser("configure", allow_abbrev=False)
|
|
44
|
+
configure.add_argument("--profile", default=argparse.SUPPRESS)
|
|
45
|
+
for leaf in (set_profile, config_commands.choices["profiles"], configure):
|
|
46
|
+
leaf.add_argument("--output", choices=("table", "json", "ndjson"), default=argparse.SUPPRESS)
|
|
47
|
+
# Load business models only when needed; help/version stay lightweight.
|
|
48
|
+
business_groups = {"listing", "activity", "optimization"}
|
|
49
|
+
supplied = list(argv) if argv is not None else sys.argv[1:]
|
|
50
|
+
if any(arg in business_groups for arg in supplied):
|
|
51
|
+
from opsai_cli.business import register
|
|
52
|
+
register(groups)
|
|
53
|
+
else:
|
|
54
|
+
for name in sorted(business_groups):
|
|
55
|
+
groups.add_parser(name, help="Use this command with --help for business operations.")
|
|
56
|
+
args = parser.parse_args(argv)
|
|
57
|
+
try:
|
|
58
|
+
if args.group in business_groups:
|
|
59
|
+
from opsai_cli.business import run
|
|
60
|
+
return run(args)
|
|
61
|
+
elif args.group == "config":
|
|
62
|
+
from opsai_cli.output import Output
|
|
63
|
+
output = Output(getattr(args, "output", None))
|
|
64
|
+
store = ConfigStore()
|
|
65
|
+
if args.command == "set-profile":
|
|
66
|
+
address = store.set_profile(args.name)
|
|
67
|
+
output.emit({"profile": args.name, "base_url": address})
|
|
68
|
+
else:
|
|
69
|
+
profiles = store.load()["profiles"]
|
|
70
|
+
if not profiles:
|
|
71
|
+
profiles = {"default": {"base_url": store.get("default")}}
|
|
72
|
+
output.emit([{"profile": name, "base_url": entry["base_url"]} for name, entry in sorted(profiles.items())])
|
|
73
|
+
elif args.group == "auth":
|
|
74
|
+
store = ConfigStore()
|
|
75
|
+
name = select_profile(getattr(args, "profile", None))
|
|
76
|
+
address = store.get(name)
|
|
77
|
+
if not sys.stdin.isatty():
|
|
78
|
+
raise CliError("Interactive terminal required; use OPSAI_API_KEY for non-interactive requests.")
|
|
79
|
+
try:
|
|
80
|
+
with warnings.catch_warnings():
|
|
81
|
+
warnings.simplefilter("error", getpass.GetPassWarning)
|
|
82
|
+
key = getpass.getpass(f"API Key for {name} ({address}): ")
|
|
83
|
+
except (getpass.GetPassWarning, EOFError):
|
|
84
|
+
raise CliError("Hidden input unavailable; use OPSAI_API_KEY instead.") from None
|
|
85
|
+
CredentialStore().save(name, address, key)
|
|
86
|
+
from opsai_cli.output import Output
|
|
87
|
+
Output(getattr(args, "output", None)).emit({"saved": True, "message": "API Key saved to system credential storage; server permissions have not been verified."})
|
|
88
|
+
else:
|
|
89
|
+
parser.print_help()
|
|
90
|
+
return 0
|
|
91
|
+
except CliError as exc:
|
|
92
|
+
from opsai_cli.output import Output
|
|
93
|
+
Output(getattr(args, "output", None), secrets=(os.environ.get("OPSAI_API_KEY", ""),)).error(exc)
|
|
94
|
+
return exc.exit_code
|
|
95
|
+
except KeyboardInterrupt:
|
|
96
|
+
from opsai_cli.output import Output
|
|
97
|
+
Output(getattr(args, "output", None)).error(CliError("Local command interrupted.", 130))
|
|
98
|
+
return 130
|
opsai_cli/client.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Synchronous Open API transport. Retry/task orchestration belongs to M4."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from email.utils import parsedate_to_datetime
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from opsai_cli.config import normalize_base_url, select_profile, ConfigStore
|
|
11
|
+
from opsai_cli.credentials import CredentialStore, validate_key
|
|
12
|
+
from opsai_cli.errors import CliError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiClient:
|
|
16
|
+
def __init__(self, base_url: str, api_key: str, *, request_timeout: float = 30,
|
|
17
|
+
transport: httpx.BaseTransport | None = None):
|
|
18
|
+
self.base_url = normalize_base_url(base_url)
|
|
19
|
+
self._key = validate_key(api_key)
|
|
20
|
+
if (isinstance(request_timeout, bool) or not isinstance(request_timeout, (int, float))
|
|
21
|
+
or not math.isfinite(request_timeout) or request_timeout <= 0):
|
|
22
|
+
raise CliError("Request timeout must be a finite positive number of seconds.")
|
|
23
|
+
self._client = httpx.Client(
|
|
24
|
+
headers={"Authorization": f"Bearer {self._key}", "Accept": "application/json"},
|
|
25
|
+
timeout=httpx.Timeout(request_timeout, connect=min(10, request_timeout)),
|
|
26
|
+
verify=True, follow_redirects=False, trust_env=False, transport=transport,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_profile(cls, profile=None, *, config=None, credentials=None, **kwargs):
|
|
31
|
+
name = select_profile(profile)
|
|
32
|
+
base_url = (config or ConfigStore()).get(name)
|
|
33
|
+
key = (credentials or CredentialStore()).resolve(name, base_url)
|
|
34
|
+
return cls(base_url, key, **kwargs)
|
|
35
|
+
|
|
36
|
+
def close(self):
|
|
37
|
+
self._client.close()
|
|
38
|
+
|
|
39
|
+
def __enter__(self):
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(self, *args):
|
|
43
|
+
self.close()
|
|
44
|
+
|
|
45
|
+
def _url(self, path):
|
|
46
|
+
if (not isinstance(path, str) or not re.fullmatch(r"/[A-Za-z0-9_/-]+", path)
|
|
47
|
+
or "//" in path):
|
|
48
|
+
raise CliError("Use a relative API path without a host, query or traversal.")
|
|
49
|
+
return self.base_url + "/api/open" + path
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _status(response):
|
|
53
|
+
status = response.status_code
|
|
54
|
+
if 200 <= status < 300:
|
|
55
|
+
return
|
|
56
|
+
retry_after = None
|
|
57
|
+
header = response.headers.get("Retry-After", "")
|
|
58
|
+
try:
|
|
59
|
+
retry_after = float(header)
|
|
60
|
+
if not math.isfinite(retry_after) or retry_after < 0:
|
|
61
|
+
retry_after = None
|
|
62
|
+
except ValueError:
|
|
63
|
+
try:
|
|
64
|
+
date = parsedate_to_datetime(header)
|
|
65
|
+
if date.tzinfo is not None:
|
|
66
|
+
retry_after = max(0, (date - datetime.now(timezone.utc)).total_seconds())
|
|
67
|
+
except (ValueError, TypeError, OverflowError):
|
|
68
|
+
pass
|
|
69
|
+
code = 3 if status in (401, 403) else 5 if status >= 500 or 300 <= status < 400 else 4
|
|
70
|
+
message = {
|
|
71
|
+
401: "API authentication failed; check the Key and account status.",
|
|
72
|
+
403: "API permission denied; check scopes, business permissions and resource ownership.",
|
|
73
|
+
429: "API rate limit exceeded; retry later.",
|
|
74
|
+
}.get(status, "API request rejected; check service configuration and request parameters.")
|
|
75
|
+
if 300 <= status < 400:
|
|
76
|
+
message = "API redirect refused; the CLI uses the official service root."
|
|
77
|
+
raise CliError(f"HTTP {status}: {message}", code, status=status,
|
|
78
|
+
retryable=status in {408, 425, 429, 500, 502, 503, 504}, retry_after=retry_after)
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _payload(response):
|
|
82
|
+
try:
|
|
83
|
+
payload = response.json()
|
|
84
|
+
except ValueError:
|
|
85
|
+
raise CliError("API returned an invalid JSON response.", 5) from None
|
|
86
|
+
if (not isinstance(payload, dict) or type(payload.get("code")) is not int
|
|
87
|
+
or "data" not in payload or not isinstance(payload.get("message"), str)):
|
|
88
|
+
raise CliError("API returned an unsupported response format.", 5)
|
|
89
|
+
if payload["code"] != 0:
|
|
90
|
+
code = 3 if payload["code"] in (401, 403) else 4
|
|
91
|
+
raise CliError("API reported a business error; check request parameters and permissions.", code)
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
def request_json(self, method, path, *, params=None, json=None, timeout=None):
|
|
95
|
+
url = self._url(path)
|
|
96
|
+
options = {} if timeout is None else {"timeout": httpx.Timeout(timeout, connect=min(10, timeout))}
|
|
97
|
+
try:
|
|
98
|
+
response = self._client.request(method, url, params=params, json=json, **options)
|
|
99
|
+
except httpx.TimeoutException:
|
|
100
|
+
raise CliError("API request timed out; a submitted operation may still have been accepted.", 5, retryable=True) from None
|
|
101
|
+
except httpx.RequestError:
|
|
102
|
+
raise CliError("Cannot reach the API service; check connectivity and TLS configuration.", 5, retryable=True) from None
|
|
103
|
+
except (ValueError, TypeError):
|
|
104
|
+
raise CliError("Cannot encode API request parameters.") from None
|
|
105
|
+
self._status(response)
|
|
106
|
+
return self._payload(response)
|
|
107
|
+
|
|
108
|
+
def stream_events(self, path, body, *, timeout=30, check=lambda: None):
|
|
109
|
+
from opsai_cli.sse import parse_events, utf8_lines
|
|
110
|
+
url = self._url(path)
|
|
111
|
+
try:
|
|
112
|
+
with self._client.stream("POST", url, json=body,
|
|
113
|
+
headers={"Accept": "text/event-stream"},
|
|
114
|
+
timeout=httpx.Timeout(timeout, connect=min(10, timeout))) as response:
|
|
115
|
+
self._status(response)
|
|
116
|
+
if response.headers.get("content-type", "").split(";", 1)[0].strip() != "text/event-stream":
|
|
117
|
+
raise CliError("API did not return an SSE stream; submission outcome may be unknown.", 5)
|
|
118
|
+
yield from parse_events(utf8_lines(response.iter_bytes(), check), check)
|
|
119
|
+
except httpx.RequestError:
|
|
120
|
+
raise CliError("SSE connection failed; submission outcome may be unknown.", 5, retryable=True) from None
|
opsai_cli/config.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Non-secret, versioned profile configuration."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import tempfile
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from opsai_cli.errors import CliError
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://www.krossray.com"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def profile_name(value: str) -> str:
|
|
18
|
+
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", value):
|
|
19
|
+
raise CliError("Profile names must contain 1-64 letters, digits, underscores or hyphens.")
|
|
20
|
+
return value
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def select_profile(explicit: str | None = None) -> str:
|
|
24
|
+
return profile_name(explicit if explicit is not None else os.environ.get("OPSAI_PROFILE", "default"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def normalize_base_url(value: str) -> str:
|
|
28
|
+
try:
|
|
29
|
+
if not isinstance(value, str) or not value or any(c.isspace() or ord(c) < 32 for c in value):
|
|
30
|
+
raise ValueError
|
|
31
|
+
if any(c in value for c in ("\\", "%", "?", "#")):
|
|
32
|
+
raise ValueError
|
|
33
|
+
parts = urlsplit(value)
|
|
34
|
+
if (parts.scheme not in {"https", "http"} or not parts.hostname
|
|
35
|
+
or parts.username is not None or parts.password is not None
|
|
36
|
+
or parts.path not in {"", "/"}):
|
|
37
|
+
raise ValueError
|
|
38
|
+
host = parts.hostname.lower()
|
|
39
|
+
if host != "localhost":
|
|
40
|
+
try:
|
|
41
|
+
address = ipaddress.ip_address(host)
|
|
42
|
+
local = address.is_loopback
|
|
43
|
+
host = address.compressed
|
|
44
|
+
except ValueError:
|
|
45
|
+
local = False
|
|
46
|
+
host = host.encode("idna").decode("ascii")
|
|
47
|
+
if not re.fullmatch(r"[a-z0-9.-]+", host):
|
|
48
|
+
raise ValueError
|
|
49
|
+
else:
|
|
50
|
+
local = True
|
|
51
|
+
if parts.scheme == "http" and not local:
|
|
52
|
+
raise ValueError
|
|
53
|
+
port = parts.port
|
|
54
|
+
if port is not None and port < 1:
|
|
55
|
+
raise ValueError
|
|
56
|
+
authority = f"[{host}]" if ":" in host else host
|
|
57
|
+
if port is not None and port != (443 if parts.scheme == "https" else 80):
|
|
58
|
+
authority += f":{port}"
|
|
59
|
+
return f"{parts.scheme}://{authority}"
|
|
60
|
+
except (ValueError, UnicodeError):
|
|
61
|
+
raise CliError("Use an HTTPS service root without credentials, path, query or fragment; HTTP is allowed only for localhost/loopback.") from None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def default_config_dir() -> Path:
|
|
65
|
+
override = os.environ.get("OPSAI_CONFIG_DIR")
|
|
66
|
+
if override:
|
|
67
|
+
return Path(override).expanduser()
|
|
68
|
+
if os.name == "nt":
|
|
69
|
+
root = os.environ.get("APPDATA")
|
|
70
|
+
return (Path(root) if root else Path.home() / "AppData/Roaming") / "opsai"
|
|
71
|
+
root = os.environ.get("XDG_CONFIG_HOME")
|
|
72
|
+
return (Path(root) if root else Path.home() / ".config") / "opsai"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ConfigStore:
|
|
76
|
+
def __init__(self, directory: Path | None = None):
|
|
77
|
+
self.path = (directory if directory is not None else default_config_dir()) / "config.json"
|
|
78
|
+
|
|
79
|
+
def load(self) -> dict:
|
|
80
|
+
try:
|
|
81
|
+
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
return {"version": 1, "profiles": {}}
|
|
84
|
+
except (OSError, ValueError, UnicodeError):
|
|
85
|
+
raise CliError("Cannot read profile configuration; check config.json permissions and JSON format.") from None
|
|
86
|
+
if (not isinstance(raw, dict) or set(raw) != {"version", "profiles"}
|
|
87
|
+
or type(raw["version"]) is not int or raw["version"] != 1
|
|
88
|
+
or not isinstance(raw["profiles"], dict)):
|
|
89
|
+
raise CliError("Unsupported profile configuration format.")
|
|
90
|
+
for name, entry in raw["profiles"].items():
|
|
91
|
+
profile_name(name)
|
|
92
|
+
if not isinstance(entry, dict) or set(entry) != {"base_url"}:
|
|
93
|
+
raise CliError("Profile configuration may contain only base_url; keep keys in the system credential store or OPSAI_API_KEY.")
|
|
94
|
+
# Legacy profiles may contain custom origins. Never use them for
|
|
95
|
+
# requests: the public CLI always connects to the official site.
|
|
96
|
+
entry["base_url"] = DEFAULT_BASE_URL
|
|
97
|
+
return raw
|
|
98
|
+
|
|
99
|
+
def get(self, name: str) -> str:
|
|
100
|
+
name = profile_name(name)
|
|
101
|
+
return DEFAULT_BASE_URL
|
|
102
|
+
|
|
103
|
+
def set_profile(self, name: str) -> str:
|
|
104
|
+
name, base_url = profile_name(name), DEFAULT_BASE_URL
|
|
105
|
+
data = self.load()
|
|
106
|
+
data["profiles"][name] = {"base_url": base_url}
|
|
107
|
+
temporary = None
|
|
108
|
+
try:
|
|
109
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
110
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=self.path.parent,
|
|
111
|
+
prefix=".config-", suffix=".tmp", delete=False) as stream:
|
|
112
|
+
temporary = Path(stream.name)
|
|
113
|
+
json.dump(data, stream, ensure_ascii=False, indent=2)
|
|
114
|
+
stream.write("\n")
|
|
115
|
+
stream.flush()
|
|
116
|
+
os.fsync(stream.fileno())
|
|
117
|
+
temporary.replace(self.path)
|
|
118
|
+
except OSError:
|
|
119
|
+
raise CliError("Cannot save profile configuration; check directory permissions.") from None
|
|
120
|
+
finally:
|
|
121
|
+
if temporary is not None:
|
|
122
|
+
with suppress(OSError):
|
|
123
|
+
temporary.unlink(missing_ok=True)
|
|
124
|
+
return base_url
|