pipecatcloud 0.2.20__tar.gz → 0.3.0__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 (62) hide show
  1. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/CHANGELOG.md +29 -4
  2. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/PKG-INFO +1 -1
  3. pipecatcloud-0.3.0/src/pipecatcloud/_utils/build_utils.py +403 -0
  4. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/deploy_utils.py +36 -1
  5. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/api.py +124 -1
  6. pipecatcloud-0.3.0/src/pipecatcloud/cli/commands/build.py +305 -0
  7. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/deploy.py +354 -35
  8. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/entry_point.py +2 -0
  9. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/config.py +1 -0
  10. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/PKG-INFO +1 -1
  11. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/SOURCES.txt +2 -0
  12. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/uv.lock +348 -114
  13. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/.github/workflows/format.yml +0 -0
  14. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/.github/workflows/publish-pypi.yml +0 -0
  15. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/.github/workflows/publish-test.yml +0 -0
  16. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/.github/workflows/tests.yml +0 -0
  17. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/.gitignore +0 -0
  18. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/.pre-commit-config.yaml +0 -0
  19. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/LICENSE +0 -0
  20. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/README.md +0 -0
  21. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/pipecat-cloud.png +0 -0
  22. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/pyproject.toml +0 -0
  23. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/pytest.ini +0 -0
  24. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/scripts/fix-ruff.sh +0 -0
  25. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/scripts/pre-commit.sh +0 -0
  26. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/setup.cfg +0 -0
  27. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/__init__.py +0 -0
  28. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/__main__.py +0 -0
  29. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/__version__.py +0 -0
  30. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/__init__.py +0 -0
  31. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/async_utils.py +0 -0
  32. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/auth_utils.py +0 -0
  33. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/console_utils.py +0 -0
  34. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/_utils/regions.py +0 -0
  35. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/agent.py +0 -0
  36. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/__init__.py +0 -0
  37. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/api.py +0 -0
  38. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/__init__.py +0 -0
  39. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/agent.py +0 -0
  40. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/auth.py +0 -0
  41. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/docker.py +0 -0
  42. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/organizations.py +0 -0
  43. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/regions.py +0 -0
  44. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/commands/secrets.py +0 -0
  45. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/cli/config.py +0 -0
  46. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/constants.py +0 -0
  47. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/exception.py +0 -0
  48. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/py.typed +0 -0
  49. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/session.py +0 -0
  50. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
  51. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
  52. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
  53. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/requires.txt +0 -0
  54. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/src/pipecatcloud.egg-info/top_level.txt +0 -0
  55. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/tests/__init__.py +0 -0
  56. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/tests/test_agent_sessions.py +0 -0
  57. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/tests/test_agent_stop.py +0 -0
  58. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/tests/test_api_client.py +0 -0
  59. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/tests/test_docker_commands.py +0 -0
  60. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/tests/test_krisp_viva.py +0 -0
  61. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/tests/test_managed_keys.py +0 -0
  62. {pipecatcloud-0.2.20 → pipecatcloud-0.3.0}/tests/test_regions.py +0 -0
@@ -5,6 +5,35 @@ 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.0] - 2026-03-12
9
+
10
+ ### Added
11
+
12
+ - **Cloud Builds**: Deploy agents without managing a container registry. Run
13
+ `pcc deploy` without an `image` and the CLI will build your image in the cloud.
14
+ - Deterministic content hashing for build caching (same files = skip rebuild)
15
+ - `pcc deploy --build-id <id>` to reuse an existing build
16
+ - `pcc deploy --yes` for non-interactive CI/CD usage
17
+ - `[build]` section in `pcc-deploy.toml` for build configuration
18
+ - New `pcc build` command group for managing cloud builds:
19
+ - `pcc build list` — list recent builds with status and region filtering
20
+ - `pcc build status <id>` — get detailed status of a build
21
+ - `pcc build logs <id>` — view build logs
22
+
23
+ ### Changed
24
+
25
+ - The `image` field in `pcc deploy` is now optional when using cloud builds
26
+ - The CLI now uses `desiredDeploymentId` (with fallback to `activeDeploymentId`)
27
+ and compares it against `reconciledDeploymentId` to determine deployment status.
28
+
29
+ ### Fixed
30
+
31
+ - Fixed `deploy` command reporting success prematurely when updating an existing
32
+ agent. The CLI now uses the new `reconciledDeploymentId` field from the API to
33
+ verify the operator has actually processed the deployment before reporting
34
+ readiness, preventing false "ready" status when the operator is slow to process
35
+ the update.
36
+
8
37
  ## [0.2.20] - 2026-02-11
9
38
 
10
39
  ### Fixed
@@ -13,7 +42,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
13
42
  output instead of Rich terminal widgets, enabling use in environments like
14
43
  Claude Code and CI/CD pipelines.
15
44
 
16
-
17
45
  ## [0.2.19] - 2026-01-27
18
46
 
19
47
  ### Added
@@ -69,7 +97,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
69
97
 
70
98
  - Added `pcc organizations properties` commands for managing organization-level
71
99
  configuration:
72
-
73
100
  - `properties list` - Display current property values
74
101
  - `properties set <name> <value>` - Update a property value
75
102
  - `properties schema` - Show available properties with metadata and allowed
@@ -254,13 +281,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
254
281
 
255
282
  - Relaxed the package requirements for `python-dotenv` and `uvicorn` to make it
256
283
  easier to work with Pipecat Cloud. The new supported versions are:
257
-
258
284
  - `"python-dotenv>=1.0.1,<2.0.0"`
259
285
  - `"uvicorn>=0.32.0,<1.0.0"`
260
286
 
261
287
  - Updated all types to inherit from `RunnerArguments` to align the types with
262
288
  the Pipecat development runner types. This adds types:
263
-
264
289
  - `handle_sigint`
265
290
  - `handle_sigterm`
266
291
  - `pipeline_idle_timeout_secs`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipecatcloud
3
- Version: 0.2.20
3
+ Version: 0.3.0
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/
@@ -0,0 +1,403 @@
1
+ #
2
+ # Copyright (c) 2025, Daily
3
+ #
4
+ # SPDX-License-Identifier: BSD 2-Clause License
5
+ #
6
+
7
+ """
8
+ Build utilities for Pipecat Cloud CLI.
9
+
10
+ Provides functions for creating deterministic build contexts, uploading to S3,
11
+ and polling build status.
12
+ """
13
+
14
+ import asyncio
15
+ import fnmatch
16
+ import gzip
17
+ import hashlib
18
+ import io
19
+ import os
20
+ import tarfile
21
+ import time
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Callable, List, Optional, Set, Tuple
25
+
26
+ import aiohttp
27
+ from loguru import logger
28
+
29
+
30
+ # Default patterns to exclude from build context
31
+ DEFAULT_EXCLUSIONS: Set[str] = {
32
+ # Version control
33
+ ".git",
34
+ ".gitignore",
35
+ ".gitattributes",
36
+ # Environment and secrets
37
+ ".env",
38
+ ".env.*",
39
+ "*.pem",
40
+ "*.key",
41
+ # Python artifacts
42
+ "__pycache__",
43
+ "*.pyc",
44
+ "*.pyo",
45
+ "*.pyd",
46
+ "*.so",
47
+ ".Python",
48
+ # Virtual environments
49
+ ".venv",
50
+ "venv",
51
+ "ENV",
52
+ "env",
53
+ # Testing
54
+ ".pytest_cache",
55
+ ".coverage",
56
+ "htmlcov",
57
+ ".tox",
58
+ ".nox",
59
+ # Type checking / Linting
60
+ ".mypy_cache",
61
+ ".ruff_cache",
62
+ # IDE
63
+ ".vscode",
64
+ ".idea",
65
+ "*.swp",
66
+ "*.swo",
67
+ # Build artifacts
68
+ "dist",
69
+ "build",
70
+ "*.egg-info",
71
+ # Node (if present)
72
+ "node_modules",
73
+ # Misc
74
+ ".DS_Store",
75
+ "Thumbs.db",
76
+ "*.log",
77
+ }
78
+
79
+
80
+ @dataclass
81
+ class BuildContext:
82
+ """Represents a packaged build context."""
83
+
84
+ tarball: bytes
85
+ context_hash: str
86
+ file_count: int
87
+ total_size: int
88
+
89
+
90
+ class BuildStatus:
91
+ """Build status constants."""
92
+
93
+ PENDING = "pending"
94
+ BUILDING = "building"
95
+ SUCCESS = "success"
96
+ FAILED = "failed"
97
+ TIMEOUT = "timeout"
98
+
99
+ TERMINAL_STATUSES = {SUCCESS, FAILED, TIMEOUT}
100
+
101
+ @classmethod
102
+ def is_terminal(cls, status: str) -> bool:
103
+ return status in cls.TERMINAL_STATUSES
104
+
105
+
106
+ def _should_exclude(path: Path, exclusions: Set[str], base_path: Path) -> bool:
107
+ """
108
+ Check if a path should be excluded from the build context.
109
+
110
+ Args:
111
+ path: Path to check
112
+ exclusions: Set of exclusion patterns
113
+ base_path: Base directory for relative path calculation
114
+
115
+ Returns:
116
+ True if path should be excluded
117
+ """
118
+ rel_path = path.relative_to(base_path)
119
+ path_parts = rel_path.parts
120
+
121
+ for pattern in exclusions:
122
+ # Check each part of the path against the pattern
123
+ for part in path_parts:
124
+ if fnmatch.fnmatch(part, pattern):
125
+ return True
126
+ # Check full relative path
127
+ if fnmatch.fnmatch(str(rel_path), pattern):
128
+ return True
129
+
130
+ return False
131
+
132
+
133
+ def load_dockerignore(context_dir: Path) -> Optional[Set[str]]:
134
+ """
135
+ Load patterns from .dockerignore file if it exists.
136
+
137
+ Args:
138
+ context_dir: Directory containing .dockerignore
139
+
140
+ Returns:
141
+ Set of patterns or None if file doesn't exist
142
+ """
143
+ dockerignore_path = context_dir / ".dockerignore"
144
+ if not dockerignore_path.exists():
145
+ return None
146
+
147
+ patterns = set()
148
+ try:
149
+ with open(dockerignore_path, "r") as f:
150
+ for line in f:
151
+ line = line.strip()
152
+ # Skip comments and empty lines
153
+ if line and not line.startswith("#"):
154
+ patterns.add(line)
155
+ except Exception as e:
156
+ logger.warning(f"Failed to read .dockerignore: {e}")
157
+ return None
158
+
159
+ return patterns
160
+
161
+
162
+ def get_exclusions(context_dir: Path, extra_patterns: Optional[List[str]] = None) -> Set[str]:
163
+ """
164
+ Get the set of exclusion patterns to use.
165
+
166
+ Priority:
167
+ 1. .dockerignore (if exists, takes precedence)
168
+ 2. Default exclusions + extra patterns
169
+
170
+ Args:
171
+ context_dir: Build context directory
172
+ extra_patterns: Additional patterns from config
173
+
174
+ Returns:
175
+ Set of exclusion patterns
176
+ """
177
+ dockerignore = load_dockerignore(context_dir)
178
+ if dockerignore is not None:
179
+ # .dockerignore takes precedence
180
+ return dockerignore
181
+
182
+ # Use defaults + extras
183
+ exclusions = DEFAULT_EXCLUSIONS.copy()
184
+ if extra_patterns:
185
+ exclusions.update(extra_patterns)
186
+ return exclusions
187
+
188
+
189
+ def create_deterministic_tarball(
190
+ context_dir: str,
191
+ exclusions: Set[str],
192
+ dockerfile_path: str = "Dockerfile",
193
+ ) -> BuildContext:
194
+ """
195
+ Create a deterministic tarball from the build context directory.
196
+
197
+ Determinism is achieved by:
198
+ - Sorting files alphabetically
199
+ - Setting mtime to Unix epoch (0)
200
+ - Normalizing permissions (uid=0, gid=0)
201
+ - Using gzip with mtime=0
202
+
203
+ Args:
204
+ context_dir: Directory containing build context
205
+ exclusions: Set of patterns to exclude
206
+ dockerfile_path: Path to Dockerfile (verified to exist)
207
+
208
+ Returns:
209
+ BuildContext with tarball bytes and metadata
210
+
211
+ Raises:
212
+ FileNotFoundError: If context_dir or Dockerfile doesn't exist
213
+ ValueError: If context is too large (>500MB)
214
+ """
215
+ base_path = Path(context_dir).resolve()
216
+
217
+ if not base_path.exists():
218
+ raise FileNotFoundError(f"Context directory not found: {context_dir}")
219
+
220
+ dockerfile_full = base_path / dockerfile_path
221
+ if not dockerfile_full.exists():
222
+ raise FileNotFoundError(f"Dockerfile not found: {dockerfile_full}")
223
+
224
+ # Collect all files, sorted alphabetically
225
+ files_to_add: List[Tuple[Path, str]] = []
226
+
227
+ for root, dirs, files in os.walk(base_path):
228
+ root_path = Path(root)
229
+
230
+ # Filter directories in-place to prevent descending into excluded dirs
231
+ dirs[:] = [
232
+ d for d in sorted(dirs) if not _should_exclude(root_path / d, exclusions, base_path)
233
+ ]
234
+
235
+ for filename in sorted(files):
236
+ file_path = root_path / filename
237
+ if not _should_exclude(file_path, exclusions, base_path):
238
+ rel_path = file_path.relative_to(base_path)
239
+ files_to_add.append((file_path, str(rel_path)))
240
+
241
+ # Sort by relative path for determinism
242
+ files_to_add.sort(key=lambda x: x[1])
243
+
244
+ # Create tarball in memory
245
+ tar_buffer = io.BytesIO()
246
+ total_size = 0
247
+
248
+ with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
249
+ for file_path, arc_name in files_to_add:
250
+ # Create TarInfo with normalized attributes
251
+ info = tarfile.TarInfo(name=arc_name)
252
+ info.size = file_path.stat().st_size
253
+ info.mtime = 0 # Unix epoch for determinism
254
+ info.uid = 0
255
+ info.gid = 0
256
+ info.uname = ""
257
+ info.gname = ""
258
+ # Normalize permissions: 0o644 for files, 0o755 for executables
259
+ if os.access(file_path, os.X_OK):
260
+ info.mode = 0o755
261
+ else:
262
+ info.mode = 0o644
263
+
264
+ with open(file_path, "rb") as f:
265
+ tar.addfile(info, f)
266
+
267
+ total_size += info.size
268
+
269
+ # Check size limit (500MB)
270
+ MAX_CONTEXT_SIZE = 500 * 1024 * 1024
271
+ if total_size > MAX_CONTEXT_SIZE:
272
+ raise ValueError(
273
+ f"Build context too large: {total_size / (1024 * 1024):.1f}MB "
274
+ f"(max {MAX_CONTEXT_SIZE / (1024 * 1024):.0f}MB)"
275
+ )
276
+
277
+ # Compress with gzip (mtime=0 for determinism)
278
+ tar_bytes = tar_buffer.getvalue()
279
+ gzip_buffer = io.BytesIO()
280
+ with gzip.GzipFile(fileobj=gzip_buffer, mode="wb", mtime=0) as gz:
281
+ gz.write(tar_bytes)
282
+
283
+ tarball = gzip_buffer.getvalue()
284
+
285
+ # Compute MD5 hash (first 16 hex chars to match server-side)
286
+ context_hash = hashlib.md5(tarball).hexdigest()[:16]
287
+
288
+ logger.debug(
289
+ f"Created tarball: {len(files_to_add)} files, "
290
+ f"{len(tarball)} bytes compressed, hash={context_hash}"
291
+ )
292
+
293
+ return BuildContext(
294
+ tarball=tarball,
295
+ context_hash=context_hash,
296
+ file_count=len(files_to_add),
297
+ total_size=total_size,
298
+ )
299
+
300
+
301
+ async def upload_to_s3(
302
+ tarball: bytes,
303
+ upload_url: str,
304
+ upload_fields: dict,
305
+ ) -> bool:
306
+ """
307
+ Upload tarball to S3 using presigned POST.
308
+
309
+ Args:
310
+ tarball: Compressed tarball bytes
311
+ upload_url: Presigned S3 URL
312
+ upload_fields: Fields for multipart form upload
313
+
314
+ Returns:
315
+ True on success, False on failure
316
+ """
317
+ # Create multipart form data
318
+ data = aiohttp.FormData()
319
+
320
+ # Add all presigned fields first (order matters for S3)
321
+ for key, value in upload_fields.items():
322
+ data.add_field(key, value)
323
+
324
+ # S3 presigned POST requires Content-Type as a form field (not just multipart header)
325
+ # Add it if not already in upload_fields
326
+ if "Content-Type" not in upload_fields:
327
+ data.add_field("Content-Type", "application/gzip")
328
+
329
+ # Add file last (must be named "file" for S3 presigned POST)
330
+ data.add_field("file", tarball, filename="context.tar.gz", content_type="application/gzip")
331
+
332
+ try:
333
+ async with aiohttp.ClientSession() as session:
334
+ async with session.post(upload_url, data=data) as response:
335
+ if response.status in (200, 201, 204):
336
+ logger.debug(f"Upload successful: {response.status}")
337
+ return True
338
+ else:
339
+ body = await response.text()
340
+ logger.error(f"Upload failed: {response.status} - {body}")
341
+ return False
342
+ except Exception as e:
343
+ logger.error(f"Upload error: {e}")
344
+ return False
345
+
346
+
347
+ async def poll_build_status(
348
+ build_id: str,
349
+ org: str,
350
+ api_client,
351
+ poll_interval: float = 3.0,
352
+ max_duration: float = 600.0, # 10 minutes
353
+ status_callback: Optional[Callable[[dict], None]] = None,
354
+ ) -> Tuple[bool, dict]:
355
+ """
356
+ Poll build status until completion or timeout.
357
+
358
+ Args:
359
+ build_id: Build ID to poll
360
+ org: Organization ID
361
+ api_client: API client instance
362
+ poll_interval: Seconds between polls
363
+ max_duration: Maximum wait time in seconds
364
+ status_callback: Optional callback(build_data) on each poll
365
+
366
+ Returns:
367
+ Tuple of (success: bool, final_build_data: dict)
368
+ """
369
+ start_time = time.time()
370
+
371
+ while True:
372
+ elapsed = time.time() - start_time
373
+ if elapsed > max_duration:
374
+ return False, {"status": "timeout", "error": "Polling timeout exceeded"}
375
+
376
+ data, error = await api_client.build_get(org=org, build_id=build_id)
377
+
378
+ if error:
379
+ return False, {"status": "error", "error": str(error)}
380
+
381
+ if not data:
382
+ return False, {"status": "error", "error": "Build not found"}
383
+
384
+ build = data.get("build", data)
385
+ status = build.get("status", "unknown")
386
+
387
+ if status_callback:
388
+ status_callback(build)
389
+
390
+ if BuildStatus.is_terminal(status):
391
+ return status == BuildStatus.SUCCESS, build
392
+
393
+ await asyncio.sleep(poll_interval)
394
+
395
+
396
+ def format_size(size_bytes: int) -> str:
397
+ """Format byte size as human-readable string."""
398
+ if size_bytes < 1024:
399
+ return f"{size_bytes} B"
400
+ elif size_bytes < 1024 * 1024:
401
+ return f"{size_bytes / 1024:.1f} KB"
402
+ else:
403
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
@@ -6,7 +6,7 @@
6
6
 
