socaity-cli 0.1.1__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,128 @@
1
+ """XDG credentials storage for the Socaity CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import asdict, dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ DEFAULT_BACKEND_URL = "https://webapi.socaity.ai"
13
+ DEFAULT_FRONTEND_URL = "https://socaity.ai"
14
+
15
+ _LEGACY_TOKEN_FILE = Path.home() / ".apipod" / "token"
16
+
17
+
18
+ @dataclass
19
+ class Credentials:
20
+ api_key: str
21
+ user_id: Optional[str] = None
22
+ user_email: Optional[str] = None
23
+ backend_url: str = DEFAULT_BACKEND_URL
24
+ logged_in_at: Optional[str] = None
25
+
26
+ def apply_to_env(self) -> None:
27
+ # setdefault: an explicitly set env var (e.g. pointing at a local
28
+ # backend) always wins over stored credentials.
29
+ os.environ.setdefault("SOCAITY_API_KEY", self.api_key)
30
+ os.environ.setdefault("SOCAITY_BACKEND_URL", self.backend_url.rstrip("/") + "/")
31
+
32
+
33
+ def config_dir() -> Path:
34
+ xdg = os.environ.get("XDG_CONFIG_HOME", "").strip()
35
+ base = Path(xdg) if xdg else Path.home() / ".config"
36
+ return base / "socaity"
37
+
38
+
39
+ def credentials_path() -> Path:
40
+ return config_dir() / "credentials.json"
41
+
42
+
43
+ def load_credentials() -> Optional[Credentials]:
44
+ path = credentials_path()
45
+ if path.is_file():
46
+ try:
47
+ data = json.loads(path.read_text(encoding="utf-8"))
48
+ api_key = (data.get("api_key") or "").strip()
49
+ if api_key:
50
+ return Credentials(
51
+ api_key=api_key,
52
+ user_id=data.get("user_id"),
53
+ user_email=data.get("user_email"),
54
+ backend_url=(data.get("backend_url") or DEFAULT_BACKEND_URL).rstrip("/"),
55
+ logged_in_at=data.get("logged_in_at"),
56
+ )
57
+ except (json.JSONDecodeError, OSError):
58
+ pass
59
+
60
+ legacy = _load_legacy_token()
61
+ if legacy:
62
+ save_credentials(legacy)
63
+ return legacy
64
+ return None
65
+
66
+
67
+ def _load_legacy_token() -> Optional[Credentials]:
68
+ if not _LEGACY_TOKEN_FILE.is_file():
69
+ return None
70
+ try:
71
+ token = _LEGACY_TOKEN_FILE.read_text(encoding="utf-8").strip()
72
+ if not token:
73
+ return None
74
+ backend = os.environ.get("SOCAITY_BACKEND_URL", DEFAULT_BACKEND_URL).rstrip("/")
75
+ return Credentials(
76
+ api_key=token,
77
+ backend_url=backend,
78
+ logged_in_at=datetime.now(timezone.utc).isoformat(),
79
+ )
80
+ except OSError:
81
+ return None
82
+
83
+
84
+ def save_credentials(credentials: Credentials) -> Path:
85
+ directory = config_dir()
86
+ directory.mkdir(parents=True, exist_ok=True)
87
+ path = credentials_path()
88
+ payload: dict[str, Any] = asdict(credentials)
89
+ payload["backend_url"] = credentials.backend_url.rstrip("/")
90
+ if not payload.get("logged_in_at"):
91
+ payload["logged_in_at"] = datetime.now(timezone.utc).isoformat()
92
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
93
+ path.chmod(0o600)
94
+ credentials.apply_to_env()
95
+ return path
96
+
97
+
98
+ def clear_credentials() -> bool:
99
+ path = credentials_path()
100
+ if path.is_file():
101
+ path.unlink()
102
+ return True
103
+ return False
104
+
105
+
106
+ def get_api_key() -> Optional[str]:
107
+ """Return the active API key. Explicit ``SOCAITY_API_KEY`` wins over login file."""
108
+ env_key = os.environ.get("SOCAITY_API_KEY", "").strip()
109
+ if env_key:
110
+ return env_key
111
+ creds = load_credentials()
112
+ if creds:
113
+ creds.apply_to_env()
114
+ return creds.api_key
115
+ return None
116
+
117
+
118
+ def require_credentials() -> Credentials:
119
+ stored = load_credentials()
120
+ if stored:
121
+ stored.apply_to_env()
122
+ return stored
123
+ env_key = os.environ.get("SOCAITY_API_KEY", "").strip()
124
+ if env_key:
125
+ backend = os.environ.get("SOCAITY_BACKEND_URL", DEFAULT_BACKEND_URL).rstrip("/")
126
+ return Credentials(api_key=env_key, backend_url=backend)
127
+ print("Not logged in. Run: socaity login (or set SOCAITY_API_KEY).")
128
+ raise SystemExit(1)
@@ -0,0 +1,349 @@
1
+ """Socaity platform deployment helpers (analyze, draft, HF token prompts).
2
+
3
+ Used by ``apipod deploy`` and ``socaity deploy``.
4
+ Requires login; apipod scan/build/start work without the backend.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import time
9
+ from typing import Any, Dict, Optional
10
+
11
+ from socaity_cli.backend_client import SocaityBackendClient
12
+ from socaity_cli.credentials import require_credentials
13
+ from socaity_cli.errors import PrivateSlotLimitError
14
+ from socaity_cli.prompts import input_yes_no
15
+ from socaity_cli.registry_push import (
16
+ CredentialsExpired,
17
+ DockerNotAvailable,
18
+ PushFailed,
19
+ image_size_bytes,
20
+ push_image,
21
+ )
22
+
23
+ _TERMINAL_STATUSES = frozenset({
24
+ "live",
25
+ "failed_push",
26
+ "failed_validation",
27
+ "failed_provision",
28
+ "cancelled",
29
+ })
30
+ _POLL_INTERVAL_S = 5.0
31
+ _DEFAULT_TIMEOUT_S = 3600.0
32
+
33
+
34
+ def print_analysis_report(report: Dict[str, Any]) -> None:
35
+ """Render a backend analysis payload for the terminal."""
36
+ for model in report.get("models", []):
37
+ ref = model.get("ref", "?")
38
+ status = model.get("hf_status", "unknown")
39
+ line = f" {ref}: {status}"
40
+ catalog = model.get("catalog_match")
41
+ if catalog:
42
+ vram = (catalog.get("min_gpu_vram_gb") and f", needs ~{catalog['min_gpu_vram_gb']} GB VRAM") or ""
43
+ line += f" | catalog: {catalog.get('name')}{vram}"
44
+ print(line)
45
+ for warning in model.get("warnings", []):
46
+ print(f" warning: {warning}")
47
+ gpu = report.get("recommended_gpu")
48
+ if gpu:
49
+ print(f" Recommended GPU tier: {gpu}")
50
+ for service in report.get("similar_services", []):
51
+ name = service.get("display_name") or service.get("name")
52
+ print(f" Similar public service: {name} (id={service.get('id')})")
53
+ for warning in report.get("warnings", []):
54
+ print(f" warning: {warning}")
55
+
56
+
57
+ def analyze_deployment(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
58
+ """Run backend analysis. Returns None on failure."""
59
+ require_credentials()
60
+ client = SocaityBackendClient()
61
+ print("Analyzing deployment against the Socaity catalog...")
62
+ report = client.analyze_deployment(config)
63
+ if report is None:
64
+ print("Analysis failed. Check your connection and login (socaity login), then retry.")
65
+ return None
66
+ print_analysis_report(report)
67
+ return report
68
+
69
+
70
+ def prompt_model_resolutions(report: Dict[str, Any]) -> Dict[str, Optional[str]]:
71
+ """Ask the user to confirm fuzzy catalog matches for declared HF models.
72
+
73
+ Returns HF ref -> catalog model id (confirmed candidate) or None (create
74
+ a new catalog entry). Exact matches need no confirmation and are omitted.
75
+ """
76
+ resolutions: Dict[str, Optional[str]] = {}
77
+ for model in report.get("models", []):
78
+ ref = model.get("ref")
79
+ if not ref or model.get("catalog_match") or model.get("hf_status") in ("invalid", "not_found"):
80
+ continue
81
+ candidates = model.get("candidates") or []
82
+ picked = None
83
+ for candidate in candidates:
84
+ label = candidate.get("display_name") or candidate.get("name")
85
+ hf_url = candidate.get("hugging_face_url")
86
+ hint = f" ({hf_url})" if hf_url else ""
87
+ if input_yes_no(f" {ref}: we found '{label}'{hint} in our catalog. Is this your model?"):
88
+ picked = candidate.get("id")
89
+ break
90
+ if picked is None:
91
+ print(f" {ref}: will be added to the Socaity model catalog.")
92
+ resolutions[ref] = picked
93
+ return resolutions
94
+
95
+
96
+ def prompt_gated_hf_token(client: SocaityBackendClient, report: Dict[str, Any]) -> None:
97
+ """Offer to store an HF token when gated repos were detected."""
98
+ gated = [
99
+ m["ref"]
100
+ for m in report.get("models", [])
101
+ if m.get("hf_status") == "gated" and not report.get("has_hf_token")
102
+ ]
103
+ if not gated:
104
+ return
105
+ print(f"\nGated Hugging Face repos detected: {', '.join(gated)}")
106
+ if not input_yes_no("Store a Hugging Face access token now?"):
107
+ return
108
+ token = input("HF token (hf_...): ").strip()
109
+ if token and client.upsert_hf_token(token):
110
+ print("Token stored. It will be attached to your deployment.")
111
+ else:
112
+ print("Token not stored; gated repos will fail to download on deploy.")
113
+
114
+
115
+ def create_deployment_draft(config: Dict[str, Any], report: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
116
+ """Create a deployment draft after optional confirmation."""
117
+ require_credentials()
118
+ client = SocaityBackendClient()
119
+ if report is None:
120
+ report = client.analyze_deployment(config)
121
+ if report is None:
122
+ print("Analysis failed. Draft not created.")
123
+ return None
124
+ print_analysis_report(report)
125
+
126
+ resolutions = prompt_model_resolutions(report)
127
+ prompt_gated_hf_token(client, report)
128
+
129
+ if not input_yes_no("\nCreate a deployment draft on Socaity?"):
130
+ print("Aborted. Nothing was created.")
131
+ return None
132
+
133
+ try:
134
+ draft = client.create_deployment_draft(config, model_resolutions=resolutions)
135
+ except PrivateSlotLimitError as exc:
136
+ print(f"\n{exc}")
137
+ print("Tip: publish a private service from the service page, or upgrade your plan.")
138
+ raise
139
+ if draft is None:
140
+ print("Draft creation failed.")
141
+ return None
142
+ print(
143
+ f"Draft created: service_id={draft.get('service_id')} deployment_id={draft.get('deployment_id')}\n"
144
+ "Next: the image will be pushed to the Socaity registry and provisioned automatically."
145
+ )
146
+ return draft
147
+
148
+
149
+ def push_image_to_registry(
150
+ deployment_id: str,
151
+ local_tag: str,
152
+ *,
153
+ client: Optional[SocaityBackendClient] = None,
154
+ ) -> Optional[str]:
155
+ """Push a local image to the deployment staging repo. Returns the manifest digest."""
156
+ client = client or SocaityBackendClient()
157
+ estimated_size = image_size_bytes(local_tag)
158
+ creds = client.get_push_credentials(deployment_id, estimated_size_bytes=estimated_size)
159
+ if creds is None:
160
+ print("Could not obtain registry push credentials.")
161
+ return None
162
+
163
+ for attempt in range(2):
164
+ try:
165
+ digest, _size = push_image(
166
+ registry=creds["registry"],
167
+ repository=creds["repository"],
168
+ tag=creds.get("tag", "build"),
169
+ username=creds["username"],
170
+ password=creds["password"],
171
+ local_tag=local_tag,
172
+ )
173
+ return digest
174
+ except CredentialsExpired:
175
+ if attempt == 0:
176
+ print("Push credentials expired; requesting fresh credentials...")
177
+ creds = client.get_push_credentials(deployment_id, estimated_size_bytes=estimated_size)
178
+ if creds is None:
179
+ print("Could not refresh registry push credentials.")
180
+ return None
181
+ continue
182
+ print("Registry rejected the push credentials again.")
183
+ return None
184
+ except DockerNotAvailable as exc:
185
+ print(str(exc))
186
+ return None
187
+ except PushFailed as exc:
188
+ print(str(exc))
189
+ return None
190
+ return None
191
+
192
+
193
+ def poll_deployment_until_terminal(
194
+ deployment_id: str,
195
+ *,
196
+ timeout_s: float = _DEFAULT_TIMEOUT_S,
197
+ client: Optional[SocaityBackendClient] = None,
198
+ ) -> Optional[Dict[str, Any]]:
199
+ """Poll pipeline status until live or a terminal failure."""
200
+ client = client or SocaityBackendClient()
201
+ deadline = time.monotonic() + timeout_s
202
+ last_status: Optional[Dict[str, Any]] = None
203
+
204
+ while time.monotonic() < deadline:
205
+ status = client.get_deployment_pipeline_status(deployment_id)
206
+ if status is None:
207
+ print("Pipeline status poll failed; retrying...")
208
+ time.sleep(_POLL_INTERVAL_S)
209
+ continue
210
+
211
+ last_status = status
212
+ phase = status.get("phase")
213
+ state = str(status.get("status", "")).lower()
214
+ label = f"{state}" + (f" ({phase})" if phase else "")
215
+ print(f" deployment status: {label}")
216
+
217
+ if state in _TERMINAL_STATUSES:
218
+ return status
219
+ time.sleep(_POLL_INTERVAL_S)
220
+
221
+ print(f"Timed out after {int(timeout_s)}s waiting for deployment {deployment_id}.")
222
+ return last_status
223
+
224
+
225
+ def _create_draft_with_prompts(
226
+ config: Dict[str, Any],
227
+ *,
228
+ client: SocaityBackendClient,
229
+ assume_yes: bool,
230
+ ) -> Optional[Dict[str, Any]]:
231
+ report = client.analyze_deployment(config)
232
+ if report is None:
233
+ print("Analysis failed. Draft not created.")
234
+ return None
235
+ print_analysis_report(report)
236
+
237
+ if assume_yes:
238
+ resolutions: Dict[str, Optional[str]] = {}
239
+ for model in report.get("models", []):
240
+ ref = model.get("ref")
241
+ if not ref or model.get("catalog_match"):
242
+ continue
243
+ candidates = model.get("candidates") or []
244
+ resolutions[ref] = candidates[0]["id"] if candidates else None
245
+ else:
246
+ resolutions = prompt_model_resolutions(report)
247
+
248
+ prompt_gated_hf_token(client, report)
249
+
250
+ if not assume_yes and not input_yes_no("\nCreate a deployment draft on Socaity?"):
251
+ print("Aborted. Nothing was created.")
252
+ return None
253
+
254
+ try:
255
+ draft = client.create_deployment_draft(config, model_resolutions=resolutions)
256
+ except PrivateSlotLimitError as exc:
257
+ print(f"\n{exc}")
258
+ print("Tip: publish a private service from the service page, or upgrade your plan.")
259
+ raise
260
+ if draft is None:
261
+ print("Draft creation failed.")
262
+ return None
263
+ print(
264
+ f"Draft created: service_id={draft.get('service_id')} "
265
+ f"deployment_id={draft.get('deployment_id')}"
266
+ )
267
+ return draft
268
+
269
+
270
+ def run_full_deploy(
271
+ config: Dict[str, Any],
272
+ *,
273
+ local_tag: str,
274
+ resume_deployment_id: Optional[str] = None,
275
+ assume_yes: bool = False,
276
+ ) -> Optional[Dict[str, Any]]:
277
+ """Analyze → draft (unless resuming) → push → poll until live or failure."""
278
+ require_credentials()
279
+ client = SocaityBackendClient()
280
+
281
+ deployment_id = resume_deployment_id
282
+ if deployment_id:
283
+ status = client.get_deployment_pipeline_status(deployment_id)
284
+ if status is None:
285
+ print(f"Deployment {deployment_id} not found or not accessible.")
286
+ return None
287
+ state = str(status.get("status", "")).lower()
288
+ if state == "live":
289
+ print(f"Deployment {deployment_id} is already live.")
290
+ return status
291
+ if state in _TERMINAL_STATUSES - {"live"}:
292
+ print(
293
+ f"Deployment {deployment_id} is in terminal state '{state}'. "
294
+ "Create a new draft or fix the issue before resuming."
295
+ )
296
+ return status
297
+ print(f"Resuming deployment {deployment_id} (status={state}).")
298
+ else:
299
+ draft = _create_draft_with_prompts(config, client=client, assume_yes=assume_yes)
300
+ if draft is None:
301
+ return None
302
+ deployment_id = draft.get("deployment_id")
303
+ if not deployment_id:
304
+ print("Draft response did not include deployment_id.")
305
+ return None
306
+
307
+ current = client.get_deployment_pipeline_status(deployment_id) or {}
308
+ state = str(current.get("status", "")).lower()
309
+ if state in {"draft", "awaiting_image", "pushing", "failed_push"}:
310
+ print(f"Pushing image '{local_tag}' to the Socaity registry...")
311
+ digest = push_image_to_registry(deployment_id, local_tag, client=client)
312
+ if digest is None:
313
+ return None
314
+ size_bytes = image_size_bytes(local_tag)
315
+ pushed = client.confirm_image_pushed(deployment_id, digest=digest, size_bytes=size_bytes)
316
+ if pushed is None:
317
+ print("Backend did not accept the image push confirmation.")
318
+ return None
319
+ print(f"Image pushed (digest={digest}). Validation and provisioning started.")
320
+ elif state in {"validating", "promoting", "provisioning"}:
321
+ print(f"Image already pushed; waiting for pipeline (status={state}).")
322
+ elif state == "live":
323
+ print(f"Deployment {deployment_id} is already live.")
324
+ return current
325
+
326
+ print("Waiting for deployment to go live (this may take several minutes)...")
327
+ final = poll_deployment_until_terminal(deployment_id, client=client)
328
+ if final is None:
329
+ return None
330
+
331
+ final_state = str(final.get("status", "")).lower()
332
+ if final_state == "live":
333
+ print(f"\nDeployment live: deployment_id={deployment_id}")
334
+ if final.get("service_id"):
335
+ print(f" service_id={final['service_id']}")
336
+ print(" https://www.socaity.ai")
337
+ elif final.get("error"):
338
+ print(f"\nDeployment failed: {final['error']}")
339
+ else:
340
+ print(f"\nDeployment ended with status={final_state}.")
341
+ return final
342
+
343
+
344
+ def run_platform_deploy(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
345
+ """Full analyze → optional HF token → optional draft flow."""
346
+ report = analyze_deployment(config)
347
+ if report is None:
348
+ return None
349
+ return create_deployment_draft(config, report=report)
socaity_cli/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """CLI-facing structured API errors."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Optional
5
+
6
+
7
+ class BackendApiError(Exception):
8
+ """HTTP error from the Socaity backend with optional structured detail."""
9
+
10
+ def __init__(
11
+ self,
12
+ status_code: int,
13
+ message: str,
14
+ *,
15
+ code: Optional[str] = None,
16
+ detail: Optional[Any] = None,
17
+ ):
18
+ self.status_code = status_code
19
+ self.code = code
20
+ self.detail = detail
21
+ super().__init__(message)
22
+
23
+
24
+ class PrivateSlotLimitError(BackendApiError):
25
+ """Private deployment slot limit reached (plan quota)."""
26
+
27
+ def __init__(self, detail: dict):
28
+ used = detail.get("used")
29
+ limit = detail.get("limit")
30
+ plan = detail.get("plan") or "free"
31
+ message = detail.get("message") or (
32
+ f"Private deployment limit reached ({used}/{limit} on plan '{plan}'). "
33
+ "Publish an existing private service or upgrade your plan to free a slot."
34
+ )
35
+ super().__init__(403, message, code="PRIVATE_SLOT_LIMIT", detail=detail)
socaity_cli/http.py ADDED
@@ -0,0 +1,105 @@
1
+ """Shared HTTP transport for platform backend clients."""
2
+ import json
3
+ import os
4
+ from typing import Any, Dict, Optional
5
+
6
+ import httpx
7
+
8
+ from socaity_cli.credentials import DEFAULT_BACKEND_URL, get_api_key
9
+ from socaity_cli.errors import BackendApiError, PrivateSlotLimitError
10
+
11
+
12
+ class BackendHttp:
13
+ """Base HTTP client: URL, Bearer auth, query flattening, JSON requests."""
14
+
15
+ def __init__(self, backend_url: Optional[str] = None, api_key: Optional[str] = None):
16
+ default_backend = DEFAULT_BACKEND_URL.rstrip("/") + "/"
17
+ self.backend_url = (backend_url or os.getenv("SOCAITY_BACKEND_URL", default_backend)).rstrip("/") + "/"
18
+ self.api_key = api_key if api_key is not None else get_api_key()
19
+
20
+ @property
21
+ def _auth_headers(self) -> Optional[Dict]:
22
+ return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else None
23
+
24
+ @staticmethod
25
+ def _raise_for_structured_error(status_code: int, body_text: str) -> None:
26
+ try:
27
+ body = json.loads(body_text) if body_text else None
28
+ except Exception:
29
+ body = None
30
+ detail = body.get("detail") if isinstance(body, dict) else None
31
+ if isinstance(detail, dict) and detail.get("code") == "PRIVATE_SLOT_LIMIT":
32
+ raise PrivateSlotLimitError(detail)
33
+ if isinstance(detail, dict) and detail.get("code"):
34
+ raise BackendApiError(
35
+ status_code,
36
+ detail.get("message") or str(detail),
37
+ code=detail.get("code"),
38
+ detail=detail,
39
+ )
40
+ # Auth / ACL denials often use a plain string detail (e.g. private install).
41
+ if status_code in (401, 403):
42
+ if isinstance(detail, str) and detail.strip():
43
+ message = detail.strip()
44
+ elif isinstance(detail, dict):
45
+ message = str(detail.get("message") or detail)
46
+ else:
47
+ message = (body_text or f"HTTP {status_code}")[:300]
48
+ raise BackendApiError(status_code, message, detail=detail)
49
+
50
+ def _request(self, method: str, path: str, payload=None, params: Dict = None) -> Optional[Any]:
51
+ params = self._catalog_params(params or {})
52
+ try:
53
+ with httpx.Client() as client:
54
+ response = client.request(
55
+ method,
56
+ self.backend_url + path,
57
+ json=payload,
58
+ params=params,
59
+ headers=self._auth_headers,
60
+ timeout=400,
61
+ )
62
+ if response.status_code in (200, 202):
63
+ return response.json() if response.content else {}
64
+ self._raise_for_structured_error(response.status_code, response.text)
65
+ print(f"Request to {path} failed with status {response.status_code}: {response.text[:300]}")
66
+ return None
67
+ except (PrivateSlotLimitError, BackendApiError):
68
+ raise
69
+ except Exception as e:
70
+ print(f"Request to {path} failed: {e}")
71
+ return None
72
+
73
+ @staticmethod
74
+ def _catalog_params(params: Dict) -> Dict:
75
+ """Flatten list params for FastAPI repeated query keys."""
76
+ flat: Dict[str, Any] = {}
77
+ for key, value in params.items():
78
+ if value is None:
79
+ continue
80
+ if isinstance(value, list):
81
+ if value:
82
+ flat[key] = value
83
+ else:
84
+ flat[key] = value
85
+ return flat
86
+
87
+ def _post(self, path: str, payload=None, params: Dict = None) -> Optional[Any]:
88
+ return self._request("POST", path, payload=payload, params=params)
89
+
90
+ def _get(self, path: str, params: Dict = None) -> Optional[Any]:
91
+ return self._request("GET", path, params=params)
92
+
93
+ @staticmethod
94
+ def _unwrap_list(payload: Any) -> list:
95
+ """Accept bare arrays or ``{ids, entities}`` query envelopes."""
96
+ if payload is None:
97
+ return []
98
+ if isinstance(payload, list):
99
+ return payload
100
+ if isinstance(payload, dict):
101
+ if "entities" in payload:
102
+ return payload.get("entities") or []
103
+ if "data" in payload and isinstance(payload["data"], list):
104
+ return payload["data"]
105
+ return []
socaity_cli/prompts.py ADDED
@@ -0,0 +1,17 @@
1
+ """Interactive CLI prompts shared across socaity and apipod commands."""
2
+ import sys
3
+
4
+ _VALID_YES_NO = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
5
+
6
+
7
+ def input_yes_no(question: str, default: bool = True) -> bool:
8
+ """Prompt for yes/no. Empty input returns ``default``."""
9
+ prompt = " [Y/n] " if default else " [y/N] "
10
+ while True:
11
+ sys.stdout.write(question + prompt)
12
+ choice = input().lower()
13
+ if choice == "":
14
+ return default
15
+ if choice in _VALID_YES_NO:
16
+ return _VALID_YES_NO[choice]
17
+ sys.stdout.write("Please respond with 'yes' or 'no' (or 'y'/'n').\n")