lcloud-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,66 @@
1
+ """Runtime state shared by all commands (built once per invocation)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, NoReturn
6
+
7
+ import typer
8
+ from rich.console import RenderableType
9
+
10
+ from ..api import LambdaCloudClient
11
+ from ..core.config import resolve_api_key
12
+ from .ui.console import OutputFormat, emit, failure
13
+
14
+
15
+ class State:
16
+ """Holds global CLI options and lazily builds the API client."""
17
+
18
+ def __init__(self, api_key: str | None, output: OutputFormat, verbose: bool) -> None:
19
+ self.api_key = api_key
20
+ self.output = output
21
+ self.verbose = verbose
22
+ self.history: dict[str, Any] = {}
23
+ self._client: LambdaCloudClient | None = None
24
+
25
+ @property
26
+ def client(self) -> LambdaCloudClient:
27
+ """API client, created on first use (so --help works without a key)."""
28
+ if self._client is None:
29
+ self._client = LambdaCloudClient(resolve_api_key(self.api_key))
30
+ return self._client
31
+
32
+ def close(self) -> None:
33
+ """Release any resource allocated lazily (HTTP connections)."""
34
+ if self._client is not None:
35
+ self._client.close()
36
+ self._client = None
37
+
38
+
39
+ class CommandBase:
40
+ """Per-command helper wrapping the global :class:`State`.
41
+
42
+ Attributes:
43
+ state: The global state built in the root callback.
44
+ command: Name of the invoked command.
45
+ output: The resolved output format.
46
+ """
47
+
48
+ def __init__(self, ctx: typer.Context, *, needs_client: bool = True) -> None:
49
+ state: State = ctx.obj
50
+ if needs_client:
51
+ _ = state.client # resolve early → clear failure on missing key
52
+ self.state = state
53
+ self.command = ctx.info_name or ""
54
+ self.output = state.output
55
+
56
+ @property
57
+ def client(self) -> LambdaCloudClient:
58
+ return self.state.client
59
+
60
+ def emit(self, data: Any, renderable: RenderableType | None = None) -> None:
61
+ """Print ``data`` according to the configured output format."""
62
+ emit(data, self.output, renderable)
63
+
64
+ def failure(self, action: str, message: str) -> NoReturn:
65
+ """Report a failed action and exit."""
66
+ failure(action, message)
@@ -0,0 +1,13 @@
1
+ """User interface layer: console, formatters, tables, history."""
2
+
3
+ from .console import OutputFormat, confirm_or_exit, console, emit, err_console, failure, success
4
+
5
+ __all__ = [
6
+ "OutputFormat",
7
+ "console",
8
+ "confirm_or_exit",
9
+ "emit",
10
+ "err_console",
11
+ "failure",
12
+ "success",
13
+ ]
@@ -0,0 +1,94 @@
1
+ """Console output helpers: rich tables for humans, JSON for scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from enum import Enum
7
+ from typing import Any, NoReturn
8
+
9
+ import typer
10
+ from pydantic import BaseModel
11
+ from rich.console import Console, RenderableType
12
+
13
+ from ...core.errors import APIError, LambdaCloudError
14
+
15
+ console = Console()
16
+ err_console = Console(stderr=True, style="bold red")
17
+
18
+
19
+ class OutputFormat(str, Enum):
20
+ TABLE = "table"
21
+ JSON = "json"
22
+
23
+
24
+ def _normalize(data: Any) -> Any:
25
+ """Convert pydantic models and containers to plain JSON-able data."""
26
+ if isinstance(data, BaseModel):
27
+ return data.model_dump(mode="json")
28
+ if isinstance(data, list):
29
+ return [_normalize(item) for item in data]
30
+ if isinstance(data, dict):
31
+ return {key: _normalize(value) for key, value in data.items()}
32
+ return data
33
+
34
+
35
+ def emit(
36
+ data: Any,
37
+ output_format: OutputFormat,
38
+ renderable: RenderableType | None = None,
39
+ ) -> None:
40
+ """Print ``data`` as JSON, or print ``renderable`` in table mode."""
41
+ if output_format is OutputFormat.JSON:
42
+ console.print(
43
+ json.dumps(_normalize(data), indent=2, ensure_ascii=False),
44
+ highlight=False,
45
+ soft_wrap=True,
46
+ )
47
+ elif renderable is not None:
48
+ console.print(renderable)
49
+
50
+
51
+ def success(message: str) -> None:
52
+ console.print(f"[green]✓[/green] {message}")
53
+
54
+
55
+ def info(message: str) -> None:
56
+ console.print(message)
57
+
58
+
59
+ def warn(message: str) -> None:
60
+ console.print(f"[yellow]![/yellow] {message}")
61
+
62
+
63
+ def failure(header: str, message: str) -> NoReturn:
64
+ """Print a failed action's details and exit with a non-zero code."""
65
+ err_console.print(f"Action failed: {header}")
66
+ err_console.print(f"Error: {message}")
67
+ raise typer.Exit(code=1)
68
+
69
+
70
+ def confirm_or_exit(message: str, assume_yes: bool) -> None:
71
+ """Ask for confirmation; abort the command when declined."""
72
+ if assume_yes:
73
+ return
74
+ if not typer.confirm(message):
75
+ console.print("Aborted.")
76
+ raise typer.Exit(code=0)
77
+
78
+
79
+ def exit_with_error(exc: Exception) -> NoReturn:
80
+ """Render an error nicely on stderr and exit with a non-zero code."""
81
+ if isinstance(exc, APIError):
82
+ err_console.print(f"Error: {exc.message} [dim]({exc.code}, HTTP {exc.status_code})[/dim]")
83
+ if exc.suggestion:
84
+ err_console.print(f"Suggestion: {exc.suggestion}", style="yellow")
85
+ if exc.status_code == 401:
86
+ err_console.print(
87
+ "Hint: check your API key with `lambda-cloud login`.",
88
+ style="dim",
89
+ )
90
+ elif isinstance(exc, LambdaCloudError):
91
+ err_console.print(f"Error: {exc}")
92
+ else: # unexpected bug: keep the message but stay polite
93
+ err_console.print(f"Unexpected error: {exc}")
94
+ raise typer.Exit(code=1)
@@ -0,0 +1,62 @@
1
+ """Lightweight result history stored under the config directory.
2
+
3
+ Best-effort: history write failures never break a command.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ from ...core.config import config_dir
13
+
14
+ _HISTORY_FILE = "history.json"
15
+ _MAX_ENTRIES = 25
16
+
17
+
18
+ def _history_path():
19
+ return config_dir() / _HISTORY_FILE
20
+
21
+
22
+ def _default_entry(kind: str, data: dict[str, Any]) -> dict[str, Any]:
23
+ return {
24
+ "recorded_at": datetime.now(timezone.utc).isoformat(),
25
+ "kind": kind,
26
+ "data": data,
27
+ "result_preview": str(data)[:80],
28
+ }
29
+
30
+
31
+ def record_result(kind: str, data: dict[str, Any]) -> None:
32
+ """Append a result entry to the local history file (best-effort)."""
33
+ path = _history_path()
34
+ try:
35
+ history = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else []
36
+ except (OSError, ValueError):
37
+ history = []
38
+ history.append(_default_entry(kind, data))
39
+ history = history[-_MAX_ENTRIES:]
40
+ try:
41
+ path.parent.mkdir(parents=True, exist_ok=True)
42
+ path.write_text(
43
+ json.dumps(history, indent=2, ensure_ascii=False) + "\n",
44
+ encoding="utf-8",
45
+ )
46
+ except OSError:
47
+ pass
48
+
49
+
50
+ def last_result(kind: str) -> dict[str, Any] | None:
51
+ """Return the most recent recorded result for ``kind``, if any."""
52
+ path = _history_path()
53
+ if not path.is_file():
54
+ return None
55
+ try:
56
+ history = json.loads(path.read_text(encoding="utf-8"))
57
+ except (OSError, ValueError):
58
+ return None
59
+ for entry in reversed(history):
60
+ if entry.get("kind") == kind:
61
+ return entry
62
+ return None
@@ -0,0 +1,258 @@
1
+ """Rich table renderers for every API resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from rich.table import Table
8
+
9
+ from ...mngr.models import (
10
+ AuditEvent,
11
+ Filesystem,
12
+ FirewallRule,
13
+ FirewallRuleset,
14
+ Image,
15
+ Instance,
16
+ InstanceStatus,
17
+ InstanceTypeOffer,
18
+ Region,
19
+ SSHKey,
20
+ )
21
+
22
+ _STATUS_STYLES = {
23
+ InstanceStatus.ACTIVE: "green",
24
+ InstanceStatus.BOOTING: "cyan",
25
+ InstanceStatus.UNHEALTHY: "yellow",
26
+ InstanceStatus.TERMINATING: "magenta",
27
+ InstanceStatus.TERMINATED: "red",
28
+ InstanceStatus.PREEMPTED: "red",
29
+ }
30
+
31
+
32
+ def _styled_status(status: InstanceStatus) -> str:
33
+ style = _STATUS_STYLES.get(status, "default")
34
+ return f"[{style}]{status.value}[/{style}]"
35
+
36
+
37
+ def _short(value: str | None, length: int = 40) -> str:
38
+ if not value:
39
+ return "-"
40
+ return value if len(value) <= length else value[: length - 1] + "…"
41
+
42
+
43
+ def _fmt_dt(value: datetime | str | None) -> str:
44
+ if value is None:
45
+ return "-"
46
+ if isinstance(value, str):
47
+ return value[:19].replace("T", " ")
48
+ return value.strftime("%Y-%m-%d %H:%M:%S")
49
+
50
+
51
+ def _fmt_bytes(value: int | None) -> str:
52
+ if value is None:
53
+ return "-"
54
+ size = float(value)
55
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
56
+ if size < 1024 or unit == "TiB":
57
+ return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} B"
58
+ size /= 1024
59
+ return "-"
60
+
61
+
62
+ def instance_detail_table(instance: Instance) -> Table:
63
+ """Key/value detail view for a single instance."""
64
+ table = Table(title=f"Instance {instance.id}", show_header=False)
65
+ table.add_column("FIELD", style="bold cyan")
66
+ table.add_column("VALUE")
67
+
68
+ rows: list[tuple[str, str]] = [
69
+ ("ID", instance.id),
70
+ ("Name", instance.name or "-"),
71
+ ("Status", _styled_status(instance.status)),
72
+ ("Type", instance.instance_type.name if instance.instance_type else "-"),
73
+ ("Region", instance.region.name if instance.region else "-"),
74
+ ("IP", instance.ip or "-"),
75
+ ("Private IP", instance.private_ip or "-"),
76
+ ("Hostname", instance.hostname or "-"),
77
+ ("SSH keys", ", ".join(instance.ssh_key_names) or "-"),
78
+ ("Filesystems", ", ".join(instance.file_system_names) or "-"),
79
+ ("Jupyter", instance.jupyter_url or "-"),
80
+ (
81
+ "Tags",
82
+ ", ".join(f"{tag.key}={tag.value}" for tag in instance.tags or []) or "-",
83
+ ),
84
+ ]
85
+ if instance.instance_type:
86
+ rows.append(
87
+ ("Price/hour", f"${instance.instance_type.price_per_hour:.2f}"),
88
+ )
89
+ for field, value in rows:
90
+ table.add_row(field, value)
91
+ return table
92
+
93
+
94
+ def instances_table(instances: list[Instance]) -> Table:
95
+ table = Table(title="Instances")
96
+ table.add_column("ID", style="cyan")
97
+ table.add_column("NAME")
98
+ table.add_column("TYPE")
99
+ table.add_column("REGION")
100
+ table.add_column("STATUS")
101
+ table.add_column("IP")
102
+ table.add_column("PRICE/H", justify="right")
103
+ for instance in instances:
104
+ price = f"${instance.instance_type.price_per_hour:.2f}" if instance.instance_type else "-"
105
+ table.add_row(
106
+ instance.id,
107
+ instance.name or "-",
108
+ instance.instance_type.name if instance.instance_type else "-",
109
+ instance.region.name if instance.region else "-",
110
+ _styled_status(instance.status),
111
+ instance.ip or "-",
112
+ price,
113
+ )
114
+ return table
115
+
116
+
117
+ def instance_types_table(offers: list[InstanceTypeOffer]) -> Table:
118
+ table = Table(title="Instance types")
119
+ table.add_column("NAME", style="cyan")
120
+ table.add_column("DESCRIPTION")
121
+ table.add_column("GPUS", justify="right")
122
+ table.add_column("VCPUS", justify="right")
123
+ table.add_column("MEM (GiB)", justify="right")
124
+ table.add_column("STORAGE (GiB)", justify="right")
125
+ table.add_column("PRICE/H", justify="right")
126
+ table.add_column("REGIONS AVAILABLE")
127
+ for offer in offers:
128
+ instance_type = offer.instance_type
129
+ regions = ", ".join(r.name for r in offer.regions_with_capacity_available) or "-"
130
+ table.add_row(
131
+ instance_type.name,
132
+ _short(instance_type.description, 32),
133
+ str(instance_type.specs.gpus),
134
+ str(instance_type.specs.vcpus),
135
+ str(instance_type.specs.memory_gib),
136
+ str(instance_type.specs.storage_gib),
137
+ f"${instance_type.price_per_hour:.2f}",
138
+ regions,
139
+ )
140
+ return table
141
+
142
+
143
+ def ssh_keys_table(keys: list[SSHKey]) -> Table:
144
+ table = Table(title="SSH keys")
145
+ table.add_column("ID", style="cyan")
146
+ table.add_column("NAME")
147
+ table.add_column("PUBLIC KEY")
148
+ for key in keys:
149
+ table.add_row(key.id, key.name, _short(key.public_key, 60))
150
+ return table
151
+
152
+
153
+ def filesystems_table(filesystems: list[Filesystem]) -> Table:
154
+ table = Table(title="Filesystems")
155
+ table.add_column("ID", style="cyan")
156
+ table.add_column("NAME")
157
+ table.add_column("REGION")
158
+ table.add_column("MOUNT POINT")
159
+ table.add_column("USED", justify="right")
160
+ table.add_column("IN USE")
161
+ table.add_column("CREATED")
162
+ for filesystem in filesystems:
163
+ table.add_row(
164
+ filesystem.id,
165
+ filesystem.name,
166
+ filesystem.region.name if filesystem.region else "-",
167
+ filesystem.mount_point,
168
+ _fmt_bytes(filesystem.bytes_used),
169
+ "yes" if filesystem.is_in_use else "no",
170
+ _fmt_dt(filesystem.created),
171
+ )
172
+ return table
173
+
174
+
175
+ def images_table(images: list[Image]) -> Table:
176
+ table = Table(title="Images")
177
+ table.add_column("ID", style="cyan")
178
+ table.add_column("NAME")
179
+ table.add_column("FAMILY")
180
+ table.add_column("VERSION")
181
+ table.add_column("ARCH")
182
+ table.add_column("REGION")
183
+ table.add_column("UPDATED")
184
+ for image in images:
185
+ table.add_row(
186
+ image.id,
187
+ _short(image.name, 40),
188
+ image.family,
189
+ image.version,
190
+ image.architecture,
191
+ image.region.name if image.region else "-",
192
+ _fmt_dt(image.updated_time),
193
+ )
194
+ return table
195
+
196
+
197
+ def regions_table(regions: list[Region]) -> Table:
198
+ table = Table(title="Regions")
199
+ table.add_column("NAME", style="cyan")
200
+ table.add_column("DESCRIPTION")
201
+ for region in regions:
202
+ table.add_row(region.name, region.description)
203
+ return table
204
+
205
+
206
+ def firewall_rules_table(rules: list[FirewallRule]) -> Table:
207
+ table = Table()
208
+ table.add_column("PROTOCOL", style="cyan")
209
+ table.add_column("PORTS")
210
+ table.add_column("SOURCE")
211
+ table.add_column("DESCRIPTION")
212
+ for rule in rules:
213
+ table.add_row(
214
+ rule.protocol.value, rule.ports_display, rule.source_network, rule.description
215
+ )
216
+ return table
217
+
218
+
219
+ def rulesets_table(rulesets: list[FirewallRuleset]) -> Table:
220
+ table = Table(title="Firewall rulesets")
221
+ table.add_column("ID", style="cyan")
222
+ table.add_column("NAME")
223
+ table.add_column("REGION")
224
+ table.add_column("RULES", justify="right")
225
+ table.add_column("INSTANCES", justify="right")
226
+ table.add_column("CREATED")
227
+ for ruleset in rulesets:
228
+ table.add_row(
229
+ ruleset.id,
230
+ ruleset.name,
231
+ ruleset.region.name if ruleset.region else "-",
232
+ str(len(ruleset.rules)),
233
+ str(len(ruleset.instance_ids)),
234
+ _fmt_dt(ruleset.created),
235
+ )
236
+ return table
237
+
238
+
239
+ def audit_events_table(events: list[AuditEvent]) -> Table:
240
+ table = Table(title="Audit events")
241
+ table.add_column("TIME")
242
+ table.add_column("ACTION", style="cyan")
243
+ table.add_column("RESOURCE")
244
+ table.add_column("ACTOR")
245
+ table.add_column("SURFACE")
246
+ table.add_column("RESULT")
247
+ for event in events:
248
+ result = event.result.status if event.result else "-"
249
+ styled = f"[green]{result}[/green]" if result == "success" else result
250
+ table.add_row(
251
+ _fmt_dt(event.event_time),
252
+ event.action,
253
+ event.resource_name,
254
+ event.actor_email or event.actor_display_name or "-",
255
+ event.surface or "-",
256
+ styled,
257
+ )
258
+ return table
@@ -0,0 +1 @@
1
+ """Core foundation: shared configuration, error types, standards."""
@@ -0,0 +1,125 @@
1
+ """Local configuration and API key resolution.
2
+
3
+ Resolution order for the API key:
4
+
5
+ 1. ``--api-key`` command-line flag
6
+ 2. ``LAMBDA_API_KEY`` environment variable
7
+ 3. Config file written by ``lambda-cloud login``
8
+
9
+ The config directory is ``$LAMBDA_CLOUD_CONFIG_DIR`` if set, otherwise
10
+ ``$XDG_CONFIG_HOME/lambda-cloud`` (defaulting to ``~/.config/lambda-cloud``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import stat
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from .errors import ConfigError
22
+
23
+ API_KEY_ENV_VAR = "LAMBDA_API_KEY"
24
+ CONFIG_DIR_ENV_VAR = "LAMBDA_CLOUD_CONFIG_DIR"
25
+ _CONFIG_FILE_NAME = "config.json"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class StoredConfig:
30
+ """Content of the on-disk config file."""
31
+
32
+ api_key: str
33
+
34
+
35
+ def config_dir() -> Path:
36
+ """Return the directory where lambda-cloud stores its configuration."""
37
+ override = os.environ.get(CONFIG_DIR_ENV_VAR)
38
+ if override:
39
+ return Path(override).expanduser()
40
+ xdg_home = os.environ.get("XDG_CONFIG_HOME")
41
+ base = Path(xdg_home).expanduser() if xdg_home else Path.home() / ".config"
42
+ return base / "lambda-cloud"
43
+
44
+
45
+ def config_path() -> Path:
46
+ """Return the path of the config file."""
47
+ return config_dir() / _CONFIG_FILE_NAME
48
+
49
+
50
+ def load_stored_config() -> StoredConfig | None:
51
+ """Load the config file, returning ``None`` if it does not exist."""
52
+ path = config_path()
53
+ if not path.is_file():
54
+ return None
55
+ try:
56
+ raw = json.loads(path.read_text(encoding="utf-8"))
57
+ except (ValueError, OSError) as exc:
58
+ raise ConfigError(f"Invalid config file {path}: {exc}") from exc
59
+ api_key = raw.get("api_key")
60
+ if not api_key:
61
+ raise ConfigError(f"Config file {path} does not contain an API key.")
62
+ return StoredConfig(api_key=api_key)
63
+
64
+
65
+ def write_secret_file(path: Path, content: str) -> None:
66
+ """Write ``content`` to ``path`` with owner-only permissions (0600)."""
67
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
68
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
69
+ handle.write(content)
70
+
71
+
72
+ def save_api_key(api_key: str) -> Path:
73
+ """Persist the API key with owner-only permissions (0600)."""
74
+ directory = config_dir()
75
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
76
+ path = config_path()
77
+ write_secret_file(path, json.dumps({"api_key": api_key}) + "\n")
78
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
79
+ return path
80
+
81
+
82
+ def delete_stored_config() -> bool:
83
+ """Delete the config file. Returns ``True`` if a file was removed."""
84
+ path = config_path()
85
+ if path.is_file():
86
+ path.unlink()
87
+ return True
88
+ return False
89
+
90
+
91
+ def resolve_api_key(flag_value: str | None = None) -> str:
92
+ """Resolve the API key from flag, environment, then config file."""
93
+ if flag_value:
94
+ return flag_value
95
+ env_value = os.environ.get(API_KEY_ENV_VAR)
96
+ if env_value:
97
+ return env_value
98
+ stored = load_stored_config()
99
+ if stored:
100
+ return stored.api_key
101
+ raise ConfigError(
102
+ "No API key configured. Run `lambda-cloud login`, "
103
+ f"set the {API_KEY_ENV_VAR} environment variable, "
104
+ "or pass --api-key."
105
+ )
106
+
107
+
108
+ def mask_api_key(api_key: str) -> str:
109
+ """Return a masked representation of an API key, safe to display."""
110
+ if len(api_key) <= 8:
111
+ return "…"
112
+ return f"{api_key[:8]}…"
113
+
114
+
115
+ def describe_api_key_source(flag_value: str | None = None) -> tuple[str, str] | None:
116
+ """Return ``(source, api_key)`` describing where the key comes from."""
117
+ if flag_value:
118
+ return "--api-key flag", flag_value
119
+ env_value = os.environ.get(API_KEY_ENV_VAR)
120
+ if env_value:
121
+ return f"{API_KEY_ENV_VAR} environment variable", env_value
122
+ stored = load_stored_config()
123
+ if stored:
124
+ return str(config_path()), stored.api_key
125
+ return None
@@ -0,0 +1,58 @@
1
+ """Exceptions raised by lambda-cloud."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+
7
+
8
+ class LambdaCloudError(Exception):
9
+ """Base class for all lambda-cloud errors."""
10
+
11
+
12
+ class ConfigError(LambdaCloudError):
13
+ """Local configuration is missing or invalid."""
14
+
15
+
16
+ class APIError(LambdaCloudError):
17
+ """The Lambda Cloud API returned an error response.
18
+
19
+ Attributes:
20
+ status_code: HTTP status code of the response.
21
+ code: Machine-readable error code returned by the API
22
+ (e.g. ``global/invalid-api-key``).
23
+ message: Human-readable explanation returned by the API.
24
+ suggestion: Optional hint returned by the API on how to fix the error.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ status_code: int,
30
+ code: str,
31
+ message: str,
32
+ suggestion: str | None = None,
33
+ ) -> None:
34
+ super().__init__(f"{code}: {message}")
35
+ self.status_code = status_code
36
+ self.code = code
37
+ self.message = message
38
+ self.suggestion = suggestion
39
+
40
+ @classmethod
41
+ def from_response(cls, response: httpx.Response) -> APIError:
42
+ """Build an :class:`APIError` from a failed API response."""
43
+ try:
44
+ payload = response.json()
45
+ except ValueError:
46
+ return cls(
47
+ response.status_code,
48
+ "http/unexpected-response",
49
+ f"HTTP {response.status_code}: non-JSON response body",
50
+ )
51
+
52
+ error = payload.get("error") or {}
53
+ return cls(
54
+ status_code=response.status_code,
55
+ code=error.get("code", "unknown"),
56
+ message=error.get("message", response.text or "Unknown error"),
57
+ suggestion=error.get("suggestion"),
58
+ )
@@ -0,0 +1,5 @@
1
+ """Manager layer: resource models and state helpers."""
2
+
3
+ from . import models
4
+
5
+ __all__ = ["models"]