deepflow-opendesk-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.
- deepflow_opendesk_cli/__init__.py +3 -0
- deepflow_opendesk_cli/__main__.py +7 -0
- deepflow_opendesk_cli/batch.py +53 -0
- deepflow_opendesk_cli/cli.py +696 -0
- deepflow_opendesk_cli/client.py +323 -0
- deepflow_opendesk_cli/profiles.py +91 -0
- deepflow_opendesk_cli/schema.py +384 -0
- deepflow_opendesk_cli/workflows.py +37 -0
- deepflow_opendesk_cli-0.1.0.dist-info/METADATA +152 -0
- deepflow_opendesk_cli-0.1.0.dist-info/RECORD +13 -0
- deepflow_opendesk_cli-0.1.0.dist-info/WHEEL +5 -0
- deepflow_opendesk_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deepflow_opendesk_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""HTTP client and request helpers for OpenDesk configuration APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.parse import urlencode, urlparse
|
|
14
|
+
from urllib.request import Request, urlopen
|
|
15
|
+
|
|
16
|
+
DEFAULT_HOST = "http://localhost:5001"
|
|
17
|
+
ENV_HOST = "OPENDESK_HOST"
|
|
18
|
+
ENV_BASE_URL = "OPENDESK_BASE_URL"
|
|
19
|
+
ENV_TOKEN = "OPENDESK_TOKEN"
|
|
20
|
+
API_PREFIX = "/api/v1"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CliError(Exception):
|
|
24
|
+
"""Expected CLI error with a user-facing message."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class HttpResult:
|
|
29
|
+
"""Structured HTTP result for JSON output."""
|
|
30
|
+
|
|
31
|
+
ok: bool
|
|
32
|
+
status: int | None
|
|
33
|
+
method: str
|
|
34
|
+
path: str
|
|
35
|
+
url: str
|
|
36
|
+
data: Any = None
|
|
37
|
+
error: str | None = None
|
|
38
|
+
body: Any = None
|
|
39
|
+
dry_run: bool = False
|
|
40
|
+
curl: str | None = None
|
|
41
|
+
raw_text: str | None = None
|
|
42
|
+
|
|
43
|
+
def to_dict(self) -> dict[str, Any]:
|
|
44
|
+
"""Return a compact JSON-serializable result."""
|
|
45
|
+
result: dict[str, Any] = {
|
|
46
|
+
"ok": self.ok,
|
|
47
|
+
"status": self.status,
|
|
48
|
+
"method": self.method,
|
|
49
|
+
"path": self.path,
|
|
50
|
+
}
|
|
51
|
+
if self.dry_run:
|
|
52
|
+
result["dry_run"] = True
|
|
53
|
+
if self.data is not None:
|
|
54
|
+
result["data"] = self.data
|
|
55
|
+
if self.error:
|
|
56
|
+
result["error"] = self.error
|
|
57
|
+
if self.body is not None:
|
|
58
|
+
result["body"] = self.body
|
|
59
|
+
if self.curl:
|
|
60
|
+
result["curl"] = self.curl
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class OpenDeskClient:
|
|
65
|
+
"""Small urllib-based HTTP client for OpenDesk."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, host: str = DEFAULT_HOST, token: str | None = None, timeout: int = 30) -> None:
|
|
68
|
+
self.host = normalize_host(host)
|
|
69
|
+
self.token = token
|
|
70
|
+
self.timeout = timeout
|
|
71
|
+
|
|
72
|
+
def request(
|
|
73
|
+
self,
|
|
74
|
+
method: str,
|
|
75
|
+
path: str,
|
|
76
|
+
*,
|
|
77
|
+
query: dict[str, Any] | None = None,
|
|
78
|
+
body: dict[str, Any] | None = None,
|
|
79
|
+
files: dict[str, str] | None = None,
|
|
80
|
+
headers: dict[str, str] | None = None,
|
|
81
|
+
dry_run: bool = False,
|
|
82
|
+
emit_curl: bool = False,
|
|
83
|
+
) -> HttpResult:
|
|
84
|
+
"""Send an HTTP request or return a dry-run representation."""
|
|
85
|
+
method = method.upper()
|
|
86
|
+
url = self.build_url(path, query or {})
|
|
87
|
+
req_headers = {"Accept": "application/json"}
|
|
88
|
+
if self.token:
|
|
89
|
+
req_headers["Authorization"] = f"Bearer {self.token}"
|
|
90
|
+
if headers:
|
|
91
|
+
req_headers.update(headers)
|
|
92
|
+
|
|
93
|
+
data = None
|
|
94
|
+
if body is not None and files:
|
|
95
|
+
raise CliError("JSON body and multipart files cannot be used together")
|
|
96
|
+
if body is not None:
|
|
97
|
+
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
98
|
+
req_headers["Content-Type"] = "application/json"
|
|
99
|
+
elif files:
|
|
100
|
+
data, content_type = encode_multipart(files)
|
|
101
|
+
req_headers["Content-Type"] = content_type
|
|
102
|
+
|
|
103
|
+
curl = emit_curl_command(method, url, req_headers, body, files) if emit_curl else None
|
|
104
|
+
if dry_run:
|
|
105
|
+
return HttpResult(
|
|
106
|
+
ok=True,
|
|
107
|
+
status=None,
|
|
108
|
+
method=method,
|
|
109
|
+
path=path,
|
|
110
|
+
url=url,
|
|
111
|
+
data={
|
|
112
|
+
"query": query or {},
|
|
113
|
+
"body": body,
|
|
114
|
+
"files": files or {},
|
|
115
|
+
"headers": redact_headers(req_headers),
|
|
116
|
+
},
|
|
117
|
+
dry_run=True,
|
|
118
|
+
curl=curl,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
request = Request(url, data=data, headers=req_headers, method=method)
|
|
122
|
+
try:
|
|
123
|
+
with urlopen(request, timeout=self.timeout) as response: # noqa: S310 - CLI calls user-provided hosts.
|
|
124
|
+
raw = response.read().decode("utf-8", errors="replace")
|
|
125
|
+
parsed = parse_response_body(raw)
|
|
126
|
+
return HttpResult(
|
|
127
|
+
ok=True,
|
|
128
|
+
status=response.status,
|
|
129
|
+
method=method,
|
|
130
|
+
path=path,
|
|
131
|
+
url=url,
|
|
132
|
+
data=parsed,
|
|
133
|
+
raw_text=raw,
|
|
134
|
+
curl=curl,
|
|
135
|
+
)
|
|
136
|
+
except HTTPError as exc:
|
|
137
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
138
|
+
parsed = parse_response_body(raw)
|
|
139
|
+
return HttpResult(
|
|
140
|
+
ok=False,
|
|
141
|
+
status=exc.code,
|
|
142
|
+
method=method,
|
|
143
|
+
path=path,
|
|
144
|
+
url=url,
|
|
145
|
+
error=f"HTTP {exc.code}",
|
|
146
|
+
body=parsed,
|
|
147
|
+
raw_text=raw,
|
|
148
|
+
curl=curl,
|
|
149
|
+
)
|
|
150
|
+
except URLError as exc:
|
|
151
|
+
raise CliError(f"Failed to connect to {url}: {exc.reason}") from exc
|
|
152
|
+
|
|
153
|
+
def build_url(self, path: str, query: dict[str, Any] | None = None) -> str:
|
|
154
|
+
"""Build a full URL from a path and query parameters."""
|
|
155
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
156
|
+
base = path
|
|
157
|
+
else:
|
|
158
|
+
normalized_path = path if path.startswith("/") else f"/{path}"
|
|
159
|
+
base = f"{self.host}{normalized_path}"
|
|
160
|
+
clean_query = {
|
|
161
|
+
key: value
|
|
162
|
+
for key, value in (query or {}).items()
|
|
163
|
+
if value is not None and value is not False
|
|
164
|
+
}
|
|
165
|
+
suffix = f"?{urlencode(clean_query, doseq=True)}" if clean_query else ""
|
|
166
|
+
return f"{base}{suffix}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def normalize_host(value: str | None) -> str:
|
|
170
|
+
"""Normalize an API host or base URL to a host without trailing slash."""
|
|
171
|
+
raw = (value or DEFAULT_HOST).rstrip("/")
|
|
172
|
+
if raw.endswith(API_PREFIX):
|
|
173
|
+
raw = raw[: -len(API_PREFIX)]
|
|
174
|
+
parsed = urlparse(raw)
|
|
175
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
176
|
+
raise CliError(f"Invalid OpenDesk host: {value}")
|
|
177
|
+
return raw
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def host_from_env_or_profile(
|
|
181
|
+
*,
|
|
182
|
+
host: str | None = None,
|
|
183
|
+
base_url: str | None = None,
|
|
184
|
+
profile_data: dict[str, Any] | None = None,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""Resolve host from flags, env, profile, or default."""
|
|
187
|
+
selected = (
|
|
188
|
+
host
|
|
189
|
+
or base_url
|
|
190
|
+
or os.getenv(ENV_HOST)
|
|
191
|
+
or os.getenv(ENV_BASE_URL)
|
|
192
|
+
or (profile_data or {}).get("host")
|
|
193
|
+
or DEFAULT_HOST
|
|
194
|
+
)
|
|
195
|
+
return normalize_host(str(selected))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def token_from_env_or_profile(
|
|
199
|
+
*,
|
|
200
|
+
token: str | None = None,
|
|
201
|
+
profile_data: dict[str, Any] | None = None,
|
|
202
|
+
) -> str | None:
|
|
203
|
+
"""Resolve token from flags, env, or profile."""
|
|
204
|
+
return token or os.getenv(ENV_TOKEN) or (profile_data or {}).get("token")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_json_payload(json_text: str | None, json_file: str | None) -> dict[str, Any]:
|
|
208
|
+
"""Load an object JSON payload from text or file."""
|
|
209
|
+
if json_text and json_file:
|
|
210
|
+
raise CliError("--json and --json-file cannot be used together")
|
|
211
|
+
if not json_text and not json_file:
|
|
212
|
+
return {}
|
|
213
|
+
source = json_text
|
|
214
|
+
if json_file:
|
|
215
|
+
source = Path(json_file).read_text(encoding="utf-8")
|
|
216
|
+
try:
|
|
217
|
+
payload = json.loads(source or "{}")
|
|
218
|
+
except json.JSONDecodeError as exc:
|
|
219
|
+
raise CliError(f"Invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}") from exc
|
|
220
|
+
if not isinstance(payload, dict):
|
|
221
|
+
raise CliError("JSON payload must be an object")
|
|
222
|
+
return payload
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def parse_key_value(items: list[str] | None) -> dict[str, Any]:
|
|
226
|
+
"""Parse repeated key=value arguments into a dictionary."""
|
|
227
|
+
result: dict[str, Any] = {}
|
|
228
|
+
for item in items or []:
|
|
229
|
+
if "=" not in item:
|
|
230
|
+
raise CliError(f"Expected key=value, got: {item}")
|
|
231
|
+
key, value = item.split("=", 1)
|
|
232
|
+
if not key:
|
|
233
|
+
raise CliError(f"Expected non-empty key in: {item}")
|
|
234
|
+
if key in result:
|
|
235
|
+
current = result[key]
|
|
236
|
+
if isinstance(current, list):
|
|
237
|
+
current.append(value)
|
|
238
|
+
else:
|
|
239
|
+
result[key] = [current, value]
|
|
240
|
+
else:
|
|
241
|
+
result[key] = value
|
|
242
|
+
return result
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def parse_response_body(raw: str) -> Any:
|
|
246
|
+
"""Parse a response body as JSON when possible."""
|
|
247
|
+
if raw == "":
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
return json.loads(raw)
|
|
251
|
+
except json.JSONDecodeError:
|
|
252
|
+
return raw
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def print_result(result: HttpResult, *, pretty: bool = True, raw: bool = False) -> None:
|
|
256
|
+
"""Print a result to stdout."""
|
|
257
|
+
if raw:
|
|
258
|
+
text = result.raw_text
|
|
259
|
+
if text is None:
|
|
260
|
+
text = "" if result.data is None else json.dumps(result.data, ensure_ascii=False)
|
|
261
|
+
print(text, end="" if text.endswith("\n") else "\n")
|
|
262
|
+
return
|
|
263
|
+
payload = result.to_dict()
|
|
264
|
+
if pretty:
|
|
265
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
266
|
+
else:
|
|
267
|
+
print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def print_json(data: Any, *, pretty: bool = True) -> None:
|
|
271
|
+
"""Print JSON data to stdout."""
|
|
272
|
+
if pretty:
|
|
273
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
274
|
+
else:
|
|
275
|
+
print(json.dumps(data, ensure_ascii=False, separators=(",", ":")))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def redact_headers(headers: dict[str, str]) -> dict[str, str]:
|
|
279
|
+
"""Redact sensitive headers for output."""
|
|
280
|
+
redacted = dict(headers)
|
|
281
|
+
if "Authorization" in redacted:
|
|
282
|
+
redacted["Authorization"] = "Bearer ***"
|
|
283
|
+
return redacted
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def emit_curl_command(
|
|
287
|
+
method: str,
|
|
288
|
+
url: str,
|
|
289
|
+
headers: dict[str, str],
|
|
290
|
+
body: dict[str, Any] | None,
|
|
291
|
+
files: dict[str, str] | None = None,
|
|
292
|
+
) -> str:
|
|
293
|
+
"""Build a curl command with sensitive values redacted."""
|
|
294
|
+
parts = ["curl", "-X", method, shlex.quote(url)]
|
|
295
|
+
for key, value in redact_headers(headers).items():
|
|
296
|
+
parts.extend(["-H", shlex.quote(f"{key}: {value}")])
|
|
297
|
+
if body is not None:
|
|
298
|
+
parts.extend(["-d", shlex.quote(json.dumps(body, ensure_ascii=False))])
|
|
299
|
+
for field, path in (files or {}).items():
|
|
300
|
+
parts.extend(["-F", shlex.quote(f"{field}=@{path}")])
|
|
301
|
+
return " ".join(parts)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def encode_multipart(files: dict[str, str]) -> tuple[bytes, str]:
|
|
305
|
+
"""Encode file fields as multipart/form-data."""
|
|
306
|
+
boundary = f"----opendesk-cli-{uuid.uuid4().hex}"
|
|
307
|
+
chunks: list[bytes] = []
|
|
308
|
+
for field, file_path in files.items():
|
|
309
|
+
path = Path(file_path)
|
|
310
|
+
if not path.is_file():
|
|
311
|
+
raise CliError(f"File not found: {file_path}")
|
|
312
|
+
chunks.append(f"--{boundary}\r\n".encode("utf-8"))
|
|
313
|
+
chunks.append(
|
|
314
|
+
(
|
|
315
|
+
f'Content-Disposition: form-data; name="{field}"; '
|
|
316
|
+
f'filename="{path.name}"\r\n'
|
|
317
|
+
"Content-Type: application/octet-stream\r\n\r\n"
|
|
318
|
+
).encode("utf-8")
|
|
319
|
+
)
|
|
320
|
+
chunks.append(path.read_bytes())
|
|
321
|
+
chunks.append(b"\r\n")
|
|
322
|
+
chunks.append(f"--{boundary}--\r\n".encode("utf-8"))
|
|
323
|
+
return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Local profile storage for OpenDesk CLI credentials and schema cache."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
CONFIG_HOME_ENV = "OPENDESK_CONFIG_HOME"
|
|
11
|
+
DEFAULT_PROFILE = "default"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def config_home() -> Path:
|
|
15
|
+
"""Return the base directory used for CLI profiles."""
|
|
16
|
+
override = os.getenv(CONFIG_HOME_ENV)
|
|
17
|
+
if override:
|
|
18
|
+
return Path(override).expanduser()
|
|
19
|
+
return Path.home() / ".opendesk" / "cli"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def profile_dir(profile: str = DEFAULT_PROFILE) -> Path:
|
|
23
|
+
"""Return a profile directory path without creating it."""
|
|
24
|
+
return config_home() / profile
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def profile_path(profile: str = DEFAULT_PROFILE) -> Path:
|
|
28
|
+
"""Return the profile JSON path."""
|
|
29
|
+
return profile_dir(profile) / "profile.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def schema_cache_path(profile: str = DEFAULT_PROFILE) -> Path:
|
|
33
|
+
"""Return the cached OpenAPI schema path."""
|
|
34
|
+
return profile_dir(profile) / "openapi.json"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _secure_parent(path: Path) -> None:
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
try:
|
|
40
|
+
os.chmod(path.parent, 0o700)
|
|
41
|
+
except OSError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_json(path: Path) -> dict[str, Any] | None:
|
|
46
|
+
"""Read a JSON object if the file exists."""
|
|
47
|
+
if not path.exists():
|
|
48
|
+
return None
|
|
49
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
50
|
+
if not isinstance(data, dict):
|
|
51
|
+
raise ValueError(f"{path} must contain a JSON object")
|
|
52
|
+
return data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def write_json(path: Path, data: dict[str, Any]) -> None:
|
|
56
|
+
"""Write a JSON object with owner-only permissions where supported."""
|
|
57
|
+
_secure_parent(path)
|
|
58
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
59
|
+
try:
|
|
60
|
+
os.chmod(path, 0o600)
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_profile(profile: str = DEFAULT_PROFILE) -> dict[str, Any] | None:
|
|
66
|
+
"""Load a saved CLI profile."""
|
|
67
|
+
return read_json(profile_path(profile))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def save_profile(profile: str, data: dict[str, Any]) -> None:
|
|
71
|
+
"""Save a CLI profile."""
|
|
72
|
+
write_json(profile_path(profile), data)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def delete_profile(profile: str = DEFAULT_PROFILE) -> bool:
|
|
76
|
+
"""Delete a saved CLI profile, returning whether a file was removed."""
|
|
77
|
+
path = profile_path(profile)
|
|
78
|
+
if not path.exists():
|
|
79
|
+
return False
|
|
80
|
+
path.unlink()
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_schema_cache(profile: str, schema: dict[str, Any]) -> None:
|
|
85
|
+
"""Save the OpenAPI schema cache for a profile."""
|
|
86
|
+
write_json(schema_cache_path(profile), schema)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_schema_cache(profile: str = DEFAULT_PROFILE) -> dict[str, Any] | None:
|
|
90
|
+
"""Load the cached OpenAPI schema for a profile."""
|
|
91
|
+
return read_json(schema_cache_path(profile))
|