pipecatcloud 0.3.0__tar.gz → 0.3.1__tar.gz

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.
Files changed (63) hide show
  1. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/CHANGELOG.md +30 -0
  2. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/PKG-INFO +1 -1
  3. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/build_utils.py +14 -1
  4. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/deploy_utils.py +180 -0
  5. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/agent.py +40 -3
  6. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/build.py +10 -5
  7. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/deploy.py +47 -31
  8. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/PKG-INFO +1 -1
  9. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/SOURCES.txt +1 -0
  10. pipecatcloud-0.3.1/tests/test_deployment_status.py +478 -0
  11. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.github/workflows/format.yml +0 -0
  12. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.github/workflows/publish-pypi.yml +0 -0
  13. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.github/workflows/publish-test.yml +0 -0
  14. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.github/workflows/tests.yml +0 -0
  15. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.gitignore +0 -0
  16. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/.pre-commit-config.yaml +0 -0
  17. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/LICENSE +0 -0
  18. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/README.md +0 -0
  19. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/pipecat-cloud.png +0 -0
  20. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/pyproject.toml +0 -0
  21. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/pytest.ini +0 -0
  22. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/scripts/fix-ruff.sh +0 -0
  23. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/scripts/pre-commit.sh +0 -0
  24. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/setup.cfg +0 -0
  25. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/__init__.py +0 -0
  26. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/__main__.py +0 -0
  27. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/__version__.py +0 -0
  28. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/__init__.py +0 -0
  29. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/async_utils.py +0 -0
  30. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/auth_utils.py +0 -0
  31. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/console_utils.py +0 -0
  32. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/_utils/regions.py +0 -0
  33. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/agent.py +0 -0
  34. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/api.py +0 -0
  35. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/__init__.py +0 -0
  36. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/api.py +0 -0
  37. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/__init__.py +0 -0
  38. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/auth.py +0 -0
  39. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/docker.py +0 -0
  40. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/organizations.py +0 -0
  41. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/regions.py +0 -0
  42. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/commands/secrets.py +0 -0
  43. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/config.py +0 -0
  44. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/cli/entry_point.py +0 -0
  45. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/config.py +0 -0
  46. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/constants.py +0 -0
  47. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/exception.py +0 -0
  48. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/py.typed +0 -0
  49. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/session.py +0 -0
  50. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
  51. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
  52. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
  53. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/requires.txt +0 -0
  54. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/src/pipecatcloud.egg-info/top_level.txt +0 -0
  55. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/__init__.py +0 -0
  56. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_agent_sessions.py +0 -0
  57. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_agent_stop.py +0 -0
  58. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_api_client.py +0 -0
  59. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_docker_commands.py +0 -0
  60. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_krisp_viva.py +0 -0
  61. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_managed_keys.py +0 -0
  62. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/tests/test_regions.py +0 -0
  63. {pipecatcloud-0.3.0 → pipecatcloud-0.3.1}/uv.lock +0 -0
@@ -5,6 +5,34 @@ All notable changes to **Pipecat Cloud** will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.1] - 2026-03-13
9
+
10
+ ### Added
11
+
12
+ - Added `.github`, `.claude`, `pcc-deploy.toml`, `.ipynb_checkpoints`, and
13
+ `.cache` to default cloud build context exclusions to reduce upload size and
14
+ avoid including non-runtime files in container images.
15
+
16
+ ### Changed
17
+
18
+ - Increased deploy polling timeout from 90 seconds to 10 minutes to support
19
+ large deployments with big images, high replica counts, and cross-region pulls.
20
+
21
+ - Deploy polling now shows rich, multi-line status using API conditions and
22
+ revision info instead of a static "Waiting for deployment to become ready..."
23
+ message. The headline shows condition state (e.g. "Progressing · Available"),
24
+ with per-revision detail lines showing phase, replica counts, and elapsed time
25
+ for both current and previous deployments during rolling updates.
26
+
27
+ - Deploy timeout now differentiates exit behavior: services that are available
28
+ and serving traffic show a soft warning instead of a hard error, with guidance
29
+ to check status. Hard errors are reserved for services that are truly
30
+ unavailable.
31
+
32
+ - `agent status` now shows per-revision details (phase, deployment ID, replica
33
+ counts) in the health panel when the API provides revision info, replacing the
34
+ binary "Health: Ready/Stopped" display.
35
+
8
36
  ## [0.3.0] - 2026-03-12
9
37
 
10
38
  ### Added
@@ -15,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
15
43
  - `pcc deploy --build-id <id>` to reuse an existing build
16
44
  - `pcc deploy --yes` for non-interactive CI/CD usage
17
45
  - `[build]` section in `pcc-deploy.toml` for build configuration
46
+
18
47
  - New `pcc build` command group for managing cloud builds:
19
48
  - `pcc build list` — list recent builds with status and region filtering
20
49
  - `pcc build status <id>` — get detailed status of a build
@@ -23,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
23
52
  ### Changed
24
53
 
25
54
  - The `image` field in `pcc deploy` is now optional when using cloud builds
55
+
26
56
  - The CLI now uses `desiredDeploymentId` (with fallback to `activeDeploymentId`)
27
57
  and compares it against `reconciledDeploymentId` to determine deployment status.
28
58
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipecatcloud
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Cloud hosting for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Website, https://docs.pipecat.daily.co/
@@ -26,7 +26,6 @@ from typing import Callable, List, Optional, Set, Tuple
26
26
  import aiohttp
27
27
  from loguru import logger
28
28
 
29
-
30
29
  # Default patterns to exclude from build context
