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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,11 +9,17 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

### Added

- MIT license and public contribution, security, and ownership information.
- MIT license, including explicit coverage for the published `calle-ai`
versions `0.6.0` and `0.7.0`, plus public contribution, security, and
ownership information.
- A public-repository hygiene check for tracked paths, tracked text, and pull
request metadata.

### Changed

- Prevented generated client representations from exposing credentials and
kept call identifiers inside their intended URL path segment.
- Bounded webhook example request bodies and documented its production limits.
- Locked the release build and package-validation toolchain.
- Stable publishing is initiated by a versioned GitHub Release and uses PyPI
Trusted Publishing.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,7 +21,7 @@ export CALLE_BASE_URL="https://api.heycall-e.com"
export CALLE_EXAMPLE_PHONE="+14155550100"
uv run python examples/create_and_wait.py

export CALLE_BASE_URL="https://test-api.heycall-e.com"
export CALLE_BASE_URL="<APPROVED_TEST_API_BASE_URL>"
export CALLE_GOAL_ID="<PUBLISHED_GOAL_ID>"
export CALLE_GOAL_PHONE="<AUTHORIZED_E164_PHONE>"
export CALLE_GOAL_VARIABLES='{"name":"Alex"}'
Expand Down
15 changes: 12 additions & 3 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -95,10 +95,10 @@ export CALLE_IDEMPOTENCY_KEY="<DURABLE_WORKFLOW_KEY>"
uv run python examples/run_goal_and_wait.py
```

To test against the test environment, explicitly set:
To use an approved non-production environment, explicitly set:

```bash
export CALLE_BASE_URL="https://test-api.heycall-e.com"
export CALLE_BASE_URL="<APPROVED_TEST_API_BASE_URL>"
```

Run the webhook receiver example:
Expand All@@ -115,6 +115,13 @@ CALL-E webhook delivery does not use a webhook secret, `CALL-E-Timestamp`, or
`CALL-E-Signature`. Use the required `CALL-E-Event-Id` header to deduplicate
at-least-once deliveries before performing side effects. The receiver example
parses JSON directly and checks that this header matches the body event id.
It defaults to a 10 MiB request-body limit and returns `413` for larger
payloads. Set `CALLE_WEBHOOK_MAX_BODY_BYTES` to match your ingress limits.

The bounded in-memory deduplication cache is only for local example use and
retains the latest 10,000 event ids. Production deployments must use a
production server or ingress with read deadlines and durable deduplication
storage with an explicit retention policy.

The `client.webhooks.verify` and `client.webhooks.unwrap` methods implement the
legacy signed-payload contract from SDK `0.2`. They remain available for source
Expand DownExpand Up@@ -231,7 +238,9 @@ public issue. Follow [SECURITY.md](./SECURITY.md) for private reporting.

## License

This project is licensed under the [MIT License](./LICENSE).
This project is licensed under the [MIT License](./LICENSE). The same license
applies to the published PyPI distributions `calle-ai==0.6.0` and
`calle-ai==0.7.0`.

## Project Documents

Expand Down
43 changes: 39 additions & 4 deletions examples/webhook_server.py
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
import hashlib
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any

port = int(os.environ.get("PORT", "3000"))
processed_event_ids: set[str] = set()
try:
max_request_body_bytes = int(
os.environ.get("CALLE_WEBHOOK_MAX_BODY_BYTES", "10485760")
)
except ValueError:
raise ValueError(
"CALLE_WEBHOOK_MAX_BODY_BYTES must be a positive integer."
) from None
if max_request_body_bytes <= 0:
raise ValueError("CALLE_WEBHOOK_MAX_BODY_BYTES must be a positive integer.")
max_processed_event_ids = 10_000
processed_event_ids: dict[bytes, None] = {}


class WebhookHandler(BaseHTTPRequestHandler):
Expand All@@ -13,7 +25,27 @@ def do_POST(self) -> None:
self._send_json(404, {"error": "not_found"})
return

raw_body = self.rfile.read(int(self.headers.get("content-length", "0")))
content_lengths = self.headers.get_all("content-length", [])
if (
len(content_lengths) != 1
or not content_lengths[0].isascii()
or not content_lengths[0].isdigit()
):
self.close_connection = True
self._send_json(400, {"error": "invalid_content_length"})
return
try:
content_length = int(content_lengths[0])
except ValueError:
self.close_connection = True
self._send_json(400, {"error": "invalid_content_length"})
return
if content_length > max_request_body_bytes:
self.close_connection = True
self._send_json(413, {"error": "payload_too_large"})
return

raw_body = self.rfile.read(content_length)

try:
parsed: Any = json.loads(raw_body)
Expand All@@ -29,6 +61,7 @@ def do_POST(self) -> None:
if not event_id or event.get("id") != event_id:
self._send_json(400, {"error": "invalid_event_id"})
return
event_key = hashlib.sha256(event_id.encode()).digest()
event_type = event.get("type")
call = event.get("data")
if (
Expand All@@ -40,12 +73,14 @@ def do_POST(self) -> None:
return
call_id = call["id"]

if event_id in processed_event_ids:
if event_key in processed_event_ids:
self._send_json(200, {"received": True, "duplicate": True})
return

# Use durable storage in production and persist the id before side effects.
processed_event_ids.add(event_id)
processed_event_ids[event_key] = None
if len(processed_event_ids) > max_processed_event_ids:
processed_event_ids.pop(next(iter(processed_event_ids)))

if event_type == "call.completed":
print(
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,20 +27,22 @@ Repository = "https://github.com/CALLE-AI/server-sdk-python"
"Bug Tracker" = "https://github.com/CALLE-AI/server-sdk-python/issues"

[build-system]
requires = ["hatchling"]
requires = ["hatchling==1.32.0"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/calle"]

[dependency-groups]
dev = [
"hatchling==1.32.0",
"mypy>=1.19.0",
"openapi-python-client>=0.29.0,<0.30.0",
"pytest>=8.0.0",
"pyyaml>=6.0.3",
"respx>=0.22.0",
"ruff>=0.14.0",
"twine==7.0.0",
]

[tool.pytest.ini_options]
Expand Down
20 changes: 10 additions & 10 deletions scripts/validate.sh
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,18 +2,18 @@
set -euo pipefail

rm -rf dist
uv sync --all-groups
uv run python scripts/verify_openapi_contract.py
uv run pytest -q
uv run ruff check .
uv run mypy src/calle
uv run python -m py_compile \
uv sync --all-groups --locked
uv run --locked python scripts/verify_openapi_contract.py
uv run --locked pytest -q
uv run --locked ruff check .
uv run --locked mypy src/calle
uv run --locked python -m py_compile \
examples/create_and_wait.py \
examples/run_goal_and_wait.py \
examples/webhook_server.py
python3 scripts/check_public_repo_hygiene.py
uv build
uvx twine check dist/*
uv run python scripts/verify_distribution_artifacts.py dist --write-manifest
uv run --locked python scripts/check_public_repo_hygiene.py
uv build --no-build-isolation
uv run --locked twine check dist/*
uv run --locked python scripts/verify_distribution_artifacts.py dist --write-manifest
bash scripts/smoke_install_dist.sh dist/*.whl
bash scripts/smoke_install_dist.sh dist/*.tar.gz
10 changes: 8 additions & 2 deletions src/calle/calls.py
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import time
from typing import Any
from urllib.parse import quote

import httpx

Expand DownExpand Up@@ -40,11 +41,11 @@ def create(
return self._request("POST", "/v1/calls", json=payload, headers=headers)

def get(self, call_id: str) -> JsonObject:
return self._request("GET", f"/v1/calls/{call_id}")
return self._request("GET", _call_path(call_id))

def list_events(self, call_id: str, *, cursor: str | None = None, limit: int | None = None) -> JsonObject:
params = {key: value for key, value in {"cursor": cursor, "limit": limit}.items() if value is not None}
return self._request("GET", f"/v1/calls/{call_id}/events", params=params)
return self._request("GET", f"{_call_path(call_id)}/events", params=params)

def wait_for_result(
self,
Expand DownExpand Up@@ -94,3 +95,8 @@ def _normalize_recipient(recipient: JsonObject) -> JsonObject:
normalized = {key: value for key, value in recipient.items() if key != "phone"}
normalized["phones"] = [phone] if phone is not None else []
return normalized


def _call_path(call_id: str) -> str:
encoded_call_id = quote(call_id, safe="").replace(".", "%2E")
return f"/v1/calls/{encoded_call_id}"
2 changes: 1 addition & 1 deletion src/calle/generated/api/calls/get_call.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -18,7 +18,7 @@ def _get_kwargs(
_kwargs: dict[str, Any] = {
"method": "get",
"url": "/v1/calls/{call_id}".format(
call_id=quote(str(call_id), safe=""),
call_id=quote(str(call_id), safe="").replace(".", "%2E"),
),
}

Expand Down
2 changes: 1 addition & 1 deletion src/calle/generated/api/calls/list_call_events.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -31,7 +31,7 @@ def _get_kwargs(
_kwargs: dict[str, Any] = {
"method": "get",
"url": "/v1/calls/{call_id}/events".format(
call_id=quote(str(call_id), safe=""),
call_id=quote(str(call_id), safe="").replace(".", "%2E"),
),
"params": params,
}
Expand Down
26 changes: 13 additions & 13 deletions src/calle/generated/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -35,19 +35,19 @@ class Client:
"""

raise_on_unexpected_status: bool = field(default=False, kw_only=True)
_base_url: str = field(alias="base_url")
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
_base_url: str = field(alias="base_url", repr=False)
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies", repr=False)
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers", repr=False)
_timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout")
_verify_ssl: str | bool | ssl.SSLContext = field(
default=True, kw_only=True, alias="verify_ssl"
)
_follow_redirects: bool = field(
default=False, kw_only=True, alias="follow_redirects"
)
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
_client: httpx.Client | None = field(default=None, init=False)
_async_client: httpx.AsyncClient | None = field(default=None, init=False)
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args", repr=False)
_client: httpx.Client | None = field(default=None, init=False, repr=False)
_async_client: httpx.AsyncClient | None = field(default=None, init=False, repr=False)

def with_headers(self, headers: dict[str, str]) -> "Client":
"""Get a new client matching this one with additional headers"""
Expand DownExpand Up@@ -169,21 +169,21 @@ class AuthenticatedClient:
"""

raise_on_unexpected_status: bool = field(default=False, kw_only=True)
_base_url: str = field(alias="base_url")
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
_base_url: str = field(alias="base_url", repr=False)
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies", repr=False)
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers", repr=False)
_timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout")
_verify_ssl: str | bool | ssl.SSLContext = field(
default=True, kw_only=True, alias="verify_ssl"
)
_follow_redirects: bool = field(
default=False, kw_only=True, alias="follow_redirects"
)
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
_client: httpx.Client | None = field(default=None, init=False)
_async_client: httpx.AsyncClient | None = field(default=None, init=False)
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args", repr=False)
_client: httpx.Client | None = field(default=None, init=False, repr=False)
_async_client: httpx.AsyncClient | None = field(default=None, init=False, repr=False)

token: str
token: str = field(repr=False)
prefix: str = "Bearer"
auth_header_name: str = "Authorization"

Expand Down
21 changes: 21 additions & 0 deletions tests/test_calls.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -161,3 +161,24 @@ def test_wait_for_result_raises_timeout() -> None:

with pytest.raises(CalleTimeoutError):
client.calls.wait_for_result("call_123", interval_seconds=0.001, timeout_seconds=0.002)


def test_call_id_stays_in_one_url_path_segment() -> None:
requests: list[httpx.Request] = []

def handle_request(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={})

call_id = "../goals?admin=1#fragment%2F"
with httpx.Client(
base_url="https://api.heycall-e.com",
transport=httpx.MockTransport(handle_request),
) as http_client:
client = CalleClient(api_key="key_test", http_client=http_client)
client.calls.get(call_id)
client.calls.list_events(call_id)

encoded_id = "%2E%2E%2Fgoals%3Fadmin%3D1%23fragment%252F"
assert requests[0].url.raw_path == f"/v1/calls/{encoded_id}".encode()
assert requests[1].url.raw_path == f"/v1/calls/{encoded_id}/events".encode()
71 changes: 71 additions & 0 deletions tests/test_generated_client.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
import httpx
from attrs import define

from calle.generated import AuthenticatedClient, Client
from calle.generated.api.calls.get_call import _get_kwargs as get_call_kwargs
from calle.generated.api.calls.list_call_events import (
_get_kwargs as list_call_events_kwargs,
)


@define
class DerivedClient(Client):
pass


@define
class DerivedAuthenticatedClient(AuthenticatedClient):
pass


def test_generated_client_repr_does_not_expose_credentials() -> None:
secret = "repr_sentinel_secret"
client = Client(
base_url="https://api.heycall-e.com",
cookies={"session": secret},
headers={"X-Secret": secret},
)
authenticated_client = AuthenticatedClient(
base_url="https://api.heycall-e.com",
token=secret,
)
derived_client = DerivedClient(
base_url=f"https://{secret}@api.heycall-e.com",
headers={"X-Secret": secret},
)
derived_authenticated_client = DerivedAuthenticatedClient(
base_url="https://api.heycall-e.com",
token=secret,
)

for value in (
client,
authenticated_client,
derived_client,
derived_authenticated_client,
):
assert secret not in repr(value)

client.get_httpx_client()
authenticated_client.get_httpx_client()
try:
assert secret not in repr(client)
assert secret not in repr(authenticated_client)
finally:
client.get_httpx_client().close()
authenticated_client.get_httpx_client().close()


def test_generated_call_id_stays_in_one_url_path_segment() -> None:
client = httpx.Client(base_url="https://api.heycall-e.com")
try:
get_path = get_call_kwargs("..")["url"]
events_path = list_call_events_kwargs("..")["url"]

assert client.build_request("GET", get_path).url.raw_path == b"/v1/calls/%2E%2E"
assert (
client.build_request("GET", events_path).url.raw_path
== b"/v1/calls/%2E%2E/events"
)
finally:
client.close()
Loading