7
7
  import functools
8
8
  import os
9
- from typing import Callable, Optional
9
+ from typing import Callable, List, Optional
10
10
 
11
11
  import toml
12
12
  from attr import dataclass, field
@@ -74,10 +74,27 @@ class KrispVivaConfig:
74
74
  return {"audio_filter": self.audio_filter}
75
75
 
76
76
 
77
+ @dataclass
78
+ class BuildConfig:
79
+ """Configuration for cloud builds."""
80
+
81
+ context_dir: str = "."
82
+ dockerfile: str = "Dockerfile"
83
+ exclude_patterns: List[str] = field(factory=list)
84
+
85
+ def to_dict(self):
86
+ return {
87
+ "context_dir": self.context_dir,
88
+ "dockerfile": self.dockerfile,
89
+ "exclude_patterns": self.exclude_patterns,
90
+ }
91
+
92
+
77
93
  @dataclass
78
94
  class DeployConfigParams:
79
95
  agent_name: Optional[str] = None
80
96
  image: Optional[str] = None
97
+ build_id: Optional[str] = None # For cloud builds
81
98
  image_credentials: Optional[str] = None
82
99
  secret_set: Optional[str] = None
83
100
  region: Optional[str] = None
@@ -85,17 +102,22 @@ class DeployConfigParams:
85
102
  enable_krisp: bool = False