31
30
  DEFAULT_EXCLUSIONS: Set[str] = {
32
31
  # Version control
@@ -68,8 +67,22 @@ DEFAULT_EXCLUSIONS: Set[str] = {
68
67
  "dist",
69
68
  "build",
70
69
  "*.egg-info",
70
+ "*.egg",
71
+ ".eggs",
71
72
  # Node (if present)
72
73
  "node_modules",
74
+ # CI/CD
75
+ ".github",
76
+ # AI tools
77
+ ".claude",
78
+ ".codex",
79
+ ".cursor",
80
+ # Pipecat config
81
+ "pcc-deploy.toml",
82
+ # Jupyter
83
+ ".ipynb_checkpoints",
84
+ # Caches
85
+ ".cache",
73
86
  # Misc
74
87
  ".DS_Store",
75
88
  "Thumbs.db",
@@ -6,6 +6,8 @@
6
6
 
7
7
  import functools
8
8
  import os
9
+ from datetime import datetime, timezone
10
+ from enum import Enum
9
11
  from typing import Callable, List, Optional
10
12
 
11
13
  import toml
@@ -22,6 +24,184 @@ DEPLOY_STATUS_MAP = {
22
24
  }
23
25
 
24
26
 
27
+ class DeploymentPhase(Enum):
28
+ WAITING_FOR_OPERATOR = "waiting_for_operator"
29
+ PROGRESSING_AVAILABLE = "progressing_available"
30
+ PROGRESSING_NEW = "progressing_new"
31
+ DEGRADED_AVAILABLE = "degraded_available"
32
+ UNAVAILABLE = "unavailable"
33
+ READY = "ready"
34
+
35
+
36
+ @dataclass
37
+ class DeploymentStatus:
38
+ phase: DeploymentPhase
39
+ status_message: str
40
+ is_available: bool = False
41
+ is_ready: bool = False
42
+ degraded_reason: Optional[str] = None
43
+ current_revision: Optional[dict] = None
44
+ previous_revision: Optional[dict] = None
45
+
46
+
47
+ def _find_condition(conditions: list, condition_type: str) -> Optional[dict]:
48
+ """Find a condition by type from the conditions array."""
49
+ for c in conditions:
50
+ if c.get("type") == condition_type:
51
+ return c
52
+ return None
53
+
54
+
55
+ def _format_elapsed(phase_started_at: Optional[str]) -> str:
56
+ """Format elapsed time since phaseStartedAt as a human-readable string."""
57
+ if not phase_started_at:
58
+ return ""
59
+ try:
60
+ started = datetime.fromisoformat(phase_started_at.replace("Z", "+00:00"))
61
+ elapsed = datetime.now(timezone.utc) - started
62
+ total_seconds = int(elapsed.total_seconds())
63
+ if total_seconds < 0:
64
+ return ""
65
+ if total_seconds < 60:
66
+ return f"{total_seconds}s"
67
+ minutes = total_seconds // 60
68
+ seconds = total_seconds % 60
69
+ return f"{minutes}m {seconds}s"
70
+ except (ValueError, TypeError):
71
+ return ""
72
+
73
+
74
+ def _format_revision_line(label: str, rev: dict) -> str:
75
+ """Format a single revision as an indented detail line."""
76
+ deploy_id = rev.get("deploymentID", "unknown")[:8]
77
+ phase = rev.get("phase", "Unknown")
78
+ ready_replicas = rev.get("readyReplicas")
79
+
80
+ parts = [f" [dim]{label}[/dim] [bold]({deploy_id})[/bold] {phase}"]
81
+ if ready_replicas is not None:
82
+ parts.append(f"[dim]·[/dim] {ready_replicas} agents")
83
+
84
+ elapsed = _format_elapsed(rev.get("phaseStartedAt"))
85
+ if elapsed:
86
+ parts.append(f"[dim]·[/dim] [dim]{elapsed}[/dim]")
87
+
88
+ return " ".join(parts)
89
+
90
+
91
+ def _build_status_message(headline: str, current_rev=None, previous_rev=None) -> str:
92
+ """Build a multi-line status message with optional revision detail lines."""
93
+ lines = [headline]
94
+ if current_rev:
95
+ lines.append(_format_revision_line("Current ", current_rev))
96
+ if previous_rev:
97
+ lines.append(_format_revision_line("Previous", previous_rev))
98
+ return "\n".join(lines)
99
+
100
+
101
+ def interpret_deployment_status(
102
+ agent_status: dict,
103
+ desired_deployment_id: Optional[str] = None,
104
+ ) -> DeploymentStatus:
105
+ """Interpret the raw API response into a structured deployment status.
106
+
107
+ Examines reconciledDeploymentId, conditions array, available/ready booleans,
108
+ and revision info to determine the current deployment phase and build a
109
+ human-readable status message.
110
+ """
111
+ reconciled_id = agent_status.get("reconciledDeploymentId")
112
+ desired_id = desired_deployment_id or agent_status.get(
113
+ "desiredDeploymentId", agent_status.get("activeDeploymentId")
114
+ )
115
+ conditions = agent_status.get("conditions") or []
116
+ available = agent_status.get("available", agent_status.get("ready", False))
117
+ ready = agent_status.get("ready", False)
118
+ active_deployment_ready = agent_status.get("activeDeploymentReady", False)
119
+
120
+ current_rev = agent_status.get("currentRevision")
121
+ previous_rev = agent_status.get("previousRevision")
122
+
123
+ # 1. Check if operator has reconciled
124
+ if desired_id and reconciled_id != desired_id:
125
+ return DeploymentStatus(
126
+ phase=DeploymentPhase.WAITING_FOR_OPERATOR,
127
+ status_message="[dim]Waiting for operator to process deployment...[/dim]",
128
+ current_revision=current_rev,
129
+ previous_revision=previous_rev,
130
+ )
131
+
132
+ # 2. Fully ready
133
+ if available and (ready or active_deployment_ready):
134
+ return DeploymentStatus(
135
+ phase=DeploymentPhase.READY,
136
+ status_message="[green]Deployment is ready[/green]",
137
+ is_available=True,
138
+ is_ready=True,
139
+ current_revision=current_rev,
140
+ previous_revision=previous_rev,
141
+ )
142
+
143
+ # 3. Look at conditions for richer status
144
+ degraded = _find_condition(conditions, "Degraded")
145
+ progressing = _find_condition(conditions, "Progressing")
146
+
147
+ degraded_active = degraded and degraded.get("status") == "True"
148
+ degraded_reason = (
149
+ degraded.get("message", degraded.get("reason", "")) if degraded_active else None
150
+ )
151
+
152
+ # 4. Degraded but available — warning state
153
+ if degraded_active and available:
154
+ headline = f"[yellow]Degraded · Available[/yellow] [dim]— {degraded_reason}[/dim]"
155
+ return DeploymentStatus(
156
+ phase=DeploymentPhase.DEGRADED_AVAILABLE,
157
+ status_message=_build_status_message(headline, current_rev, previous_rev),
158
+ is_available=True,
159
+ degraded_reason=degraded_reason,
160
+ current_revision=current_rev,
161
+ previous_revision=previous_rev,
162
+ )
163
+
164
+ # 5. Available but not ready — rolling update in progress
165
+ if available and not ready:
166
+ headline = "[cyan]Progressing · Available[/cyan]"
167
+ return DeploymentStatus(
168
+ phase=DeploymentPhase.PROGRESSING_AVAILABLE,
169
+ status_message=_build_status_message(headline, current_rev, previous_rev),
170
+ is_available=True,
171
+ current_revision=current_rev,
172
+ previous_revision=previous_rev,
173
+ )
174
+
175
+ # 6. Not available, progressing — new service coming up
176
+ if not available and (progressing and progressing.get("status") == "True"):
177
+ headline = "[dim]Progressing[/dim]"
178
+ return DeploymentStatus(
179
+ phase=DeploymentPhase.PROGRESSING_NEW,
180
+ status_message=_build_status_message(headline, current_rev, previous_rev),
181
+ current_revision=current_rev,
182
+ previous_revision=previous_rev,
183
+ )
184
+
185
+ # 7. Not available, not progressing — broken
186
+ if not available and not ready:
187
+ reason_suffix = f" [dim]— {degraded_reason}[/dim]" if degraded_reason else ""
188
+ headline = f"[red]Unavailable[/red]{reason_suffix}"
189
+ return DeploymentStatus(
190
+ phase=DeploymentPhase.UNAVAILABLE,
191
+ status_message=_build_status_message(headline, current_rev, previous_rev),
192
+ current_revision=current_rev,
193
+ previous_revision=previous_rev,
194
+ )
195
+
196
+ # Fallback — shouldn't normally reach here
197
+ return DeploymentStatus(
198
+ phase=DeploymentPhase.PROGRESSING_NEW,
199
+ status_message="[dim]Waiting for deployment to become ready...[/dim]",
200
+ current_revision=current_rev,
201
+ previous_revision=previous_rev,
202
+ )
203
+
204
+
25
205
  @dataclass
26
206
  class ScalingParams:
27
207
  min_agents: Optional[int] = 0
@@ -277,7 +277,44 @@ async def status(
277
277
  border_style="red",
278
278
  )
279
279
 
280
- color = "bold green" if data["ready"] else "bold yellow"
280
+ # Build health/status panel with revision info when available
281
+ current_rev = data.get("currentRevision")
282
+ previous_rev = data.get("previousRevision")
283
+
284
+ if current_rev:
285
+ # Rich status with revision details
286
+ health_lines = []
287
+
288
+ rev_phase = current_rev.get("phase", "Unknown")
289
+ rev_id = current_rev.get("deploymentID", "")[:8]
290
+ rev_replicas = current_rev.get("readyReplicas")
291
+
292
+ if data["ready"]:
293
+ health_lines.append("[bold green]Ready[/bold green]")
294
+ else:
295
+ health_lines.append(f"[bold yellow]{rev_phase}[/bold yellow]")
296
+
297
+ current_parts = [f" Current [bold]({rev_id})[/bold] {rev_phase}"]
298
+ if rev_replicas is not None:
299
+ current_parts.append(f"[dim]·[/dim] {rev_replicas} agents")
300
+ health_lines.append(" ".join(current_parts))
301
+
302
+ if previous_rev:
303
+ prev_phase = previous_rev.get("phase", "Unknown")
304
+ prev_id = previous_rev.get("deploymentID", "")[:8]
305
+ prev_replicas = previous_rev.get("readyReplicas")
306
+ prev_parts = [f" Previous [bold]({prev_id})[/bold] {prev_phase}"]
307
+ if prev_replicas is not None:
308
+ prev_parts.append(f"[dim]·[/dim] {prev_replicas} agents")
309
+ health_lines.append(" ".join(prev_parts))
310
+
311
+ health_content = "\n".join(health_lines)
312
+ health_border = "green" if data["ready"] else "yellow"
313
+ else:
314
+ # Fallback: no revision data from API
315
+ health_content = f"[{'bold green' if data['ready'] else 'bold yellow'}]Health: {'Ready' if data['ready'] else 'Stopped'}[/]"
316
+ health_border = "green" if data["ready"] else "yellow"
317
+
281
318
  subtitle = (
282
319
  f"[dim]Start a new active session with[/dim] [bold cyan]{PIPECAT_CLI_NAME} agent start {agent_name}[/bold cyan]"
283
320
  if data["ready"]
@@ -289,8 +326,8 @@ async def status(
289
326
  deployment_table,
290
327
  scaling_panel if scaling_panel else "",
291
328
  Panel(
292
- f"[{color}]Health: {'Ready' if data['ready'] else 'Stopped'}[/]",
293
- border_style="green" if data["ready"] else "yellow",
329
+ health_content,
330
+ border_style=health_border,
294
331
  expand=False,
295
332
  ),
296
333
  error_panel if error_panel else "",
@@ -20,6 +20,7 @@ from pipecatcloud._utils.async_utils import synchronizer
20
20
  from pipecatcloud._utils.auth_utils import requires_login
21
21
  from pipecatcloud._utils.build_utils import BuildStatus, format_size
22
22
  from pipecatcloud._utils.console_utils import console, format_timestamp
23
+ from pipecatcloud.cli import PIPECAT_CLI_NAME
23
24
  from pipecatcloud.cli.api import API
24
25
  from pipecatcloud.cli.config import config
25
26
 
@@ -90,7 +91,7 @@ async def logs(
90
91
  console.print("[dim]No logs available for this build yet[/dim]")
91
92
  console.print(
92
93
  f"\n[dim]The build may still be starting. "
93
- f"Check status with:[/dim] [bold]pcc build status {build_id}[/bold]"
94
+ f"Check status with:[/dim] [bold]{PIPECAT_CLI_NAME} build status {build_id}[/bold]"
94
95
  )
95
96
  return
96
97
 
@@ -211,11 +212,13 @@ async def status(
211
212
 
212
213
  # Show helpful commands based on status
213
214
  if status_val in (BuildStatus.FAILED, BuildStatus.TIMEOUT):
214
- console.print(f"\n[dim]View full logs:[/dim] [bold]pcc build logs {build_id}[/bold]")
215
+ console.print(
216
+ f"\n[dim]View full logs:[/dim] [bold]{PIPECAT_CLI_NAME} build logs {build_id}[/bold]"
217
+ )
215
218
  elif status_val in (BuildStatus.PENDING, BuildStatus.BUILDING):
216
219
  console.print("\n[dim]Build in progress. Check again or view logs:[/dim]")
217
- console.print(f" [bold]pcc build status {build_id}[/bold]")
218
- console.print(f" [bold]pcc build logs {build_id}[/bold]")
220
+ console.print(f" [bold]{PIPECAT_CLI_NAME} build status {build_id}[/bold]")
221
+ console.print(f" [bold]{PIPECAT_CLI_NAME} build logs {build_id}[/bold]")
219
222
 
220
223
 
221
224
  @build_cli.command(name="list", help="List recent cloud builds")
@@ -302,4 +305,6 @@ async def list_builds(
302
305
  title_extra=showing_text,
303
306
  )
304
307
 
305
- console.print("\n[dim]View build details:[/dim] [bold]pcc build status <build-id>[/bold]")
308
+ console.print(
309
+ f"\n[dim]View build details:[/dim] [bold]{PIPECAT_CLI_NAME} build status <build-id>[/bold]"
310
+ )
@@ -32,6 +32,7 @@ from pipecatcloud._utils.deploy_utils import (
32
32
  DeployConfigParams,
33
33
  KrispVivaConfig,
34
34
  ScalingParams,
35
+ interpret_deployment_status,
35
36
  with_deploy_config,
36
37
  )
37
38
  from pipecatcloud._utils.regions import get_region_codes, validate_region
@@ -40,7 +41,7 @@ from pipecatcloud.cli.api import API
40
41
  from pipecatcloud.cli.config import config
41
42
  from pipecatcloud.constants import KRISP_VIVA_MODELS, Region
42
43
 
43
- MAX_ALIVE_CHECKS = 18
44
+ MAX_ALIVE_CHECKS = 120
44
45
  ALIVE_CHECK_SLEEP = 5
45
46
 
46
47
  # ----- Cloud Build Flow
@@ -238,7 +239,7 @@ async def _cloud_build_flow(
238
239
  error_msg = final_build.get("errorMessage", final_build.get("error", "Unknown error"))
239
240
  console.error(
240
241
  f"Build failed: {error_msg}\n\n"
241
- f"[dim]View logs with:[/dim] [bold]pcc build logs {build_id}[/bold]"
242
+ f"[dim]View logs with:[/dim] [bold]{PIPECAT_CLI_NAME} build logs {build_id}[/bold]"
242
243
  )
243
244
  return None
244
245
 
@@ -349,6 +350,7 @@ async def _deploy(params: DeployConfigParams, org, force: bool = False):
349
350
  """
350
351
  active_deployment_id = None
351
352
  is_ready = False
353
+ last_status = None
352
354
  checks_performed = 0
353
355
 
354
356
  console.print(
@@ -387,42 +389,29 @@ async def _deploy(params: DeployConfigParams, org, force: bool = False):
387
389
  console.error("Error checking deployment status")
388
390
  return typer.Exit()
389
391
 
390
- # Get deployment IDs
391
- # - desiredDeploymentId (or activeDeploymentId): what we requested
392
- # - reconciledDeploymentId: what the operator has actually processed
392
+ # Capture the deployment ID for display
393
393
  desired_deployment_id = agent_status.get(
394
394
  "desiredDeploymentId", agent_status.get("activeDeploymentId")
395
395
  )
396
- reconciled_deployment_id = agent_status.get("reconciledDeploymentId")
397
-
398
- # Update status message with deployment ID
399
396
  if desired_deployment_id and not active_deployment_id:
400
397
  active_deployment_id = desired_deployment_id
401
- deployment_status_message = f"[dim]Waiting for deployment to become ready (deployment ID: {active_deployment_id})...[/dim]"
402
- status.update(deployment_status_message)
403
-
404
- # Check if operator has reconciled this deployment
405
- deployment_reconciled = reconciled_deployment_id == desired_deployment_id
406
- if not deployment_reconciled:
407
- logger.debug(
408
- f"Waiting for operator to reconcile: desired={desired_deployment_id}, reconciled={reconciled_deployment_id}"
409
- )
410
- await asyncio.sleep(ALIVE_CHECK_SLEEP)
411
- checks_performed += 1
412
- continue
413
-
414
- # Check if deployment is ready
415
- # For KEDA deployments, we need:
416
- # - available: true (can handle traffic)
417
- # - activeDeploymentReady: true (ReplicaSet is ready)
418
- # The 'available' field falls back to 'ready' if not present (for backwards compatibility)
419
- available = agent_status.get("available", agent_status.get("ready", False))
420
- deployment_ready = agent_status.get("activeDeploymentReady", False)
421
-
422
- if available and deployment_ready:
398
+
399
+ # Interpret status using conditions, availability, and revision info
400
+ last_status = interpret_deployment_status(
401
+ agent_status, desired_deployment_id=desired_deployment_id
402
+ )
403
+
404
+ logger.debug(
405
+ f"Interpreted phase: {last_status.phase}, available: {last_status.is_available}"
406
+ )
407
+
408
+ if last_status.is_ready:
423
409
  is_ready = True
424
410
  break
425
411
 
412
+ # Update spinner with current status message
413
+ status.update(last_status.status_message)
414
+
426
415
  # Wait before checking again
427
416
  await asyncio.sleep(ALIVE_CHECK_SLEEP)
428
417
  checks_performed += 1
@@ -450,9 +439,36 @@ async def _deploy(params: DeployConfigParams, org, force: bool = False):
450
439
  f"{extra_message}",
451
440
  title_extra=f"{'Update' if existing_agent else 'Deployment'} complete",
452
441
  )
442
+ elif last_status and last_status.is_available:
443
+ # Timed out but service is available — soft warning, not a hard error
444
+ timeout_seconds = MAX_ALIVE_CHECKS * ALIVE_CHECK_SLEEP
445
+ console.print(
446
+ Panel(
447
+ f"Service is available and serving traffic, but the new deployment hasn't fully rolled out after {timeout_seconds}s.\n"
448
+ f"This is normal for deployments with large images or high replica counts.\n\n"
449
+ f"[bold]Check status:[/bold] `{PIPECAT_CLI_NAME} agent status {params.agent_name}`\n"
450
+ f"[bold]View logs:[/bold] `{PIPECAT_CLI_NAME} agent logs {params.agent_name}`",
451
+ title="Deployment still in progress",
452
+ title_align="left",
453
+ border_style="yellow",
454
+ )
455
+ )
456
+ elif last_status and last_status.degraded_reason:
457
+ # Timed out with degraded status
458
+ console.print(
459
+ Panel(
460
+ f"Deployment is degraded: {last_status.degraded_reason}\n\n"
461
+ f"[bold]Check status:[/bold] `{PIPECAT_CLI_NAME} agent status {params.agent_name}`\n"
462
+ f"[bold]View logs:[/bold] `{PIPECAT_CLI_NAME} agent logs {params.agent_name}`",
463
+ title="Deployment degraded",
464
+ title_align="left",
465
+ border_style="red",
466
+ )
467
+ )
453
468
  else:
469
+ timeout_seconds = MAX_ALIVE_CHECKS * ALIVE_CHECK_SLEEP
454
470
  console.error(
455
- f"Deployment did not enter ready state within {MAX_ALIVE_CHECKS * ALIVE_CHECK_SLEEP} seconds. "
471
+ f"Deployment did not become available within {timeout_seconds} seconds.\n"
456
472
  f"Please check logs with `{PIPECAT_CLI_NAME} agent logs {params.agent_name}`"
457
473
  )
458
474
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipecatcloud
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Cloud hosting for Pipecat AI applications
5
5
  License: BSD 2-Clause License
6
6
  Project-URL: Website, https://docs.pipecat.daily.co/
@@ -54,6 +54,7 @@ tests/__init__.py
54
54
  tests/test_agent_sessions.py
55
55
  tests/test_agent_stop.py
56
56
  tests/test_api_client.py
57
+ tests/test_deployment_status.py
57
58
  tests/test_docker_commands.py
58
59
  tests/test_krisp_viva.py
59
60
  tests/test_managed_keys.py
@@ -0,0 +1,478 @@
1
+ """
2
+ Unit tests for deployment status interpretation.
3
+
4
+ Tests the interpret_deployment_status function which maps raw API responses
5
+ to structured DeploymentStatus objects used for CLI display during deploy polling.
6
+ """
7
+
8
+ from pipecatcloud._utils.deploy_utils import (
9
+ DeploymentPhase,
10
+ _format_elapsed,
11
+ _format_revision_line,
12
+ interpret_deployment_status,
13
+ )
14
+
15
+
16
+ class TestWaitingForOperator:
17
+ """When reconciledDeploymentId != desiredDeploymentId, operator hasn't processed yet."""
18
+
19
+ def test_unreconciled_deployment(self):
20
+ status = interpret_deployment_status(
21
+ {
22
+ "desiredDeploymentId": "deploy-new",
23
+ "reconciledDeploymentId": "deploy-old",
24
+ "available": False,
25
+ "ready": False,
26
+ },
27
+ desired_deployment_id="deploy-new",
28
+ )
29
+ assert status.phase == DeploymentPhase.WAITING_FOR_OPERATOR
30
+ assert not status.is_available
31
+ assert not status.is_ready
32
+
33
+ def test_no_reconciled_id_yet(self):
34
+ status = interpret_deployment_status(
35
+ {
36
+ "desiredDeploymentId": "deploy-new",
37
+ "reconciledDeploymentId": None,
38
+ "available": False,
39
+ "ready": False,
40
+ },
41
+ desired_deployment_id="deploy-new",
42
+ )
43
+ assert status.phase == DeploymentPhase.WAITING_FOR_OPERATOR
44
+
45
+
46
+ class TestReady:
47
+ """When available=true and ready=true (or activeDeploymentReady=true)."""
48
+
49
+ def test_available_and_ready(self):
50
+ status = interpret_deployment_status(
51
+ {
52
+ "desiredDeploymentId": "deploy-1",
53
+ "reconciledDeploymentId": "deploy-1",
54
+ "available": True,
55
+ "ready": True,
56
+ },
57
+ desired_deployment_id="deploy-1",
58
+ )
59
+ assert status.phase == DeploymentPhase.READY
60
+ assert status.is_available
61
+ assert status.is_ready
62
+
63
+ def test_available_and_active_deployment_ready(self):
64
+ """activeDeploymentReady is the legacy field, should still trigger READY."""
65
+ status = interpret_deployment_status(
66
+ {
67
+ "desiredDeploymentId": "deploy-1",
68
+ "reconciledDeploymentId": "deploy-1",
69
+ "available": True,
70
+ "ready": False,
71
+ "activeDeploymentReady": True,
72
+ },
73
+ desired_deployment_id="deploy-1",
74
+ )
75
+ assert status.phase == DeploymentPhase.READY
76
+ assert status.is_available
77
+ assert status.is_ready
78
+
79
+ def test_ready_fallback_to_ready_field(self):
80
+ """When 'available' is missing, falls back to 'ready'."""
81
+ status = interpret_deployment_status(
82
+ {
83
+ "desiredDeploymentId": "deploy-1",
84
+ "reconciledDeploymentId": "deploy-1",
85
+ "ready": True,
86
+ "activeDeploymentReady": True,
87
+ },
88
+ desired_deployment_id="deploy-1",
89
+ )
90
+ assert status.phase == DeploymentPhase.READY
91
+
92
+
93
+ class TestProgressingAvailable:
94
+ """Available but not ready — rolling update with existing service serving traffic."""
95
+
96
+ def test_available_not_ready(self):
97
+ status = interpret_deployment_status(
98
+ {
99
+ "desiredDeploymentId": "deploy-2",
100
+ "reconciledDeploymentId": "deploy-2",
101
+ "available": True,
102
+ "ready": False,
103
+ "conditions": [
104
+ {"type": "Available", "status": "True"},
105
+ {"type": "Progressing", "status": "True"},
106
+ ],
107
+ },
108
+ desired_deployment_id="deploy-2",
109
+ )
110
+ assert status.phase == DeploymentPhase.PROGRESSING_AVAILABLE
111
+ assert status.is_available
112
+ assert not status.is_ready
113
+ assert "Progressing" in status.status_message
114
+ assert "Available" in status.status_message
115
+
116
+ def test_available_not_ready_with_revision_info(self):
117
+ status = interpret_deployment_status(
118
+ {
119
+ "desiredDeploymentId": "deploy-2",
120
+ "reconciledDeploymentId": "deploy-2",
121
+ "available": True,
122
+ "ready": False,
123
+ "currentRevision": {
124
+ "deploymentID": "deploy-2",
125
+ "phase": "Validating",
126
+ "readyReplicas": 42,
127
+ },
128
+ "previousRevision": {
129
+ "deploymentID": "deploy-1",
130
+ "phase": "Draining",
131
+ "readyReplicas": 84,
132
+ },
133
+ },
134
+ desired_deployment_id="deploy-2",
135
+ )
136
+ assert status.phase == DeploymentPhase.PROGRESSING_AVAILABLE
137
+ assert status.is_available
138
+ # Multi-line: headline + current + previous
139
+ lines = status.status_message.split("\n")
140
+ assert len(lines) == 3
141
+ assert "42 agents" in lines[1]
142
+ assert "Validating" in lines[1]
143
+ assert "84 agents" in lines[2]
144
+ assert "Draining" in lines[2]
145
+ assert status.current_revision["readyReplicas"] == 42
146
+ assert status.previous_revision["phase"] == "Draining"
147
+
148
+
149
+ class TestProgressingNew:
150
+ """Not available, but progressing — new service coming up for the first time."""
151
+
152
+ def test_not_available_progressing(self):
153
+ status = interpret_deployment_status(
154
+ {
155
+ "desiredDeploymentId": "deploy-1",
156
+ "reconciledDeploymentId": "deploy-1",
157
+ "available": False,
158
+ "ready": False,
159
+ "conditions": [
160
+ {"type": "Progressing", "status": "True"},
161
+ ],
162
+ },
163
+ desired_deployment_id="deploy-1",
164
+ )
165
+ assert status.phase == DeploymentPhase.PROGRESSING_NEW
166
+ assert not status.is_available
167
+ assert not status.is_ready
168
+ assert "Progressing" in status.status_message
169
+
170
+ def test_not_available_progressing_with_agents(self):
171
+ status = interpret_deployment_status(
172
+ {
173
+ "desiredDeploymentId": "deploy-1",
174
+ "reconciledDeploymentId": "deploy-1",
175
+ "available": False,
176
+ "ready": False,
177
+ "conditions": [
178
+ {"type": "Progressing", "status": "True"},
179
+ ],
180
+ "currentRevision": {
181
+ "deploymentID": "deploy-1",
182
+ "phase": "Creating",
183
+ "readyReplicas": 3,
184
+ },
185
+ },
186
+ desired_deployment_id="deploy-1",
187
+ )
188
+ assert status.phase == DeploymentPhase.PROGRESSING_NEW
189
+ lines = status.status_message.split("\n")
190
+ assert len(lines) == 2
191
+ assert "3 agents" in lines[1]
192
+ assert "Creating" in lines[1]
193
+
194
+
195
+ class TestDegradedAvailable:
196
+ """Available but degraded — serving traffic but something is wrong."""
197
+
198
+ def test_degraded_and_available(self):
199
+ status = interpret_deployment_status(
200
+ {
201
+ "desiredDeploymentId": "deploy-1",
202
+ "reconciledDeploymentId": "deploy-1",
203
+ "available": True,
204
+ "ready": False,
205
+ "conditions": [
206
+ {"type": "Available", "status": "True"},
207
+ {
208
+ "type": "Degraded",
209
+ "status": "True",
210
+ "reason": "ImageResolutionFailed",
211
+ "message": "Failed to resolve image tag",
212
+ },
213
+ ],
214
+ },
215
+ desired_deployment_id="deploy-1",
216
+ )
217
+ assert status.phase == DeploymentPhase.DEGRADED_AVAILABLE
218
+ assert status.is_available
219
+ assert not status.is_ready
220
+ assert status.degraded_reason == "Failed to resolve image tag"
221
+ assert "Degraded" in status.status_message
222
+ assert "Available" in status.status_message
223
+
224
+ def test_degraded_reason_falls_back_to_reason_field(self):
225
+ status = interpret_deployment_status(
226
+ {
227
+ "desiredDeploymentId": "deploy-1",
228
+ "reconciledDeploymentId": "deploy-1",
229
+ "available": True,
230
+ "ready": False,
231
+ "conditions": [
232
+ {
233
+ "type": "Degraded",
234
+ "status": "True",
235
+ "reason": "SomeReason",
236
+ },
237
+ ],
238
+ },
239
+ desired_deployment_id="deploy-1",
240
+ )
241
+ assert status.phase == DeploymentPhase.DEGRADED_AVAILABLE
242
+ assert status.degraded_reason == "SomeReason"
243
+
244
+
245
+ class TestUnavailable:
246
+ """Not available and not progressing — broken."""
247
+
248
+ def test_not_available_not_progressing(self):
249
+ status = interpret_deployment_status(
250
+ {
251
+ "desiredDeploymentId": "deploy-1",
252
+ "reconciledDeploymentId": "deploy-1",
253
+ "available": False,
254
+ "ready": False,
255
+ "conditions": [],
256
+ },
257
+ desired_deployment_id="deploy-1",
258
+ )
259
+ assert status.phase == DeploymentPhase.UNAVAILABLE
260
+ assert not status.is_available
261
+ assert not status.is_ready
262
+ assert "Unavailable" in status.status_message
263
+
264
+ def test_unavailable_with_degraded_reason(self):
265
+ status = interpret_deployment_status(
266
+ {
267
+ "desiredDeploymentId": "deploy-1",
268
+ "reconciledDeploymentId": "deploy-1",
269
+ "available": False,
270
+ "ready": False,
271
+ "conditions": [
272
+ {
273
+ "type": "Degraded",
274
+ "status": "True",
275
+ "message": "CrashLoopBackOff",
276
+ },
277
+ ],
278
+ },
279
+ desired_deployment_id="deploy-1",
280
+ )
281
+ assert status.phase == DeploymentPhase.UNAVAILABLE
282
+ assert "CrashLoopBackOff" in status.status_message
283
+
284
+
285
+ class TestBackwardCompatibility:
286
+ """Tests for API responses that don't include new fields (conditions, revisions)."""
287
+
288
+ def test_no_conditions_field(self):
289
+ """Old API without conditions array should still work."""
290
+ status = interpret_deployment_status(
291
+ {
292
+ "desiredDeploymentId": "deploy-1",
293
+ "reconciledDeploymentId": "deploy-1",
294
+ "available": True,
295
+ "ready": True,
296
+ },
297
+ desired_deployment_id="deploy-1",
298
+ )
299
+ assert status.phase == DeploymentPhase.READY
300
+
301
+ def test_no_revision_info(self):
302
+ """API without revision fields should work — single-line message, no replica info."""
303
+ status = interpret_deployment_status(
304
+ {
305
+ "desiredDeploymentId": "deploy-1",
306
+ "reconciledDeploymentId": "deploy-1",
307
+ "available": True,
308
+ "ready": False,
309
+ },
310
+ desired_deployment_id="deploy-1",
311
+ )
312
+ assert status.phase == DeploymentPhase.PROGRESSING_AVAILABLE
313
+ assert status.current_revision is None
314
+ # No revision info → single line, no "agents"
315
+ assert "\n" not in status.status_message
316
+ assert "agents" not in status.status_message
317
+
318
+ def test_legacy_ready_field_only(self):
319
+ """Very old API that only has 'ready' field."""
320
+ status = interpret_deployment_status(
321
+ {
322
+ "activeDeploymentId": "deploy-1",
323
+ "reconciledDeploymentId": "deploy-1",
324
+ "ready": True,
325
+ "activeDeploymentReady": True,
326
+ },
327
+ desired_deployment_id="deploy-1",
328
+ )
329
+ assert status.phase == DeploymentPhase.READY
330
+
331
+ def test_no_desired_deployment_id(self):
332
+ """When desired_deployment_id is None, skip reconciliation check."""
333
+ status = interpret_deployment_status(
334
+ {
335
+ "available": True,
336
+ "ready": True,
337
+ },
338
+ )
339
+ assert status.phase == DeploymentPhase.READY
340
+
341
+
342
+ class TestRevisionLines:
343
+ """Tests for multi-line revision detail rendering."""
344
+
345
+ def test_current_revision_line_format(self):
346
+ line = _format_revision_line(
347
+ "Current ",
348
+ {
349
+ "deploymentID": "73071caf-abcd-1234",
350
+ "phase": "Validating",
351
+ "readyReplicas": 42,
352
+ },
353
+ )
354
+ assert "73071caf" in line
355
+ assert "Validating" in line
356
+ assert "42 agents" in line
357
+
358
+ def test_previous_revision_line_format(self):
359
+ line = _format_revision_line(
360
+ "Previous",
361
+ {
362
+ "deploymentID": "15122a6b-efgh-5678",
363
+ "phase": "Draining",
364
+ "readyReplicas": 68,
365
+ },
366
+ )
367
+ assert "15122a6b" in line
368
+ assert "Draining" in line
369
+ assert "68 agents" in line
370
+
371
+ def test_revision_line_with_elapsed_time(self):
372
+ from datetime import datetime, timezone
373
+
374
+ recent = (datetime.now(timezone.utc)).isoformat()
375
+ line = _format_revision_line(
376
+ "Current ",
377
+ {
378
+ "deploymentID": "abcdef12",
379
+ "phase": "Validating",
380
+ "readyReplicas": 10,
381
+ "phaseStartedAt": recent,
382
+ },
383
+ )
384
+ assert "Validating" in line
385
+ assert "10 agents" in line
386
+ # Should have a time component (0s or 1s)
387
+ assert "s" in line
388
+
389
+ def test_revision_line_without_agents(self):
390
+ """Revision with no readyReplicas field should omit replica count."""
391
+ line = _format_revision_line(
392
+ "Current ",
393
+ {
394
+ "deploymentID": "abcdef12",
395
+ "phase": "Creating",
396
+ },
397
+ )
398
+ assert "Creating" in line
399
+ assert "agents" not in line
400
+
401
+ def test_multi_line_message_with_both_revisions(self):
402
+ status = interpret_deployment_status(
403
+ {
404
+ "desiredDeploymentId": "deploy-2",
405
+ "reconciledDeploymentId": "deploy-2",
406
+ "available": True,
407
+ "ready": False,
408
+ "currentRevision": {
409
+ "deploymentID": "deploy-2-full-uuid",
410
+ "phase": "Validating",
411
+ "readyReplicas": 42,
412
+ },
413
+ "previousRevision": {
414
+ "deploymentID": "deploy-1-full-uuid",
415
+ "phase": "Draining",
416
+ "readyReplicas": 68,
417
+ },
418
+ },
419
+ desired_deployment_id="deploy-2",
420
+ )
421
+ lines = status.status_message.split("\n")
422
+ assert len(lines) == 3
423
+ # Line 0: condition headline
424
+ assert "Progressing" in lines[0]
425
+ assert "Available" in lines[0]
426
+ # Line 1: current revision (deploymentID truncated to 8 chars)
427
+ assert "Current" in lines[1]
428
+ assert "deploy-2" in lines[1]
429
+ assert "42 agents" in lines[1]
430
+ # Line 2: previous revision
431
+ assert "Previous" in lines[2]
432
+ assert "deploy-1" in lines[2]
433
+ assert "68 agents" in lines[2]
434
+
435
+ def test_single_line_when_no_revisions(self):
436
+ """Without revision data, message stays single-line."""
437
+ status = interpret_deployment_status(
438
+ {
439
+ "desiredDeploymentId": "deploy-1",
440
+ "reconciledDeploymentId": "deploy-1",
441
+ "available": False,
442
+ "ready": False,
443
+ "conditions": [{"type": "Progressing", "status": "True"}],
444
+ },
445
+ desired_deployment_id="deploy-1",
446
+ )
447
+ assert "\n" not in status.status_message
448
+
449
+
450
+ class TestFormatElapsed:
451
+ """Tests for the elapsed time formatter."""
452
+
453
+ def test_none_input(self):
454
+ assert _format_elapsed(None) == ""
455
+
456
+ def test_invalid_input(self):
457
+ assert _format_elapsed("not-a-date") == ""
458
+
459
+ def test_recent_timestamp(self):
460
+ from datetime import datetime, timezone
461
+
462
+ now = datetime.now(timezone.utc).isoformat()
463
+ result = _format_elapsed(now)
464
+ assert result in ("0s", "1s")
465
+
466
+ def test_minutes_ago(self):
467
+ from datetime import datetime, timedelta, timezone
468
+
469
+ two_min_ago = (datetime.now(timezone.utc) - timedelta(minutes=2, seconds=30)).isoformat()
470
+ result = _format_elapsed(two_min_ago)
471
+ assert result.startswith("2m")
472
+
473
+ def test_z_suffix(self):
474
+ from datetime import datetime, timedelta, timezone
475
+
476
+ ts = (datetime.now(timezone.utc) - timedelta(seconds=45)).strftime("%Y-%m-%dT%H:%M:%SZ")
477
+ result = _format_elapsed(ts)
478
+ assert "s" in result
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes