pragmatiks-cli 0.5.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,1165 @@
1
+ """Provider management commands.
2
+
3
+ Commands for scaffolding, syncing, and pushing Pragmatiks providers to the platform.
4
+ """
5
+
6
+ import io
7
+ import os
8
+ import tarfile
9
+ import time
10
+ import tomllib
11
+ from pathlib import Path
12
+ from typing import Annotated
13
+
14
+ import copier
15
+ import httpx
16
+ import typer
17
+ from pragma_sdk import (
18
+ BuildResult,
19
+ BuildStatus,
20
+ Config,
21
+ DeploymentStatus,
22
+ PragmaClient,
23
+ ProviderDeleteResult,
24
+ ProviderInfo,
25
+ PushResult,
26
+ Resource,
27
+ )
28
+ from pragma_sdk.provider import discover_resources
29
+ from rich.console import Console
30
+ from rich.progress import Progress, SpinnerColumn, TextColumn
31
+ from rich.table import Table
32
+
33
+ from pragma_cli import get_client
34
+
35
+
36
+ app = typer.Typer(help="Provider management commands")
37
+ console = Console()
38
+
39
+ TARBALL_EXCLUDES = {
40
+ ".git",
41
+ "__pycache__",
42
+ ".venv",
43
+ ".env",
44
+ ".pytest_cache",
45
+ ".mypy_cache",
46
+ ".ruff_cache",
47
+ "*.pyc",
48
+ "*.pyo",
49
+ "*.egg-info",
50
+ "dist",
51
+ "build",
52
+ ".tox",
53
+ ".nox",
54
+ }
55
+
56
+ DEFAULT_TEMPLATE_URL = "gh:pragmatiks/provider-template"
57
+ TEMPLATE_PATH_ENV = "PRAGMA_PROVIDER_TEMPLATE"
58
+
59
+ BUILD_POLL_INTERVAL = 2.0
60
+ BUILD_TIMEOUT = 600
61
+
62
+
63
+ def create_tarball(source_dir: Path) -> bytes:
64
+ """Create a gzipped tarball of the provider source directory.
65
+
66
+ Excludes common development artifacts like .git, __pycache__, .venv, etc.
67
+
68
+ Args:
69
+ source_dir: Path to the provider source directory.
70
+
71
+ Returns:
72
+ Gzipped tarball bytes suitable for upload.
73
+ """
74
+ buffer = io.BytesIO()
75
+
76
+ def exclude_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
77
+ """Filter out excluded files and directories.
78
+
79
+ Returns:
80
+ The TarInfo object if included, None if excluded.
81
+ """
82
+ name = tarinfo.name
83
+ parts = Path(name).parts
84
+
85
+ for part in parts:
86
+ if part in TARBALL_EXCLUDES:
87
+ return None
88
+ for pattern in TARBALL_EXCLUDES:
89
+ if pattern.startswith("*") and part.endswith(pattern[1:]):
90
+ return None
91
+ return tarinfo
92
+
93
+ with tarfile.open(fileobj=buffer, mode="w:gz") as tar:
94
+ tar.add(source_dir, arcname=".", filter=exclude_filter)
95
+
96
+ buffer.seek(0)
97
+ return buffer.read()
98
+
99
+
100
+ def get_template_source() -> str:
101
+ """Get the template source path or URL.
102
+
103
+ Priority:
104
+ 1. PRAGMA_PROVIDER_TEMPLATE environment variable
105
+ 2. Local development path (if running from repo)
106
+ 3. Default GitHub URL
107
+
108
+ Returns:
109
+ Template path (local) or URL (GitHub).
110
+ """
111
+ if env_template := os.environ.get(TEMPLATE_PATH_ENV):
112
+ return env_template
113
+
114
+ local_template = Path(__file__).parents[5] / "templates" / "provider"
115
+ if local_template.exists() and (local_template / "copier.yml").exists():
116
+ return str(local_template)
117
+
118
+ return DEFAULT_TEMPLATE_URL
119
+
120
+
121
+ @app.command("list")
122
+ def list_providers():
123
+ """List all deployed providers.
124
+
125
+ Shows providers with their deployment status. Displays:
126
+ - Provider ID
127
+ - Deployed version
128
+ - Status (running/stopped)
129
+ - Last deployed timestamp
130
+
131
+ Example:
132
+ pragma provider list
133
+
134
+ Raises:
135
+ typer.Exit: If authentication is missing or API call fails.
136
+ """
137
+ client = get_client()
138
+
139
+ if client._auth is None:
140
+ console.print("[red]Error:[/red] Authentication required. Run 'pragma auth login' first.")
141
+ raise typer.Exit(1)
142
+
143
+ try:
144
+ with Progress(
145
+ SpinnerColumn(),
146
+ TextColumn("[progress.description]{task.description}"),
147
+ console=console,
148
+ transient=True,
149
+ ) as progress:
150
+ progress.add_task("Fetching providers...", total=None)
151
+ providers = client.list_providers()
152
+ except httpx.HTTPStatusError as e:
153
+ console.print(f"[red]Error:[/red] {e.response.text}")
154
+ raise typer.Exit(1)
155
+ except Exception as e:
156
+ console.print(f"[red]Error:[/red] {e}")
157
+ raise typer.Exit(1)
158
+
159
+ if not providers:
160
+ console.print("[dim]No providers found.[/dim]")
161
+ return
162
+
163
+ _print_providers_table(providers)
164
+
165
+
166
+ def _print_providers_table(providers: list[ProviderInfo]) -> None:
167
+ """Print providers in a formatted table.
168
+
169
+ Args:
170
+ providers: List of ProviderInfo to display.
171
+ """
172
+ table = Table(show_header=True, header_style="bold")
173
+ table.add_column("Provider ID")
174
+ table.add_column("Version")
175
+ table.add_column("Status")
176
+ table.add_column("Last Deployed")
177
+
178
+ for provider in providers:
179
+ status = _format_deployment_status(provider.deployment_status)
180
+ version = provider.current_version or "[dim]never deployed[/dim]"
181
+ updated = (
182
+ provider.updated_at.strftime("%Y-%m-%d %H:%M:%S")
183
+ if provider.updated_at
184
+ else "[dim]-[/dim]"
185
+ )
186
+
187
+ table.add_row(provider.provider_id, version, status, updated)
188
+
189
+ console.print(table)
190
+
191
+
192
+ def _format_deployment_status(status: DeploymentStatus | None) -> str:
193
+ """Format deployment status with color coding.
194
+
195
+ Args:
196
+ status: Deployment status or None if not deployed.
197
+
198
+ Returns:
199
+ Formatted status string with Rich markup.
200
+ """
201
+ if status is None:
202
+ return "[dim]not deployed[/dim]"
203
+
204
+ match status:
205
+ case DeploymentStatus.AVAILABLE:
206
+ return "[green]running[/green]"
207
+ case DeploymentStatus.PROGRESSING:
208
+ return "[yellow]deploying[/yellow]"
209
+ case DeploymentStatus.PENDING:
210
+ return "[yellow]pending[/yellow]"
211
+ case DeploymentStatus.FAILED:
212
+ return "[red]failed[/red]"
213
+ case _:
214
+ return f"[dim]{status}[/dim]"
215
+
216
+
217
+ @app.command()
218
+ def init(
219
+ name: Annotated[str, typer.Argument(help="Provider name (e.g., 'postgres', 'mycompany')")],
220
+ output_dir: Annotated[
221
+ Path | None,
222
+ typer.Option("--output", "-o", help="Output directory (default: ./{name}-provider)"),
223
+ ] = None,
224
+ description: Annotated[
225
+ str | None,
226
+ typer.Option("--description", "-d", help="Provider description"),
227
+ ] = None,
228
+ author_name: Annotated[
229
+ str | None,
230
+ typer.Option("--author", help="Author name"),
231
+ ] = None,
232
+ author_email: Annotated[
233
+ str | None,
234
+ typer.Option("--email", help="Author email"),
235
+ ] = None,
236
+ defaults: Annotated[
237
+ bool,
238
+ typer.Option("--defaults", help="Accept all defaults without prompting"),
239
+ ] = False,
240
+ ):
241
+ """Initialize a new provider project.
242
+
243
+ Creates a complete provider project structure with:
244
+ - pyproject.toml for packaging
245
+ - README.md with documentation
246
+ - src/{name}_provider/ with example resources
247
+ - tests/ with example tests
248
+ - mise.toml for tool management
249
+
250
+ Example:
251
+ pragma provider init mycompany
252
+ pragma provider init postgres --output ./providers/postgres
253
+ pragma provider init mycompany --defaults --description "My provider"
254
+
255
+ Raises:
256
+ typer.Exit: If directory already exists or template copy fails.
257
+ """
258
+ project_dir = output_dir or Path(f"./{name}-provider")
259
+
260
+ if project_dir.exists():
261
+ typer.echo(f"Error: Directory {project_dir} already exists", err=True)
262
+ raise typer.Exit(1)
263
+
264
+ template_source = get_template_source()
265
+
266
+ data = {"name": name}
267
+ if description:
268
+ data["description"] = description
269
+ if author_name:
270
+ data["author_name"] = author_name
271
+ if author_email:
272
+ data["author_email"] = author_email
273
+
274
+ typer.echo(f"Creating provider project: {project_dir}")
275
+ typer.echo(f" Template: {template_source}")
276
+ typer.echo("")
277
+
278
+ try:
279
+ copier.run_copy(
280
+ src_path=template_source,
281
+ dst_path=project_dir,
282
+ data=data,
283
+ defaults=defaults,
284
+ unsafe=True,
285
+ )
286
+ except Exception as e:
287
+ typer.echo(f"Error creating provider: {e}", err=True)
288
+ raise typer.Exit(1)
289
+
290
+ package_name = name.lower().replace("-", "_").replace(" ", "_") + "_provider"
291
+
292
+ typer.echo("")
293
+ typer.echo(f"Created provider project: {project_dir}")
294
+ typer.echo("")
295
+ typer.echo("Next steps:")
296
+ typer.echo(f" cd {project_dir}")
297
+ typer.echo(" uv sync --dev")
298
+ typer.echo(" uv run pytest tests/")
299
+ typer.echo("")
300
+ typer.echo(f"Edit src/{package_name}/resources.py to add your resources.")
301
+ typer.echo("")
302
+ typer.echo("To update this project when the template changes:")
303
+ typer.echo(" copier update")
304
+ typer.echo("")
305
+ typer.echo("When ready to deploy:")
306
+ typer.echo(" pragma provider push")
307
+
308
+
309
+ @app.command()
310
+ def update(
311
+ project_dir: Annotated[
312
+ Path,
313
+ typer.Argument(help="Provider project directory"),
314
+ ] = Path("."),
315
+ ):
316
+ """Update an existing provider project with latest template changes.
317
+
318
+ Uses Copier's 3-way merge to preserve your customizations while
319
+ incorporating template updates.
320
+
321
+ Example:
322
+ pragma provider update
323
+ pragma provider update ./my-provider
324
+
325
+ Raises:
326
+ typer.Exit: If directory is not a Copier project or update fails.
327
+ """
328
+ answers_file = project_dir / ".copier-answers.yml"
329
+ if not answers_file.exists():
330
+ typer.echo(f"Error: {project_dir} is not a Copier-generated project", err=True)
331
+ typer.echo("(missing .copier-answers.yml)", err=True)
332
+ raise typer.Exit(1)
333
+
334
+ typer.echo(f"Updating provider project: {project_dir}")
335
+ typer.echo("")
336
+
337
+ try:
338
+ copier.run_update(dst_path=project_dir, unsafe=True)
339
+ except Exception as e:
340
+ typer.echo(f"Error updating provider: {e}", err=True)
341
+ raise typer.Exit(1)
342
+
343
+ typer.echo("")
344
+ typer.echo("Provider project updated successfully.")
345
+
346
+
347
+ @app.command()
348
+ def push(
349
+ package: Annotated[
350
+ str | None,
351
+ typer.Option("--package", "-p", help="Provider package name (auto-detected if not specified)"),
352
+ ] = None,
353
+ directory: Annotated[
354
+ Path,
355
+ typer.Option("--directory", "-d", help="Provider source directory"),
356
+ ] = Path("."),
357
+ deploy: Annotated[
358
+ bool,
359
+ typer.Option("--deploy", help="Deploy after successful build"),
360
+ ] = False,
361
+ logs: Annotated[
362
+ bool,
363
+ typer.Option("--logs", help="Stream build logs"),
364
+ ] = False,
365
+ wait: Annotated[
366
+ bool,
367
+ typer.Option("--wait/--no-wait", help="Wait for build to complete"),
368
+ ] = True,
369
+ ):
370
+ """Build and push provider code to the platform.
371
+
372
+ Creates a tarball of the provider source code and uploads it to the
373
+ Pragmatiks platform for building. The platform uses BuildKit to create
374
+ a container image.
375
+
376
+ Build only:
377
+ pragma provider push
378
+ -> Uploads code and waits for build
379
+
380
+ Build and deploy:
381
+ pragma provider push --deploy
382
+ -> Uploads code, builds, and deploys
383
+
384
+ Async build:
385
+ pragma provider push --no-wait
386
+ -> Uploads code and returns immediately
387
+
388
+ With logs:
389
+ pragma provider push --logs
390
+ -> Shows build output in real-time
391
+
392
+ Example:
393
+ pragma provider push
394
+ pragma provider push --deploy
395
+ pragma provider push --logs --deploy
396
+
397
+ Raises:
398
+ typer.Exit: If provider detection fails or build fails.
399
+ """
400
+ provider_name = package or detect_provider_package()
401
+
402
+ if not provider_name:
403
+ console.print("[red]Error:[/red] Could not detect provider package.")
404
+ console.print("Run from a provider directory or specify --package")
405
+ raise typer.Exit(1)
406
+
407
+ provider_id = provider_name.replace("_", "-").removesuffix("-provider")
408
+
409
+ if not directory.exists():
410
+ console.print(f"[red]Error:[/red] Directory not found: {directory}")
411
+ raise typer.Exit(1)
412
+
413
+ console.print(f"[bold]Pushing provider:[/bold] {provider_id}")
414
+ console.print(f"[dim]Source directory:[/dim] {directory.absolute()}")
415
+ console.print()
416
+
417
+ with Progress(
418
+ SpinnerColumn(),
419
+ TextColumn("[progress.description]{task.description}"),
420
+ console=console,
421
+ transient=True,
422
+ ) as progress:
423
+ progress.add_task("Creating tarball...", total=None)
424
+ tarball = create_tarball(directory)
425
+
426
+ console.print(f"[green]Created tarball:[/green] {len(tarball) / 1024:.1f} KB")
427
+
428
+ client = get_client()
429
+
430
+ if client._auth is None:
431
+ console.print("[red]Error:[/red] Authentication required. Run 'pragma login' first.")
432
+ raise typer.Exit(1)
433
+
434
+ try:
435
+ push_result = _upload_code(client, provider_id, tarball)
436
+
437
+ if not wait:
438
+ console.print()
439
+ console.print("[dim]Build running in background. Check status with:[/dim]")
440
+ console.print(f" pragma provider status {provider_id} --job {push_result.job_name}")
441
+ return
442
+
443
+ build_result = _wait_for_build(client, provider_id, push_result.job_name, logs)
444
+
445
+ if deploy:
446
+ if not build_result.image:
447
+ console.print("[red]Error:[/red] Build succeeded but no image was produced")
448
+ raise typer.Exit(1)
449
+
450
+ console.print()
451
+ _deploy_provider(client, provider_id, build_result.image)
452
+ except Exception as e:
453
+ if isinstance(e, typer.Exit):
454
+ raise
455
+ console.print(f"[red]Error:[/red] {e}")
456
+ raise typer.Exit(1)
457
+
458
+
459
+ def _upload_code(client: PragmaClient, provider_id: str, tarball: bytes) -> PushResult:
460
+ """Upload provider code tarball to the platform.
461
+
462
+ Args:
463
+ client: SDK client instance.
464
+ provider_id: Provider identifier.
465
+ tarball: Gzipped tarball bytes of provider source.
466
+
467
+ Returns:
468
+ PushResult with build job details.
469
+ """
470
+ with Progress(
471
+ SpinnerColumn(),
472
+ TextColumn("[progress.description]{task.description}"),
473
+ console=console,
474
+ transient=True,
475
+ ) as progress:
476
+ progress.add_task("Uploading code...", total=None)
477
+ push_result = client.push_provider(provider_id, tarball)
478
+
479
+ console.print(f"[green]Build started:[/green] {push_result.job_name}")
480
+ return push_result
481
+
482
+
483
+ def _wait_for_build(
484
+ client: PragmaClient,
485
+ provider_id: str,
486
+ job_name: str,
487
+ logs: bool,
488
+ ) -> BuildResult:
489
+ """Wait for build to complete, optionally streaming logs.
490
+
491
+ Args:
492
+ client: SDK client instance.
493
+ provider_id: Provider identifier.
494
+ job_name: Build job name.
495
+ logs: Whether to stream build logs.
496
+
497
+ Returns:
498
+ Final BuildResult.
499
+
500
+ Raises:
501
+ typer.Exit: On build failure or timeout.
502
+ """
503
+ if logs:
504
+ _stream_build_logs(client, provider_id, job_name)
505
+ else:
506
+ build_result = _poll_build_status(client, provider_id, job_name)
507
+
508
+ if build_result.status == BuildStatus.FAILED:
509
+ console.print(f"[red]Build failed:[/red] {build_result.error_message}")
510
+ raise typer.Exit(1)
511
+
512
+ console.print(f"[green]Build successful:[/green] {build_result.image}")
513
+
514
+ final_build = client.get_build_status(provider_id, job_name)
515
+
516
+ if final_build.status != BuildStatus.SUCCESS:
517
+ console.print(f"[red]Build failed:[/red] {final_build.error_message}")
518
+ raise typer.Exit(1)
519
+
520
+ return final_build
521
+
522
+
523
+ def _poll_build_status(client: PragmaClient, provider_id: str, job_name: str) -> BuildResult:
524
+ """Poll build status until completion or timeout.
525
+
526
+ Args:
527
+ client: SDK client instance.
528
+ provider_id: Provider identifier.
529
+ job_name: Build job name.
530
+
531
+ Returns:
532
+ Final BuildResult.
533
+
534
+ Raises:
535
+ typer.Exit: If build times out.
536
+ """
537
+ start_time = time.time()
538
+
539
+ with Progress(
540
+ SpinnerColumn(),
541
+ TextColumn("[progress.description]{task.description}"),
542
+ console=console,
543
+ transient=True,
544
+ ) as progress:
545
+ task = progress.add_task("Building...", total=None)
546
+
547
+ while True:
548
+ build_result = client.get_build_status(provider_id, job_name)
549
+
550
+ if build_result.status in (BuildStatus.SUCCESS, BuildStatus.FAILED):
551
+ return build_result
552
+
553
+ elapsed = time.time() - start_time
554
+ if elapsed > BUILD_TIMEOUT:
555
+ console.print("[red]Error:[/red] Build timed out")
556
+ raise typer.Exit(1)
557
+
558
+ progress.update(task, description=f"Building... ({build_result.status.value})")
559
+ time.sleep(BUILD_POLL_INTERVAL)
560
+
561
+
562
+ def _stream_build_logs(client: PragmaClient, provider_id: str, job_name: str) -> None:
563
+ """Stream build logs to console.
564
+
565
+ Args:
566
+ client: SDK client instance.
567
+ provider_id: Provider identifier.
568
+ job_name: Build job name.
569
+ """
570
+ console.print()
571
+ console.print("[bold]Build logs:[/bold]")
572
+ console.print("-" * 40)
573
+
574
+ try:
575
+ with client.stream_build_logs(provider_id, job_name) as response:
576
+ for line in response.iter_lines():
577
+ console.print(line)
578
+ except httpx.HTTPError as e:
579
+ console.print(f"[yellow]Warning:[/yellow] Could not stream logs: {e}")
580
+ console.print("[dim]Falling back to polling...[/dim]")
581
+ _poll_build_status(client, provider_id, job_name)
582
+
583
+ console.print("-" * 40)
584
+
585
+
586
+ def _deploy_provider(client: PragmaClient, provider_id: str, image: str) -> None:
587
+ """Deploy the provider.
588
+
589
+ Args:
590
+ client: SDK client instance.
591
+ provider_id: Provider identifier.
592
+ image: Container image to deploy.
593
+ """
594
+ with Progress(
595
+ SpinnerColumn(),
596
+ TextColumn("[progress.description]{task.description}"),
597
+ console=console,
598
+ transient=True,
599
+ ) as progress:
600
+ progress.add_task("Deploying...", total=None)
601
+ deploy_result = client.deploy_provider(provider_id, image)
602
+
603
+ console.print(f"[green]Deployment started:[/green] {deploy_result.deployment_name}")
604
+ console.print(f"[dim]Status:[/dim] {deploy_result.status.value}")
605
+
606
+
607
+ @app.command()
608
+ def deploy(
609
+ image: Annotated[
610
+ str,
611
+ typer.Option("--image", "-i", help="Container image to deploy (required)"),
612
+ ],
613
+ package: Annotated[
614
+ str | None,
615
+ typer.Option("--package", "-p", help="Provider package name (auto-detected if not specified)"),
616
+ ] = None,
617
+ ):
618
+ """Deploy a provider from a built container image.
619
+
620
+ Deploys a provider image to Kubernetes. Use after 'pragma provider push'
621
+ completes successfully, or to redeploy/rollback to a specific version.
622
+
623
+ Example:
624
+ pragma provider deploy --image europe-west4-docker.pkg.dev/project/repo/provider:tag
625
+ pragma provider deploy -i provider:latest --package my_provider
626
+
627
+ Raises:
628
+ typer.Exit: If deployment fails.
629
+ """
630
+ provider_name = package or detect_provider_package()
631
+
632
+ if not provider_name:
633
+ console.print("[red]Error:[/red] Could not detect provider package.")
634
+ console.print("Run from a provider directory or specify --package")
635
+ raise typer.Exit(1)
636
+
637
+ provider_id = provider_name.replace("_", "-").removesuffix("-provider")
638
+
639
+ console.print(f"[bold]Deploying provider:[/bold] {provider_id}")
640
+ console.print(f"[dim]Image:[/dim] {image}")
641
+ console.print()
642
+
643
+ client = get_client()
644
+
645
+ if client._auth is None:
646
+ console.print("[red]Error:[/red] Authentication required. Run 'pragma auth login' first.")
647
+ raise typer.Exit(1)
648
+
649
+ try:
650
+ _deploy_provider(client, provider_id, image)
651
+ except Exception as e:
652
+ console.print(f"[red]Error:[/red] {e}")
653
+ raise typer.Exit(1)
654
+
655
+
656
+ @app.command()
657
+ def sync(
658
+ package: Annotated[
659
+ str | None,
660
+ typer.Option("--package", "-p", help="Provider package name (auto-detected if not specified)"),
661
+ ] = None,
662
+ dry_run: Annotated[
663
+ bool,
664
+ typer.Option("--dry-run", help="Show what would be registered without making changes"),
665
+ ] = False,
666
+ ):
667
+ """Sync resource type definitions to the Pragmatiks platform.
668
+
669
+ Discovers resources in the provider package and registers their schemas
670
+ with the API. This allows users to create instances of these resource types.
671
+
672
+ The command introspects the provider code to extract:
673
+ - Provider and resource names from @provider.resource() decorator
674
+ - JSON schema from Pydantic Config classes
675
+
676
+ Example:
677
+ pragma provider sync
678
+ pragma provider sync --package postgres_provider
679
+ pragma provider sync --dry-run
680
+
681
+ Raises:
682
+ typer.Exit: If package not found or registration fails.
683
+ """
684
+ package_name = package or detect_provider_package()
685
+
686
+ if not package_name:
687
+ typer.echo("Error: Could not detect provider package.", err=True)
688
+ typer.echo("Run from a provider directory or specify --package", err=True)
689
+ raise typer.Exit(1)
690
+
691
+ typer.echo(f"Discovering resources in {package_name}...")
692
+
693
+ try:
694
+ resources = discover_resources(package_name)
695
+ except ImportError as e:
696
+ typer.echo(f"Error importing package: {e}", err=True)
697
+ raise typer.Exit(1)
698
+
699
+ if not resources:
700
+ typer.echo("No resources found. Ensure resources are decorated with @provider.resource().")
701
+ raise typer.Exit(0)
702
+
703
+ typer.echo(f"Found {len(resources)} resource(s):")
704
+ typer.echo("")
705
+
706
+ if dry_run:
707
+ for (provider, resource_name), resource_class in resources.items():
708
+ typer.echo(f" {provider}/{resource_name} ({resource_class.__name__})")
709
+ typer.echo("")
710
+ typer.echo("Dry run - no changes made.")
711
+ raise typer.Exit(0)
712
+
713
+ client = get_client()
714
+
715
+ for (provider, resource_name), resource_class in resources.items():
716
+ try:
717
+ config_class = get_config_class(resource_class)
718
+ schema = config_class.model_json_schema()
719
+ except ValueError as e:
720
+ typer.echo(f" {provider}/{resource_name}: skipped ({e})", err=True)
721
+ continue
722
+
723
+ try:
724
+ client.register_resource(
725
+ provider=provider,
726
+ resource=resource_name,
727
+ schema=schema,
728
+ )
729
+ typer.echo(f" {provider}/{resource_name}: registered")
730
+ except Exception as e:
731
+ typer.echo(f" {provider}/{resource_name}: failed ({e})", err=True)
732
+
733
+ typer.echo("")
734
+ typer.echo("Sync complete.")
735
+
736
+
737
+ def get_config_class(resource_class: type[Resource]) -> type[Config]:
738
+ """Extract Config subclass from Resource's config field annotation.
739
+
740
+ Args:
741
+ resource_class: A Resource subclass.
742
+
743
+ Returns:
744
+ Config subclass type from the Resource's config field.
745
+
746
+ Raises:
747
+ ValueError: If Resource has no config field or wrong type.
748
+ """
749
+ annotations = resource_class.model_fields
750
+ config_field = annotations.get("config")
751
+
752
+ if config_field is None:
753
+ raise ValueError(f"Resource {resource_class.__name__} has no config field")
754
+
755
+ config_type = config_field.annotation
756
+
757
+ if not isinstance(config_type, type) or not issubclass(config_type, Config):
758
+ raise ValueError(f"Resource {resource_class.__name__} config field is not a Config subclass")
759
+
760
+ return config_type
761
+
762
+
763
+ def detect_provider_package() -> str | None:
764
+ """Detect provider package name from current directory.
765
+
766
+ Returns:
767
+ Package name with underscores if found, None otherwise.
768
+ """
769
+ pyproject = Path("pyproject.toml")
770
+ if not pyproject.exists():
771
+ return None
772
+
773
+ with open(pyproject, "rb") as f:
774
+ data = tomllib.load(f)
775
+
776
+ name = data.get("project", {}).get("name", "")
777
+ if name and name.endswith("-provider"):
778
+ return name.replace("-", "_")
779
+
780
+ return None
781
+
782
+
783
+ @app.command()
784
+ def delete(
785
+ provider_id: Annotated[
786
+ str,
787
+ typer.Argument(help="Provider ID to delete (e.g., 'postgres', 'my-provider')"),
788
+ ],
789
+ cascade: Annotated[
790
+ bool,
791
+ typer.Option("--cascade", help="Delete all resources for this provider"),
792
+ ] = False,
793
+ force: Annotated[
794
+ bool,
795
+ typer.Option("--force", "-f", help="Skip confirmation prompt"),
796
+ ] = False,
797
+ ):
798
+ """Delete a provider and all associated resources.
799
+
800
+ Removes the provider deployment, resource definitions, and pending events
801
+ from the platform. By default, fails if the provider has any resources.
802
+
803
+ Without --cascade:
804
+ pragma provider delete my-provider
805
+ -> Fails if provider has resources
806
+
807
+ With --cascade:
808
+ pragma provider delete my-provider --cascade
809
+ -> Deletes provider and all its resources
810
+
811
+ Skip confirmation:
812
+ pragma provider delete my-provider --force
813
+ pragma provider delete my-provider --cascade --force
814
+
815
+ Example:
816
+ pragma provider delete postgres
817
+ pragma provider delete postgres --cascade
818
+ pragma provider delete postgres --cascade --force
819
+
820
+ Raises:
821
+ typer.Exit: If deletion fails or user cancels.
822
+ """
823
+ client = get_client()
824
+
825
+ if client._auth is None:
826
+ console.print("[red]Error:[/red] Authentication required. Run 'pragma login' first.")
827
+ raise typer.Exit(1)
828
+
829
+ console.print(f"[bold]Provider:[/bold] {provider_id}")
830
+ if cascade:
831
+ console.print("[yellow]Warning:[/yellow] --cascade will delete all resources for this provider")
832
+ console.print()
833
+
834
+ if not force:
835
+ action = "DELETE provider and all its resources" if cascade else "DELETE provider"
836
+ confirm = typer.confirm(f"Are you sure you want to {action}?")
837
+ if not confirm:
838
+ console.print("[dim]Cancelled.[/dim]")
839
+ raise typer.Exit(0)
840
+
841
+ try:
842
+ with Progress(
843
+ SpinnerColumn(),
844
+ TextColumn("[progress.description]{task.description}"),
845
+ console=console,
846
+ transient=True,
847
+ ) as progress:
848
+ progress.add_task("Deleting provider...", total=None)
849
+ result = client.delete_provider(provider_id, cascade=cascade)
850
+
851
+ _print_delete_result(result)
852
+
853
+ except httpx.HTTPStatusError as e:
854
+ if e.response.status_code == 409:
855
+ detail = e.response.json().get("detail", "Provider has resources")
856
+ console.print(f"[red]Error:[/red] {detail}")
857
+ console.print("[dim]Use --cascade to delete all resources with the provider.[/dim]")
858
+ else:
859
+ console.print(f"[red]Error:[/red] {e.response.text}")
860
+ raise typer.Exit(1)
861
+ except Exception as e:
862
+ console.print(f"[red]Error:[/red] {e}")
863
+ raise typer.Exit(1)
864
+
865
+
866
+ def _print_delete_result(result: ProviderDeleteResult) -> None:
867
+ """Print a summary of the deletion result.
868
+
869
+ Args:
870
+ result: ProviderDeleteResult from the API.
871
+ """
872
+ console.print()
873
+ console.print(f"[green]Provider deleted:[/green] {result.provider_id}")
874
+ console.print()
875
+
876
+ table = Table(show_header=True, header_style="bold")
877
+ table.add_column("Component")
878
+ table.add_column("Deleted", justify="right")
879
+
880
+ table.add_row("Builds Cancelled", str(result.builds_cancelled))
881
+ table.add_row("Source Archives", str(result.source_archives_deleted))
882
+ table.add_row("Deployment", "Yes" if result.deployment_deleted else "No (not found)")
883
+ table.add_row("Resources", str(result.resources_deleted))
884
+ table.add_row("Resource Definitions", str(result.resource_definitions_deleted))
885
+ table.add_row("Outbox Events", str(result.outbox_events_deleted))
886
+ table.add_row("Dead Letter Events", str(result.dead_letter_events_deleted))
887
+ table.add_row("NATS Messages Purged", str(result.messages_purged))
888
+ table.add_row("NATS Consumer", "Deleted" if result.consumer_deleted else "Not found")
889
+
890
+ console.print(table)
891
+
892
+
893
+ @app.command()
894
+ def rollback(
895
+ provider_id: Annotated[
896
+ str,
897
+ typer.Argument(help="Provider ID to rollback (e.g., 'postgres', 'my-provider')"),
898
+ ],
899
+ version: Annotated[
900
+ str | None,
901
+ typer.Option("--version", "-v", help="Target version to rollback to (default: previous successful build)"),
902
+ ] = None,
903
+ ):
904
+ """Rollback a provider to a previous version.
905
+
906
+ Redeploys a previously successful build. If no version is specified,
907
+ rolls back to the previous successful version.
908
+
909
+ Rollback to specific version:
910
+ pragma provider rollback my-provider --version 20250114.120000
911
+
912
+ Rollback to previous version:
913
+ pragma provider rollback my-provider
914
+
915
+ Example:
916
+ pragma provider rollback postgres --version 20250114.120000
917
+ pragma provider rollback postgres -v 20250114.120000
918
+ pragma provider rollback postgres
919
+
920
+ Raises:
921
+ typer.Exit: If rollback fails or no suitable version found.
922
+ """
923
+ client = get_client()
924
+
925
+ if client._auth is None:
926
+ console.print("[red]Error:[/red] Authentication required. Run 'pragma login' first.")
927
+ raise typer.Exit(1)
928
+
929
+ try:
930
+ target_version = version
931
+ if target_version is None:
932
+ target_version = _find_previous_version(client, provider_id)
933
+
934
+ console.print(f"[bold]Rolling back provider:[/bold] {provider_id}")
935
+ console.print(f"[dim]Target version:[/dim] {target_version}")
936
+ console.print()
937
+
938
+ with Progress(
939
+ SpinnerColumn(),
940
+ TextColumn("[progress.description]{task.description}"),
941
+ console=console,
942
+ transient=True,
943
+ ) as progress:
944
+ progress.add_task("Deploying previous version...", total=None)
945
+ result = client.rollback_provider(provider_id, target_version)
946
+
947
+ console.print(f"[green]Rollback initiated:[/green] {result.deployment_name}")
948
+ console.print(f"[dim]Status:[/dim] {result.status.value}")
949
+
950
+ except httpx.HTTPStatusError as e:
951
+ if e.response.status_code == 404:
952
+ detail = e.response.json().get("detail", "Build not found")
953
+ console.print(f"[red]Error:[/red] {detail}")
954
+ elif e.response.status_code == 400:
955
+ detail = e.response.json().get("detail", "Build not deployable")
956
+ console.print(f"[red]Error:[/red] {detail}")
957
+ else:
958
+ console.print(f"[red]Error:[/red] {e.response.text}")
959
+ raise typer.Exit(1)
960
+ except ValueError as e:
961
+ console.print(f"[red]Error:[/red] {e}")
962
+ raise typer.Exit(1)
963
+ except Exception as e:
964
+ console.print(f"[red]Error:[/red] {e}")
965
+ raise typer.Exit(1)
966
+
967
+
968
+ def _find_previous_version(client: PragmaClient, provider_id: str) -> str:
969
+ """Find the previous successful version for rollback.
970
+
971
+ Gets the build history and finds the second-most-recent successful build,
972
+ which represents the version before the current deployment.
973
+
974
+ Args:
975
+ client: SDK client instance.
976
+ provider_id: Provider identifier.
977
+
978
+ Returns:
979
+ CalVer version string of the previous successful build.
980
+
981
+ Raises:
982
+ ValueError: If no previous successful build exists.
983
+ """
984
+ builds = client.list_builds(provider_id)
985
+ successful_builds = [b for b in builds if b.status == BuildStatus.SUCCESS]
986
+
987
+ if len(successful_builds) < 2:
988
+ raise ValueError(
989
+ f"No previous successful build found for provider '{provider_id}'. "
990
+ "Specify a version explicitly with --version."
991
+ )
992
+
993
+ return successful_builds[1].version
994
+
995
+
996
+ @app.command()
997
+ def status(
998
+ provider_id: Annotated[
999
+ str,
1000
+ typer.Argument(help="Provider ID to check status (e.g., 'postgres', 'my-provider')"),
1001
+ ],
1002
+ ):
1003
+ """Check the deployment status of a provider.
1004
+
1005
+ Displays:
1006
+ - Deployment status (pending/progressing/available/failed)
1007
+ - Replica count (available/ready)
1008
+ - Current image version
1009
+ - Last updated timestamp
1010
+
1011
+ Example:
1012
+ pragma provider status postgres
1013
+ pragma provider status my-provider
1014
+
1015
+ Raises:
1016
+ typer.Exit: If deployment not found or status check fails.
1017
+ """
1018
+ client = get_client()
1019
+
1020
+ if client._auth is None:
1021
+ console.print("[red]Error:[/red] Authentication required. Run 'pragma login' first.")
1022
+ raise typer.Exit(1)
1023
+
1024
+ try:
1025
+ result = client.get_deployment_status(provider_id)
1026
+ except httpx.HTTPStatusError as e:
1027
+ if e.response.status_code == 404:
1028
+ console.print(f"[red]Error:[/red] Deployment not found for provider: {provider_id}")
1029
+ else:
1030
+ console.print(f"[red]Error:[/red] {e.response.text}")
1031
+ raise typer.Exit(1)
1032
+ except Exception as e:
1033
+ console.print(f"[red]Error:[/red] {e}")
1034
+ raise typer.Exit(1)
1035
+
1036
+ _print_deployment_status(provider_id, result)
1037
+
1038
+
1039
+ def _print_deployment_status(provider_id: str, result) -> None:
1040
+ """Print deployment status in a formatted table.
1041
+
1042
+ Args:
1043
+ provider_id: Provider identifier.
1044
+ result: DeploymentResult from the API.
1045
+ """
1046
+ status_colors = {
1047
+ "pending": "yellow",
1048
+ "progressing": "cyan",
1049
+ "available": "green",
1050
+ "failed": "red",
1051
+ }
1052
+ status_color = status_colors.get(result.status.value, "white")
1053
+
1054
+ console.print()
1055
+ console.print(f"[bold]Provider:[/bold] {provider_id}")
1056
+ console.print()
1057
+
1058
+ table = Table(show_header=True, header_style="bold")
1059
+ table.add_column("Property")
1060
+ table.add_column("Value")
1061
+
1062
+ table.add_row("Status", f"[{status_color}]{result.status.value}[/{status_color}]")
1063
+ table.add_row("Replicas", f"{result.available_replicas} available / {result.ready_replicas} ready")
1064
+
1065
+ if result.image:
1066
+ version = result.image.split(":")[-1] if ":" in result.image else "unknown"
1067
+ table.add_row("Version", version)
1068
+ table.add_row("Image", f"[dim]{result.image}[/dim]")
1069
+
1070
+ if result.updated_at:
1071
+ table.add_row("Updated", result.updated_at.strftime("%Y-%m-%d %H:%M:%S UTC"))
1072
+
1073
+ if result.message:
1074
+ table.add_row("Message", result.message)
1075
+
1076
+ console.print(table)
1077
+
1078
+
1079
+ @app.command()
1080
+ def builds(
1081
+ provider_id: Annotated[
1082
+ str,
1083
+ typer.Argument(help="Provider ID to list builds for (e.g., 'postgres', 'my-provider')"),
1084
+ ],
1085
+ ):
1086
+ """List build history for a provider.
1087
+
1088
+ Shows the last 10 builds ordered by creation time (newest first).
1089
+ Useful for selecting versions for rollback and verifying build status.
1090
+
1091
+ Example:
1092
+ pragma provider builds postgres
1093
+ pragma provider builds my-provider
1094
+
1095
+ Raises:
1096
+ typer.Exit: If request fails.
1097
+ """
1098
+ client = get_client()
1099
+
1100
+ if client._auth is None:
1101
+ console.print("[red]Error:[/red] Authentication required. Run 'pragma login' first.")
1102
+ raise typer.Exit(1)
1103
+
1104
+ try:
1105
+ with Progress(
1106
+ SpinnerColumn(),
1107
+ TextColumn("[progress.description]{task.description}"),
1108
+ console=console,
1109
+ transient=True,
1110
+ ) as progress:
1111
+ progress.add_task("Fetching builds...", total=None)
1112
+ build_list = client.list_builds(provider_id)
1113
+
1114
+ if not build_list:
1115
+ console.print(f"[dim]No builds found for provider:[/dim] {provider_id}")
1116
+ raise typer.Exit(0)
1117
+
1118
+ console.print(f"[bold]Builds for provider:[/bold] {provider_id}")
1119
+ console.print()
1120
+
1121
+ table = Table(show_header=True, header_style="bold")
1122
+ table.add_column("Version")
1123
+ table.add_column("Status")
1124
+ table.add_column("Created")
1125
+ table.add_column("Error")
1126
+
1127
+ for build in build_list:
1128
+ status_color = _get_build_status_color(build.status)
1129
+ error_display = (
1130
+ build.error_message[:50] + "..."
1131
+ if build.error_message and len(build.error_message) > 50
1132
+ else (build.error_message or "-")
1133
+ )
1134
+ table.add_row(
1135
+ build.version,
1136
+ f"[{status_color}]{build.status.value}[/{status_color}]",
1137
+ build.created_at.strftime("%Y-%m-%d %H:%M:%S"),
1138
+ error_display,
1139
+ )
1140
+
1141
+ console.print(table)
1142
+
1143
+ except httpx.HTTPStatusError as e:
1144
+ console.print(f"[red]Error:[/red] {e.response.text}")
1145
+ raise typer.Exit(1)
1146
+ except Exception as e:
1147
+ console.print(f"[red]Error:[/red] {e}")
1148
+ raise typer.Exit(1)
1149
+
1150
+
1151
+ def _get_build_status_color(status: BuildStatus) -> str:
1152
+ """Get the color for a build status.
1153
+
1154
+ Args:
1155
+ status: Build status enum value.
1156
+
1157
+ Returns:
1158
+ Rich color name for the status.
1159
+ """
1160
+ return {
1161
+ BuildStatus.PENDING: "yellow",
1162
+ BuildStatus.BUILDING: "blue",
1163
+ BuildStatus.SUCCESS: "green",
1164
+ BuildStatus.FAILED: "red",
1165
+ }.get(status, "white")