86
103
  enable_managed_keys: bool = False
87
104
  docker_config: dict = field(factory=dict)
105
+ build_config: BuildConfig = field(factory=BuildConfig) # Cloud build configuration
88
106
  agent_profile: Optional[str] = None
89
107
  krisp_viva: KrispVivaConfig = field(factory=KrispVivaConfig)
90
108
 
91
109
  def __attrs_post_init__(self):
92
110
  if self.image is not None and ":" not in self.image:
93
111
  raise ValueError("Provided image must include tag e.g. my-image:latest")
112
+ # Cannot specify both image and build_id
113
+ if self.image is not None and self.build_id is not None:
114
+ raise ValueError("Cannot specify both 'image' and 'build_id'")
94
115
 
95
116
  def to_dict(self):
96
117
  return {
97
118
  "agent_name": self.agent_name,
98
119
  "image": self.image,
120
+ "build_id": self.build_id,
99
121
  "image_credentials": self.image_credentials,
100
122
  "secret_set": self.secret_set,
101
123
  "region": self.region,
@@ -103,6 +125,7 @@ class DeployConfigParams:
103
125
  "enable_krisp": self.enable_krisp,
104
126
  "enable_managed_keys": self.enable_managed_keys,
105
127
  "docker_config": self.docker_config,
128
+ "build_config": self.build_config.to_dict() if self.build_config else None,
106
129
  "agent_profile": self.agent_profile,
107
130
  "krisp_viva": self.krisp_viva.to_dict() if self.krisp_viva else None,
108
131
  }
