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