pipecatcloud 1.5.0__tar.gz → 1.6.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.
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/CHANGELOG.md +87 -0
- {pipecatcloud-1.5.0/src/pipecatcloud.egg-info → pipecatcloud-1.6.0}/PKG-INFO +1 -1
- pipecatcloud-1.6.0/src/pipecatcloud/_utils/base_image.py +89 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/deploy_utils.py +1 -1
- pipecatcloud-1.6.0/src/pipecatcloud/_utils/environment_utils.py +136 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/api.py +228 -12
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/agent.py +194 -82
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/agent_profiles.py +3 -3
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/deploy.py +22 -15
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/docker.py +5 -0
- pipecatcloud-1.6.0/src/pipecatcloud/cli/commands/project.py +465 -0
- pipecatcloud-1.6.0/src/pipecatcloud/cli/commands/project_group.py +209 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/secrets.py +16 -3
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/entry_point.py +18 -7
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/config.py +10 -3
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0/src/pipecatcloud.egg-info}/PKG-INFO +1 -1
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/SOURCES.txt +8 -1
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/scm_file_list.json +7 -0
- pipecatcloud-1.6.0/src/pipecatcloud.egg-info/scm_version.json +8 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_agent_sessions.py +27 -3
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_api_client.py +185 -1
- pipecatcloud-1.6.0/tests/test_base_image.py +98 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_deploy_poll.py +15 -2
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_exit_codes.py +16 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_organizations.py +28 -0
- pipecatcloud-1.6.0/tests/test_projects.py +929 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_regions.py +1 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_secrets_bulk_write.py +6 -1
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_secrets_retry.py +1 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_secrets_set_referenced.py +4 -0
- pipecatcloud-1.6.0/tests/test_unread_answers.py +153 -0
- pipecatcloud-1.5.0/src/pipecatcloud.egg-info/scm_version.json +0 -8
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.claude/settings.json +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/format.yml +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/publish-pypi.yml +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/publish-test.yml +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/python-compatibility.yml +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/tests.yml +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/update-docs.yml +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.gitignore +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.pre-commit-config.yaml +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/CLAUDE.md +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/LICENSE +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/README.md +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/pipecat-cloud.png +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/pyproject.toml +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/pytest.ini +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/scripts/fix-ruff.sh +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/scripts/release-changelog.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/setup.cfg +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/__init__.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/__version__.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/__init__.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/async_utils.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/auth_utils.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/build_utils.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/console_utils.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/github_utils.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/log_utils.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/regions.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/agent.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/__init__.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/api.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/__init__.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/auth.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/build.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/github.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/organizations.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/regions.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/registry_keys.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/spend_limit.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/config.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/constants.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/exception.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/py.typed +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/session.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/requires.txt +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/top_level.txt +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/__init__.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/conftest.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_agent_args.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_agent_profiles_and_resources.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_agent_status_output.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_agent_stop.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_auth.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_build_id_display.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_build_utils.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_config_file.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_deploy_architecture.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_deploy_image_pull_secret.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_deploy_referenced_secret_set.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_deployment_status.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_docker_commands.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_github_integration.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_json_output.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_krisp_viva.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_log_redaction.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_max_session_duration.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_output_modes.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_pat_auth.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_regions_lifecycle.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_secrets_keys.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_secrets_reference.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_spend_limit.py +0 -0
- {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/uv.lock +0 -0
|
@@ -5,6 +5,93 @@ 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
|
+
## [1.6.0] - 2026-11-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `project` is the command group for everything you deploy and run. A project
|
|
13
|
+
is an agent with environments; each environment is an agent of its own, and
|
|
14
|
+
`--env` addresses one through its project.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
pipecat cloud project list
|
|
18
|
+
pipecat cloud project status my-bot --env staging
|
|
19
|
+
pipecat cloud project logs my-bot --env pr-7
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Every command that took an agent name takes `--env` as well: `status`,
|
|
23
|
+
`logs`, `sessions`, `deployments`, `delete`, `start`, `stop`, `link`,
|
|
24
|
+
`unlink` and `deploy`. A positional agent name still works, so
|
|
25
|
+
`project status my-bot-pr-7` resolves the same preview.
|
|
26
|
+
|
|
27
|
+
- `project env` manages a project's environments.
|
|
28
|
+
|
|
29
|
+
- `project env add <project> <name> --agent <agent>` links an existing agent
|
|
30
|
+
as an environment. `project env remove` unlinks it, leaving it running as
|
|
31
|
+
a project of its own. `project env list` lists them: `production`, which
|
|
32
|
+
is the project's own agent, then the named environments and then the PR
|
|
33
|
+
previews.
|
|
34
|
+
- `--env production` is the project itself, which is the environment it
|
|
35
|
+
doubles as, so it means the same as naming the project with no `--env`.
|
|
36
|
+
`env promote` and `env remove` name an environment to act on, so they
|
|
37
|
+
refuse `production` and say why.
|
|
38
|
+
- `project env promote <project> <name>` deploys an environment's running
|
|
39
|
+
build, or the image it was deployed from, onto the project. Code only: the
|
|
40
|
+
project keeps its own secrets, scaling and profile. It confirms first, and
|
|
41
|
+
`--force` skips the prompt.
|
|
42
|
+
|
|
43
|
+
- `project previews enable <project> --secret-set <set>` turns PR previews on,
|
|
44
|
+
with `--bot-pull-requests` to include pull requests opened by bots.
|
|
45
|
+
`project previews disable` turns them off and deletes the previews it has.
|
|
46
|
+
`project previews status` shows the setting and the open previews.
|
|
47
|
+
|
|
48
|
+
- `project watch-paths set <project> <pattern>...` sets the glob patterns a
|
|
49
|
+
change has to match to deploy, `clear` removes them, and `show` prints them. They are a
|
|
50
|
+
project setting: its environments and previews follow the same list.
|
|
51
|
+
|
|
52
|
+
- `project list --all` includes environments and PR previews. Without it the
|
|
53
|
+
listing is projects only, as the dashboard shows them. Both carry columns for
|
|
54
|
+
a project's environment count and preview setting, and `--all` names each
|
|
55
|
+
environment's project.
|
|
56
|
+
|
|
57
|
+
- `deploy`, when it runs a cloud build, and `docker build-push` warn when the
|
|
58
|
+
Dockerfile builds `FROM dailyco/pipecat-base:latest`, or from
|
|
59
|
+
`dailyco/pipecat-base` with no tag. That tag names no Python, runs Python
|
|
60
|
+
3.12, and stops building on 2027-04-05. The warning names the line, and the
|
|
61
|
+
tag that keeps the same Python: `dailyco/pipecat-base:latest-py3.12`.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- `agent` is **deprecated**. It is the same group under its old name: every
|
|
66
|
+
command goes on working and `agent list` goes on listing every agent, with a
|
|
67
|
+
notice on stderr pointing at `project`. Use `project`, and `--env` to address
|
|
68
|
+
an environment.
|
|
69
|
+
- `status` reports the project and environment of an agent that is one, its
|
|
70
|
+
watch paths, and, for a project, whether PR previews are on and whether bot
|
|
71
|
+
pull requests get them. For a PR preview it names the pull request.
|
|
72
|
+
|
|
73
|
+
### Removed
|
|
74
|
+
|
|
75
|
+
- `agent scale` is gone. It was never implemented: it printed "Not implemented"
|
|
76
|
+
and exited. Set an agent's instance counts with `pipecat cloud deploy
|
|
77
|
+
--min-agents/--max-agents`, or the `[scaling]` section of `pcc-deploy.toml`.
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- A command no longer reports an operation as done when the CLI never managed
|
|
82
|
+
to make the request. A mistake inside the client, such as a call made with a
|
|
83
|
+
name the API method does not take, was handed back as an empty result, and
|
|
84
|
+
every command reads an empty error as success: a build promotion printed
|
|
85
|
+
"Promoted build ..." having sent no request at all. Such a failure now ends
|
|
86
|
+
the command with a non-zero exit code.
|
|
87
|
+
- A command that cannot reach the API, or cannot read its answer, now says so
|
|
88
|
+
and exits 1. A dropped connection, a host that does not resolve, a request
|
|
89
|
+
that timed out and an answer that stops part way through were all read as an
|
|
90
|
+
empty answer, which most commands reported as success. `secrets set`
|
|
91
|
+
already said so, and still says whether a write it got no answer for may have
|
|
92
|
+
been stored. `deploy` and `github connect` still treat it as a blip while
|
|
93
|
+
they are waiting, and keep polling.
|
|
94
|
+
|
|
8
95
|
## [1.5.0] - 2026-11-05
|
|
9
96
|
|
|
10
97
|
### Added
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2026, Daily
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: BSD 2-Clause License
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
"""Warnings about the base image an agent's Dockerfile builds from.
|
|
8
|
+
|
|
9
|
+
``dailyco/pipecat-base:latest`` names no Python, so it carries Python 3.12, and
|
|
10
|
+
it stops building on ``LATEST_RETIRES``. A Dockerfile that takes it is warned
|
|
11
|
+
before its build. Version tags without ``-py3.X``, such as ``0.3.0``, are
|
|
12
|
+
deprecated too, but each is fixed to one image and stays published, so a
|
|
13
|
+
Dockerfile pinned to one keeps building and is not warned.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from rich.markup import escape
|
|
20
|
+
|
|
21
|
+
from pipecatcloud._utils.console_utils import console
|
|
22
|
+
|
|
23
|
+
LATEST_RETIRES = "2027-04-05"
|
|
24
|
+
LATEST_PYTHON = "3.12"
|
|
25
|
+
|
|
26
|
+
# Docker Hub's own names for the repository, which Docker resolves alike.
|
|
27
|
+
_BASE_IMAGE = re.compile(
|
|
28
|
+
r"(?:(?:docker\.io|index\.docker\.io|registry-1\.docker\.io)/)?dailyco/pipecat-base"
|
|
29
|
+
r"(?::(?P<tag>[\w][\w.-]*))?(?P<digest>@\S+)?"
|
|
30
|
+
)
|
|
31
|
+
_FROM = re.compile(r"\s*FROM\s+(?:--\S+\s+)*(?P<image>\S+)", re.IGNORECASE)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _instructions(text: str):
|
|
35
|
+
"""Each instruction with the line it starts on, continuation lines joined.
|
|
36
|
+
|
|
37
|
+
Comment lines are dropped, inside a continuation too, as Docker drops them.
|
|
38
|
+
"""
|
|
39
|
+
start, parts = None, []
|
|
40
|
+
for number, line in enumerate(text.splitlines(), start=1):
|
|
41
|
+
if line.lstrip().startswith("#"):
|
|
42
|
+
continue
|
|
43
|
+
if start is None:
|
|
44
|
+
start = number
|
|
45
|
+
if line.rstrip().endswith("\\"):
|
|
46
|
+
parts.append(line.rstrip()[:-1])
|
|
47
|
+
continue
|
|
48
|
+
parts.append(line)
|
|
49
|
+
yield start, " ".join(parts)
|
|
50
|
+
start, parts = None, []
|
|
51
|
+
if parts:
|
|
52
|
+
yield start, " ".join(parts)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def latest_base_images(text: str) -> list[tuple[int, str]]:
|
|
56
|
+
"""The FROM instructions that take ``dailyco/pipecat-base:latest``.
|
|
57
|
+
|
|
58
|
+
Returns each one's line number and image, as written. A FROM with no tag
|
|
59
|
+
takes ``latest`` too. One pinned by digest is fixed, and one built from a
|
|
60
|
+
variable cannot be read here, so neither is returned.
|
|
61
|
+
"""
|
|
62
|
+
found = []
|
|
63
|
+
for number, instruction in _instructions(text):
|
|
64
|
+
match = _FROM.match(instruction)
|
|
65
|
+
if not match:
|
|
66
|
+
continue
|
|
67
|
+
image = match["image"]
|
|
68
|
+
base = _BASE_IMAGE.fullmatch(image)
|
|
69
|
+
if base and not base["digest"] and base["tag"] in (None, "latest"):
|
|
70
|
+
found.append((number, image))
|
|
71
|
+
return found
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def warn_latest_base_image(dockerfile: Path) -> None:
|
|
75
|
+
"""Warn for each FROM in ``dockerfile`` that takes the base image's ``latest``.
|
|
76
|
+
|
|
77
|
+
An unreadable Dockerfile is left to the build to report.
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
text = dockerfile.read_text(errors="replace")
|
|
81
|
+
except OSError:
|
|
82
|
+
return
|
|
83
|
+
for number, image in latest_base_images(text):
|
|
84
|
+
console.print(
|
|
85
|
+
f"[yellow]Warning: {escape(dockerfile.name)} line {number} builds FROM "
|
|
86
|
+
f"{escape(image)}, which stops building on {LATEST_RETIRES}. It names no "
|
|
87
|
+
f"Python, and runs Python {LATEST_PYTHON}: to keep it, use "
|
|
88
|
+
f"dailyco/pipecat-base:latest-py{LATEST_PYTHON}.[/yellow]"
|
|
89
|
+
)
|
|
@@ -763,7 +763,7 @@ def report_git_deploy_result(
|
|
|
763
763
|
from pipecatcloud.cli import PIPECAT_CLI_NAME
|
|
764
764
|
|
|
765
765
|
check_hint = (
|
|
766
|
-
f"[dim]Check it with [bold]{PIPECAT_CLI_NAME}
|
|
766
|
+
f"[dim]Check it with [bold]{PIPECAT_CLI_NAME} project status {agent_name}[/bold].[/dim]"
|
|
767
767
|
)
|
|
768
768
|
label = "First deploy" if first_deploy else "Deploy"
|
|
769
769
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright (c) 2026, Daily
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: BSD 2-Clause License
|
|
5
|
+
#
|
|
6
|
+
|
|
7
|
+
"""Shared helpers for projects and environments (PCC-1255).
|
|
8
|
+
|
|
9
|
+
An environment is an agent of its own, linked to the project it belongs to.
|
|
10
|
+
The project doubles as its own `production` environment, so a project is
|
|
11
|
+
simply an agent with no parent. These helpers read that shape out of the
|
|
12
|
+
service payloads the API already returns, and mirror the name rules it
|
|
13
|
+
enforces so a typo fails before the round trip.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
#: What the project itself is called among its environments.
|
|
19
|
+
PRODUCTION_ENVIRONMENT_NAME = "production"
|
|
20
|
+
|
|
21
|
+
#: PR previews are named `pr-<n>`, so the API reserves the prefix.
|
|
22
|
+
PREVIEW_ENVIRONMENT_PREFIX = "pr-"
|
|
23
|
+
|
|
24
|
+
#: The width of the environments.name column.
|
|
25
|
+
MAX_ENVIRONMENT_NAME_LENGTH = 50
|
|
26
|
+
|
|
27
|
+
# The agent-name rule, which environment names follow too.
|
|
28
|
+
_ENVIRONMENT_NAME_SHAPE = re.compile(r"^[a-z0-9]([-a-z0-9]*[a-z0-9])$")
|
|
29
|
+
|
|
30
|
+
_PREVIEW_NAME = re.compile(r"^pr-(\d+)$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def environment_name_problem(name: str) -> str | None:
|
|
34
|
+
"""Why the API would refuse this environment name, or None when it's fine."""
|
|
35
|
+
if not name:
|
|
36
|
+
return "Enter a name for the environment."
|
|
37
|
+
if name == PRODUCTION_ENVIRONMENT_NAME:
|
|
38
|
+
return f"'{PRODUCTION_ENVIRONMENT_NAME}' is the project itself, so it can't be an environment name."
|
|
39
|
+
if name.startswith(PREVIEW_ENVIRONMENT_PREFIX):
|
|
40
|
+
return f"Names starting with '{PREVIEW_ENVIRONMENT_PREFIX}' are reserved for PR previews."
|
|
41
|
+
if len(name) > MAX_ENVIRONMENT_NAME_LENGTH:
|
|
42
|
+
return f"Use at most {MAX_ENVIRONMENT_NAME_LENGTH} characters."
|
|
43
|
+
if not _ENVIRONMENT_NAME_SHAPE.match(name):
|
|
44
|
+
return (
|
|
45
|
+
"Use two or more lowercase letters, digits and dashes, starting and "
|
|
46
|
+
"ending with a letter or digit."
|
|
47
|
+
)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_preview_name(name: str | None) -> bool:
|
|
52
|
+
"""Whether an environment's name is a PR preview's."""
|
|
53
|
+
return bool(name and _PREVIEW_NAME.match(name))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def environment_sort_key(name: str) -> tuple[int, int, str]:
|
|
57
|
+
"""Named environments by name, then PR previews, newest pull request first."""
|
|
58
|
+
preview = _PREVIEW_NAME.match(name or "")
|
|
59
|
+
if preview:
|
|
60
|
+
return (1, -int(preview.group(1)), "")
|
|
61
|
+
return (0, 0, name or "")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_environment(service: dict) -> bool:
|
|
65
|
+
"""Whether this agent is an environment of another agent."""
|
|
66
|
+
return bool(service.get("parentServiceId"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def projects_in(services: list[dict]) -> list[dict]:
|
|
70
|
+
"""The agents that are projects: the ones that are not environments."""
|
|
71
|
+
return [service for service in services if not is_environment(service)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def environments_of(services: list[dict], project: dict) -> list[dict]:
|
|
75
|
+
"""A project's environments, named ones first and then its PR previews."""
|
|
76
|
+
children = [
|
|
77
|
+
service for service in services if service.get("parentServiceId") == project.get("id")
|
|
78
|
+
]
|
|
79
|
+
return sorted(children, key=lambda s: environment_sort_key(s.get("environmentName") or ""))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def environments_with_production(services: list[dict], project: dict) -> list[dict]:
|
|
83
|
+
"""A project's environments, the project itself first as `production`.
|
|
84
|
+
|
|
85
|
+
The project doubles as that environment, so a listing that left it out
|
|
86
|
+
would contradict the model the rest of the group works to. Callers that
|
|
87
|
+
mean only the agents linked to the project use `environments_of`.
|
|
88
|
+
"""
|
|
89
|
+
return [
|
|
90
|
+
{**project, "environmentName": PRODUCTION_ENVIRONMENT_NAME},
|
|
91
|
+
*environments_of(services, project),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def promotion_source(environment: dict) -> dict | None:
|
|
96
|
+
"""What promoting this environment deploys: its build, or the image it runs.
|
|
97
|
+
|
|
98
|
+
None when it has nothing deployed. Promotion moves code only, so this is
|
|
99
|
+
the whole payload the project is updated with.
|
|
100
|
+
"""
|
|
101
|
+
deployment = environment.get("deployment") or {}
|
|
102
|
+
build_id = deployment.get("buildId")
|
|
103
|
+
if build_id:
|
|
104
|
+
return {"buildId": build_id}
|
|
105
|
+
image = ((deployment.get("manifest") or {}).get("spec") or {}).get("image")
|
|
106
|
+
if image:
|
|
107
|
+
return {"image": image}
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def describe_promotion_source(source: dict) -> str:
|
|
112
|
+
"""A promotion source as a short phrase, for confirmations and messages."""
|
|
113
|
+
build_id = source.get("buildId")
|
|
114
|
+
return f"build {build_id}" if build_id else str(source.get("image"))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def previews_on(service: dict) -> bool:
|
|
118
|
+
"""Whether this project has PR previews on.
|
|
119
|
+
|
|
120
|
+
Choosing the secret set previews mount is what turns them on, so the set
|
|
121
|
+
being present is the setting.
|
|
122
|
+
"""
|
|
123
|
+
git = service.get("git") or {}
|
|
124
|
+
return bool(git.get("previewSecretSet"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def project_label(service: dict, project_names: dict[str, str]) -> str:
|
|
128
|
+
"""`project > environment` for an environment, or a dash for a project.
|
|
129
|
+
|
|
130
|
+
`project_names` maps agent id to name, which is how a parent's name is
|
|
131
|
+
resolved: the service payload carries only `parentServiceId`.
|
|
132
|
+
"""
|
|
133
|
+
if not is_environment(service):
|
|
134
|
+
return "—"
|
|
135
|
+
parent = project_names.get(service.get("parentServiceId") or "", "?")
|
|
136
|
+
return f"{parent} > {service.get('environmentName') or '?'}"
|
|
@@ -164,6 +164,30 @@ class _API:
|
|
|
164
164
|
self.token = new_token
|
|
165
165
|
return new_token
|
|
166
166
|
|
|
167
|
+
def _record_unread_answer(self, e: BaseException, reading_the_answer: bool = False) -> None:
|
|
168
|
+
"""Record a request that produced no answer this client could read, in
|
|
169
|
+
the shape of an error body from the API so that it renders as the
|
|
170
|
+
failure it is. The exception's type is named, never its text, which can
|
|
171
|
+
carry the URL and the body the request was sent with.
|
|
172
|
+
|
|
173
|
+
`error_status` and `error_retry_after` stay None whether the answer never
|
|
174
|
+
began or stopped part way through: there is no answer to take the
|
|
175
|
+
outcome from. That is what `no_response()` reads, and what tells a
|
|
176
|
+
caller the request may still have been carried out.
|
|
177
|
+
"""
|
|
178
|
+
reach = (
|
|
179
|
+
"Could not read the answer from the Pipecat Cloud API"
|
|
180
|
+
if reading_the_answer
|
|
181
|
+
else "Could not reach the Pipecat Cloud API"
|
|
182
|
+
)
|
|
183
|
+
self.error = {
|
|
184
|
+
"error": (
|
|
185
|
+
f"{reach} ({describe_exception(e)}). Check your network connection and try again."
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
self.error_status = None
|
|
189
|
+
self.error_retry_after = None
|
|
190
|
+
|
|
167
191
|
# TODO: several auth.py and agent.py calls bypass _base_request() with their
|
|
168
192
|
# own aiohttp.ClientSession(). A longer-term cleanup could route all PCC API
|
|
169
193
|
# calls through _base_request() so headers (User-Agent, Auth) are set in one place.
|
|
@@ -193,13 +217,22 @@ class _API:
|
|
|
193
217
|
f"params: {describe_body(params)} body: {describe_body(json)}"
|
|
194
218
|
)
|
|
195
219
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
220
|
+
try:
|
|
221
|
+
response = await session.request(
|
|
222
|
+
method=method,
|
|
223
|
+
url=url,
|
|
224
|
+
headers=self._configure_headers(override_token),
|
|
225
|
+
params=params,
|
|
226
|
+
json=json,
|
|
227
|
+
)
|
|
228
|
+
except (aiohttp.ClientError, TimeoutError) as e:
|
|
229
|
+
# The request got no answer at all: the host could not be
|
|
230
|
+
# resolved or reached, the connection dropped, the request timed
|
|
231
|
+
# out. Record it before raising, so it renders like any other
|
|
232
|
+
# failure rather than turning a dropped network into a traceback
|
|
233
|
+
# from the wrapper in `create_api_method`.
|
|
234
|
+
self._record_unread_answer(e)
|
|
235
|
+
raise
|
|
203
236
|
self.capabilities_header = response.headers.get(CAPABILITIES_HEADER)
|
|
204
237
|
|
|
205
238
|
if not response.ok:
|
|
@@ -232,7 +265,30 @@ class _API:
|
|
|
232
265
|
if response.status == 204:
|
|
233
266
|
return None
|
|
234
267
|
|
|
235
|
-
|
|
268
|
+
try:
|
|
269
|
+
return await response.json()
|
|
270
|
+
except aiohttp.ContentTypeError:
|
|
271
|
+
# An ok answer that is not JSON. The operation itself succeeded,
|
|
272
|
+
# so this stays None, the same answer a 204 gives, rather than
|
|
273
|
+
# becoming a failure: a caller tolerates a missing body already
|
|
274
|
+
# (`... or {}`), and reporting a completed write as failed would
|
|
275
|
+
# send the user to repeat it. Spelled out because the swallow in
|
|
276
|
+
# `create_api_method` that used to produce this is gone.
|
|
277
|
+
logger.debug(f"Response body is not JSON: {response.status}")
|
|
278
|
+
return None
|
|
279
|
+
except (aiohttp.ClientError, TimeoutError, ValueError) as e:
|
|
280
|
+
# The answer's headers arrived but its body did not: aiohttp
|
|
281
|
+
# returns from `request()` as soon as the headers are in, so a
|
|
282
|
+
# connection lost mid-body, a read that timed out, or a body
|
|
283
|
+
# that ends part way through its JSON all land here rather than
|
|
284
|
+
# in the catch above. It is the same transport failure, just
|
|
285
|
+
# later, and the status says only that the server had begun to
|
|
286
|
+
# answer — not what became of the request. So it is recorded the
|
|
287
|
+
# same way, with no status: a poll reads it as a blip worth
|
|
288
|
+
# repeating and a write as one that may or may not have landed,
|
|
289
|
+
# which is what they did before this answer could be read.
|
|
290
|
+
self._record_unread_answer(e, reading_the_answer=True)
|
|
291
|
+
raise
|
|
236
292
|
|
|
237
293
|
def create_api_method(self, method_func: Callable) -> Callable:
|
|
238
294
|
"""Factory method that wraps API methods with error handling and live context"""
|
|
@@ -250,7 +306,34 @@ class _API:
|
|
|
250
306
|
if live and not self.bubble_next:
|
|
251
307
|
live.stop()
|
|
252
308
|
|
|
309
|
+
if not self.error:
|
|
310
|
+
# Nothing recorded an error, so the API did not refuse the
|
|
311
|
+
# request: this is a failure on our side of it — a TypeError
|
|
312
|
+
# from a keyword that does not match the method's signature,
|
|
313
|
+
# an AttributeError, a ValueError for a config value that is
|
|
314
|
+
# not set, a KeyError on a response that is not shaped the
|
|
315
|
+
# way the method reads it. There is no error to hand back,
|
|
316
|
+
# and every caller reads a falsy error as success, so
|
|
317
|
+
# returning `(None, None)` here reports a call that never
|
|
318
|
+
# happened as a completed operation (cli#223 review: a build
|
|
319
|
+
# promotion printed "Promoted build ..." having sent no HTTP
|
|
320
|
+
# request at all). Let the exception out instead. In the CLI
|
|
321
|
+
# it ends the process non-zero, like any other mistake in a
|
|
322
|
+
# command body, and an SDK caller sees the original
|
|
323
|
+
# exception rather than a silent None. A request that got no
|
|
324
|
+
# response does not land here: `_base_request` records that
|
|
325
|
+
# as an error before re-raising, so it still renders.
|
|
326
|
+
self.bubble_next = False
|
|
327
|
+
raise
|
|
328
|
+
|
|
253
329
|
if not self.is_cli and self.error and not self.bubble_next:
|
|
330
|
+
if self.no_response():
|
|
331
|
+
# Nothing the API said, so there is nothing to put in an
|
|
332
|
+
# AgentStartError: the exception itself is what went
|
|
333
|
+
# wrong, and it is what a caller of a client library
|
|
334
|
+
# expects to catch. The recorded error stays readable on
|
|
335
|
+
# the client for anyone who wants the rendered text.
|
|
336
|
+
raise e
|
|
254
337
|
if isinstance(self.error, dict) and self.error.get("status", "429"):
|
|
255
338
|
raise AgentStartError(self.error)
|
|
256
339
|
else:
|
|
@@ -283,6 +366,15 @@ class _API:
|
|
|
283
366
|
self.bubble_next = True
|
|
284
367
|
return self
|
|
285
368
|
|
|
369
|
+
def no_response(self) -> bool:
|
|
370
|
+
"""Whether the last call failed without an answer this client could
|
|
371
|
+
read: the API could not be reached, the connection dropped, the request
|
|
372
|
+
timed out, or the answer stopped part way through its body. A read that
|
|
373
|
+
got no answer simply has none; a write that got no answer may or may not
|
|
374
|
+
have landed, which is what the user has to be told. A failure the server
|
|
375
|
+
did answer with carries its status, so this is False for it."""
|
|
376
|
+
return self.error is not None and self.error_status is None
|
|
377
|
+
|
|
286
378
|
# Auth
|
|
287
379
|
|
|
288
380
|
async def _whoami(self) -> dict:
|
|
@@ -323,10 +415,14 @@ class _API:
|
|
|
323
415
|
url = self.construct_api_url("organization_path")
|
|
324
416
|
results = await self._base_request("GET", url)
|
|
325
417
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
418
|
+
# An account that belongs to no organization is not a failed request:
|
|
419
|
+
# the empty list is the answer, and `organization list` says so in
|
|
420
|
+
# words. This used to be a bare `raise` — a RuntimeError, since no
|
|
421
|
+
# exception was being handled — which the wrapper swallowed into
|
|
422
|
+
# (None, None); now that it no longer swallows, say it plainly.
|
|
423
|
+
if not results:
|
|
424
|
+
return []
|
|
425
|
+
return results.get("organizations") or []
|
|
330
426
|
|
|
331
427
|
@property
|
|
332
428
|
def organizations(self):
|
|
@@ -1463,3 +1559,123 @@ class _API:
|
|
|
1463
1559
|
The deploy intent: id, commitSha, ref, status
|
|
1464
1560
|
"""
|
|
1465
1561
|
return self.create_api_method(self._agent_git_deploy)
|
|
1562
|
+
|
|
1563
|
+
# Projects and environments
|
|
1564
|
+
|
|
1565
|
+
async def _environment_link(
|
|
1566
|
+
self, agent_name: str, org: str, parent: str, environment_name: str
|
|
1567
|
+
) -> dict | None:
|
|
1568
|
+
url = self.construct_api_url("service_parent_path").format(org=org, service=agent_name)
|
|
1569
|
+
return await self._base_request(
|
|
1570
|
+
"PUT", url, json={"parent": parent, "environmentName": environment_name}
|
|
1571
|
+
)
|
|
1572
|
+
|
|
1573
|
+
@property
|
|
1574
|
+
def environment_link(self):
|
|
1575
|
+
"""Link an agent as an environment of a project.
|
|
1576
|
+
|
|
1577
|
+
A PUT, so it also renames an environment or moves it to another
|
|
1578
|
+
project. The API refuses a link across regions, a chain in either
|
|
1579
|
+
direction, a name another environment holds, and any change to a PR
|
|
1580
|
+
preview's own link.
|
|
1581
|
+
|
|
1582
|
+
Args:
|
|
1583
|
+
agent_name: Name of the agent to become an environment
|
|
1584
|
+
org: Organization ID
|
|
1585
|
+
parent: Name of the project it belongs to
|
|
1586
|
+
environment_name: What to call it among that project's environments
|
|
1587
|
+
Returns:
|
|
1588
|
+
serviceName, parent, environmentName
|
|
1589
|
+
"""
|
|
1590
|
+
return self.create_api_method(self._environment_link)
|
|
1591
|
+
|
|
1592
|
+
async def _environment_unlink(self, agent_name: str, org: str) -> dict | None:
|
|
1593
|
+
url = self.construct_api_url("service_parent_path").format(org=org, service=agent_name)
|
|
1594
|
+
return await self._base_request("DELETE", url)
|
|
1595
|
+
|
|
1596
|
+
@property
|
|
1597
|
+
def environment_unlink(self):
|
|
1598
|
+
"""Unlink an environment from its project.
|
|
1599
|
+
|
|
1600
|
+
It stays an ordinary agent, running as before. Idempotent: an agent
|
|
1601
|
+
that isn't an environment answers 200.
|
|
1602
|
+
|
|
1603
|
+
Args:
|
|
1604
|
+
agent_name: Name of the environment's agent
|
|
1605
|
+
org: Organization ID
|
|
1606
|
+
Returns:
|
|
1607
|
+
serviceName, parent (null), environmentName (null)
|
|
1608
|
+
"""
|
|
1609
|
+
return self.create_api_method(self._environment_unlink)
|
|
1610
|
+
|
|
1611
|
+
async def _previews_update(
|
|
1612
|
+
self, agent_name: str, org: str, secret_set: str | None, bot_pull_requests: bool | None
|
|
1613
|
+
) -> dict | None:
|
|
1614
|
+
url = self.construct_api_url("service_previews_path").format(org=org, service=agent_name)
|
|
1615
|
+
payload: dict = {"secretSet": secret_set}
|
|
1616
|
+
if bot_pull_requests is not None:
|
|
1617
|
+
payload["botPullRequests"] = bot_pull_requests
|
|
1618
|
+
return await self._base_request("PUT", url, json=payload)
|
|
1619
|
+
|
|
1620
|
+
@property
|
|
1621
|
+
def previews_update(self):
|
|
1622
|
+
"""Turn a project's PR previews on with the secret set they mount, or off.
|
|
1623
|
+
|
|
1624
|
+
Choosing the set is what turns previews on; `secret_set=None` turns
|
|
1625
|
+
them off, which also deletes the previews that exist. Leaving
|
|
1626
|
+
`bot_pull_requests` as None keeps the stored value.
|
|
1627
|
+
|
|
1628
|
+
Args:
|
|
1629
|
+
agent_name: Name of the project
|
|
1630
|
+
org: Organization ID
|
|
1631
|
+
secret_set: Secret set previews mount, or None to turn them off
|
|
1632
|
+
bot_pull_requests: Whether PRs opened by bots get previews too
|
|
1633
|
+
"""
|
|
1634
|
+
return self.create_api_method(self._previews_update)
|
|
1635
|
+
|
|
1636
|
+
async def _watch_paths_update(
|
|
1637
|
+
self, agent_name: str, org: str, watch_paths: list[str]
|
|
1638
|
+
) -> dict | None:
|
|
1639
|
+
url = self.construct_api_url("service_watch_paths_path").format(org=org, service=agent_name)
|
|
1640
|
+
result = await self._base_request("PUT", url, json={"watchPaths": watch_paths}) or {}
|
|
1641
|
+
return result
|
|
1642
|
+
|
|
1643
|
+
@property
|
|
1644
|
+
def watch_paths_update(self):
|
|
1645
|
+
"""Set a project's watch paths. An empty list clears them.
|
|
1646
|
+
|
|
1647
|
+
A project setting: its production, its environments and its PR
|
|
1648
|
+
previews all follow this one list, so the API refuses it on an
|
|
1649
|
+
environment.
|
|
1650
|
+
|
|
1651
|
+
Args:
|
|
1652
|
+
agent_name: Name of the project
|
|
1653
|
+
org: Organization ID
|
|
1654
|
+
watch_paths: Glob patterns a change must match to deploy
|
|
1655
|
+
Returns:
|
|
1656
|
+
watchPaths, as stored
|
|
1657
|
+
"""
|
|
1658
|
+
return self.create_api_method(self._watch_paths_update)
|
|
1659
|
+
|
|
1660
|
+
async def _agent_update_source(
|
|
1661
|
+
self, agent_name: str, org: str, build_id: str | None = None, image: str | None = None
|
|
1662
|
+
) -> dict | None:
|
|
1663
|
+
url = f"{self.construct_api_url('services_path').format(org=org)}/{agent_name}"
|
|
1664
|
+
payload = {"buildId": build_id} if build_id else {"image": image}
|
|
1665
|
+
return await self._base_request("POST", url, json=payload)
|
|
1666
|
+
|
|
1667
|
+
@property
|
|
1668
|
+
def agent_update_source(self):
|
|
1669
|
+
"""Deploy a build or an image onto an agent, changing nothing else.
|
|
1670
|
+
|
|
1671
|
+
The update merges only the artifact into the agent's live manifest, so
|
|
1672
|
+
its secrets, scaling and profile are left as they are. This is what
|
|
1673
|
+
promotion does.
|
|
1674
|
+
|
|
1675
|
+
Args:
|
|
1676
|
+
agent_name: Name of the agent to deploy onto
|
|
1677
|
+
org: Organization ID
|
|
1678
|
+
build_id: Build to deploy
|
|
1679
|
+
image: Image to deploy, when there is no build
|
|
1680
|
+
"""
|
|
1681
|
+
return self.create_api_method(self._agent_update_source)
|