solwyn-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,449 @@
1
+ """Project creation and metadata inspection commands."""
2
+
3
+ import math
4
+ from typing import Annotated
5
+ from urllib.parse import quote
6
+
7
+ import typer
8
+ from pydantic import (
9
+ AwareDatetime,
10
+ BaseModel,
11
+ ConfigDict,
12
+ Field,
13
+ SecretStr,
14
+ TypeAdapter,
15
+ ValidationError,
16
+ field_serializer,
17
+ field_validator,
18
+ model_validator,
19
+ )
20
+
21
+ from solwyn_cli._vendor.validation import validate_project_key_format
22
+ from solwyn_cli._vendor.wire import BudgetMode, BudgetPeriod, ProjectCreateRequest
23
+ from solwyn_cli.auth import (
24
+ AuthError,
25
+ CredentialStorageError,
26
+ JwtCredential,
27
+ ProjectKeyCredential,
28
+ resolve_credential,
29
+ store_project_key,
30
+ )
31
+ from solwyn_cli.client import SolwynClient
32
+ from solwyn_cli.commands._shared import exit_with_error, required_credential
33
+ from solwyn_cli.errors import ApiError
34
+ from solwyn_cli.exit_codes import ErrorSemantic
35
+ from solwyn_cli.help import help_contract
36
+ from solwyn_cli.output import OneTimeSecretPanel, OutputError, TableData
37
+ from solwyn_cli.runtime import command_context, output_engine
38
+
39
+
40
+ app = typer.Typer(
41
+ help="Inspect Solwyn projects.",
42
+ epilog=help_contract(
43
+ endpoint="composite router for project subcommands",
44
+ auth="varies by subcommand",
45
+ exits="0 success; 2 usage",
46
+ schema="varies by subcommand",
47
+ ),
48
+ )
49
+
50
+
51
+ class _Project(BaseModel):
52
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
53
+
54
+ id: str
55
+ name: str
56
+ budget_limit: float
57
+ budget_period: str
58
+ budget_mode: str
59
+ created_at: AwareDatetime
60
+
61
+
62
+ class _ProjectsListData(BaseModel):
63
+ model_config = ConfigDict(extra="forbid")
64
+
65
+ projects: list[_Project]
66
+
67
+
68
+ _PROJECTS = TypeAdapter(list[_Project])
69
+ Project = _Project
70
+ ProjectsListData = _ProjectsListData
71
+
72
+
73
+ class _CreatedProject(BaseModel):
74
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
75
+
76
+ id: str = Field(min_length=1)
77
+ name: str = Field(min_length=1)
78
+ budget_limit: float = Field(gt=0, allow_inf_nan=False)
79
+ budget_period: BudgetPeriod
80
+ budget_mode: BudgetMode
81
+ created_at: AwareDatetime
82
+ key_prefix: str = Field(pattern=r"^sk_proj_[a-f0-9]{16}$")
83
+
84
+
85
+ class _ProjectCreateResponse(_CreatedProject):
86
+ key: SecretStr
87
+
88
+ @field_validator("key")
89
+ @classmethod
90
+ def validate_key(cls, value: SecretStr) -> SecretStr:
91
+ validate_project_key_format(value.get_secret_value())
92
+ return value
93
+
94
+ @model_validator(mode="after")
95
+ def validate_key_prefix(self) -> "_ProjectCreateResponse":
96
+ raw_key = self.key.get_secret_value()
97
+ if self.key_prefix != raw_key[:24]:
98
+ raise ValueError("Invalid project key prefix")
99
+ return self
100
+
101
+
102
+ ProjectCreateResponse = _ProjectCreateResponse
103
+
104
+
105
+ def fetch_projects(*, client: SolwynClient) -> list[Project]:
106
+ """Fetch and validate the projects visible to a JWT credential."""
107
+ response = client.request("GET", "/api/v1/projects")
108
+ return _PROJECTS.validate_python(response.json())
109
+
110
+
111
+ def request_project_creation(
112
+ *,
113
+ client: SolwynClient,
114
+ request: ProjectCreateRequest,
115
+ ) -> ProjectCreateResponse:
116
+ """Create and validate a project through the no-replay wire boundary."""
117
+ response = client.request(
118
+ "POST",
119
+ "/api/v1/projects",
120
+ json=request.model_dump(mode="json"),
121
+ retry_rate_limit=False,
122
+ retry_auth=False,
123
+ )
124
+ if response.status_code != 201:
125
+ raise ValueError("Project creation did not return 201")
126
+ return _ProjectCreateResponse.model_validate(response.json())
127
+
128
+
129
+ class _ProjectCreateDisplayData(_CreatedProject):
130
+ key: SecretStr
131
+
132
+ @field_serializer("key")
133
+ def serialize_key(self, value: SecretStr) -> str:
134
+ return value.get_secret_value()
135
+
136
+
137
+ class _ProjectCreateStoredData(_CreatedProject):
138
+ stored: bool = True
139
+
140
+
141
+ def _date_time(value: AwareDatetime) -> str:
142
+ return value.isoformat().replace("+00:00", "Z")
143
+
144
+
145
+ def _list_table(data: _ProjectsListData) -> TableData:
146
+ return TableData(
147
+ columns=("ID", "Name", "Budget", "Period", "Mode", "Created"),
148
+ rows=tuple(
149
+ (
150
+ project.id,
151
+ project.name,
152
+ f"{project.budget_limit:.2f}",
153
+ project.budget_period,
154
+ project.budget_mode,
155
+ _date_time(project.created_at),
156
+ )
157
+ for project in data.projects
158
+ ),
159
+ right_align=("Budget", "Created"),
160
+ )
161
+
162
+
163
+ def _show_table(project: _Project) -> TableData:
164
+ return TableData(
165
+ columns=("Field", "Value"),
166
+ rows=(
167
+ ("ID", project.id),
168
+ ("Name", project.name),
169
+ ("Budget limit", f"{project.budget_limit:.2f}"),
170
+ ("Budget period", project.budget_period),
171
+ ("Budget mode", project.budget_mode),
172
+ ("Created", _date_time(project.created_at)),
173
+ ),
174
+ right_align=("Value",),
175
+ )
176
+
177
+
178
+ def _create_table(project: _CreatedProject, *, stored: bool = False) -> TableData:
179
+ rows = (
180
+ ("ID", project.id),
181
+ ("Name", project.name),
182
+ ("Budget limit", f"{project.budget_limit:.2f}"),
183
+ ("Budget period", project.budget_period),
184
+ ("Budget mode", project.budget_mode),
185
+ ("Created", _date_time(project.created_at)),
186
+ ("Key prefix", project.key_prefix),
187
+ )
188
+ if stored:
189
+ rows = (*rows, ("Stored", "true"))
190
+ return TableData(
191
+ columns=("Field", "Value"),
192
+ rows=rows,
193
+ right_align=("Value",),
194
+ )
195
+
196
+
197
+ def _jwt_credential_for_create(context: typer.Context) -> JwtCredential:
198
+ credential = required_credential(context, resolve=resolve_credential)
199
+ if isinstance(credential, ProjectKeyCredential):
200
+ hint = (
201
+ "Unset SOLWYN_API_KEY, run solwyn login, then retry projects create."
202
+ if credential.source == "environment"
203
+ else "Run solwyn logout, then solwyn login, and retry projects create."
204
+ )
205
+ exit_with_error(
206
+ context,
207
+ OutputError(
208
+ code="authentication_required",
209
+ message="User login is required to create projects.",
210
+ hint=hint,
211
+ ),
212
+ semantic=ErrorSemantic.AUTH,
213
+ )
214
+ return credential
215
+
216
+
217
+ @app.command(
218
+ "create",
219
+ epilog=help_contract(
220
+ endpoint="POST /api/v1/projects",
221
+ auth="JWT",
222
+ exits="0 success; 1 generic/API; 2 usage; 3 auth",
223
+ schema="solwyn.projects-create.v1",
224
+ guidance=(
225
+ "The new project key is shown exactly once or stored directly in the OS keychain.",
226
+ ),
227
+ ),
228
+ )
229
+ def create_project(
230
+ context: typer.Context,
231
+ name: Annotated[str, typer.Option("--name", help="Name for the new project.")],
232
+ budget: Annotated[
233
+ float,
234
+ typer.Option("--budget", help="Initial project budget in USD."),
235
+ ] = 100.0,
236
+ period: Annotated[
237
+ BudgetPeriod,
238
+ typer.Option("--period", help="Initial budget period."),
239
+ ] = BudgetPeriod.MONTHLY,
240
+ mode: Annotated[
241
+ BudgetMode,
242
+ typer.Option("--mode", help="Initial budget enforcement mode."),
243
+ ] = BudgetMode.ALERT_ONLY,
244
+ store_key: Annotated[
245
+ bool,
246
+ typer.Option("--store-key", help="Store the new key in the OS keychain."),
247
+ ] = False,
248
+ ) -> None:
249
+ """Create a project and capture its first API key exactly once."""
250
+ if not name.strip() or not math.isfinite(budget) or budget <= 0:
251
+ exit_with_error(
252
+ context,
253
+ OutputError(
254
+ code="invalid_project_create_request",
255
+ message="Project name and budget are invalid.",
256
+ hint="Provide a nonblank name and a finite positive budget.",
257
+ ),
258
+ semantic=ErrorSemantic.USAGE,
259
+ )
260
+ try:
261
+ request = ProjectCreateRequest(
262
+ name=name.strip(),
263
+ budget_limit=budget,
264
+ budget_period=period.value,
265
+ budget_mode=mode.value,
266
+ )
267
+ except ValidationError:
268
+ exit_with_error(
269
+ context,
270
+ OutputError(
271
+ code="invalid_project_create_request",
272
+ message="The project creation request is invalid.",
273
+ hint="Check the project name and initial budget settings.",
274
+ ),
275
+ semantic=ErrorSemantic.USAGE,
276
+ )
277
+
278
+ credential = _jwt_credential_for_create(context)
279
+ runtime = command_context(context)
280
+ try:
281
+ with SolwynClient(api_url=runtime.api_url, credential=credential) as client:
282
+ created = request_project_creation(client=client, request=request)
283
+ except ApiError as error:
284
+ exit_with_error(context, error)
285
+ except AuthError as error:
286
+ exit_with_error(
287
+ context,
288
+ OutputError(code="authentication_failed", message=str(error)),
289
+ semantic=ErrorSemantic.AUTH,
290
+ )
291
+ except (ValidationError, ValueError, TypeError):
292
+ exit_with_error(
293
+ context,
294
+ OutputError(
295
+ code="invalid_projects_response",
296
+ message="The project creation response was invalid.",
297
+ ),
298
+ )
299
+
300
+ raw_key = created.key.get_secret_value()
301
+ if store_key:
302
+ try:
303
+ store_project_key(raw_key)
304
+ except CredentialStorageError as error:
305
+ exit_with_error(
306
+ context,
307
+ OutputError(
308
+ code="credential_storage_failed",
309
+ message=str(error),
310
+ hint="Retry after configuring an operating-system keychain.",
311
+ ),
312
+ semantic=ErrorSemantic.AUTH,
313
+ )
314
+ stored_data = _ProjectCreateStoredData.model_validate(created.model_dump())
315
+ output_engine(context).write_success(
316
+ schema="solwyn.projects-create.v1",
317
+ data=stored_data,
318
+ table=_create_table(stored_data, stored=True),
319
+ )
320
+ return
321
+
322
+ display_data = _ProjectCreateDisplayData.model_validate(created.model_dump())
323
+ output_engine(context).write_success(
324
+ schema="solwyn.projects-create.v1",
325
+ data=display_data,
326
+ table=_create_table(display_data),
327
+ one_time_secret=OneTimeSecretPanel(
328
+ title="One-time project key",
329
+ value=created.key,
330
+ ),
331
+ )
332
+
333
+
334
+ @app.command(
335
+ "list",
336
+ epilog=help_contract(
337
+ endpoint="GET /api/v1/projects",
338
+ auth="JWT",
339
+ exits="0 success; 1 generic/API; 2 usage; 3 auth",
340
+ schema="solwyn.projects-list.v1",
341
+ ),
342
+ )
343
+ def list_projects(context: typer.Context) -> None:
344
+ """List projects available to the logged-in user."""
345
+ credential = required_credential(context, resolve=resolve_credential)
346
+ if isinstance(credential, ProjectKeyCredential):
347
+ hint = (
348
+ "Unset SOLWYN_API_KEY, run solwyn login, then retry projects list."
349
+ if credential.source == "environment"
350
+ else "Run solwyn logout, then solwyn login, and retry projects list."
351
+ )
352
+ exit_with_error(
353
+ context,
354
+ OutputError(
355
+ code="authentication_required",
356
+ message="User login is required to list projects.",
357
+ hint=hint,
358
+ ),
359
+ semantic=ErrorSemantic.AUTH,
360
+ )
361
+
362
+ runtime = command_context(context)
363
+ try:
364
+ with SolwynClient(api_url=runtime.api_url, credential=credential) as client:
365
+ data = _ProjectsListData(projects=fetch_projects(client=client))
366
+ except ApiError as error:
367
+ exit_with_error(context, error)
368
+ except AuthError as error:
369
+ exit_with_error(
370
+ context,
371
+ OutputError(code="authentication_failed", message=str(error)),
372
+ semantic=ErrorSemantic.AUTH,
373
+ )
374
+ except (ValidationError, ValueError, TypeError):
375
+ exit_with_error(
376
+ context,
377
+ OutputError(
378
+ code="invalid_projects_response",
379
+ message="The projects response was invalid.",
380
+ ),
381
+ )
382
+
383
+ output_engine(context).write_success(
384
+ schema="solwyn.projects-list.v1",
385
+ data=data,
386
+ table=_list_table(data),
387
+ )
388
+
389
+
390
+ @app.command(
391
+ "show",
392
+ epilog=help_contract(
393
+ endpoint="GET /api/v1/projects/{project}",
394
+ auth="any",
395
+ exits="0 success; 1 generic/API; 2 usage; 3 auth; 5 not found",
396
+ schema="solwyn.projects-show.v1",
397
+ ),
398
+ )
399
+ def show_project(context: typer.Context) -> None:
400
+ """Show the project selected by the global project context."""
401
+ runtime = command_context(context)
402
+ if runtime.project is None:
403
+ exit_with_error(
404
+ context,
405
+ OutputError(
406
+ code="project_required",
407
+ message="A project is required.",
408
+ hint=(
409
+ "Choose a project with --project, SOLWYN_PROJECT_ID, "
410
+ "or config default_project."
411
+ ),
412
+ ),
413
+ semantic=ErrorSemantic.USAGE,
414
+ )
415
+
416
+ credential = required_credential(context, resolve=resolve_credential)
417
+ project_id = runtime.project
418
+ project_path = quote(project_id, safe="")
419
+ try:
420
+ with SolwynClient(
421
+ api_url=runtime.api_url,
422
+ credential=credential,
423
+ ) as client:
424
+ response = client.request("GET", f"/api/v1/projects/{project_path}")
425
+ project = _Project.model_validate(response.json())
426
+ if isinstance(credential, ProjectKeyCredential) and project.id != project_id:
427
+ raise ValueError("Key-scoped project response did not match the request.")
428
+ except ApiError as error:
429
+ exit_with_error(context, error)
430
+ except AuthError as error:
431
+ exit_with_error(
432
+ context,
433
+ OutputError(code="authentication_failed", message=str(error)),
434
+ semantic=ErrorSemantic.AUTH,
435
+ )
436
+ except (ValidationError, ValueError, TypeError):
437
+ exit_with_error(
438
+ context,
439
+ OutputError(
440
+ code="invalid_projects_response",
441
+ message="The projects response was invalid.",
442
+ ),
443
+ )
444
+
445
+ output_engine(context).write_success(
446
+ schema="solwyn.projects-show.v1",
447
+ data=project,
448
+ table=_show_table(project),
449
+ )
@@ -0,0 +1,173 @@
1
+ """At-a-glance project status command."""
2
+
3
+ from typing import Literal
4
+ from urllib.parse import quote
5
+
6
+ import typer
7
+ from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
8
+
9
+ from solwyn_cli.auth import AuthError, resolve_credential
10
+ from solwyn_cli.client import SolwynClient
11
+ from solwyn_cli.commands._shared import exit_with_error, required_credential
12
+ from solwyn_cli.errors import ApiError
13
+ from solwyn_cli.exit_codes import ErrorSemantic
14
+ from solwyn_cli.help import help_contract
15
+ from solwyn_cli.output import OutputError, TableData
16
+ from solwyn_cli.runtime import command_context, output_engine
17
+
18
+
19
+ class _BudgetSummary(BaseModel):
20
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
21
+
22
+ budget_limit: float
23
+ current_usage: float
24
+ remaining_budget: float
25
+ budget_period: Literal["daily", "weekly", "monthly"]
26
+ budget_mode: Literal["alert_only", "hard_deny"]
27
+ period_date: str
28
+ utilization_pct: float
29
+
30
+
31
+ STATUS_HELP = help_contract(
32
+ endpoint=(
33
+ "GET /api/v1/projects/{project}/budget and "
34
+ "GET /api/v1/projects/{project}/providers"
35
+ ),
36
+ auth="any",
37
+ exits="0 success; 1 generic/API; 2 usage; 3 auth; 5 not found",
38
+ schema="solwyn.status.v1",
39
+ )
40
+
41
+
42
+ class _BudgetResponse(_BudgetSummary):
43
+ project_id: str
44
+
45
+
46
+ class _ProviderHealth(BaseModel):
47
+ model_config = ConfigDict(extra="ignore", hide_input_in_errors=True)
48
+
49
+ provider: str
50
+ total_calls: int
51
+ success_count: int
52
+ error_count: int
53
+ failover_count: int
54
+ success_rate: float
55
+ avg_latency_ms: float
56
+ circuit_state: str
57
+
58
+
59
+ class _StatusData(BaseModel):
60
+ model_config = ConfigDict(extra="forbid")
61
+
62
+ project_id: str
63
+ budget: _BudgetSummary
64
+ providers: list[_ProviderHealth]
65
+
66
+
67
+ _PROVIDERS = TypeAdapter(list[_ProviderHealth])
68
+ ProjectStatusData = _StatusData
69
+
70
+
71
+ def fetch_project_status(*, client: SolwynClient, project_id: str) -> ProjectStatusData:
72
+ """Read and validate budget plus provider health through one shared client."""
73
+ project = quote(project_id, safe="")
74
+ budget_response = client.request("GET", f"/api/v1/projects/{project}/budget")
75
+ providers_response = client.request("GET", f"/api/v1/projects/{project}/providers")
76
+ budget = _BudgetResponse.model_validate(budget_response.json())
77
+ providers = _PROVIDERS.validate_python(providers_response.json())
78
+ data = _StatusData(
79
+ project_id=budget.project_id,
80
+ budget=_BudgetSummary.model_validate(budget.model_dump()),
81
+ providers=providers,
82
+ )
83
+ if data.project_id != project_id:
84
+ raise ValueError("Status response project did not match the request.")
85
+ return data
86
+
87
+
88
+ def _table(data: _StatusData) -> TableData:
89
+ budget = data.budget
90
+ rows: list[tuple[str, str]] = [
91
+ ("Project", data.project_id),
92
+ ("Budget limit", f"{budget.budget_limit:.2f}"),
93
+ ("Current usage", f"{budget.current_usage:.2f}"),
94
+ ("Remaining budget", f"{budget.remaining_budget:.2f}"),
95
+ (
96
+ "Budget period",
97
+ f"{budget.budget_period} ({budget.period_date})",
98
+ ),
99
+ ("Budget mode", budget.budget_mode),
100
+ ("Utilization", f"{budget.utilization_pct:.2f}%"),
101
+ ]
102
+ for provider in data.providers:
103
+ rows.extend(
104
+ [
105
+ (f"{provider.provider} circuit", provider.circuit_state),
106
+ (
107
+ f"{provider.provider} success",
108
+ f"{provider.success_count} / {provider.total_calls} "
109
+ f"({provider.success_rate:.2f}%)",
110
+ ),
111
+ (
112
+ f"{provider.provider} latency",
113
+ f"{provider.avg_latency_ms:.2f} ms",
114
+ ),
115
+ (
116
+ f"{provider.provider} errors / failovers",
117
+ f"{provider.error_count} / {provider.failover_count}",
118
+ ),
119
+ ]
120
+ )
121
+ return TableData(
122
+ columns=("Field", "Value"),
123
+ rows=tuple(rows),
124
+ right_align=("Value",),
125
+ )
126
+
127
+
128
+ def status(context: typer.Context) -> None:
129
+ """Show project budget and provider health."""
130
+ runtime = command_context(context)
131
+ if runtime.project is None:
132
+ exit_with_error(
133
+ context,
134
+ OutputError(
135
+ code="project_required",
136
+ message="A project is required.",
137
+ hint=(
138
+ "Choose a project with --project, SOLWYN_PROJECT_ID, "
139
+ "or config default_project."
140
+ ),
141
+ ),
142
+ semantic=ErrorSemantic.USAGE,
143
+ )
144
+
145
+ credential = required_credential(context, resolve=resolve_credential)
146
+ try:
147
+ with SolwynClient(
148
+ api_url=runtime.api_url,
149
+ credential=credential,
150
+ ) as client:
151
+ data = fetch_project_status(client=client, project_id=runtime.project)
152
+ except ApiError as error:
153
+ exit_with_error(context, error)
154
+ except AuthError as error:
155
+ exit_with_error(
156
+ context,
157
+ OutputError(code="authentication_failed", message=str(error)),
158
+ semantic=ErrorSemantic.AUTH,
159
+ )
160
+ except (ValidationError, ValueError, TypeError):
161
+ exit_with_error(
162
+ context,
163
+ OutputError(
164
+ code="invalid_status_response",
165
+ message="The status response was invalid.",
166
+ ),
167
+ )
168
+
169
+ output_engine(context).write_success(
170
+ schema="solwyn.status.v1",
171
+ data=data,
172
+ table=_table(data),
173
+ )