Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/format.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: format

on:
workflow_dispatch:
push:
branches:
- main
pull_request:
branches:
- '**'
paths-ignore:
- 'docs/**'

concurrency:
group: build-format-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
ruff-format:
name: 'Code quality checks'
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v3
with:
version: 'latest'

- name: Set up Python
run: uv python install 3.12

- name: Install development dependencies
run: uv sync --group dev

- name: Ruff formatter
id: ruff-format
run: uv run ruff format --diff

- name: Ruff linter (all rules)
id: ruff-check
run: uv run ruff check
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.1
hooks:
- id: ruff
language_version: python3
args: [--fix]
- id: ruff-format
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,40 @@ async def main():
if __name__ == "__main__":
asyncio.run(main())
```

## 🛠️ Contributing

### Setup Steps

1. Clone the repository and navigate to it:

```bash
git clone https://github.com/daily-co/pipecat-cloud.git
cd pipecat-cloud
```

2. Install development and testing dependencies:

```bash
uv sync --group dev
```

3. Install the git pre-commit hooks:

```bash
uv run pre-commit install
```

### Running tests

To run all tests, from the root directory:

```bash
uv run pytest
```

Run a specific test suite:

```bash
uv run pytest tests/test_name.py
```
10 changes: 10 additions & 0 deletions scripts/fix-ruff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@

#!/bin/bash

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"

echo "Running ruff format..."
uv run ruff format "$PROJECT_ROOT"
echo "Running ruff check..."
uv run ruff check --fix "$PROJECT_ROOT"
27 changes: 27 additions & 0 deletions scripts/pre-commit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/bin/bash

# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color

echo "🔍 Running pre-commit checks..."

# Change to project root (one level up from scripts/)
cd "$(dirname "$0")/.."

# Format check
echo "📝 Checking code formatting..."
if ! NO_COLOR=1 uv run ruff format --diff --check; then
echo -e "${RED}❌ Code formatting issues found. Run 'uv run ruff format' to fix.${NC}"
exit 1
fi

# Lint check
echo "🔍 Running linter..."
if ! uv run ruff check; then
echo -e "${RED}❌ Linting issues found.${NC}"
exit 1
fi

echo -e "${GREEN}✅ All pre-commit checks passed!${NC}"
2 changes: 2 additions & 0 deletions src/pipecatcloud/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@

try:
from importlib.metadata import version as get_version

version = get_version("pipecatcloud")
except ImportError:
# For Python < 3.8
try:
from importlib_metadata import version as get_version

version = get_version("pipecatcloud")
except ImportError:
version = "Unknown"
18 changes: 11 additions & 7 deletions src/pipecatcloud/_utils/console_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ def unauthorized(self):
title_align="left",
subtitle_align="left",
border_style="red",
))
)
)

def api_error(
self,
Expand All @@ -80,9 +81,11 @@ def api_error(
DEFAULT_ERROR_MESSAGE = "Unknown error. Please contact support."

if isinstance(error_code, dict):
error_message = error_code.get(
"error", None) or error_code.get(
"message", None) or DEFAULT_ERROR_MESSAGE
error_message = (
error_code.get("error", None)
or error_code.get("message", None)
or DEFAULT_ERROR_MESSAGE
)
code = error_code.get("code")
else:
error_message = str(error_code) if error_code else DEFAULT_ERROR_MESSAGE
Expand All @@ -93,7 +96,7 @@ def api_error(

self.print(
Panel(
f"[red]{title}[/red]\n\n" f"[dim]Error message:[/dim]\n{error_message}",
f"[red]{title}[/red]\n\n[dim]Error message:[/dim]\n{error_message}",
title=f"[bold red]{PANEL_TITLE_ERROR}{f' - {code}' if code else ''}[/bold red]",
subtitle=f"[dim]Docs: https://docs.pipecat.daily.co/agents/error-codes#{code}[/dim]"
if not hide_subtitle and code
Expand Down Expand Up @@ -150,8 +153,8 @@ def format_duration(created_at_str: str, ended_at_str: str) -> str | None:
return None

try:
created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00'))
ended_at = datetime.fromisoformat(ended_at_str.replace('Z', '+00:00'))
created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
ended_at = datetime.fromisoformat(ended_at_str.replace("Z", "+00:00"))
duration = ended_at - created_at

# Convert to total seconds
Expand Down Expand Up @@ -213,6 +216,7 @@ async def cli_updates_available() -> str | None:

try:
from importlib.metadata import version as get_version

current_version = get_version("pipecatcloud")
except ImportError:
return None
Expand Down
8 changes: 6 additions & 2 deletions src/pipecatcloud/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,9 @@ def api_key_delete(self):

# Secret

async def _secrets_list(self, org: str, secret_set: Optional[str] = None, region: Optional[str] = None) -> dict | None:
async def _secrets_list(
self, org: str, secret_set: Optional[str] = None, region: Optional[str] = None
) -> dict | None:
if secret_set:
url = f"{self.construct_api_url('secrets_path').format(org=org)}/{secret_set}"
else:
Expand Down Expand Up @@ -266,7 +268,9 @@ def secrets_list(self):
"""
return self.create_api_method(self._secrets_list)

async def _secrets_upsert(self, data: dict, set_name: str, org: str, region: Optional[str] = None) -> dict:
async def _secrets_upsert(
self, data: dict, set_name: str, org: str, region: Optional[str] = None
) -> dict:
url = f"{self.construct_api_url('secrets_path').format(org=org)}/{set_name}"

# Add region to data payload only if explicitly provided
Expand Down
12 changes: 9 additions & 3 deletions src/pipecatcloud/cli/commands/organizations.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
name="organizations", help="User organizations", no_args_is_help=True
)
keys_cli = typer.Typer(name="keys", help="API key management commands", no_args_is_help=True)
properties_cli = typer.Typer(name="properties", help="Organization property management", no_args_is_help=True)
properties_cli = typer.Typer(
name="properties", help="Organization property management", no_args_is_help=True
)
organization_cli.add_typer(keys_cli)
organization_cli.add_typer(properties_cli)

Expand Down Expand Up @@ -528,7 +530,9 @@ async def properties_set(
# ---- Convenience Commands ----


@organization_cli.command(name="default-region", help="Get or set the default region for an organization.")
@organization_cli.command(
name="default-region", help="Get or set the default region for an organization."
)
@synchronizer.create_blocking
@requires_login
async def default_region(
Expand Down Expand Up @@ -557,7 +561,9 @@ async def default_region(
console.error("Failed to update default region.")
return typer.Exit(1)

console.success(f"Default region set to [bold green]{data.get('defaultRegion', region)}[/bold green]")
console.success(
f"Default region set to [bold green]{data.get('defaultRegion', region)}[/bold green]"
)
else:
# Show the current default region
with console.status(
Expand Down
4 changes: 3 additions & 1 deletion src/pipecatcloud/cli/commands/secrets.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ async def set(
props, error = await API.properties(org)
if error:
return typer.Exit()
console.print(f"[bold white]Region:[/bold white] {props['defaultRegion']} [dim](organization default)[/dim]\n")
console.print(
f"[bold white]Region:[/bold white] {props['defaultRegion']} [dim](organization default)[/dim]\n"
)
console.print(
Panel(
table,
Expand Down
4 changes: 3 additions & 1 deletion src/pipecatcloud/smallwebrtc/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ async def timeout_handler():
await asyncio.sleep(self._timeout_seconds)
if self._pending_future and not self._pending_future.done():
self._pending_future.set_exception(
TimeoutError(f"WebRTC connection not received within {self._timeout_seconds} seconds")
TimeoutError(
f"WebRTC connection not received within {self._timeout_seconds} seconds"
)
)

# Create and store the timeout task
Expand Down
2 changes: 1 addition & 1 deletion tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# Test package for pipecatcloud
# Test package for pipecatcloud
39 changes: 20 additions & 19 deletions tests/test_agent_stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
Tests focus on core behaviors and edge cases, not implementation details.
"""

import pytest
import typer
from unittest.mock import patch, AsyncMock, MagicMock

# Import from source, not installed package
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import typer

sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

from pipecatcloud.cli.commands.agent import stop
Expand All @@ -26,11 +26,11 @@ class TestAgentStopCommand:

def test_stop_respects_force_flag(self):
"""Verify force flag skips confirmation when set to True."""
with patch('pipecatcloud.cli.commands.agent.console') as mock_console, \
patch('pipecatcloud.cli.commands.agent.config') as mock_config, \
patch('pipecatcloud.cli.commands.agent.questionary') as mock_questionary, \
patch('pipecatcloud.cli.commands.agent.DeployConfigParams') as mock_params:

with (
patch("pipecatcloud.cli.commands.agent.config") as mock_config,
patch("pipecatcloud.cli.commands.agent.questionary") as mock_questionary,
patch("pipecatcloud.cli.commands.agent.DeployConfigParams") as mock_params,
):
mock_config.get.return_value = TEST_ORG
mock_params.return_value = MagicMock(agent_name=TEST_AGENT)
# Mock questionary - should NOT be called when force=True
Expand All @@ -51,11 +51,11 @@ def test_stop_respects_force_flag(self):

def test_stop_shows_confirmation_without_force(self):
"""Verify confirmation prompt is shown when force is False."""
with patch('pipecatcloud.cli.commands.agent.console') as mock_console, \
patch('pipecatcloud.cli.commands.agent.config') as mock_config, \
patch('pipecatcloud.cli.commands.agent.questionary') as mock_questionary, \
patch('pipecatcloud.cli.commands.agent.DeployConfigParams') as mock_params:

with (
patch("pipecatcloud.cli.commands.agent.config") as mock_config,
patch("pipecatcloud.cli.commands.agent.questionary") as mock_questionary,
patch("pipecatcloud.cli.commands.agent.DeployConfigParams") as mock_params,
):
mock_config.get.return_value = TEST_ORG
mock_params.return_value = MagicMock(agent_name=TEST_AGENT)
# User agrees to the confirmation
Expand All @@ -76,11 +76,12 @@ def test_stop_shows_confirmation_without_force(self):

def test_stop_aborts_on_user_rejection(self):
"""Verify command aborts when user rejects the confirmation."""
with patch('pipecatcloud.cli.commands.agent.console') as mock_console, \
patch('pipecatcloud.cli.commands.agent.config') as mock_config, \
patch('pipecatcloud.cli.commands.agent.questionary') as mock_questionary, \
patch('pipecatcloud.cli.commands.agent.DeployConfigParams') as mock_params:

with (
patch("pipecatcloud.cli.commands.agent.console") as mock_console,
patch("pipecatcloud.cli.commands.agent.config") as mock_config,
patch("pipecatcloud.cli.commands.agent.questionary") as mock_questionary,
patch("pipecatcloud.cli.commands.agent.DeployConfigParams") as mock_params,
):
mock_config.get.return_value = TEST_ORG
mock_params.return_value = MagicMock(agent_name=TEST_AGENT)
# User rejects the confirmation
Expand Down
Loading