@@ -132,11 +155,21 @@ def load_deploy_config_file() -> Optional[DeployConfigParams]:
132
155
  krisp_viva_data = config_data.pop("krisp_viva", {})
133
156
  krisp_viva_config = KrispVivaConfig(**krisp_viva_data)
134
157
 
158
+ # Extract build configuration if present
159
+ build_data = config_data.pop("build", {})
160
+ exclude_data = build_data.pop("exclude", {})
161
+ build_config = BuildConfig(
162
+ context_dir=build_data.get("context_dir", "."),
163
+ dockerfile=build_data.get("dockerfile", "Dockerfile"),
164
+ exclude_patterns=exclude_data.get("patterns", []),
165
+ )
166
+
135
167
  # Create DeployConfigParams with validated data
136
168
  validated_config = DeployConfigParams(
137
169
  **config_data,
138
170
  scaling=scaling_params,
139
171
  docker_config=docker_data,
172
+ build_config=build_config,
140
173
  krisp_viva=krisp_viva_config,
141
174
  )
142
175
 
@@ -144,6 +177,7 @@ def load_deploy_config_file() -> Optional[DeployConfigParams]:
144
177
  expected_keys = {
145
178
  "agent_name",
146
179
  "image",
180
+ "build_id",
147
181
  "image_credentials",
148
182
  "secret_set",
149
183
  "region",
@@ -151,6 +185,7 @@ def load_deploy_config_file() -> Optional[DeployConfigParams]:
151
185
  "enable_krisp",
152
186
  "enable_managed_keys",
153
187
  "docker",
188
+ "build",
154
189
  "agent_profile",
155
190
  "krisp_viva",
156
191
  }