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.
Files changed (109) hide show
  1. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/CHANGELOG.md +87 -0
  2. {pipecatcloud-1.5.0/src/pipecatcloud.egg-info → pipecatcloud-1.6.0}/PKG-INFO +1 -1
  3. pipecatcloud-1.6.0/src/pipecatcloud/_utils/base_image.py +89 -0
  4. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/deploy_utils.py +1 -1
  5. pipecatcloud-1.6.0/src/pipecatcloud/_utils/environment_utils.py +136 -0
  6. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/api.py +228 -12
  7. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/agent.py +194 -82
  8. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/agent_profiles.py +3 -3
  9. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/deploy.py +22 -15
  10. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/docker.py +5 -0
  11. pipecatcloud-1.6.0/src/pipecatcloud/cli/commands/project.py +465 -0
  12. pipecatcloud-1.6.0/src/pipecatcloud/cli/commands/project_group.py +209 -0
  13. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/secrets.py +16 -3
  14. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/entry_point.py +18 -7
  15. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/config.py +10 -3
  16. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0/src/pipecatcloud.egg-info}/PKG-INFO +1 -1
  17. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/SOURCES.txt +8 -1
  18. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/scm_file_list.json +7 -0
  19. pipecatcloud-1.6.0/src/pipecatcloud.egg-info/scm_version.json +8 -0
  20. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_agent_sessions.py +27 -3
  21. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_api_client.py +185 -1
  22. pipecatcloud-1.6.0/tests/test_base_image.py +98 -0
  23. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_deploy_poll.py +15 -2
  24. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_exit_codes.py +16 -0
  25. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_organizations.py +28 -0
  26. pipecatcloud-1.6.0/tests/test_projects.py +929 -0
  27. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_regions.py +1 -0
  28. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_secrets_bulk_write.py +6 -1
  29. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_secrets_retry.py +1 -0
  30. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_secrets_set_referenced.py +4 -0
  31. pipecatcloud-1.6.0/tests/test_unread_answers.py +153 -0
  32. pipecatcloud-1.5.0/src/pipecatcloud.egg-info/scm_version.json +0 -8
  33. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.claude/settings.json +0 -0
  34. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.claude/skills/update-docs/SOURCE_DOC_MAPPING.md +0 -0
  35. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/format.yml +0 -0
  36. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/publish-pypi.yml +0 -0
  37. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/publish-test.yml +0 -0
  38. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/python-compatibility.yml +0 -0
  39. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/tests.yml +0 -0
  40. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.github/workflows/update-docs.yml +0 -0
  41. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.gitignore +0 -0
  42. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/.pre-commit-config.yaml +0 -0
  43. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/CLAUDE.md +0 -0
  44. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/LICENSE +0 -0
  45. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/README.md +0 -0
  46. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/pipecat-cloud.png +0 -0
  47. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/pyproject.toml +0 -0
  48. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/pytest.ini +0 -0
  49. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/scripts/fix-ruff.sh +0 -0
  50. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/scripts/release-changelog.py +0 -0
  51. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/setup.cfg +0 -0
  52. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/__init__.py +0 -0
  53. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/__version__.py +0 -0
  54. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/__init__.py +0 -0
  55. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/async_utils.py +0 -0
  56. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/auth_utils.py +0 -0
  57. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/build_utils.py +0 -0
  58. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/console_utils.py +0 -0
  59. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/github_utils.py +0 -0
  60. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/log_utils.py +0 -0
  61. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/_utils/regions.py +0 -0
  62. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/agent.py +0 -0
  63. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/__init__.py +0 -0
  64. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/api.py +0 -0
  65. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/__init__.py +0 -0
  66. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/auth.py +0 -0
  67. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/build.py +0 -0
  68. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/github.py +0 -0
  69. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/organizations.py +0 -0
  70. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/regions.py +0 -0
  71. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/registry_keys.py +0 -0
  72. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/commands/spend_limit.py +0 -0
  73. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/cli/config.py +0 -0
  74. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/constants.py +0 -0
  75. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/exception.py +0 -0
  76. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/py.typed +0 -0
  77. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/session.py +0 -0
  78. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud/smallwebrtc/session_manager.py +0 -0
  79. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/dependency_links.txt +0 -0
  80. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/entry_points.txt +0 -0
  81. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/requires.txt +0 -0
  82. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/src/pipecatcloud.egg-info/top_level.txt +0 -0
  83. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/__init__.py +0 -0
  84. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/conftest.py +0 -0
  85. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_agent_args.py +0 -0
  86. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_agent_profiles_and_resources.py +0 -0
  87. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_agent_status_output.py +0 -0
  88. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_agent_stop.py +0 -0
  89. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_auth.py +0 -0
  90. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_build_id_display.py +0 -0
  91. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_build_utils.py +0 -0
  92. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_config_file.py +0 -0
  93. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_deploy_architecture.py +0 -0
  94. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_deploy_image_pull_secret.py +0 -0
  95. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_deploy_referenced_secret_set.py +0 -0
  96. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_deployment_status.py +0 -0
  97. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_docker_commands.py +0 -0
  98. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_github_integration.py +0 -0
  99. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_json_output.py +0 -0
  100. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_krisp_viva.py +0 -0
  101. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_log_redaction.py +0 -0
  102. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_max_session_duration.py +0 -0
  103. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_output_modes.py +0 -0
  104. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_pat_auth.py +0 -0
  105. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_regions_lifecycle.py +0 -0
  106. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_secrets_keys.py +0 -0
  107. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_secrets_reference.py +0 -0
  108. {pipecatcloud-1.5.0 → pipecatcloud-1.6.0}/tests/test_spend_limit.py +0 -0
  109. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pipecatcloud
3
- Version: 1.5.0
3
+ Version: 1.6.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.ai/pipecat-cloud
@@ -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} agent status {agent_name}[/bold].[/dim]"
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
- response = await session.request(
197
- method=method,
198
- url=url,
199
- headers=self._configure_headers(override_token),
200
- params=params,
201
- json=json,
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
- return await response.json()
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
- if not results or not results.get("organizations", None):
327
- raise
328
-
329
- return results.get("organizations", None) or []
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)