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.
@@ -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))