diff --git a/.coveragerc b/.coveragerc index 9f93c099..7556a4fc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -9,6 +9,13 @@ omit = setup.py */examples/* */docs/* + # Spec-generated static type stubs (TypedDicts emitted from the canonical + # specs). Type-only — imported solely under TYPE_CHECKING, never executed at + # runtime, so they carry no testable statements; counting them would dilute + # coverage with ~1500 unreachable lines. Regenerated + freshness-gated by the + # GEN-FRESH gate, not by tests. + */rest/namespaces/*_types_generated.py + */relay/protocol_types_generated.py [report] # Regexes for lines to exclude from consideration diff --git a/.gitignore b/.gitignore index 8bd44695..650d6cff 100644 --- a/.gitignore +++ b/.gitignore @@ -247,3 +247,6 @@ Anaconda3-2024.02-1-Linux-x86_64.sh chunks.json my_chunks *.swsearch + +# run-ci gate-scheduler scratch (repo-local; auto-cleaned) +.sw-tmp/ diff --git a/pyproject.toml b/pyproject.toml index 5fcd4c8b..0c50525e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -182,6 +182,16 @@ select = ["E4", "E7", "E9", "F", "B", "S", "C4", "PERF", "SIM", "PTH", "RET", "R # call) and S607 (partial executable path: git/ssh resolved via PATH, the normal # idiom) are expected and safe across this file. "signalwire/signalwire/cli/dokku.py" = ["S603", "S607"] +# REST namespace files import spec-generated types under TYPE_CHECKING and use +# them ONLY inside quoted forward-refs — method return annotations +# (`-> "RoomResponse"`) and the generic CRUD-base subscript bindings +# (`class VideoRooms(CrudResource["ListRoomsResponse", "RoomResponse", ...])`). +# Ruff's F401 cannot see usages inside a class-base subscript's string literals, +# so it false-positives "unused import" and `--fix` would strip imports that ARE +# used (breaking mypy). The bindings must stay quoted (a bare name in the base +# subscript is a runtime NameError under TYPE_CHECKING). Exempt F401 here; mypy + +# the SIGNATURES/DRIFT gate prove these imports are real and correct. +"signalwire/signalwire/rest/namespaces/*.py" = ["F401"] [tool.ruff.lint.flake8-bugbear] # FastAPI's dependency-injection idiom puts Depends()/Query()/etc. in argument @@ -202,16 +212,22 @@ extend-immutable-calls = [ [tool.mypy] python_version = "3.10" -files = ["signalwire/signalwire"] +# Both the source tree AND the test suite are in scope — the generated REST tests are +# strict-clean by construction, and the hand-written tests were fully annotated, so the +# strict bar now covers tests too (a new untyped test fails the gate). +files = ["signalwire/signalwire", "tests"] # Third-party packages without type stubs (nltk, sentence_transformers, the # search/relay stacks) are out of scope — we type our own code, not theirs. ignore_missing_imports = true -# The bar: our own code is clean. Bodies of untyped defs are checked so real -# type errors inside them surface (this is what found the mixin cluster). -check_untyped_defs = true -warn_unused_ignores = true -# `# type: ignore` is reserved for genuinely-unfixable third-party-stub gaps and -# must carry an error code + reason. warn_unused_ignores keeps them honest. +# The bar: the whole source tree is mypy --strict clean (full annotations + all +# the strict sub-flags). Achieved by typing handlers with REAL types (FastAPI +# Response/dict, generated payload types where they exist) — never papered with +# bare `Any`. `strict` subsumes check_untyped_defs / warn_unused_ignores / etc. +strict = true +# `# type: ignore` is reserved for genuinely-unfixable third-party-stub gaps (e.g. +# flask/flask-limiter optional extras with no stubs) and the documented +# _HostTyped/AgentBase TYPE_CHECKING-vs-runtime split; each must carry an error +# code + reason. warn_unused_ignores (via strict) keeps them honest. show_error_codes = true # numpy (a search/relay-extra runtime dep we do NOT type — same class as nltk / diff --git a/scripts/run-ci.sh b/scripts/run-ci.sh index aa244864..b4f3f366 100755 --- a/scripts/run-ci.sh +++ b/scripts/run-ci.sh @@ -4,23 +4,27 @@ # Same script invoked locally (`bash scripts/run-ci.sh`) AND by the # GitHub Actions workflow. No drift between local and CI behavior. # -# Python is the reference SDK. Per-port "drift gate" doesn't apply — -# python IS the spec. Instead we regenerate the python_signatures.json -# oracle (in porting-sdk/) from the live Python source and verify the -# regenerated file matches what's checked in. That way a Python-side -# surface change can't silently invalidate every other port's audit. +# Python is the reference SDK. Per-port "drift gate" doesn't apply — python IS the +# spec. Instead we regenerate the python_signatures.json oracle (in porting-sdk/) +# from the live Python source and verify the regenerated file matches what's checked +# in, so a Python-side surface change can't silently invalidate every other port's audit. # -# Gates (in order, fail-fast): -# 1. python -m pytest tests/unit/ — language test runner -# 2. signature regen — porting-sdk's enumerate_python_signatures.py -# 3. drift gate — git-diff python_signatures.json (must be unchanged) -# 4. no-cheat gate — porting-sdk audit_no_cheat_tests.py -# 4b. rest-coverage gate — porting-sdk rest_coverage.py over a REST-suite -# journal: every implemented route hit success+error -# (parity), accepted gaps allowlisted -# 5. fmt gate — ruff format (local: apply; CI: --check) -# 6. lint gate — ruff check, zero findings -# 7. typecheck gate — mypy, zero findings +# GATE SCHEDULING (porting-sdk/scripts/gate_scheduler.sh — CI_PERF S1 + S2): +# Gates run CONCURRENTLY up to a cap (SW_CI_JOBS, default nproc), scheduled by +# their DATA dependencies: +# * S2 concurrent wave: the pure-Python side-effect-free gates (NO-CHEAT, +# SPEC-PARITY, LINT, TYPECHECK, GEN-FRESH) overlap — they share no mutable state. +# * S1 fail-fast: heavy gates (TEST, REST-COVERAGE, FMT) are deferred behind the +# cheap wave, so a trivial cheap-gate failure surfaces in seconds; --fail-fast +# aborts the run before TEST starts. +# HARD ordering is data-dependency ONLY: +# * SIGNATURES regenerates python_signatures.json in porting-sdk; DRIFT git-diffs +# that file → deps=SIGNATURES (never diff before the regen writes). +# Per-gate PASS/FAIL + the FAILED_GATES tally preserved exactly; each gate's output +# captured + replayed atomically. +# +# Flags: +# --fail-fast stop launching new gates at the first failure (local dev loop). set -u set -o pipefail @@ -46,37 +50,9 @@ PORTING_SDK_DIR="$(resolve_porting_sdk)" || { exit 2 } -FAILED_GATES="" - -run_gate() { - local name="$1"; shift - local description="$1"; shift - local logfile - logfile="$(mktemp)" - "$@" >"$logfile" 2>&1 - local rc=$? - if [ "$rc" -eq 0 ]; then - echo "[$name] $description ... PASS" - rm -f "$logfile" - return 0 - fi - echo "[$name] $description ... FAIL: exit $rc" - # On failure show the LAST 400 lines (was 40) so pytest's per-test - # tracebacks — which print before the short-summary — actually reach the CI - # log. A blind `tail -40` on a multi-thousand-test run captured only the - # FAILED summary lines and discarded every traceback, making CI-only - # failures undiagnosable. 400 lines covers a realistic handful of failing - # tests; a fully green run prints nothing here regardless. - sed 's/^/ /' "$logfile" | tail -400 - rm -f "$logfile" - FAILED_GATES="$FAILED_GATES $name" - return $rc -} +# shellcheck source=/dev/null +source "$PORTING_SDK_DIR/scripts/gate_scheduler.sh" -# Ask the OS for a free TCP port (bind :0, read it back, release). Used by the -# REST-COVERAGE gate so a stale mock_signalwire squatting a fixed port can't -# silently steal the gate's traffic (a previously hardcoded 8951 did exactly -# that). Prints the port on stdout; nonzero exit on failure (caller fails loud). pick_free_port() { python3 - <<'PY' import socket @@ -91,63 +67,35 @@ cd "$PORT_ROOT" echo "==> running CI gates for $PORT_NAME (porting-sdk at $PORTING_SDK_DIR)" -# Record the resolved web-stack versions up front. CI-only test failures in the -# web layer have been impossible to diagnose because the runner's fresh -# dependency resolution differs from local envs; logging it makes that visible. +# Record the resolved web-stack versions up front (CI-only web-layer failures have +# been impossible to diagnose without the runner's actual resolution). echo "==> environment: $(python3 --version 2>&1)" python3 -m pip freeze 2>/dev/null \ | grep -iE '^(fastapi|starlette|pydantic|pydantic-core|anyio|uvicorn|httpx|requests)==' \ | sed 's/^/ /' || true -# Gate 1: pytest unit suite -run_gate "TEST" "python -m pytest tests/unit/" \ - python -m pytest tests/unit/ - -# Gate 2: regenerate the python reference oracle into porting-sdk -run_gate "SIGNATURES" "regenerate python_signatures.json (reference oracle)" \ - python3 "$PORTING_SDK_DIR/scripts/enumerate_python_signatures.py" \ - --signalwire-python "$PORT_ROOT/signalwire" \ - --out "$PORTING_SDK_DIR/python_signatures.json" +# FMT — ruff format. LOCAL applies; CI --check. +fmt_gate() { + if [ -n "${CI:-}" ]; then + python3 -m ruff format --check "$PORT_ROOT/signalwire" + else + python3 -m ruff format "$PORT_ROOT/signalwire" >/dev/null + if ! (cd "$PORT_ROOT" && git diff --quiet 2>/dev/null); then + echo " (FMT auto-applied formatting to your working tree — review & stage)" + fi + python3 -m ruff format --check "$PORT_ROOT/signalwire" + fi +} -# Gate 3: oracle-drift gate — the regenerated file must match what's -# committed in porting-sdk. Drift here means a Python-side surface -# change wasn't propagated to the audit oracle, which would silently -# invalidate every port's drift report. -run_gate "DRIFT" "python_signatures.json unchanged after regen" \ - bash -c "cd '$PORTING_SDK_DIR' && git diff --quiet -- python_signatures.json" - -# Gate 4: no-cheat -run_gate "NO-CHEAT" "audit_no_cheat_tests" \ - python3 "$PORTING_SDK_DIR/scripts/audit_no_cheat_tests.py" --root "$PORT_ROOT" - -# Gate 4b: REST-COVERAGE — every canonical REST route the SDK implements must be -# exercised with BOTH a success (2xx) AND an error (4xx/5xx) response, on the -# correct on-the-wire path (parity). Measured by replaying the mock journal of a -# REST-suite run through porting-sdk's rest_coverage checker. Accepted gaps — -# routes with no SDK method, malformed canonical routes, mock-router collisions — -# are allowlisted: the shared baseline (porting-sdk/REST_COVERAGE_BASELINE.md) for -# universal gaps + this port's REST_COVERAGE_GAPS.md for its own sdk-gaps. A -# stale allowlist entry (a route now actually covered) fails the gate. -# -# Self-contained: spins up its own mock on an ephemeral port, runs the rest/ suite -# serially against it (MOCK_SIGNALWIRE_PORT so all traffic lands in one journal), -# then checks that journal. Same shape on every port. -# -# The mock + checker are run as `-m mock_signalwire.`; that package lives at -# porting-sdk/test_harness/mock_signalwire/ and is NOT pip-installed in CI (the -# runner only checks porting-sdk out). Put the package parent on PYTHONPATH so the -# module resolves regardless of install state — local and CI, every port. +# REST-COVERAGE — every implemented REST route covered success+error. Self- +# contained: spins its own mock on an ephemeral port, runs the rest/ suite serially, +# then checks the journal. rest_coverage_gate() { local port port="$(pick_free_port)" || { echo "FATAL: could not acquire a free port for mock_signalwire" >&2 return 1 } - # mock_signalwire is pip-installed (-e) in CI from porting-sdk/test_harness/. - # Locally it may be discovered via adjacency instead; put the package parent on - # PYTHONPATH as a belt-and-suspenders fallback so `-m mock_signalwire.*` imports - # regardless of install state. (rest_coverage.py must exist in the checked-out - # porting-sdk — it ships in the same change that adds this gate.) local mock_pkg_parent="$PORTING_SDK_DIR/test_harness/mock_signalwire" export PYTHONPATH="$mock_pkg_parent${PYTHONPATH:+:$PYTHONPATH}" python3 -m mock_signalwire --host 127.0.0.1 --port "$port" --log-level error \ @@ -155,9 +103,6 @@ rest_coverage_gate() { local mock_pid=$! # shellcheck disable=SC2064 trap "kill $mock_pid 2>/dev/null" RETURN - # Wait for the control plane to answer. Fail LOUD if the mock never becomes - # healthy (or dies during startup) instead of proceeding silently into a - # journal-reset that 404s/connection-refuses with an opaque traceback. local i healthy=0 for i in $(seq 1 30); do if ! kill -0 "$mock_pid" 2>/dev/null; then @@ -185,59 +130,51 @@ rest_coverage_gate() { --allowlist "$PORT_ROOT/REST_COVERAGE_GAPS.md" \ --gap-baseline "$PORTING_SDK_DIR/REST_COVERAGE_GAP_BASELINE.md" } -run_gate "REST-COVERAGE" "every implemented REST route covered success+error (parity + allowlist)" \ - rest_coverage_gate - -# Gate 4c: SPEC-PARITY — the routes the SDK actually IMPLEMENTS must equal the -# canonical spec route set, modulo a checked-in not-implemented gaps file. This -# is the spec-first guard: REST-COVERAGE only proves tested routes match the -# spec, so a route the SDK implements but the spec doesn't define (or vice -# versa) would slip past it. The route registry is built by driving the live -# client through a recording HttpClient (not an AST scrape, not the test -# journal), so it sees every implemented route whether or not it's tested. -# * B−A (SDK route absent from the spec) → fail (new API must enter the spec) -# * A−B (canonical route not implemented) → fail unless in SPEC_IMPLEMENTATION_GAPS.md -run_gate "SPEC-PARITY" "implemented routes == canonical spec (modulo SPEC_IMPLEMENTATION_GAPS.md)" \ - python3 "$PORTING_SDK_DIR/scripts/diff_spec_implementation.py" \ + +# ---- register gates ---------------------------------------------------------- +sched_init "$@" + +sched_gate TEST defer=1 desc="python -m pytest tests/unit/" \ + -- python -m pytest tests/unit/ + +# SIGNATURES regenerates the porting-sdk oracle → DRIFT git-diffs it. deps=SIGNATURES. +sched_gate SIGNATURES desc="regenerate python_signatures.json (reference oracle)" \ + -- python3 "$PORTING_SDK_DIR/scripts/enumerate_python_signatures.py" \ + --signalwire-python "$PORT_ROOT/signalwire" \ + --out "$PORTING_SDK_DIR/python_signatures.json" + +sched_gate DRIFT deps=SIGNATURES desc="python_signatures.json unchanged after regen" \ + -- bash -c "cd '$PORTING_SDK_DIR' && git diff --quiet -- python_signatures.json" + +sched_gate NO-CHEAT desc="audit_no_cheat_tests" \ + -- python3 "$PORTING_SDK_DIR/scripts/audit_no_cheat_tests.py" --root "$PORT_ROOT" + +sched_gate REST-COVERAGE defer=1 desc="every implemented REST route covered success+error (parity + allowlist)" \ + --fn rest_coverage_gate + +sched_gate SPEC-PARITY defer=1 desc="implemented routes == canonical spec (modulo SPEC_IMPLEMENTATION_GAPS.md)" \ + -- python3 "$PORTING_SDK_DIR/scripts/diff_spec_implementation.py" \ --sdk "$PORT_ROOT/signalwire/signalwire" \ --gaps "$PORTING_SDK_DIR/SPEC_IMPLEMENTATION_GAPS.md" -# Gate 5: FMT — ruff format. Config in pyproject.toml [tool.ruff]. Source-style -# only (no public-API change → the SIGNATURES/DRIFT oracle is unaffected). -# * LOCAL ($CI unset) → `ruff format` reformats the tree in place. -# * CI ($CI=true) → `ruff format --check` (read-only): fails on unformatted. -fmt_gate() { - if [ -n "${CI:-}" ]; then - python3 -m ruff format --check "$PORT_ROOT/signalwire" - else - python3 -m ruff format "$PORT_ROOT/signalwire" >/dev/null - if ! (cd "$PORT_ROOT" && git diff --quiet 2>/dev/null); then - echo " (FMT auto-applied formatting to your working tree — review & stage)" - fi - python3 -m ruff format --check "$PORT_ROOT/signalwire" - fi -} -run_gate "FMT" "ruff format (local: apply; CI: --check)" fmt_gate - -# Gate 6: LINT — ruff check, zero findings. Burned to zero (the DISABLE list is -# none; the few intentional lazy-import / re-export sites carry per-line # noqa -# with a rationale). Mirrors the go golangci / ruby rubocop blocking-lint gate. -run_gate "LINT" "ruff check zero findings" \ - python3 -m ruff check "$PORT_ROOT/signalwire" - -# Gate 7: TYPECHECK — mypy, zero findings. Config in pyproject.toml [tool.mypy] -# (files=signalwire/signalwire, ignore_missing_imports, check_untyped_defs). Burned -# to zero across all 176 source files. `# type: ignore` is reserved for genuine -# third-party-stub gaps / optional-dependency import shims, each with an error code -# and rationale (warn_unused_ignores keeps them honest). Source-type only — mypy is -# dev-time and adds no runtime validation, so wire payloads stay forward-compatible. -run_gate "TYPECHECK" "mypy zero findings" \ - python3 -m mypy --config-file "$PORT_ROOT/pyproject.toml" - -if [ -z "$FAILED_GATES" ]; then +sched_gate FMT defer=1 desc="ruff format (local: apply; CI: --check)" \ + --fn fmt_gate + +sched_gate LINT desc="ruff check zero findings" \ + -- python3 -m ruff check "$PORT_ROOT/signalwire" + +sched_gate TYPECHECK desc="mypy zero findings" \ + -- python3 -m mypy --config-file "$PORT_ROOT/pyproject.toml" + +sched_gate GEN-FRESH desc="generated REST/RELAY types reproduce from specs" \ + -- python3 "$PORTING_SDK_DIR/scripts/generate_python_rest_types.py" \ + --signalwire-python "$PORT_ROOT/signalwire" --check + +sched_run +rc=$? +if [ "$rc" -eq 0 ]; then echo "==> CI PASS" - exit 0 else echo "==> CI FAIL (gates:$FAILED_GATES )" - exit 1 fi +exit "$rc" diff --git a/signalwire/signalwire/__init__.py b/signalwire/signalwire/__init__.py index ff3a1120..5b53ba22 100644 --- a/signalwire/signalwire/__init__.py +++ b/signalwire/signalwire/__init__.py @@ -12,9 +12,16 @@ A package for building AI agents using SignalWire's AI and SWML capabilities. """ +from typing import TYPE_CHECKING, Any + # Configure logging before any other imports to ensure early initialization from .core.logging_config import configure_logging +if TYPE_CHECKING: + from signalwire.rest.client import RestClient as _RestClient + from signalwire.core.skill_base import SkillBase + from signalwire.skills.registry import SkillRegistry + configure_logging() __version__ = "3.0.2" @@ -53,14 +60,14 @@ # Lazy import skills to avoid slow startup for CLI tools # Skills are now loaded on-demand when requested -def _get_skill_registry(): +def _get_skill_registry() -> "SkillRegistry": """Lazy import and return skill registry""" import signalwire.skills return signalwire.skills.skill_registry -def list_skills(): +def list_skills() -> list[dict[str, Any]]: """List all available skills with metadata. Returns one dict per skill (name, description, version, required packages / @@ -71,7 +78,7 @@ def list_skills(): return skill_registry.list_skills() -def list_skills_with_params(): +def list_skills_with_params() -> dict[str, dict[str, Any]]: """ Get complete schema for all available skills including parameter metadata @@ -98,7 +105,7 @@ def list_skills_with_params(): return skill_registry.get_all_skills_schema() -def register_skill(skill_class): +def register_skill(skill_class: "type[SkillBase]") -> None: """ Register a custom skill class @@ -119,7 +126,7 @@ def register_skill(skill_class): return skill_registry.register_skill(skill_class) -def add_skill_directory(path): +def add_skill_directory(path: str) -> None: """ Add a directory to search for skills @@ -138,7 +145,7 @@ def add_skill_directory(path): return skill_registry.add_skill_directory(path) -def RestClient(*args, **kwargs): +def RestClient(*args: Any, **kwargs: Any) -> "_RestClient": """Create a SignalWire REST API client (lazy import)""" from signalwire.rest import RestClient as _RestClient diff --git a/signalwire/signalwire/agent_server.py b/signalwire/signalwire/agent_server.py index 17ff5a8c..494d1bce 100644 --- a/signalwire/signalwire/agent_server.py +++ b/signalwire/signalwire/agent_server.py @@ -13,7 +13,7 @@ import re from pathlib import Path from typing import Any -from collections.abc import Callable +from collections.abc import Awaitable, Callable try: from fastapi import FastAPI, Request, Response @@ -26,6 +26,7 @@ from signalwire.core.agent_base import AgentBase from signalwire.core.swml_service import SWMLService from signalwire.core.logging_config import get_logger +from signalwire.core.mixins.web_mixin import _as_response import contextlib @@ -76,7 +77,9 @@ def __init__( # Add security headers middleware @self.app.middleware("http") - async def add_security_headers(request, call_next): + async def add_security_headers( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" @@ -104,7 +107,7 @@ async def add_security_headers(request, call_next): # all other routes are registered. This ensures custom routes like /get_token # don't get overshadowed by the catch-all /{full_path:path} route. @self.app.on_event("startup") - async def _setup_catch_all(): + async def _setup_catch_all() -> None: self._register_catch_all_handler() def register(self, agent: AgentBase, route: str | None = None) -> None: @@ -343,8 +346,8 @@ def get_agent(self, route: str) -> AgentBase | None: def run( self, - event=None, - context=None, + event: Any = None, + context: Any = None, host: str | None = None, port: int | None = None, ) -> Any: @@ -511,7 +514,7 @@ def _handle_cgi_request(self) -> str: error_response = {"error": "Not Found"} return self._format_cgi_response(error_response, status="404 Not Found") - def _handle_lambda_request(self, event, context) -> dict: + def _handle_lambda_request(self, event: Any, context: Any) -> dict[str, Any]: """Handle Lambda request using same routing logic as server""" import json @@ -600,7 +603,7 @@ def _handle_lambda_request(self, event, context) -> dict: } def _format_cgi_response( - self, data, content_type: str = "application/json", status: str = "200 OK" + self, data: Any, content_type: str = "application/json", status: str = "200 OK" ) -> str: """Format response for CGI output""" import json @@ -634,7 +637,7 @@ def _register_health_endpoints(self) -> None: """ @self.app.get("/health") - def health_check(): + def health_check() -> dict[str, Any]: return { "status": "ok", "agents": len(self.agents), @@ -642,7 +645,7 @@ def health_check(): } @self.app.get("/ready") - def readiness_check(): + def readiness_check() -> dict[str, Any]: return {"status": "ready", "agents": len(self.agents)} def _run_server(self, host: str | None = None, port: int | None = None) -> None: @@ -845,7 +848,7 @@ def _register_catch_all_handler(self) -> None: @self.app.get("/{full_path:path}") @self.app.post("/{full_path:path}") - async def handle_all_routes(request: Request, full_path: str): + async def handle_all_routes(request: Request, full_path: str) -> Response: """Handle requests that don't match registered routes (e.g. /matti instead of /matti/)""" # Check if this path maps to one of our registered agents for route, agent in self.agents.items(): @@ -868,11 +871,20 @@ async def handle_all_routes(request: Request, full_path: str): if clean_path == "swaig": from fastapi import Response - return await agent._handle_swaig_request(request, Response()) + # _handle_swaig_request returns Response | dict (the dict is a + # SWAIG-result passthrough); funnel through _as_response so this + # route handler stays -> Response (FastAPI-safe). + return _as_response( + await agent._handle_swaig_request(request, Response()) + ) if clean_path == "post_prompt": - return await agent._handle_post_prompt_request(request) + return _as_response( + await agent._handle_post_prompt_request(request) + ) if clean_path == "check_for_input": - return await agent._handle_check_for_input_request(request) + return _as_response( + await agent._handle_check_for_input_request(request) + ) # Check for custom routing callbacks if hasattr(agent, "_routing_callbacks"): diff --git a/signalwire/signalwire/cli/core/service_loader.py b/signalwire/signalwire/cli/core/service_loader.py index d8bb45a5..5bcd8b55 100644 --- a/signalwire/signalwire/cli/core/service_loader.py +++ b/signalwire/signalwire/cli/core/service_loader.py @@ -196,8 +196,10 @@ async def simulate_request_to_service( import json # json.loads() is typed -> Any; a FastAPI Response body is the rendered - # SWML JSON object, matching the declared dict return. - return cast(dict[str, Any], json.loads(result.body.decode())) + # SWML JSON object, matching the declared dict return. result.body is + # bytes | memoryview — bytes() handles both before decode (memoryview has + # no .decode()), a real latent bug strict typing surfaced. + return cast(dict[str, Any], json.loads(bytes(result.body).decode())) if isinstance(result, dict): return result # Try to get content from response diff --git a/signalwire/signalwire/core/_agent_host.py b/signalwire/signalwire/core/_agent_host.py index 64b711fc..7889d91b 100644 --- a/signalwire/signalwire/core/_agent_host.py +++ b/signalwire/signalwire/core/_agent_host.py @@ -22,3 +22,6 @@ from signalwire.core.agent_base import AgentBase as AgentHost # type: ignore[attr-defined] # intentional TYPE_CHECKING-only re-export; agent_base imports this module's consumers (the mixins), so the name resolves for them but not in self-check else: AgentHost = object + +# Explicit re-export so --strict (no_implicit_reexport) lets _mixin_host import AgentHost. +__all__ = ["AgentHost"] diff --git a/signalwire/signalwire/core/agent/prompt/manager.py b/signalwire/signalwire/core/agent/prompt/manager.py index debbc0ce..3121e438 100644 --- a/signalwire/signalwire/core/agent/prompt/manager.py +++ b/signalwire/signalwire/core/agent/prompt/manager.py @@ -20,7 +20,7 @@ class PromptManager: """Manages prompt building and configuration.""" - def __init__(self, agent): + def __init__(self, agent: Any) -> None: """ Initialize PromptManager with reference to parent agent. @@ -29,10 +29,10 @@ def __init__(self, agent): """ self.agent = agent self._prompt_text: str | None = None - self._post_prompt_text = None - self._contexts = None + self._post_prompt_text: str | None = None + self._contexts: dict[str, Any] | None = None - def _validate_prompt_mode_exclusivity(self): + def _validate_prompt_mode_exclusivity(self) -> None: """ Check that only one prompt mode is in use. diff --git a/signalwire/signalwire/core/agent/tools/decorator.py b/signalwire/signalwire/core/agent/tools/decorator.py index d144f49a..4eebd9c6 100644 --- a/signalwire/signalwire/core/agent/tools/decorator.py +++ b/signalwire/signalwire/core/agent/tools/decorator.py @@ -9,16 +9,23 @@ Tool decorator functionality. """ +from typing import Any, TypeVar +from collections.abc import Callable + from signalwire.core.logging_config import get_logger logger = get_logger(__name__) +_F = TypeVar("_F", bound=Callable[..., Any]) + class ToolDecorator: """Handles tool decoration logic.""" @staticmethod - def create_instance_decorator(registry): + def create_instance_decorator( + registry: Any, + ) -> Callable[..., Callable[[_F], _F]]: """ Create instance tool decorator. @@ -29,7 +36,7 @@ def create_instance_decorator(registry): Decorator function """ - def decorator(name=None, **kwargs): + def decorator(name: str | None = None, **kwargs: Any) -> Callable[[_F], _F]: """ Decorator for defining SWAIG tools in a class. @@ -78,7 +85,7 @@ def lookup_account(self, args, raw_data): guidance. """ - def inner_decorator(func): + def inner_decorator(func: _F) -> _F: nonlocal name if name is None: name = func.__name__ @@ -90,7 +97,7 @@ def inner_decorator(func): webhook_url = kwargs.pop("webhook_url", None) required = kwargs.pop("required", None) - handler = func + handler: Callable[..., Any] = func is_typed = False # If parameters not explicitly provided, try type inference @@ -138,7 +145,7 @@ def inner_decorator(func): return decorator @classmethod - def create_class_decorator(cls): + def create_class_decorator(cls) -> Callable[..., Callable[[_F], _F]]: """ Create class tool decorator. @@ -146,7 +153,7 @@ def create_class_decorator(cls): Decorator function """ - def tool(name=None, **kwargs): + def tool(name: str | None = None, **kwargs: Any) -> Callable[[_F], _F]: """ Class method decorator for defining SWAIG tools. @@ -180,11 +187,11 @@ def lookup_account(self, args, raw_data): ... """ - def decorator(func): + def decorator(func: _F) -> _F: # Mark the function as a tool - func._is_tool = True - func._tool_name = name if name else func.__name__ - func._tool_params = kwargs + func._is_tool = True # type: ignore[attr-defined] # dynamic marker attrs read back by ToolRegistry.register_class_decorated_tools + func._tool_name = name if name else func.__name__ # type: ignore[attr-defined] + func._tool_params = kwargs # type: ignore[attr-defined] # Return the original function return func diff --git a/signalwire/signalwire/core/agent/tools/registry.py b/signalwire/signalwire/core/agent/tools/registry.py index 3e8bebee..1e1b42c8 100644 --- a/signalwire/signalwire/core/agent/tools/registry.py +++ b/signalwire/signalwire/core/agent/tools/registry.py @@ -22,7 +22,7 @@ class ToolRegistry: """Manages SWAIG function registration.""" - def __init__(self, agent): + def __init__(self, agent: Any) -> None: """ Initialize ToolRegistry with reference to parent agent. @@ -30,15 +30,15 @@ def __init__(self, agent): agent: Parent AgentBase instance """ self.agent = agent - self._swaig_functions = {} - self._class_decorated_tools = [] + self._swaig_functions: dict[str, SWAIGFunction | dict[str, Any]] = {} + self._class_decorated_tools: list[Any] = [] def define_tool( self, name: str, description: str, parameters: dict[str, Any], - handler: Callable, + handler: Callable[..., Any], secure: bool = True, fillers: dict[str, list[str]] | None = None, wait_file: str | None = None, @@ -46,7 +46,7 @@ def define_tool( webhook_url: str | None = None, required: list[str] | None = None, is_typed_handler: bool = False, - **swaig_fields, + **swaig_fields: Any, ) -> None: """ Define a SWAIG function that the AI can call. diff --git a/signalwire/signalwire/core/agent/tools/type_inference.py b/signalwire/signalwire/core/agent/tools/type_inference.py index 3303896c..7f928c43 100644 --- a/signalwire/signalwire/core/agent/tools/type_inference.py +++ b/signalwire/signalwire/core/agent/tools/type_inference.py @@ -13,10 +13,11 @@ import re import typing from typing import Any, get_type_hints +from collections.abc import Callable # Map Python types to JSON Schema types -_TYPE_MAP = { +_TYPE_MAP: dict[type, str] = { str: "string", int: "integer", float: "number", @@ -26,7 +27,7 @@ } -def _resolve_type(annotation) -> tuple[dict[str, Any], bool]: +def _resolve_type(annotation: Any) -> tuple[dict[str, Any], bool]: """ Resolve a Python type annotation to a JSON Schema property dict. @@ -100,10 +101,10 @@ def _parse_docstring_args(docstring: str) -> tuple[str, dict[str, str]]: break # Find the Args: block - param_descriptions = {} + param_descriptions: dict[str, str] = {} in_args = False - current_param = None - current_desc_lines = [] + current_param: str | None = None + current_desc_lines: list[str] = [] for line in lines: stripped = line.strip() @@ -154,7 +155,9 @@ def _parse_docstring_args(docstring: str) -> tuple[str, dict[str, str]]: return summary, param_descriptions -def infer_schema(func) -> tuple[dict[str, dict], list[str], str | None, bool, bool]: +def infer_schema( + func: Callable[..., Any], +) -> tuple[dict[str, dict[str, Any]], list[str], str | None, bool, bool]: """ Inspect a function's signature and type hints to infer a JSON Schema for SWAIG tool parameters. @@ -240,15 +243,15 @@ def infer_schema(func) -> tuple[dict[str, dict], list[str], str | None, bool, bo summary, param_docs = _parse_docstring_args(docstring) if docstring else ("", {}) # Build the schema - parameters = {} - required = [] + parameters: dict[str, dict[str, Any]] = {} + required: list[str] = [] for p in schema_params: annotation = hints.get(p.name, inspect.Parameter.empty) if annotation is inspect.Parameter.empty: # No type hint for this param — default to string - prop = {"type": "string"} + prop: dict[str, Any] = {"type": "string"} is_optional = False else: prop, is_optional = _resolve_type(annotation) @@ -266,7 +269,9 @@ def infer_schema(func) -> tuple[dict[str, dict], list[str], str | None, bool, bo return parameters, required, summary or None, True, has_raw_data -def create_typed_handler_wrapper(func, has_raw_data: bool): +def create_typed_handler_wrapper( + func: Callable[..., Any], has_raw_data: bool +) -> Callable[..., Any]: """ Wrap a typed handler function so it can be called with the standard SWAIG calling convention (args_dict, raw_data). @@ -282,7 +287,7 @@ def create_typed_handler_wrapper(func, has_raw_data: bool): A wrapper function with signature (args, raw_data). """ - def wrapper(args, raw_data): + def wrapper(args: dict[str, Any], raw_data: dict[str, Any] | None) -> Any: if has_raw_data: return func(raw_data=raw_data, **args) return func(**args) diff --git a/signalwire/signalwire/core/agent_base.py b/signalwire/signalwire/core/agent_base.py index fe3f5cd7..ccdcf7ec 100644 --- a/signalwire/signalwire/core/agent_base.py +++ b/signalwire/signalwire/core/agent_base.py @@ -19,12 +19,17 @@ Any, ClassVar, TYPE_CHECKING, + cast, ) from collections.abc import Callable if TYPE_CHECKING: from signalwire.core.contexts import ContextBuilder + # The post-prompt callback payload, typed from the spec (a plain dict at runtime; + # TYPE_CHECKING-only). Generated from porting-sdk/swaig-specs/post-prompt.yaml. + from signalwire.core.post_prompt_generated import PostPrompt, PostPromptData + # These imports double as a required-dependency check: a missing package # re-raises a helpful ImportError. Several names are not referenced directly # in this module, so they are intentionally unused (F401). @@ -352,7 +357,7 @@ def __init__( _AUTO_ANSWER_VERBS: ClassVar[set[str]] = {"play", "connect"} @staticmethod - def _load_service_config(config_file: str | None, service_name: str) -> dict: + def _load_service_config(config_file: str | None, service_name: str) -> dict[str, Any]: """Load service configuration from config file if available""" from signalwire.core.config_loader import ConfigLoader @@ -501,8 +506,8 @@ def get_full_url(self, include_auth: bool = False) -> str: def on_summary( self, - summary: dict[str, Any] | None, - raw_data: dict[str, Any] | None = None, + summary: "PostPromptData | None", + raw_data: "PostPrompt | None" = None, ) -> None: """ Called when a post-prompt summary is received @@ -514,7 +519,7 @@ def on_summary( # Default implementation does nothing pass - def on_debug_event(self, handler: Callable) -> Callable: + def on_debug_event(self, handler: Callable[..., Any]) -> Callable[..., Any]: """ Register a handler for debug webhook events. @@ -855,7 +860,7 @@ def clear_swaig_query_params(self) -> "AgentBase": return self def _render_swml( - self, call_id: str | None = None, modifications: dict | None = None + self, call_id: str | None = None, modifications: dict[str, Any] | None = None ) -> str: """ Render the complete SWML document using SWMLService methods @@ -1499,7 +1504,9 @@ def _build_webhook_url( # which properly handles SWML_PROXY_URL_BASE environment variable return super()._build_webhook_url(endpoint, query_params) - def _find_summary_in_post_data(self, body, logger): + def _find_summary_in_post_data( + self, body: "PostPrompt", logger: Any + ) -> Any: """ Attempt to find a summary in the post-prompt response data @@ -1513,9 +1520,12 @@ def _find_summary_in_post_data(self, body, logger): if not body: return None - # Various ways to get summary data - if "summary" in body: - return body["summary"] + # Various ways to get summary data. A bare top-level `summary` is not a field the + # current engine sends (the summary lives in `post_prompt_data` — see below); probe + # for it through an untyped view as a tolerant fallback for legacy/alternate payloads. + legacy = cast("dict[str, Any]", body) + if "summary" in legacy: + return legacy["summary"] if "post_prompt_data" in body: pdata = body["post_prompt_data"] @@ -1535,7 +1545,7 @@ def _find_summary_in_post_data(self, body, logger): return None - def _create_ephemeral_copy(self): + def _create_ephemeral_copy(self) -> "AgentBase": """ Create a lightweight copy of this agent for ephemeral configuration. @@ -1684,7 +1694,9 @@ def _create_ephemeral_copy(self): return ephemeral_agent - async def _handle_request(self, request: Request, response: Response): + async def _handle_request( + self, request: Request, response: Response + ) -> Response: """ Override SWMLService's _handle_request to use AgentBase's _render_swml diff --git a/signalwire/signalwire/core/auth_handler.py b/signalwire/signalwire/core/auth_handler.py index 6e4b4b3c..4d832c2c 100644 --- a/signalwire/signalwire/core/auth_handler.py +++ b/signalwire/signalwire/core/auth_handler.py @@ -69,7 +69,7 @@ def __init__(self, security_config: "SecurityConfig"): # Get auth methods from config self._setup_auth_methods() - def _setup_auth_methods(self): + def _setup_auth_methods(self) -> None: """Setup enabled authentication methods from config""" self.auth_methods: dict[str, dict[str, Any]] = {} @@ -126,7 +126,9 @@ def verify_api_key(self, api_key: str) -> bool: api_config = self.auth_methods["api_key"] return secrets.compare_digest(api_key, api_config["key"]) - def get_fastapi_dependency(self, optional: bool = False): + def get_fastapi_dependency( + self, optional: bool = False + ) -> Callable[..., Any] | None: """ Get FastAPI dependency for authentication. @@ -149,7 +151,7 @@ async def auth_dependency( if self.bearer_auth else None, api_key: str | None = None, # Get from header in request - ): + ) -> dict[str, Any]: # Try each auth method authenticated = False auth_method = None @@ -178,7 +180,7 @@ async def auth_dependency( return auth_dependency - def flask_decorator(self, f: Callable) -> Callable: + def flask_decorator(self, f: Callable[..., Any]) -> Callable[..., Any]: """ Flask decorator for authentication. @@ -186,7 +188,7 @@ def flask_decorator(self, f: Callable) -> Callable: """ @wraps(f) - def decorated(*args, **kwargs): + def decorated(*args: Any, **kwargs: Any) -> Any: from flask import request, Response # Try Bearer token first diff --git a/signalwire/signalwire/core/config_loader.py b/signalwire/signalwire/core/config_loader.py index 4d357307..1ce12098 100644 --- a/signalwire/signalwire/core/config_loader.py +++ b/signalwire/signalwire/core/config_loader.py @@ -97,7 +97,7 @@ def substitute_vars(self, value: Any, max_depth: int = 10) -> Any: # Pattern to match ${VAR} or ${VAR|default} pattern = r"\$\{([^}|]+)(?:\|([^}]*))?\}" - def replacer(match): + def replacer(match: "re.Match[str]") -> str: var_name = match.group(1) default = match.group(2) if match.group(2) is not None else "" return os.environ.get(var_name, default) @@ -198,7 +198,7 @@ def merge_with_env(self, env_prefix: str = "SWML_") -> dict[str, Any]: return result - def _has_nested_key(self, data: dict, key_path: str) -> bool: + def _has_nested_key(self, data: dict[str, Any], key_path: str) -> bool: """Check if a nested key exists in dictionary.""" keys = key_path.split("_") current = data @@ -210,7 +210,7 @@ def _has_nested_key(self, data: dict, key_path: str) -> bool: return False return True - def _set_nested_key(self, data: dict, key_path: str, value: Any) -> None: + def _set_nested_key(self, data: dict[str, Any], key_path: str, value: Any) -> None: """Set a value in dictionary using underscore-separated path.""" keys = key_path.split("_") current = data diff --git a/signalwire/signalwire/core/contexts.py b/signalwire/signalwire/core/contexts.py index 7faba86a..9dc51e3d 100644 --- a/signalwire/signalwire/core/contexts.py +++ b/signalwire/signalwire/core/contexts.py @@ -71,7 +71,9 @@ def __init__( self._completion_action = completion_action self._prompt = prompt - def add_question(self, key: str, question: str, **kwargs) -> "GatherInfo": + def add_question( + self, key: str, question: str, **kwargs: Any + ) -> "GatherInfo": """ Add a question to gather. @@ -1190,7 +1192,7 @@ class ContextBuilder: - No user-defined SWAIG tool collides with a reserved native name """ - def __init__(self, agent): + def __init__(self, agent: Any) -> None: self._agent = agent self._contexts: dict[str, Context] = {} self._context_order: list[str] = [] diff --git a/signalwire/signalwire/core/data_map.py b/signalwire/signalwire/core/data_map.py index 431ee63d..e3ff8710 100644 --- a/signalwire/signalwire/core/data_map.py +++ b/signalwire/signalwire/core/data_map.py @@ -174,7 +174,7 @@ def parameter( def expression( self, test_value: str, - pattern: str | Pattern, + pattern: str | Pattern[str], output: FunctionResult, nomatch_output: FunctionResult | None = None, ) -> "DataMap": @@ -444,7 +444,7 @@ def create_simple_api_tool( name: str, url: str, response_template: str, - parameters: dict[str, dict] | None = None, + parameters: dict[str, dict[str, Any]] | None = None, method: str = "GET", headers: dict[str, str] | None = None, body: dict[str, Any] | None = None, @@ -499,7 +499,7 @@ def create_simple_api_tool( def create_expression_tool( name: str, patterns: dict[str, tuple[str, FunctionResult]], - parameters: dict[str, dict] | None = None, + parameters: dict[str, dict[str, Any]] | None = None, ) -> DataMap: """ Create an expression-based tool for pattern matching responses diff --git a/signalwire/signalwire/core/function_result.py b/signalwire/signalwire/core/function_result.py index 4790660c..e061c11d 100644 --- a/signalwire/signalwire/core/function_result.py +++ b/signalwire/signalwire/core/function_result.py @@ -388,7 +388,9 @@ def swml_change_context(self, context_name: str) -> "FunctionResult": """ return self.add_action("change_context", context_name) - def execute_swml(self, swml_content, transfer: bool = False) -> "FunctionResult": + def execute_swml( + self, swml_content: "str | dict[str, Any] | Any", transfer: bool = False + ) -> "FunctionResult": """ Execute SWML content with optional transfer behavior. diff --git a/signalwire/signalwire/core/logging_config.py b/signalwire/signalwire/core/logging_config.py index a0768069..47ed8cb3 100644 --- a/signalwire/signalwire/core/logging_config.py +++ b/signalwire/signalwire/core/logging_config.py @@ -29,7 +29,9 @@ _CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]") -def strip_control_chars(logger, method_name, event_dict): +def strip_control_chars( + logger: Any, method_name: str, event_dict: dict[str, Any] +) -> dict[str, Any]: """Strip control characters from log event values to prevent log injection.""" for key, value in event_dict.items(): if isinstance(value, str): @@ -41,7 +43,7 @@ def strip_control_chars(logger, method_name, event_dict): _logging_configured = False -def get_execution_mode(): +def get_execution_mode() -> str: """ Determine the execution mode based on environment variables @@ -76,7 +78,7 @@ def get_execution_mode(): return "server" -def reset_logging_configuration(): +def reset_logging_configuration() -> None: """ Reset the logging configuration flag to allow reconfiguration @@ -87,7 +89,7 @@ def reset_logging_configuration(): structlog.reset_defaults() -def _detect_colors(): +def _detect_colors() -> bool: """Auto-detect whether the output stream supports colors.""" stream = ( sys.stderr @@ -101,7 +103,7 @@ def _detect_colors(): return not ("--raw" in sys.argv or "--dump-swml" in sys.argv) -def configure_logging(): +def configure_logging() -> None: """ Configure logging system once, globally, based on environment variables @@ -136,7 +138,7 @@ def configure_logging(): _logging_configured = True -def _get_structlog_processors(): +def _get_structlog_processors() -> list[Any]: """Processor chain for structlog.get_logger() callers.""" return [ structlog.contextvars.merge_contextvars, @@ -151,14 +153,16 @@ def _get_structlog_processors(): ] -def _drop_internal_keys(logger, method_name, event_dict): +def _drop_internal_keys( + logger: Any, method_name: str, event_dict: dict[str, Any] +) -> dict[str, Any]: """Remove structlog/ProcessorFormatter internal keys from the event dict.""" event_dict.pop("_record", None) event_dict.pop("_from_structlog", None) return event_dict -def _get_formatter_processors(): +def _get_formatter_processors() -> list[Any]: """Processor chain for ProcessorFormatter (stdlib LogRecords). Does NOT include filter_by_level because: @@ -178,7 +182,7 @@ def _get_formatter_processors(): ] -def _configure_structlog(level_num, log_format, stream): +def _configure_structlog(level_num: int, log_format: str, stream: Any) -> None: """Configure structlog and attach a handler to the signalwire logger. Args: @@ -227,7 +231,7 @@ def _configure_structlog(level_num, log_format, stream): lgr.addHandler(handler) -def _get_sdk_logger_names(): +def _get_sdk_logger_names() -> list[str]: """Known SDK logger names that don't use the signalwire. prefix. These are used by the 11 files that call get_logger() with short names. @@ -250,7 +254,7 @@ def _get_sdk_logger_names(): ] -def _configure_off_mode(): +def _configure_off_mode() -> None: """Suppress all logging output without leaking file descriptors.""" off_level = logging.CRITICAL + 10 @@ -278,19 +282,19 @@ def _configure_off_mode(): ) -def _configure_stderr_mode(log_level, log_format="console"): +def _configure_stderr_mode(log_level: str, log_format: str = "console") -> None: """Configure logging to stderr.""" numeric_level = getattr(logging, log_level.upper(), logging.INFO) _configure_structlog(numeric_level, log_format, sys.stderr) -def _configure_default_mode(log_level, log_format="console"): +def _configure_default_mode(log_level: str, log_format: str = "console") -> None: """Configure standard logging to stdout.""" numeric_level = getattr(logging, log_level.upper(), logging.INFO) _configure_structlog(numeric_level, log_format, sys.stdout) -def get_logger(name): +def get_logger(name: str) -> Any: """ Get a logger instance for the specified name with structured logging support diff --git a/signalwire/signalwire/core/mixins/_mixin_host.py b/signalwire/signalwire/core/mixins/_mixin_host.py index 06a8d46f..dbfd6e37 100644 --- a/signalwire/signalwire/core/mixins/_mixin_host.py +++ b/signalwire/signalwire/core/mixins/_mixin_host.py @@ -40,3 +40,7 @@ from signalwire.core._agent_host import AgentHost as _HostTyped else: _HostTyped = object + +# Explicit re-export: the mixins import _HostTyped from here. Under --strict +# (no_implicit_reexport) an aliased import isn't re-exported unless named in __all__. +__all__ = ["_HostTyped"] diff --git a/signalwire/signalwire/core/mixins/ai_config_mixin.py b/signalwire/signalwire/core/mixins/ai_config_mixin.py index 5795cfa3..e6c6fadf 100644 --- a/signalwire/signalwire/core/mixins/ai_config_mixin.py +++ b/signalwire/signalwire/core/mixins/ai_config_mixin.py @@ -17,7 +17,7 @@ from signalwire.core.mixins._mixin_host import _HostTyped -class AIConfigMixin(_HostTyped): +class AIConfigMixin(_HostTyped): # type: ignore[misc] # _HostTyped is object at runtime; AgentBase under TYPE_CHECKING — intentional split """ Mixin class containing all AI configuration methods for AgentBase """ @@ -629,7 +629,7 @@ def enable_mcp_server(self) -> "AgentBase": self._mcp_server_enabled = True return self - def set_prompt_llm_params(self, **params) -> "AgentBase": + def set_prompt_llm_params(self, **params: Any) -> "AgentBase": """ Set LLM parameters for the main prompt. @@ -664,7 +664,7 @@ def set_prompt_llm_params(self, **params) -> "AgentBase": return self - def set_post_prompt_llm_params(self, **params) -> "AgentBase": + def set_post_prompt_llm_params(self, **params: Any) -> "AgentBase": """ Set LLM parameters for the post-prompt. diff --git a/signalwire/signalwire/core/mixins/auth_mixin.py b/signalwire/signalwire/core/mixins/auth_mixin.py index 6539d170..4a03c4ce 100644 --- a/signalwire/signalwire/core/mixins/auth_mixin.py +++ b/signalwire/signalwire/core/mixins/auth_mixin.py @@ -11,6 +11,7 @@ import hmac import json import base64 +from typing import Any from fastapi import Request @@ -18,7 +19,7 @@ from signalwire.core.mixins._mixin_host import _HostTyped -class AuthMixin(_HostTyped): +class AuthMixin(_HostTyped): # type: ignore[misc] # _HostTyped is object at runtime; AgentBase under TYPE_CHECKING — intentional split """ Mixin class containing all authentication-related methods for AgentBase """ @@ -146,7 +147,7 @@ def _send_cgi_auth_challenge(self) -> str: response += json.dumps({"error": "Unauthorized"}) return response - def _check_lambda_auth(self, event) -> bool: + def _check_lambda_auth(self, event: dict[str, Any] | None) -> bool: """ Check basic auth in Lambda mode using event headers @@ -179,7 +180,7 @@ def _check_lambda_auth(self, event) -> bool: except Exception: return False - def _send_lambda_auth_challenge(self) -> dict: + def _send_lambda_auth_challenge(self) -> dict[str, Any]: """ Send authentication challenge in Lambda mode @@ -195,7 +196,7 @@ def _send_lambda_auth_challenge(self) -> dict: "body": json.dumps({"error": "Unauthorized"}), } - def _check_google_cloud_function_auth(self, request) -> bool: + def _check_google_cloud_function_auth(self, request: Any) -> bool: """ Check basic auth in Google Cloud Functions mode using request headers @@ -226,7 +227,7 @@ def _check_google_cloud_function_auth(self, request) -> bool: except Exception: return False - def _send_google_cloud_function_auth_challenge(self): + def _send_google_cloud_function_auth_challenge(self) -> Any: """ Send authentication challenge in Google Cloud Functions mode @@ -244,7 +245,7 @@ def _send_google_cloud_function_auth_challenge(self): }, ) - def _check_azure_function_auth(self, req) -> bool: + def _check_azure_function_auth(self, req: Any) -> bool: """ Check basic auth in Azure Functions mode using request object @@ -274,7 +275,7 @@ def _check_azure_function_auth(self, req) -> bool: except Exception: return False - def _send_azure_function_auth_challenge(self): + def _send_azure_function_auth_challenge(self) -> Any: """ Send authentication challenge in Azure Functions mode diff --git a/signalwire/signalwire/core/mixins/mcp_server_mixin.py b/signalwire/signalwire/core/mixins/mcp_server_mixin.py index cb64c5a8..96a6041b 100644 --- a/signalwire/signalwire/core/mixins/mcp_server_mixin.py +++ b/signalwire/signalwire/core/mixins/mcp_server_mixin.py @@ -17,7 +17,7 @@ class MCPServerMixin: """Mixin that adds MCP server endpoint to an agent""" - def _build_mcp_tool_list(self) -> list: + def _build_mcp_tool_list(self) -> list[Any]: """Convert registered @tool functions to MCP tool format""" tools: list[dict[str, Any]] = [] @@ -136,7 +136,7 @@ def _handle_mcp_request(self, body: dict[str, Any]) -> dict[str, Any]: return self._mcp_error(req_id, -32601, f"Method not found: {method}") @staticmethod - def _mcp_error(req_id, code: int, message: str) -> dict[str, Any]: + def _mcp_error(req_id: str | int | None, code: int, message: str) -> dict[str, Any]: """Build a JSON-RPC error response""" return { "jsonrpc": "2.0", diff --git a/signalwire/signalwire/core/mixins/prompt_mixin.py b/signalwire/signalwire/core/mixins/prompt_mixin.py index e8131911..c7501777 100644 --- a/signalwire/signalwire/core/mixins/prompt_mixin.py +++ b/signalwire/signalwire/core/mixins/prompt_mixin.py @@ -19,7 +19,7 @@ from signalwire.core.mixins._mixin_host import _HostTyped -class PromptMixin(_HostTyped): +class PromptMixin(_HostTyped): # type: ignore[misc] # _HostTyped is object at runtime; AgentBase under TYPE_CHECKING — intentional split """ Mixin class containing all prompt-related methods for AgentBase """ @@ -28,7 +28,7 @@ class PromptMixin(_HostTyped): # (Optional) so the checker resolves it without a cross-class has-type gap. _contexts_builder: Any | None - def _process_prompt_sections(self): + def _process_prompt_sections(self) -> None: """ Process declarative PROMPT_SECTIONS attribute from a subclass @@ -128,7 +128,9 @@ def _process_prompt_sections(self): bullets=sub_bullets if sub_bullets else None, ) - def define_contexts(self, contexts=None) -> Union["AgentBase", "ContextBuilder"]: + def define_contexts( + self, contexts: "dict[str, Any] | ContextBuilder | None" = None + ) -> Union["AgentBase", "ContextBuilder"]: """ Define contexts and steps for this agent (alternative to POM/prompt) @@ -148,6 +150,7 @@ def define_contexts(self, contexts=None) -> Union["AgentBase", "ContextBuilder"] return self # Legacy behavior - return ContextBuilder if self._contexts_builder is None: + # ContextBuilder.__init__ is untyped (cross-file: core/contexts.py). self._contexts_builder = ContextBuilder(self) self._contexts_defined = True @@ -186,7 +189,7 @@ def on_dynamic_config(query, body, headers, agent): self._contexts_builder.reset() return self - def _validate_prompt_mode_exclusivity(self): + def _validate_prompt_mode_exclusivity(self) -> None: """ Validate that POM sections and raw text are not mixed in the main prompt diff --git a/signalwire/signalwire/core/mixins/serverless_mixin.py b/signalwire/signalwire/core/mixins/serverless_mixin.py index bb809a21..991ea9f2 100644 --- a/signalwire/signalwire/core/mixins/serverless_mixin.py +++ b/signalwire/signalwire/core/mixins/serverless_mixin.py @@ -21,12 +21,17 @@ MAX_CGI_BODY_SIZE = 10 * 1024 * 1024 -class ServerlessMixin(_HostTyped): +class ServerlessMixin(_HostTyped): # type: ignore[misc] # _HostTyped is object at runtime; AgentBase under TYPE_CHECKING — intentional split """ Mixin class containing all serverless/cloud platform methods for AgentBase """ - def handle_serverless_request(self, event=None, context=None, mode=None): + def handle_serverless_request( + self, + event: Any = None, + context: Any = None, + mode: str | None = None, + ) -> Any: """ Handle serverless environment requests (CGI, Lambda, Cloud Functions) @@ -222,7 +227,7 @@ def _execute_swaig_function( args: dict[str, Any] | None = None, call_id: str | None = None, raw_data: dict[str, Any] | None = None, - ): + ) -> dict[str, Any]: """ Execute a SWAIG function in serverless context @@ -307,7 +312,7 @@ def _execute_swaig_function( req_log.error("serverless_function_execution_error", error=str(e)) return {"error": str(e), "function": function_name} - def _handle_google_cloud_function_request(self, request): + def _handle_google_cloud_function_request(self, request: Any) -> Any: """ Handle Google Cloud Functions specific requests @@ -417,7 +422,7 @@ def _handle_google_cloud_function_request(self, request): headers={"Content-Type": "application/json"}, ) - def _handle_azure_function_request(self, req): + def _handle_azure_function_request(self, req: Any) -> Any: """ Handle Azure Functions specific requests diff --git a/signalwire/signalwire/core/mixins/skill_mixin.py b/signalwire/signalwire/core/mixins/skill_mixin.py index 40b2704a..526c149d 100644 --- a/signalwire/signalwire/core/mixins/skill_mixin.py +++ b/signalwire/signalwire/core/mixins/skill_mixin.py @@ -16,7 +16,7 @@ from signalwire.core.mixins._mixin_host import _HostTyped -class SkillMixin(_HostTyped): +class SkillMixin(_HostTyped): # type: ignore[misc] # _HostTyped is object at runtime; AgentBase under TYPE_CHECKING — intentional split """ Mixin class containing all skill management methods for AgentBase """ diff --git a/signalwire/signalwire/core/mixins/state_mixin.py b/signalwire/signalwire/core/mixins/state_mixin.py index e0d9754b..dc4abfc7 100644 --- a/signalwire/signalwire/core/mixins/state_mixin.py +++ b/signalwire/signalwire/core/mixins/state_mixin.py @@ -12,7 +12,7 @@ from signalwire.core.mixins._mixin_host import _HostTyped -class StateMixin(_HostTyped): +class StateMixin(_HostTyped): # type: ignore[misc] # _HostTyped is object at runtime; AgentBase under TYPE_CHECKING — intentional split """ Mixin class containing all state and session management methods for AgentBase """ diff --git a/signalwire/signalwire/core/mixins/tool_mixin.py b/signalwire/signalwire/core/mixins/tool_mixin.py index 2a998c75..c1da88c4 100644 --- a/signalwire/signalwire/core/mixins/tool_mixin.py +++ b/signalwire/signalwire/core/mixins/tool_mixin.py @@ -23,7 +23,7 @@ _tool_mixin_logger = logging.getLogger(__name__) -class ToolMixin(_HostTyped): +class ToolMixin(_HostTyped): # type: ignore[misc] # _HostTyped is object at runtime; AgentBase under TYPE_CHECKING — intentional split """ Mixin class containing all tool/function-related methods for AgentBase """ @@ -33,13 +33,13 @@ def define_tool( name: str, description: str, parameters: dict[str, Any], - handler: Callable, + handler: Callable[..., Any], secure: bool = True, fillers: dict[str, list[str]] | None = None, webhook_url: str | None = None, required: list[str] | None = None, is_typed_handler: bool = False, - **swaig_fields, + **swaig_fields: Any, ) -> "AgentBase": """ Define a SWAIG function the AI can call. @@ -177,7 +177,9 @@ def register_swaig_function(self, function_dict: dict[str, Any]) -> "AgentBase": self._tool_registry.register_swaig_function(function_dict) return self - def _tool_decorator(self, name=None, **kwargs): + def _tool_decorator( + self, name: str | None = None, **kwargs: Any + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """ Decorator for defining SWAIG tools in a class @@ -187,12 +189,16 @@ def _tool_decorator(self, name=None, **kwargs): def example_function(self, param1): # ... """ + # ToolDecorator.create_instance_decorator is untyped (cross-file: + # core/agent/tools/decorator.py); the call returns Any. return ToolDecorator.create_instance_decorator(self._tool_registry)( name, **kwargs ) @classmethod - def tool(cls, name=None, **kwargs): + def tool( + cls, name: str | None = None, **kwargs: Any + ) -> Callable[[Callable[..., Any]], Callable[..., Any]]: """ Class method decorator for defining SWAIG tools @@ -202,6 +208,8 @@ def tool(cls, name=None, **kwargs): def example_function(self, param1): # ... """ + # ToolDecorator.create_class_decorator is untyped (cross-file: + # core/agent/tools/decorator.py); the call returns Any. return ToolDecorator.create_class_decorator()(name, **kwargs) def define_tools(self) -> list[SWAIGFunction | dict[str, Any]]: @@ -290,7 +298,7 @@ def _execute_swaig_function( args: dict[str, Any] | None = None, call_id: str | None = None, raw_data: dict[str, Any] | None = None, - ): + ) -> dict[str, Any]: """ Execute a SWAIG function in serverless context diff --git a/signalwire/signalwire/core/mixins/web_mixin.py b/signalwire/signalwire/core/mixins/web_mixin.py index 90321d79..524bbc86 100644 --- a/signalwire/signalwire/core/mixins/web_mixin.py +++ b/signalwire/signalwire/core/mixins/web_mixin.py @@ -14,9 +14,10 @@ import sys import contextvars from typing import TYPE_CHECKING, Any -from collections.abc import Callable +from collections.abc import Awaitable, Callable from fastapi import Depends, FastAPI, APIRouter, Request, Response +from fastapi.responses import JSONResponse from signalwire.core.logging_config import get_execution_mode from signalwire.core.security.security_utils import ( @@ -40,7 +41,22 @@ MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024 -class WebMixin(_HostTyped): +def _as_response(result: "Response | dict[str, Any]") -> "Response": + """Coerce a handler result into a Response for FastAPI route handlers. + + The internal ``_handle_*`` methods may return a bare dict (their historical + contract, relied on by direct-call unit tests). FastAPI route handlers must + be annotated ``-> Response`` (a union return annotation breaks response-model + construction at startup), so the decorated wrappers funnel the result through + here: dicts become JSONResponse (behavior-equivalent to FastAPI's own dict + serialization), Responses pass through unchanged. + """ + if isinstance(result, Response): + return result + return JSONResponse(content=result) + + +class WebMixin(_HostTyped): # type: ignore[misc] # _HostTyped is object at runtime; AgentBase under TYPE_CHECKING — intentional split """ Mixin class containing all web server and routing-related methods for AgentBase """ @@ -51,9 +67,9 @@ class WebMixin(_HostTyped): # has-type ordering gap. Runtime is unaffected (no assignment here). _app: Any | None _proxy_url_base: str | None - _dynamic_config_callback: Callable[[dict, dict, dict, Any], None] | None + _dynamic_config_callback: Callable[[dict[str, Any], dict[str, Any], dict[str, Any], Any], None] | None - def get_app(self): + def get_app(self) -> FastAPI: """ Get the FastAPI application instance for deployment adapters like Lambda/Mangum @@ -77,13 +93,13 @@ def get_app(self): # Add health and ready endpoints directly to the main app to avoid conflicts with catch-all @app.get("/health") @app.post("/health") - async def health_check(): + async def health_check() -> dict[str, str]: """Health check endpoint for Kubernetes liveness probe""" return {"status": "healthy", "agent": self.name} @app.get("/ready") @app.post("/ready") - async def readiness_check(): + async def readiness_check() -> dict[str, str]: """Readiness check endpoint for Kubernetes readiness probe""" return {"status": "ready"} @@ -98,7 +114,9 @@ async def readiness_check(): # Add security headers middleware @app.middleware("http") - async def add_security_headers(request, call_next): + async def add_security_headers( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" @@ -129,18 +147,18 @@ async def add_security_headers(request, call_next): # Register a catch-all route for debugging and troubleshooting @app.get("/{full_path:path}") @app.post("/{full_path:path}") - async def handle_all_routes(request: Request, full_path: str): + async def handle_all_routes(request: Request, full_path: str) -> Response: self.log.debug("request_received", path=full_path) # Check if the path is meant for this agent if not full_path.startswith(self.route.lstrip("/")): - return {"error": "Invalid route"} + return JSONResponse(content={"error": "Invalid route"}) # Extract the path relative to this agent's route relative_path = full_path[len(self.route.lstrip("/")) :] relative_path = relative_path.lstrip("/") self.log.debug("relative_path_extracted", path=relative_path) - return None + return Response(status_code=204) # Log all app routes for debugging self.log.debug("app_routes_registered") @@ -191,13 +209,13 @@ def serve(self, host: str | None = None, port: int | None = None) -> None: # Add health and ready endpoints directly to the main app to avoid conflicts with catch-all @app.get("/health") @app.post("/health") - async def health_check(): + async def health_check() -> dict[str, str]: """Health check endpoint for Kubernetes liveness probe""" return {"status": "healthy", "agent": self.name} @app.get("/ready") @app.post("/ready") - async def readiness_check(): + async def readiness_check() -> Response: """Readiness check endpoint for Kubernetes readiness probe""" # Check if agent is properly initialized ready = ( @@ -215,7 +233,9 @@ async def readiness_check(): # Add security headers middleware @app.middleware("http") - async def add_security_headers(request, call_next): + async def add_security_headers( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" @@ -234,12 +254,12 @@ async def add_security_headers(request, call_next): # Register a catch-all route for debugging and troubleshooting @app.get("/{full_path:path}") @app.post("/{full_path:path}") - async def handle_all_routes(request: Request, full_path: str): + async def handle_all_routes(request: Request, full_path: str) -> Response: self.log.debug("request_received", path=full_path) # Check if the path is meant for this agent if not full_path.startswith(self.route.lstrip("/")): - return {"error": "Invalid route"} + return JSONResponse(content={"error": "Invalid route"}) # Extract the path relative to this agent's route relative_path = full_path[len(self.route.lstrip("/")) :] @@ -258,13 +278,21 @@ async def handle_all_routes(request: Request, full_path: str): if clean_path == "debug": return await self._handle_debug_request(request) if clean_path == "swaig": - return await self._handle_swaig_request(request, Response()) + return _as_response( + await self._handle_swaig_request(request, Response()) + ) if clean_path == "post_prompt": - return await self._handle_post_prompt_request(request) + return _as_response( + await self._handle_post_prompt_request(request) + ) if clean_path == "check_for_input": - return await self._handle_check_for_input_request(request) + return _as_response( + await self._handle_check_for_input_request(request) + ) if clean_path == "debug_events": - return await self._handle_debug_events_request(request) + return _as_response( + await self._handle_debug_events_request(request) + ) # Check for custom routing callbacks if hasattr(self, "_routing_callbacks"): @@ -276,7 +304,7 @@ async def handle_all_routes(request: Request, full_path: str): return await self._handle_root_request(request) # Default: 404 - return {"error": "Path not found"} + return JSONResponse(content={"error": "Path not found"}) # Include router with prefix (handle root route special case) if self.route == "/": @@ -340,12 +368,12 @@ async def handle_all_routes(request: Request, full_path: str): def run( self, - event=None, - context=None, - force_mode=None, + event: dict[str, Any] | None = None, + context: Any = None, + force_mode: str | None = None, host: str | None = None, port: int | None = None, - ): + ) -> str | dict[str, Any] | None: """ Smart run method that automatically detects environment and handles accordingly @@ -363,15 +391,25 @@ def run( try: if mode == "cgi": - response = self.handle_serverless_request(event, context, mode) - print(response) - return response + # CGI handler returns the response body as a string to print. + cgi_response: str = self.handle_serverless_request( + event, context, mode + ) + print(cgi_response) + return cgi_response if mode == "azure_function": - return self.handle_serverless_request(event, context, mode) + azure_response: dict[str, Any] = self.handle_serverless_request( + event, context, mode + ) + return azure_response if mode in ["lambda", "google_cloud_function"]: - return self.handle_serverless_request(event, context, mode) + serverless_response: dict[str, Any] = self.handle_serverless_request( + event, context, mode + ) + return serverless_response # Server mode - use existing serve method self.serve(host, port) + return None except Exception as e: import logging @@ -384,7 +422,7 @@ def run( } raise - def _register_routes(self, router): + def _register_routes(self, router: APIRouter) -> None: """ Register routes for this agent @@ -409,12 +447,12 @@ def _register_routes(self, router): # Root endpoint (handles both with and without trailing slash) @router.get("/") - async def handle_root_get(request: Request, response: Response): + async def handle_root_get(request: Request, response: Response) -> Response: """Handle GET requests to the root endpoint""" return await self._handle_root_request(request) @router.post("/", dependencies=signed_post_deps) - async def handle_root_post(request: Request, response: Response): + async def handle_root_post(request: Request, response: Response) -> Response: """Handle POST requests to the root endpoint (signature-validated when signing_key is set)""" return await self._handle_root_request(request) @@ -423,60 +461,60 @@ async def handle_root_post(request: Request, response: Response): @router.get("/debug/") @router.post("/debug") @router.post("/debug/") - async def handle_debug(request: Request): + async def handle_debug(request: Request) -> Response: """Handle GET/POST requests to the debug endpoint""" return await self._handle_debug_request(request) # SWAIG endpoint - Both versions @router.get("/swaig") @router.get("/swaig/") - async def handle_swaig_get(request: Request, response: Response): + async def handle_swaig_get(request: Request, response: Response) -> Response: """Handle GET requests to the SWAIG endpoint""" - return await self._handle_swaig_request(request, response) + return _as_response(await self._handle_swaig_request(request, response)) @router.post("/swaig", dependencies=signed_post_deps) @router.post("/swaig/", dependencies=signed_post_deps) - async def handle_swaig_post(request: Request, response: Response): + async def handle_swaig_post(request: Request, response: Response) -> Response: """Handle POST requests to the SWAIG endpoint (signature-validated when signing_key is set)""" - return await self._handle_swaig_request(request, response) + return _as_response(await self._handle_swaig_request(request, response)) # Post prompt endpoint - Both versions @router.get("/post_prompt") @router.get("/post_prompt/") - async def handle_post_prompt_get(request: Request): + async def handle_post_prompt_get(request: Request) -> Response: """Handle GET requests to the post_prompt endpoint""" - return await self._handle_post_prompt_request(request) + return _as_response(await self._handle_post_prompt_request(request)) @router.post("/post_prompt", dependencies=signed_post_deps) @router.post("/post_prompt/", dependencies=signed_post_deps) - async def handle_post_prompt_post(request: Request): + async def handle_post_prompt_post(request: Request) -> Response: """Handle POST requests to the post_prompt endpoint (signature-validated when signing_key is set)""" - return await self._handle_post_prompt_request(request) + return _as_response(await self._handle_post_prompt_request(request)) # Check for input endpoint - Both versions @router.get("/check_for_input") @router.get("/check_for_input/") @router.post("/check_for_input") @router.post("/check_for_input/") - async def handle_check_for_input(request: Request): + async def handle_check_for_input(request: Request) -> Response: """Handle GET/POST requests to the check_for_input endpoint""" - return await self._handle_check_for_input_request(request) + return _as_response(await self._handle_check_for_input_request(request)) # Debug events endpoint - Both versions @router.get("/debug_events") @router.get("/debug_events/") @router.post("/debug_events") @router.post("/debug_events/") - async def handle_debug_events(request: Request): + async def handle_debug_events(request: Request) -> Response: """Handle POST requests delivering debug webhook events""" - return await self._handle_debug_events_request(request) + return _as_response(await self._handle_debug_events_request(request)) # MCP server endpoint — exposes @tool functions as MCP tools if hasattr(self, "_mcp_server_enabled") and self._mcp_server_enabled: @router.post("/mcp") @router.post("/mcp/") - async def handle_mcp(request: Request): + async def handle_mcp(request: Request) -> Response: """Handle MCP JSON-RPC 2.0 requests""" try: body = await request.json() @@ -514,8 +552,8 @@ async def handle_mcp(request: Request): @router.post(path) @router.post(path_with_slash) async def handle_callback( - request: Request, response: Response, cb_path=callback_path - ): + request: Request, response: Response, cb_path: str | None = callback_path + ) -> Response: """Handle GET/POST requests to a registered callback path""" # Store the callback path in request state for _handle_request to use request.state.callback_path = cb_path @@ -528,7 +566,7 @@ async def handle_callback( # WebMixin still calls self._(...) — those resolve via MRO to # SWMLService. - async def _handle_root_request(self, request: Request): + async def _handle_root_request(self, request: Request) -> Response: """Handle GET/POST requests to the root endpoint""" # Debug logging to understand the state before any changes self.log.debug( @@ -736,7 +774,7 @@ async def _handle_root_request(self, request: Request): media_type="application/json", ) - async def _handle_debug_request(self, request: Request): + async def _handle_debug_request(self, request: Request) -> Response: """Handle GET/POST requests to the debug endpoint""" # Check if debug endpoint is disabled if not getattr(self, "_debug_endpoint_enabled", True): @@ -826,7 +864,9 @@ async def _handle_debug_request(self, request: Request): # _handle_swaig_request was lifted into SWMLService — see SWMLService._handle_swaig_request # and the _swaig_render_get_response / _swaig_pre_dispatch extension points overridden in AgentBase. - async def _handle_post_prompt_request(self, request: Request): + async def _handle_post_prompt_request( + self, request: Request + ) -> Response | dict[str, Any]: """Handle GET/POST requests to the post_prompt endpoint""" req_log = self.log.bind( endpoint="post_prompt", method=request.method, path=request.url.path @@ -959,7 +999,7 @@ async def _handle_post_prompt_request(self, request: Request): summary = agent_to_use._find_summary_in_post_data(body, req_log) # Call the summary handler with the summary and the full body - result = None + result: dict[str, Any] | None = None try: if summary: result = agent_to_use.on_summary(summary, body) @@ -989,7 +1029,9 @@ async def _handle_post_prompt_request(self, request: Request): media_type="application/json", ) - async def _handle_check_for_input_request(self, request: Request): + async def _handle_check_for_input_request( + self, request: Request + ) -> Response | dict[str, Any]: """Handle GET/POST requests to the check_for_input endpoint""" req_log = self.log.bind( endpoint="check_for_input", method=request.method, path=request.url.path @@ -1078,7 +1120,9 @@ async def _handle_check_for_input_request(self, request: Request): media_type="application/json", ) - async def _handle_debug_events_request(self, request: Request): + async def _handle_debug_events_request( + self, request: Request + ) -> Response | dict[str, Any]: """Handle POST requests delivering debug webhook events from the AI module""" req_log = self.log.bind( endpoint="debug_events", method=request.method, path=request.url.path @@ -1158,8 +1202,8 @@ async def _handle_debug_events_request(self, request: Request): ) def on_request( - self, request_data: dict | None = None, callback_path: str | None = None - ) -> dict | None: + self, request_data: dict[str, Any] | None = None, callback_path: str | None = None + ) -> dict[str, Any] | None: """ Called when SWML is requested, with request data when available @@ -1182,10 +1226,10 @@ def on_request( def on_swml_request( self, - request_data: dict | None = None, + request_data: dict[str, Any] | None = None, callback_path: str | None = None, request: Request | None = None, - ) -> dict | None: + ) -> dict[str, Any] | None: """ Customization point for subclasses to modify SWML based on request data @@ -1257,7 +1301,7 @@ def register_routing_callback( self._routing_callbacks[normalized_path] = callback_fn def set_dynamic_config_callback( - self, callback: Callable[[dict, dict, dict, "AgentBase"], None] + self, callback: Callable[[dict[str, Any], dict[str, Any], dict[str, Any], "AgentBase"], None] ) -> "AgentBase": """ Set a callback function for dynamic agent configuration @@ -1318,7 +1362,7 @@ def setup_graceful_shutdown(self) -> None: Setup signal handlers for graceful shutdown (useful for Kubernetes) """ - def signal_handler(signum, frame): + def signal_handler(signum: int, frame: Any) -> None: self.log.info("shutdown_signal_received", signal=signum) # Perform cleanup diff --git a/signalwire/signalwire/core/pom_builder.py b/signalwire/signalwire/core/pom_builder.py index 40cbab18..9bca4d0f 100644 --- a/signalwire/signalwire/core/pom_builder.py +++ b/signalwire/signalwire/core/pom_builder.py @@ -28,7 +28,7 @@ class PomBuilder: you can create any section structure that fits your needs. """ - def __init__(self): + def __init__(self) -> None: """Initialize a new POM builder with an empty POM""" self.pom = PromptObjectModel() self._sections: dict[str, Section] = {} diff --git a/signalwire/signalwire/core/post_prompt_generated.py b/signalwire/signalwire/core/post_prompt_generated.py new file mode 100644 index 00000000..659fa3e5 --- /dev/null +++ b/signalwire/signalwire/core/post_prompt_generated.py @@ -0,0 +1,257 @@ +# AUTO-GENERATED from porting-sdk/swaig-specs/post-prompt.yaml — DO NOT EDIT. +# (vendored from mod_openai; regenerate via +# python3 porting-sdk/scripts/generate_python_rest_types.py) +# +# The post-prompt callback payload — the call summary + enriched call log the agent's +# post-prompt handler RECEIVES. STATIC-ONLY: a plain dict at runtime; conditional fields +# are optional, extra keys tolerated. +from __future__ import annotations +from typing import Any, Literal, TypeAlias, TypedDict +from typing import TYPE_CHECKING + +# SwaigRequest is generated in swaig_request_generated; aliased here for the +# swaig_log entry's post_data field. +if TYPE_CHECKING: + from signalwire.core.swaig_request_generated import SwaigRequest as SwaigRequest + + +class PostPrompt(TypedDict, total=False): + """Built by ais_get_post_data (ai_utils.c) + openai_post_process (post_process.c). No `version` field. Open shape: conditional fields appear only when their precondition holds. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + content_type: Literal["text/json"] + content_disposition: Literal["agent.summary"] + conversation_type: Literal["voice"] + action: Literal["post_conversation"] + project_id: str + space_id: str + call_id: str + app_name: str + ai_session_id: str + ai_id_tag: str + conversation_id: str + call_ended_by: str + caller_id_name: str + caller_id_number: str + conversation_summary: str + hard_timeout: bool + call_start_date: int + call_answer_date: int + call_end_date: int + ai_start_date: int + ai_end_date: int + post_prompt_data: PostPromptData + global_data: dict[str, Any] + SWMLVars: dict[str, Any] + SWMLCall: dict[str, Any] + call_log: list[PostPromptCallLogEntry] + raw_call_log: list[PostPromptCallLogEntry] + call_timeline: list[dict[str, Any]] + previous_contexts: list[list[dict[str, Any]]] + times: list[PostPromptTimesEntry] + swaig_log: list[PostPromptSwaigLogEntry] + total_minutes: float + total_input_tokens: float + total_output_tokens: float + total_wire_input_tokens: float + total_wire_input_tokens_per_minute: float + total_wire_output_tokens: float + total_wire_output_tokens_per_minute: float + total_tts_chars: float + total_tts_chars_per_min: float + total_asr_minutes: float + total_asr_cost_factor: float + + +class PostPromptData(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + parsed: list[dict[str, Any]] + raw: str + substituted: str + + +PostPromptCallLogEntry: TypeAlias = "PostPromptUserEntry | PostPromptAssistantEntry | PostPromptThinkingEntry | PostPromptToolEntry | PostPromptSystemLogEntry | PostPromptSystemEntry" + + +class PostPromptUserEntry(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + role: str + content: str + timestamp: int + confidence: float + content_type: str + speaker: str + start_timestamp: int + end_timestamp: int + speaking_to_final_event: float + speaking_to_turn_detection: float + turn_detection_to_final_event: float + barge_count: int + merged: bool + merge_count: int + entity: PostPromptEntity + eot: PostPromptEot + timing: PostPromptTiming + + +class PostPromptAssistantEntry(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + role: str + content: str + timestamp: int + tool_calls: list[dict[str, Any]] + latency: float + utterance_latency: float + audio_latency: float + acoustic_latency: float | None + eos_to_push_latency: float | None + dg_decision_latency: float | None + poll: float | None + speech_start_wall_us: int + last_word_end_wall_us: int + turn_decided_wall_us: int + status_pushed_wall_us: int + stamps_us: PostPromptStampsUs + barged: bool + barge_elapsed_ms: float + text_heard_approx: str + text_spoken_total: str + + +class PostPromptThinkingEntry(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + role: str + content: str + timestamp: int + lang: str + tokens: int + + +class PostPromptToolEntry(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + role: str + tool_call_id: str + content: str + timestamp: int + function_name: str + latency: float + utterance_latency: float + function_latency: float + audio_latency: float + execution_latency: float + deprecation_warning: str + start_timestamp: int + end_timestamp: int + distilled: bool + original_result: str + + +class PostPromptSystemLogEntry(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + role: str + content: str + timestamp: int + action: str + lang: str + tokens: int + content_type: str + metadata: dict[str, Any] + context: str + step: str + step_index: int + + +class PostPromptSystemEntry(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + role: str + content: str + timestamp: int + + +class PostPromptSwaigLogEntry(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + command_name: str + command_arg: str + epoch_time: int + native: bool + active_count: int | Literal["endless"] + url: str + post_data: SwaigRequest + post_response: dict[str, Any] + delayed_post_response: dict[str, Any] + mcp_url: str + mcp_tool: str + mcp_response: dict[str, Any] + mcp_error: str + + +class PostPromptTimesEntry(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + response: str + response_word_count: int + answer_time: float + token_time: float + tokens: int + avg_tps: float + tps: float + + +class PostPromptEntity(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal[ + "phone", + "email", + "ssn", + "card", + "uuid", + "url", + "money", + "time", + "date", + "ordinal", + ] + value: str + valid: bool + + +class PostPromptEot(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + basis: Literal["entity_snap", "growth_stop", "ceiling", "natural"] + confidence: float + + +class PostPromptTiming(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hold_ms: float + commit_latency_ms: float + segments: int + walkbacks: int + + +class PostPromptStampsUs(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + speech_start: int + last_word_end: int + suspected_end: int + turn_decided: int + status_pushed: int + request_detect: int + first_token: int + first_utterance: int + first_audio: int diff --git a/signalwire/signalwire/core/security/security_utils.py b/signalwire/signalwire/core/security/security_utils.py index 6b4c8332..5f490f50 100644 --- a/signalwire/signalwire/core/security/security_utils.py +++ b/signalwire/signalwire/core/security/security_utils.py @@ -15,6 +15,8 @@ """ import re +from collections.abc import Mapping +from typing import Any, TypeVar # Header names whose values are credentials/secrets and must never be handed to # user callbacks or written to logs. Compared case-insensitively. @@ -35,7 +37,10 @@ _HOSTNAME_REJECT_RE = re.compile(r"[\s/\\\x00-\x1f\x7f]") -def filter_sensitive_headers(headers): +_V = TypeVar("_V") + + +def filter_sensitive_headers(headers: Mapping[str, _V]) -> dict[str, _V]: """Return a copy of ``headers`` with sensitive (credential-bearing) headers removed, so request headers can be safely passed to user callbacks. @@ -51,7 +56,7 @@ def filter_sensitive_headers(headers): return {k: v for k, v in headers.items() if k.lower() not in SENSITIVE_HEADERS} -def redact_url(url): +def redact_url(url: Any) -> Any: """Mask the password in a URL's userinfo before logging. ``https://user:secret@host/path`` -> ``https://user:****@host/path``. @@ -68,7 +73,7 @@ def redact_url(url): return _URL_CREDENTIALS_RE.sub(r"://\1:****@", url) -def is_valid_hostname(host): +def is_valid_hostname(host: str) -> bool: """Standalone hostname sanity check: reject empty hosts and any host containing whitespace, slashes, or control characters. diff --git a/signalwire/signalwire/core/security_config.py b/signalwire/signalwire/core/security_config.py index c77231e5..bd88a3bd 100644 --- a/signalwire/signalwire/core/security_config.py +++ b/signalwire/signalwire/core/security_config.py @@ -74,13 +74,13 @@ def __init__(self, config_file: str | None = None, service_name: str | None = No # Finally, apply config file if available (highest priority) self._load_config_file(config_file, service_name) - def _set_defaults(self): + def _set_defaults(self) -> None: """Set default values for all configuration""" # SSL configuration self.ssl_enabled = self.DEFAULTS[self.SSL_ENABLED] - self.ssl_cert_path = None - self.ssl_key_path = None - self.domain = None + self.ssl_cert_path: str | None = None + self.ssl_key_path: str | None = None + self.domain: str | None = None self.ssl_verify_mode = self.DEFAULTS[self.SSL_VERIFY_MODE] # Additional settings @@ -93,10 +93,12 @@ def _set_defaults(self): self.hsts_max_age = self.DEFAULTS[self.HSTS_MAX_AGE] # Authentication - self.basic_auth_user = None - self.basic_auth_password = None + self.basic_auth_user: str | None = None + self.basic_auth_password: str | None = None - def _load_config_file(self, config_file: str | None, service_name: str | None): + def _load_config_file( + self, config_file: str | None, service_name: str | None + ) -> None: """Load configuration from config file if available""" # Find config file if not config_file: @@ -165,7 +167,7 @@ def _load_config_file(self, config_file: str | None, service_name: str | None): if "password" in basic_auth: self.basic_auth_password = basic_auth["password"] - def load_from_env(self): + def load_from_env(self) -> None: """Load configuration from environment variables""" # SSL configuration ssl_enabled_env = os.environ.get(self.SSL_ENABLED, "").lower() @@ -207,7 +209,7 @@ def load_from_env(self): self.basic_auth_user = os.environ.get(self.BASIC_AUTH_USER) self.basic_auth_password = os.environ.get(self.BASIC_AUTH_PASSWORD) - def _parse_list(self, value: str | list) -> list: + def _parse_list(self, value: str | list[Any]) -> list[Any]: """Parse comma-separated list from environment variable or list from config""" if isinstance(value, list): return value @@ -285,7 +287,8 @@ def get_basic_auth(self) -> tuple[str, str]: """ username = self.basic_auth_user or "signalwire" if not self.basic_auth_password: - self.basic_auth_password = secrets.token_urlsafe(32) + password = secrets.token_urlsafe(32) + self.basic_auth_password = password if not getattr(self, "_basic_auth_autogen_warned", False): logger.warning( "basic_auth_password_autogenerated", @@ -303,7 +306,8 @@ def get_basic_auth(self) -> tuple[str, str]: ), ) self._basic_auth_autogen_warned = True - password = self.basic_auth_password + else: + password = self.basic_auth_password return username, password @@ -365,7 +369,7 @@ def get_url_scheme(self) -> str: """Get the URL scheme based on SSL configuration""" return "https" if self.ssl_enabled else "http" - def log_config(self, service_name: str): + def log_config(self, service_name: str) -> None: """Log the current security configuration""" logger.info( "security_config_loaded", diff --git a/signalwire/signalwire/core/skill_base.py b/signalwire/signalwire/core/skill_base.py index d9147116..2793172b 100644 --- a/signalwire/signalwire/core/skill_base.py +++ b/signalwire/signalwire/core/skill_base.py @@ -56,7 +56,7 @@ def register_tools(self) -> None: """Register SWAIG tools with the agent""" pass - def define_tool(self, **kwargs) -> None: + def define_tool(self, **kwargs: Any) -> None: """ Wrapper method that automatically includes swaig_fields when defining tools. diff --git a/signalwire/signalwire/core/skill_manager.py b/signalwire/signalwire/core/skill_manager.py index 56c3192b..e5176ccb 100644 --- a/signalwire/signalwire/core/skill_manager.py +++ b/signalwire/signalwire/core/skill_manager.py @@ -7,15 +7,18 @@ See LICENSE file in the project root for full license information. """ -from typing import Any +from typing import Any, TYPE_CHECKING from signalwire.core.logging_config import get_logger from signalwire.core.skill_base import SkillBase +if TYPE_CHECKING: + from signalwire.core.agent_base import AgentBase # type: ignore[attr-defined] # cycle: agent_base imports skill_manager; name resolves at type-check time + class SkillManager: """Manages loading and lifecycle of agent skills""" - def __init__(self, agent): + def __init__(self, agent: "AgentBase"): self.agent = agent self.loaded_skills: dict[str, SkillBase] = {} self.logger = get_logger("skill_manager") diff --git a/signalwire/signalwire/core/swaig_actions_generated.py b/signalwire/signalwire/core/swaig_actions_generated.py new file mode 100644 index 00000000..f967a192 --- /dev/null +++ b/signalwire/signalwire/core/swaig_actions_generated.py @@ -0,0 +1,187 @@ +# AUTO-GENERATED from porting-sdk/swaig-specs/swaig-response.yaml — DO NOT EDIT. +# (which is vendored from mod_openai; regenerate via +# python3 porting-sdk/scripts/generate_python_rest_types.py) +# +# The SWAIG response-action surface: one value TypedDict per object-shaped +# action + a _SwaigActions base with one typed method per wire action (keyed by the wire +# key). The SDK's ergonomic FunctionResult methods (say(text), hold(timeout=300), ...) are +# hand-written on top and call these typed builders. STATIC-ONLY: the action list is a +# plain list of dicts at runtime; this layer just types the shapes. +from __future__ import annotations +from typing import Any, Literal, TypedDict +from typing import TypeVar + +_Self = TypeVar("_Self", bound="_SwaigActions") + + +class ContextSwitchAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + system_prompt: Any + user_prompt: Any + system_pom: Any + user_pom: Any + consolidate: bool + full_reset: bool + + +class HoldAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + timeout: int + + +class PlaybackBgAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + file: Any + wait: bool + + +class TransferAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + dest: Any + summarize: bool + + +class _SwaigActions: + """Typed SWAIG response-action builders (one per wire action). The host class + provides ``self.action`` (the list serialized to the wire).""" + + def add_dynamic_hints(self: _Self, value: list[Any]) -> _Self: + """Add ASR hints. Strings go to `dynamic_hints`; `{hint, ...}` objects go to `dynamic_hearing_hints` (and the `hint` value is also added to `dynamic_hints`). Restarts speech detection""" # actions.c:547 + self.action.append({"add_dynamic_hints": value}) # type: ignore[attr-defined] + return self + + def back_to_back_functions(self: _Self, value: bool | Literal["forever"]) -> _Self: + """Allow consecutive function calls without a user turn. `true` = `1`, `"forever"` = `2`""" # actions.c:359 + self.action.append({"back_to_back_functions": value}) # type: ignore[attr-defined] + return self + + def change_context(self: _Self, value: str) -> _Self: + """Switch to a named **context** (same machinery as the `change_context` function)""" # actions.c:238 + self.action.append({"change_context": value}) # type: ignore[attr-defined] + return self + + def change_step(self: _Self, value: str) -> _Self: + """Switch to a named **step** (or `"next"`)""" # actions.c:248 + self.action.append({"change_step": value}) # type: ignore[attr-defined] + return self + + def clear_dynamic_hints(self: _Self, value: dict[str, Any]) -> _Self: + """Clear both dynamic hint lists and restart speech detection""" # actions.c:579 + self.action.append({"clear_dynamic_hints": value}) # type: ignore[attr-defined] + return self + + def context_switch(self: _Self, value: str | ContextSwitchAction) -> _Self: + """Replace the system prompt / start a new conversation context. Object form: `{system_prompt, user_prompt, system_pom, user_pom, consolidate, full_reset}`. `system_pom`/`user_pom` render to prompt text; prompts are expanded against prompt vars + post_data; `consolidate:true` summarizes first""" # actions.c:594 + self.action.append({"context_switch": value}) # type: ignore[attr-defined] + return self + + def end_of_speech_timeout(self: _Self, value: int) -> _Self: + """Set end-of-speech detection timeout (must be >0)""" # actions.c:312 + self.action.append({"end_of_speech_timeout": value}) # type: ignore[attr-defined] + return self + + def extensive_data(self: _Self, value: bool) -> _Self: + """Enable extensive data in the function/conversation log""" # actions.c:373 + self.action.append({"extensive_data": value}) # type: ignore[attr-defined] + return self + + def functions_on_speaker_timeout(self: _Self, value: bool) -> _Self: + """Set whether functions may fire on speaker timeout""" # actions.c:369 + self.action.append({"functions_on_speaker_timeout": value}) # type: ignore[attr-defined] + return self + + def hangup(self: _Self, value: dict[str, Any]) -> _Self: + """Set `offhook = 0` (hang up). Note: a graceful "say goodbye" hangup is the **built-in `hangup` function**, not this action""" # actions.c:294 + self.action.append({"hangup": value}) # type: ignore[attr-defined] + return self + + def hold(self: _Self, value: int | str | HoldAction) -> _Self: + """Put the call on hold for N seconds. Accepts a number, a time string (`"5m"`, `"1:30"` via `parse_time`), or `{timeout}`. Default 300s; values <0 or >900 clamp to 300""" # actions.c:258 + self.action.append({"hold": value}) # type: ignore[attr-defined] + return self + + def playback_bg(self: _Self, value: str | PlaybackBgAction) -> _Self: + """Play an audio file in the background. `{wait:true}` makes the agent wait for it. Replaces any currently-open background file""" # actions.c:695 + self.action.append({"playback_bg": value}) # type: ignore[attr-defined] + return self + + def replace_in_history(self: _Self, value: str | Literal[True]) -> _Self: + """Replace the function call's text in conversation history. A string is stored prefixed with `~LN()-; `; `true` stores an empty string""" # actions.c:379 + self.action.append({"replace_in_history": value}) # type: ignore[attr-defined] + return self + + def say(self: _Self, value: str) -> _Self: + """Speak text immediately via TTS, then wait for speaking to finish. Also logs `tl_manual_say`""" # actions.c:434 + self.action.append({"say": value}) # type: ignore[attr-defined] + return self + + def set_global_data(self: _Self, value: dict[str, Any]) -> _Self: + """Merge keys into global data, then refresh prompt vars. Gated by `swaig_set_global_data`""" # actions.c:498 + self.action.append({"set_global_data": value}) # type: ignore[attr-defined] + return self + + def set_meta_data(self: _Self, value: dict[str, Any]) -> _Self: + """Merge keys into the calling function's metadata store (keyed by its `meta_data_token`)""" # actions.c:459 + self.action.append({"set_meta_data": value}) # type: ignore[attr-defined] + return self + + def settings(self: _Self, value: dict[str, Any]) -> _Self: + """Modify LLM settings at runtime (`parse_json_settings`). Gated by `swaig_allow_settings`""" # actions.c:442 + self.action.append({"settings": value}) # type: ignore[attr-defined] + return self + + def speech_event_timeout(self: _Self, value: int) -> _Self: + """Set speech event timeout (must be >0)""" # actions.c:326 + self.action.append({"speech_event_timeout": value}) # type: ignore[attr-defined] + return self + + def stop(self: _Self, value: dict[str, Any]) -> _Self: + """Stop the AI agent immediately (interrupt + `running = 0`)""" # actions.c:452 + self.action.append({"stop": value}) # type: ignore[attr-defined] + return self + + def stop_playback_bg(self: _Self, value: dict[str, Any]) -> _Self: + """Stop/close the background audio file""" # actions.c:685 + self.action.append({"stop_playback_bg": value}) # type: ignore[attr-defined] + return self + + def toggle_functions(self: _Self, value: list[dict[str, Any]]) -> _Self: + """Enable/disable functions. `active` via `check_active`: `-1` default/toggle, `0` off, `1+` use-count. **Only affects functions sharing the calling function's `meta_data_token`** (`actions.c:419-420`)""" # actions.c:389 + self.action.append({"toggle_functions": value}) # type: ignore[attr-defined] + return self + + def transfer(self: _Self, value: str | TransferAction) -> _Self: + """Transfer the call to `dest`. `summarize:true` sets `transfer_summary`. Sets `openai_transfer_check` var, interrupts, stops the loop. Ignored if already interrupted""" # actions.c:136 + self.action.append({"transfer": value}) # type: ignore[attr-defined] + return self + + def unset_global_data(self: _Self, value: str | list[Any]) -> _Self: + """Remove key(s) from global data, then refresh prompt vars. Gated by `swaig_set_global_data`""" # actions.c:515 + self.action.append({"unset_global_data": value}) # type: ignore[attr-defined] + return self + + def unset_meta_data(self: _Self, value: str | list[Any]) -> _Self: + """Remove key(s) from the calling function's metadata store""" # actions.c:477 + self.action.append({"unset_meta_data": value}) # type: ignore[attr-defined] + return self + + def user_event(self: _Self, value: dict[str, Any]) -> _Self: + """Fire relay event `calling.user_event` with the object as payload""" # actions.c:231 + self.action.append({"user_event": value}) # type: ignore[attr-defined] + return self + + def user_input(self: _Self, value: str) -> _Self: + """Push text onto the input queue as if the user spoke it""" # actions.c:541 + self.action.append({"user_input": value}) # type: ignore[attr-defined] + return self + + def wait_for_user( + self: _Self, value: bool | int | Literal["answer_first"] + ) -> _Self: + """`true` = `1`, a number sets a count, `"answer_first"` = `2` (require caller answer)""" # actions.c:300 + self.action.append({"wait_for_user": value}) # type: ignore[attr-defined] + return self diff --git a/signalwire/signalwire/core/swaig_function.py b/signalwire/signalwire/core/swaig_function.py index 749411fa..62ce8f11 100644 --- a/signalwire/signalwire/core/swaig_function.py +++ b/signalwire/signalwire/core/swaig_function.py @@ -9,13 +9,19 @@ SwaigFunction class for defining and managing SWAIG function interfaces """ -from typing import Any +from typing import TYPE_CHECKING, Any from collections.abc import Callable import logging # Import here to avoid circular imports from signalwire.core.function_result import FunctionResult +if TYPE_CHECKING: + # The inbound SWAIG function-webhook payload, typed from the spec (a plain dict at + # runtime; TYPE_CHECKING-only so there's no import cost / cycle). Generated from + # porting-sdk/swaig-specs/swaig-request.yaml (vendored from mod_openai). + from signalwire.core.swaig_request_generated import SwaigRequest + class SWAIGFunction: """ @@ -44,9 +50,9 @@ class SWAIGFunction: def __init__( self, name: str, - handler: Callable, + handler: Callable[..., Any], description: str, - parameters: dict[str, dict] | None = None, + parameters: dict[str, dict[str, Any]] | None = None, secure: bool = False, fillers: dict[str, list[str]] | None = None, wait_file: str | None = None, @@ -54,8 +60,8 @@ def __init__( webhook_url: str | None = None, required: list[str] | None = None, is_typed_handler: bool = False, - **extra_swaig_fields, - ): + **extra_swaig_fields: Any, + ) -> None: """ Initialize a new SWAIG function. @@ -104,7 +110,7 @@ def __init__( # Mark as external if webhook_url is provided self.is_external = webhook_url is not None - def _ensure_parameter_structure(self) -> dict: + def _ensure_parameter_structure(self) -> dict[str, Any]: """ Ensure the parameters are correctly structured for SWML @@ -127,14 +133,14 @@ def _ensure_parameter_structure(self) -> dict: return result - def __call__(self, *args, **kwargs): + def __call__(self, *args: Any, **kwargs: Any) -> Any: """ Call the underlying handler function """ return self.handler(*args, **kwargs) def execute( - self, args: dict[str, Any], raw_data: dict[str, Any] | None = None + self, args: dict[str, Any], raw_data: "SwaigRequest | None" = None ) -> dict[str, Any]: """ Execute the function with the given arguments @@ -175,7 +181,7 @@ def execute( "Sorry, I couldn't complete that action. Please try again or contact support if the issue persists." ).to_dict() - def validate_args(self, args: dict[str, Any]) -> tuple: + def validate_args(self, args: dict[str, Any]) -> tuple[Any, ...]: """ Validate the arguments against the parameter schema. diff --git a/signalwire/signalwire/core/swaig_request_generated.py b/signalwire/signalwire/core/swaig_request_generated.py new file mode 100644 index 00000000..a8bb56c0 --- /dev/null +++ b/signalwire/signalwire/core/swaig_request_generated.py @@ -0,0 +1,50 @@ +# AUTO-GENERATED from porting-sdk/swaig-specs/swaig-request.yaml — DO NOT EDIT. +# (vendored from mod_openai; regenerate via +# python3 porting-sdk/scripts/generate_python_rest_types.py) +# +# The SWAIG function-webhook REQUEST payload — the body a SWAIG function handler +# RECEIVES (swaig_function.execute(raw_data), tool_mixin.on_function_call). STATIC-ONLY: +# it's a plain dict at runtime; this TypedDict types its known shape (conditional fields +# are optional; extra keys are tolerated). +from __future__ import annotations +from typing import Any, Literal, TypedDict + + +class SwaigArgument(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + parsed: list[Any] + raw: str + substituted: str + + +class SwaigRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + ai_session_id: str + app_name: str + args: str + argument: SwaigArgument + argument_desc: dict[str, Any] + call_id: str + call_log: list[Any] + caller_id_name: str + caller_id_num: str + channel_active: bool + channel_offhook: bool + channel_ready: bool + content_disposition: Literal["SWAIG Function"] + content_type: Literal["text/swaig"] + conversation_id: str + description: str + error_reason: str + fatal_error: bool + function: str + global_data: dict[str, Any] + input: str + meta_data: dict[str, Any] + meta_data_token: str + project_id: str + raw_call_log: list[Any] + space_id: str + version: Literal["2.0"] diff --git a/signalwire/signalwire/core/swml_builder.py b/signalwire/signalwire/core/swml_builder.py index b535c08c..ae0dc30e 100644 --- a/signalwire/signalwire/core/swml_builder.py +++ b/signalwire/signalwire/core/swml_builder.py @@ -13,7 +13,8 @@ """ import types -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar +from collections.abc import Callable try: from typing import Self # type: ignore[attr-defined] # 3.11+; typing_extensions fallback below @@ -22,11 +23,22 @@ from signalwire.core.swml_service import SWMLService +if TYPE_CHECKING: + # The SWML verb methods are installed dynamically at runtime (_create_verb_methods, + # from schema.json). Inheriting the generated _SwmlVerbs Protocol gives the type + # checker the static signatures for those verbs (answer/play/ai/record/...). Generated + # from schema.json — see swml_verbs_generated.py. TYPE_CHECKING-only: no runtime base. + from signalwire.core.swml_verbs_generated import _SwmlVerbs + + _VerbsBase = _SwmlVerbs +else: + _VerbsBase = object + T = TypeVar("T", bound="SWMLBuilder") -class SWMLBuilder: +class SWMLBuilder(_VerbsBase): """ Fluent builder for SWML documents @@ -94,7 +106,7 @@ def ai( post_prompt: str | None = None, post_prompt_url: str | None = None, swaig: dict[str, Any] | None = None, - **kwargs, + **kwargs: Any, ) -> Self: """ Add an 'ai' verb to the main section @@ -112,15 +124,18 @@ def ai( """ config: dict[str, Any] = {} - # Handle prompt (either text or POM, but not both) + # Handle prompt (either text or POM, but not both). The SWML `ai` verb requires + # `prompt` to be an OBJECT — {"text": ...} or {"pom": [...]}; a bare string is a + # fatal error in the AI engine (mod_openai app_config.c: `!cJSON_IsObject(prompt)` + # fires calling.error and aborts the call), so wrap accordingly. if prompt_text is not None: - config["prompt"] = prompt_text + config["prompt"] = {"text": prompt_text} elif prompt_pom is not None: - config["prompt"] = prompt_pom + config["prompt"] = {"pom": prompt_pom} - # Add optional parameters + # Add optional parameters. post_prompt is the same object contract as prompt. if post_prompt is not None: - config["post_prompt"] = post_prompt + config["post_prompt"] = {"text": post_prompt} if post_prompt_url is not None: config["post_prompt_url"] = post_prompt_url if swaig is not None: @@ -280,7 +295,11 @@ def _create_verb_methods(self) -> None: # Handle sleep verb specially since it takes an integer directly if verb_name == "sleep": - def sleep_method(self_instance, duration=None, **kwargs): + def sleep_method( + self_instance: "SWMLBuilder", + duration: int | None = None, + **kwargs: Any, + ) -> "SWMLBuilder": """ Add the sleep verb to the document. @@ -307,8 +326,12 @@ def sleep_method(self_instance, duration=None, **kwargs): continue # Generate the method implementation for normal verbs - def make_verb_method(name): - def verb_method(self_instance, **kwargs): + def make_verb_method( + name: str, + ) -> Callable[..., "SWMLBuilder"]: + def verb_method( + self_instance: "SWMLBuilder", **kwargs: Any + ) -> "SWMLBuilder": """ Dynamically generated method for SWML verb - returns self for chaining """ @@ -377,7 +400,11 @@ def __getattr__(self, name: str) -> Any: # Handle sleep verb specially since it takes an integer directly if name == "sleep": - def sleep_method(self_instance, duration=None, **kwargs): + def sleep_method( + self_instance: "SWMLBuilder", + duration: int | None = None, + **kwargs: Any, + ) -> "SWMLBuilder": """ Add the sleep verb to the document. @@ -403,7 +430,9 @@ def sleep_method(self_instance, duration=None, **kwargs): return types.MethodType(sleep_method, self) # Generate the method implementation for normal verbs - def verb_method(self_instance, **kwargs): + def verb_method( + self_instance: "SWMLBuilder", **kwargs: Any + ) -> "SWMLBuilder": """ Dynamically generated method for SWML verb - returns self for chaining """ diff --git a/signalwire/signalwire/core/swml_handler.py b/signalwire/signalwire/core/swml_handler.py index 21f1798c..e1c27f39 100644 --- a/signalwire/signalwire/core/swml_handler.py +++ b/signalwire/signalwire/core/swml_handler.py @@ -49,7 +49,7 @@ def validate_config(self, config: dict[str, Any]) -> tuple[bool, list[str]]: pass @abstractmethod - def build_config(self, **kwargs) -> dict[str, Any]: + def build_config(self, **kwargs: Any) -> dict[str, Any]: """ Build a configuration for this verb from the provided arguments @@ -137,7 +137,7 @@ def build_config( post_prompt: str | None = None, post_prompt_url: str | None = None, swaig: dict[str, Any] | None = None, - **kwargs, + **kwargs: Any, ) -> dict[str, Any]: """ Build a configuration for the AI verb @@ -219,9 +219,9 @@ class VerbHandlerRegistry: and provides methods for accessing and using them. """ - def __init__(self): + def __init__(self) -> None: """Initialize the registry with default handlers""" - self._handlers = {} + self._handlers: dict[str, SWMLVerbHandler] = {} # Register default handlers self.register_handler(AIVerbHandler()) diff --git a/signalwire/signalwire/core/swml_service.py b/signalwire/signalwire/core/swml_service.py index c9d90a64..fc9b1b79 100644 --- a/signalwire/signalwire/core/swml_service.py +++ b/signalwire/signalwire/core/swml_service.py @@ -44,6 +44,7 @@ Request, Response, ) + from fastapi.responses import JSONResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials # noqa: F401 from pydantic import BaseModel # noqa: F401 except ImportError: @@ -70,6 +71,21 @@ MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024 +def _as_response(result: "Response | dict[str, Any]") -> "Response": + """Coerce a handler result into a Response for FastAPI route handlers. + + The internal ``_handle_*`` methods may return a bare dict (their historical + contract, relied on by direct-call unit tests). FastAPI route handlers must + be annotated ``-> Response`` (a union return annotation breaks response-model + construction at startup), so the decorated wrappers funnel the result through + here: dicts become JSONResponse (behavior-equivalent to FastAPI's own dict + serialization), Responses pass through unchanged. + """ + if isinstance(result, Response): + return result + return JSONResponse(content=result) + + class SWMLService(ToolMixin): """ Base class for creating and serving SWML documents. @@ -224,7 +240,11 @@ def _create_verb_methods(self) -> None: # Handle sleep verb specially since it takes an integer directly if verb_name == "sleep": - def sleep_method(self_instance, duration=None, **kwargs): + def sleep_method( + self_instance: "SWMLService", + duration: int | None = None, + **kwargs: Any, + ) -> bool: """ Add the sleep verb to the document. @@ -252,8 +272,8 @@ def sleep_method(self_instance, duration=None, **kwargs): continue # Generate the method implementation for normal verbs - def make_verb_method(name): - def verb_method(self_instance, **kwargs): + def make_verb_method(name: str) -> Callable[..., bool]: + def verb_method(self_instance: "SWMLService", **kwargs: Any) -> bool: """ Dynamically generated method for SWML verb """ @@ -328,7 +348,11 @@ def __getattr__(self, name: str) -> Any: # Handle sleep verb specially since it takes an integer directly if name == "sleep": - def sleep_method(self_instance, duration=None, **kwargs): + def sleep_method( + self_instance: "SWMLService", + duration: int | None = None, + **kwargs: Any, + ) -> bool: """ Add the sleep verb to the document. @@ -354,7 +378,7 @@ def sleep_method(self_instance, duration=None, **kwargs): return types.MethodType(sleep_method, self) # Generate the method implementation for normal verbs - def verb_method(self_instance, **kwargs): + def verb_method(self_instance: "SWMLService", **kwargs: Any) -> bool: """ Dynamically generated method for SWML verb """ @@ -630,7 +654,7 @@ def as_router(self) -> APIRouter: # Root endpoint with and without trailing slash @router.get("/") @router.post("/") - async def handle_root(request: Request, response: Response): + async def handle_root(request: Request, response: Response) -> Response: """Handle requests to the root endpoint""" return await self._handle_request(request, response) @@ -639,8 +663,8 @@ async def handle_root(request: Request, response: Response): @router.get("/swaig/") @router.post("/swaig") @router.post("/swaig/") - async def handle_swaig(request: Request, response: Response): - return await self._handle_swaig_request(request, response) + async def handle_swaig(request: Request, response: Response) -> Response: + return _as_response(await self._handle_swaig_request(request, response)) # Register routing callbacks as needed if hasattr(self, "_routing_callbacks") and self._routing_callbacks: @@ -658,8 +682,10 @@ async def handle_swaig(request: Request, response: Response): @router.post(path) @router.post(path_with_slash) async def handle_callback( - request: Request, response: Response, cb_path=callback_path - ): + request: Request, + response: Response, + cb_path: str = callback_path, + ) -> Response: """Handle requests to callback endpoints""" # Store the callback path in the request state request.state.callback_path = cb_path @@ -725,7 +751,9 @@ def _swaig_pre_dispatch( """ return self, None - async def _handle_swaig_request(self, request: Request, response: Response): + async def _handle_swaig_request( + self, request: Request, response: Response + ) -> Response | dict[str, Any]: """Generic SWAIG endpoint handler. GET: returns the SWML document via _swaig_render_get_response(). @@ -933,7 +961,7 @@ def extract_sip_username(request_body: dict[str, Any]) -> str | None: return None - async def _handle_request(self, request: Request, response: Response): + async def _handle_request(self, request: Request, response: Response) -> Response: """ Internal handler for both GET and POST requests @@ -1021,8 +1049,8 @@ async def _handle_request(self, request: Request, response: Response): return Response(content=swml, media_type="application/json") def on_request( - self, request_data: dict | None = None, callback_path: str | None = None - ) -> dict | None: + self, request_data: dict[str, Any] | None = None, callback_path: str | None = None + ) -> dict[str, Any] | None: """ Called when SWML is requested, with request data when available @@ -1101,7 +1129,7 @@ def serve( @app.post("/{full_path:path}") async def handle_all_routes( request: Request, response: Response, full_path: str - ): + ) -> Response: # Get our route path without leading slash for comparison route_path = normalized_route.lstrip("/") route_with_slash = route_path + "/" @@ -1140,7 +1168,7 @@ async def handle_all_routes( # Not our route or not matching our patterns self.log.debug("no_route_match", path=full_path) - return {"error": "Path not found"} + return JSONResponse(content={"error": "Path not found"}) # Log all routes for debugging self.log.debug("registered_routes", service=self.name) diff --git a/signalwire/signalwire/core/swml_verbs_generated.py b/signalwire/signalwire/core/swml_verbs_generated.py new file mode 100644 index 00000000..f055ead3 --- /dev/null +++ b/signalwire/signalwire/core/swml_verbs_generated.py @@ -0,0 +1,2008 @@ +# AUTO-GENERATED from porting-sdk/schema.json — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# Typed SWML verb surface: one Config TypedDict per verb + a _SwmlVerbs +# Protocol declaring each verb method (config -> Self). SwmlBuilder installs these +# verbs dynamically from schema.json at runtime; this static surface lets the type +# checker SEE them (mirrors the TS SwmlVerbMethods.generated.ts augmentation). +# STATIC-ONLY: configs are plain dicts at runtime, never validated. +from __future__ import annotations +from collections.abc import Mapping +from typing import Any, Literal, TypeAlias, TypedDict +from typing import TypeVar + +_Self = TypeVar("_Self", bound="_SwmlVerbs") + + +class Section(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + main: list[SWMLMethod] + + +SWMLMethod: TypeAlias = "Answer | AI | AmazonBedrock | Cond | Connect | Denoise | EnterQueue | Execute | Goto | Label | LiveTranscribe | LiveTranslate | Hangup | JoinRoom | JoinConference | Play | Prompt | ReceiveFax | Record | RecordCall | Request | Return | SendDigits | SendFax | SendSMS | Set | Sleep | SIPRefer | StopDenoise | StopRecordCall | StopTap | Switch | Tap | Transfer | Unset | Pay | DetectMachine | UserEvent" + + +class Answer(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + answer: dict[str, Any] + + +class AI(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + ai: AIObject + + +class AmazonBedrock(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + amazon_bedrock: AmazonBedrockObject + + +class Cond(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + cond: list[CondParams] + + +class Connect(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + connect: ( + ConnectDeviceSingle + | ConnectDeviceSerial + | ConnectDeviceParallel + | ConnectDeviceSerialParallel + ) + + +class Denoise(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + denoise: dict[str, Any] + + +class EnterQueue(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + enter_queue: EnterQueueObject + + +class Execute(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + execute: dict[str, Any] + + +class Goto(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + goto: dict[str, Any] + + +class Label(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + label: str + + +class LiveTranscribe(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + live_transcribe: dict[str, Any] + + +class LiveTranslate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + live_translate: dict[str, Any] + + +class Hangup(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hangup: dict[str, Any] + + +class JoinRoom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + join_room: dict[str, Any] + + +class JoinConference(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + join_conference: JoinConferenceObject + + +class Play(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + play: PlayWithURL | PlayWithURLS + + +class Prompt(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + prompt: dict[str, Any] + + +class ReceiveFax(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + receive_fax: dict[str, Any] + + +class Record(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + record: dict[str, Any] + + +class RecordCall(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + record_call: dict[str, Any] + + +class Request(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + request: dict[str, Any] + + +class Return(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'return': dict[str, Any] + + +class SendDigits(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + send_digits: dict[str, Any] + + +class SendFax(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + send_fax: dict[str, Any] + + +class SendSMS(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + send_sms: SMSWithBody | SMSWithMedia + + +class Set(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + set: dict[str, Any] + + +class Sleep(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + sleep: dict[str, Any] | int | SWMLVar + + +class SIPRefer(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + sip_refer: dict[str, Any] + + +class StopDenoise(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_denoise: dict[str, Any] + + +class StopRecordCall(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_record_call: dict[str, Any] + + +class StopTap(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_tap: dict[str, Any] + + +class Switch(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + switch: dict[str, Any] + + +class Tap(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + tap: dict[str, Any] + + +class Transfer(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + transfer: dict[str, Any] + + +class Unset(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + unset: str | list[str] + + +class Pay(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + pay: dict[str, Any] + + +class DetectMachine(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + detect_machine: dict[str, Any] + + +class UserEvent(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + user_event: dict[str, Any] + + +SWMLVar: TypeAlias = "str" + + +class AIObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + global_data: dict[str, Any] + hints: list[str | Hint] + languages: list[Languages] + params: AIParams + post_prompt: AIPostPrompt + post_prompt_url: str + pronounce: list[Pronounce] + prompt: AIPrompt + SWAIG: SWAIG + + +class AmazonBedrockObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + global_data: dict[str, Any] + params: BedrockParams + post_prompt: BedrockPostPrompt + post_prompt_url: str + prompt: BedrockPrompt + SWAIG: BedrockSWAIG + + +CondParams: TypeAlias = "CondReg | CondElse" + + +class ConnectDeviceSingle(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory"] | Literal["optional"] | Literal["forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + to: str + + +class ConnectDeviceSerial(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory"] | Literal["optional"] | Literal["forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + serial: list[ConnectDeviceSingle] + + +class ConnectDeviceParallel(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory"] | Literal["optional"] | Literal["forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + parallel: list[ConnectDeviceSingle] + + +class ConnectDeviceSerialParallel(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory"] | Literal["optional"] | Literal["forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + serial_parallel: list[list[ConnectDeviceSingle]] + + +class EnterQueueObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + queue_name: str + transfer_after_bridge: str | SWMLVar + status_url: str + wait_url: str | SWMLVar + wait_time: int | SWMLVar + + +class ExecuteSwitch(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + variable: str + case: dict[str, Any] + default: list[SWMLMethod] + + +TranscribeAction: TypeAlias = ( + "TranscribeStartAction | Literal['stop'] | TranscribeSummarizeActionUnion" +) + + +TranslateAction: TypeAlias = ( + "StartAction | Literal['stop'] | SummarizeActionUnion | InjectAction" +) + + +class JoinConferenceObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + muted: bool | SWMLVar + beep: Literal["true"] | Literal["false"] | Literal["onEnter"] | Literal["onExit"] + start_on_enter: bool | SWMLVar + end_on_exit: bool | SWMLVar + wait_url: str | SWMLVar + max_participants: int | SWMLVar + record: Literal["do-not-record"] | Literal["record-from-start"] + region: str + trim: Literal["trim-silence"] | Literal["do-not-trim"] + coach: str + status_callback_event: ( + Literal["start"] + | Literal["end"] + | Literal["join"] + | Literal["leave"] + | Literal["mute"] + | Literal["hold"] + | Literal["modify"] + | Literal["speaker"] + | Literal["announcement"] + ) + status_callback: str + status_callback_method: Literal["GET"] | Literal["POST"] + recording_status_callback: str + recording_status_callback_method: Literal["GET"] | Literal["POST"] + recording_status_callback_event: ( + Literal["in-progress"] | Literal["completed"] | Literal["absent"] + ) + result: dict[str, Any] | list[CondParams] + + +class PlayWithURL(TypedDict, total=False): + """Play with a single URL + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + auto_answer: bool | SWMLVar + volume: float | SWMLVar + say_voice: str + say_language: str + say_gender: Literal["male", "female"] + status_url: str + url: play_url | SWMLVar + + +class PlayWithURLS(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + auto_answer: bool | SWMLVar + volume: float | SWMLVar + say_voice: str + say_language: str + say_gender: Literal["male", "female"] + status_url: str + urls: list[play_url] | list[SWMLVar] + + +play_url: TypeAlias = "str" + + +class SMSWithBody(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + to_number: str + from_number: str + region: str + tags: list[str] + body: str + + +class SMSWithMedia(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + to_number: str + from_number: str + region: str + tags: list[str] + media: list[str] + body: str + + +class PayParameters(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + value: str + + +class PayPrompts(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + actions: list[PayPromptAction] + # non-identifier field 'for': str + attempts: str + card_type: str + error_type: str + + +class Hint(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hint: str + pattern: str + replace: str + ignore_case: bool | SWMLVar + + +Languages: TypeAlias = "LanguagesWithSoloFillers | LanguagesWithFillers" + + +class AIParams(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + acknowledge_interruptions: bool | SWMLVar + ai_model: ( + Literal["gpt-4o-mini"] | Literal["gpt-4.1-mini"] | Literal["gpt-4.1-nano"] | str + ) + ai_name: str + ai_volume: int | SWMLVar + app_name: str + asr_smart_format: bool | SWMLVar + attention_timeout: AttentionTimeout | Literal[0] | SWMLVar + attention_timeout_prompt: str + asr_diarize: bool | SWMLVar + asr_speaker_affinity: bool | SWMLVar + audible_debug: bool | SWMLVar + audible_latency: bool | SWMLVar + background_file: str + background_file_loops: int | None | SWMLVar + background_file_volume: int | SWMLVar + enable_barge: str | bool | SWMLVar + enable_inner_dialog: bool | SWMLVar + enable_pause: bool | SWMLVar + enable_turn_detection: bool | SWMLVar + barge_match_string: str + barge_min_words: int | SWMLVar + barge_functions: bool | SWMLVar + cache_mode: bool | SWMLVar + conscience: str + convo: list[ConversationMessage] + conversation_id: str + conversation_sliding_window: int | SWMLVar + debug_webhook_level: int | SWMLVar + debug_webhook_url: str + debug: bool | int | SWMLVar + direction: Direction | SWMLVar + digit_terminators: str + digit_timeout: int | SWMLVar + end_of_speech_timeout: int | SWMLVar + enable_accounting: bool | SWMLVar + enable_thinking: bool | SWMLVar + enable_vision: bool | SWMLVar + energy_level: float | SWMLVar + first_word_timeout: int | SWMLVar + function_wait_for_talking: bool | SWMLVar + functions_on_no_response: bool | SWMLVar + hard_stop_prompt: str + hard_stop_time: str | SWMLVar + hold_music: str + hold_on_process: bool | SWMLVar + inactivity_timeout: int | SWMLVar + inner_dialog_model: ( + Literal["gpt-4o-mini"] | Literal["gpt-4.1-mini"] | Literal["gpt-4.1-nano"] | str + ) + inner_dialog_prompt: str + inner_dialog_synced: bool | SWMLVar + initial_sleep_ms: int | SWMLVar + input_poll_freq: int | SWMLVar + interrupt_on_noise: bool | SWMLVar + interrupt_prompt: str + languages_enabled: bool | SWMLVar + local_tz: str + llm_diarize_aware: bool | SWMLVar + max_emotion: int | SWMLVar + max_response_tokens: int | SWMLVar + openai_asr_engine: str + outbound_attention_timeout: int | SWMLVar + persist_global_data: bool | SWMLVar + pom_format: Literal["markdown"] | Literal["xml"] + save_conversation: bool | SWMLVar + speech_event_timeout: int | SWMLVar + speech_gen_quick_stops: int | SWMLVar + speech_timeout: int | SWMLVar + speak_when_spoken_to: bool | SWMLVar + start_paused: bool | SWMLVar + static_greeting: str + static_greeting_no_barge: bool | SWMLVar + summary_mode: Literal["string"] | Literal["original"] | SWMLVar + swaig_allow_settings: bool | SWMLVar + swaig_allow_swml: bool | SWMLVar + swaig_post_conversation: bool | SWMLVar + swaig_set_global_data: bool | SWMLVar + swaig_post_swml_vars: bool | list[str] | SWMLVar + thinking_model: ( + Literal["gpt-4o-mini"] | Literal["gpt-4.1-mini"] | Literal["gpt-4.1-nano"] | str + ) + transparent_barge: bool | SWMLVar + transparent_barge_max_time: int | SWMLVar + transfer_summary: bool | SWMLVar + turn_detection_timeout: int | SWMLVar + tts_number_format: Literal["international"] | Literal["national"] + verbose_logs: bool | SWMLVar + video_listening_file: str + video_idle_file: str + video_talking_file: str + vision_model: ( + Literal["gpt-4o-mini"] | Literal["gpt-4.1-mini"] | Literal["gpt-4.1-nano"] | str + ) + vad_config: str + wait_for_user: bool | SWMLVar + wake_prefix: str + eleven_labs_stability: float | SWMLVar + eleven_labs_similarity: float | SWMLVar + + +AIPostPrompt: TypeAlias = "AIPostPromptText | AIPostPromptPom" + + +class Pronounce(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + replace: str + # non-identifier field 'with': str + ignore_case: bool | SWMLVar + + +AIPrompt: TypeAlias = "AIPromptText | AIPromptPom" + + +class SWAIG(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + defaults: SWAIGDefaults + native_functions: list[SWAIGNativeFunction] + includes: list[SWAIGIncludes] + functions: list[SWAIGFunction] + internal_fillers: SWAIGInternalFiller + + +class BedrockParams(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + attention_timeout: AttentionTimeout | Literal[0] | SWMLVar + hard_stop_time: str | SWMLVar + inactivity_timeout: int | SWMLVar + video_listening_file: str + video_idle_file: str + video_talking_file: str + hard_stop_prompt: str + + +BedrockPostPrompt: TypeAlias = "OmitPropertiesBedrockPostPomptTextOmittedPromptProps | OmitPropertiesBedrockPostPromptPomOmittedPromptProps" + + +BedrockPrompt: TypeAlias = "OmitPropertiesBedrockPromptTextOmittedPromptProps | OmitPropertiesBedrockPromptPomOmittedPromptProps" + + +class BedrockSWAIG(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + functions: list[BedrockSWAIGFunction] + defaults: SWAIGDefaults + native_functions: list[SWAIGNativeFunction] + includes: list[SWAIGIncludes] + + +class CondReg(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + when: str + then: list[SWMLMethod] + # non-identifier field 'else': list[SWMLMethod] + + +class CondElse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'else': list[SWMLMethod] + + +class ConnectHeaders(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + value: str + + +class ConnectSwitch(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + variable: str + case: dict[str, Any] + default: list[SWMLMethod] + + +ValidConfirmMethods: TypeAlias = "Cond | Set | Unset | Hangup | Play | Prompt | Record | RecordCall | StopRecordCall | Tap | StopTap | SendDigits | SendSMS | Denoise | StopDenoise" + + +CallStatus: TypeAlias = "Literal['created', 'ringing', 'answered', 'ended']" + + +class TranscribeStartAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + start: dict[str, Any] + + +TranscribeSummarizeActionUnion: TypeAlias = ( + "TranscribeSummarizeAction | Literal['summarize']" +) + + +class StartAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + start: dict[str, Any] + + +SummarizeActionUnion: TypeAlias = "SummarizeAction | Literal['summarize']" + + +class InjectAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + inject: dict[str, Any] + + +PayPromptAction: TypeAlias = "PayPromptSayAction | PayPromptPlayAction" + + +class LanguagesWithSoloFillers(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + code: str + voice: str + model: str + emotion: Literal["auto"] + speed: Literal["auto"] + engine: str + params: LanguageParams + fillers: list[str] + + +class LanguagesWithFillers(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + code: str + voice: str + model: str + emotion: Literal["auto"] + speed: Literal["auto"] + engine: str + params: LanguageParams + function_fillers: list[str] + speech_fillers: list[str] + + +AttentionTimeout: TypeAlias = "int" + + +class ConversationMessage(TypedDict, total=False): + """A message object representing a single turn in the conversation history. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + role: ConversationRole + content: str + lang: str + + +Direction: TypeAlias = "Literal['inbound', 'outbound']" + + +class AIPostPromptText(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + text: str + + +class AIPostPromptPom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + pom: list[POM] + + +class AIPromptText(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + text: str + contexts: Contexts + + +class AIPromptPom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + pom: list[POM] + contexts: Contexts + + +class SWAIGDefaults(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + web_hook_url: str + + +SWAIGNativeFunction: TypeAlias = ( + "Literal['check_time', 'wait_seconds', 'wait_for_user', 'adjust_response_latency']" +) + + +class SWAIGIncludes(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + functions: list[str] + url: str + meta_data: dict[str, Any] + + +SWAIGFunction: TypeAlias = "UserSWAIGFunction | StartUpHookSWAIGFunction | HangUpHookSWAIGFunction | SummarizeConversationSWAIGFunction" + + +class SWAIGInternalFiller(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hangup: FunctionFillers + check_time: FunctionFillers + wait_for_user: FunctionFillers + wait_seconds: FunctionFillers + adjust_response_latency: FunctionFillers + next_step: FunctionFillers + change_context: FunctionFillers + get_visual_input: FunctionFillers + get_ideal_strategy: FunctionFillers + + +class OmitPropertiesBedrockPostPomptTextOmittedPromptProps(TypedDict, total=False): + """The template for omitting properties. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + text: str + + +class OmitPropertiesBedrockPostPromptPomOmittedPromptProps(TypedDict, total=False): + """The template for omitting properties. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + pom: list[POM] + + +class OmitPropertiesBedrockPromptTextOmittedPromptProps(TypedDict, total=False): + """The template for omitting properties. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + voice_id: ( + Literal["tiffany"] + | Literal["matthew"] + | Literal["amy"] + | Literal["lupe"] + | Literal["carlos"] + ) + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + text: str + + +class OmitPropertiesBedrockPromptPomOmittedPromptProps(TypedDict, total=False): + """The template for omitting properties. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + voice_id: ( + Literal["tiffany"] + | Literal["matthew"] + | Literal["amy"] + | Literal["lupe"] + | Literal["carlos"] + ) + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + pom: list[POM] + + +BedrockSWAIGFunction: TypeAlias = "PickPropertiesUserSWAIGFunctionPickedSWAIGFunctionProps | PickPropertiesStartUpHookSWAIGFunctionPickedSWAIGFunctionProps | PickPropertiesHangUpHookSWAIGFunctionPickedSWAIGFunctionProps | PickPropertiesSummarizeConversationSWAIGFunctionPickedSWAIGFunctionProps" + + +TranscribeDirection: TypeAlias = "Literal['remote-caller', 'local-caller']" + + +SpeechEngine: TypeAlias = "Literal['deepgram', 'google']" + + +class TranscribeSummarizeAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + summarize: dict[str, Any] + + +TranslationFilterPreset: TypeAlias = ( + "Literal['polite', 'rude', 'professional', 'shakespeare', 'gen-z']" +) + + +CustomTranslationFilter: TypeAlias = "str" + + +TranslateDirection: TypeAlias = "Literal['remote-caller', 'local-caller']" + + +class SummarizeAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + summarize: dict[str, Any] + + +class PayPromptSayAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["Say"] + phrase: str + + +class PayPromptPlayAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["Play"] + phrase: str + + +class LanguageParams(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stability: float | SWMLVar + similarity: float | SWMLVar + + +ConversationRole: TypeAlias = "Literal['user', 'assistant', 'system']" + + +POM: TypeAlias = "PomSectionBodyContent | PomSectionBulletsContent" + + +class Contexts(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + default: ContextsObject + + +class UserSWAIGFunction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: str + + +class StartUpHookSWAIGFunction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: Literal["startup_hook"] + + +class HangUpHookSWAIGFunction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: Literal["hangup_hook"] + + +class SummarizeConversationSWAIGFunction(TypedDict, total=False): + """An internal reserved function that generates a summary of the conversation and sends any specified properties to the configured webhook after the conversation has ended. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: Literal["summarize_conversation"] + + +FunctionFillers: TypeAlias = "dict[str, Any]" + + +class PickPropertiesUserSWAIGFunctionPickedSWAIGFunctionProps(TypedDict, total=False): + """The template for picking properties. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + parameters: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + web_hook_url: str + function: str + + +class PickPropertiesStartUpHookSWAIGFunctionPickedSWAIGFunctionProps( + TypedDict, total=False +): + """The template for picking properties. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + parameters: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + web_hook_url: str + function: Literal["startup_hook"] + + +class PickPropertiesHangUpHookSWAIGFunctionPickedSWAIGFunctionProps( + TypedDict, total=False +): + """The template for picking properties. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + parameters: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + web_hook_url: str + function: Literal["hangup_hook"] + + +class PickPropertiesSummarizeConversationSWAIGFunctionPickedSWAIGFunctionProps( + TypedDict, total=False +): + """The template for picking properties. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + parameters: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + web_hook_url: str + function: Literal["summarize_conversation"] + + +class PomSectionBodyContent(TypedDict, total=False): + """Content model with body text and optional bullets + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + title: str + subsections: list[POM] + numbered: bool | SWMLVar + numberedBullets: bool | SWMLVar + body: str + bullets: list[str] + + +class PomSectionBulletsContent(TypedDict, total=False): + """Content model with bullets and optional body + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + title: str + subsections: list[POM] + numbered: bool | SWMLVar + numberedBullets: bool | SWMLVar + body: str + bullets: list[str] + + +ContextsObject: TypeAlias = "ContextsPOMObject | ContextsTextObject" + + +class FunctionParameters(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["object"] + properties: dict[str, Any] + required: list[str] + + +class DataMap(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + output: Output + expressions: list[Expression] + webhooks: list[Webhook] + + +class ContextsPOMObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + steps: list[ContextSteps] + isolated: bool + enter_fillers: list[FunctionFillers] + exit_fillers: list[FunctionFillers] + pom: list[POM] + + +class ContextsTextObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + steps: list[ContextSteps] + isolated: bool + enter_fillers: list[FunctionFillers] + exit_fillers: list[FunctionFillers] + text: str + + +SchemaType: TypeAlias = "StringProperty | IntegerProperty | NumberProperty | BooleanProperty | ArrayProperty | ObjectProperty | NullProperty | OneOfProperty | AllOfProperty | AnyOfProperty | ConstProperty" + + +class Output(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + response: str + action: list[Action] + + +class Expression(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + string: str + pattern: str + output: Output + + +class Webhook(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + expressions: list[Expression] + error_keys: str | list[str] + url: str + foreach: dict[str, Any] + headers: dict[str, Any] + method: Literal["GET"] | Literal["POST"] | Literal["PUT"] | Literal["DELETE"] + input_args_as_params: bool | SWMLVar + params: dict[str, Any] + require_args: str | list[str] + output: Output + + +ContextSteps: TypeAlias = "ContextPOMSteps | ContextTextSteps" + + +class StringProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["string"] + enum: list[str] + default: str + pattern: str + format: StringFormat + + +class IntegerProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["integer"] + enum: list[int] + default: int | SWMLVar + + +class NumberProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["number"] + enum: list[int | float] | list[SWMLVar] + default: int | float | SWMLVar + + +class BooleanProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["boolean"] + default: bool | SWMLVar + + +class ArrayProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["array"] + default: list[Any] + items: SchemaType + + +class ObjectProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["object"] + default: dict[str, Any] + properties: dict[str, Any] + required: list[str] + + +class NullProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["null"] + description: str + + +class OneOfProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + oneOf: list[SchemaType] + + +class AllOfProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + allOf: list[SchemaType] + + +class AnyOfProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + anyOf: list[SchemaType] + + +class ConstProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + const: dict[str, Any] + + +Action: TypeAlias = "SWMLAction | ChangeContextAction | ChangeStepAction | ContextSwitchAction | HangupAction | HoldAction | PlaybackBGAction | SayAction | SetGlobalDataAction | SetMetaDataAction | StopAction | StopPlaybackBGAction | ToggleFunctionsAction | UnsetGlobalDataAction | UnsetMetaDataAction | UserInputAction" + + +class ContextPOMSteps(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + step_criteria: str + functions: list[str] + valid_contexts: list[str] + skip_user_turn: bool | SWMLVar + end: bool + valid_steps: list[str] + pom: list[POM] + + +class ContextTextSteps(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + step_criteria: str + functions: list[str] + valid_contexts: list[str] + skip_user_turn: bool | SWMLVar + end: bool + valid_steps: list[str] + text: str + + +StringFormat: TypeAlias = "Literal['date_time', 'time', 'date', 'duration', 'email', 'hostname', 'ipv4', 'ipv6', 'uri', 'uuid']" + + +class SWMLAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + SWML: dict[str, Any] + + +class ChangeContextAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + change_context: str + + +class ChangeStepAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + change_step: str + + +class ContextSwitchAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + context_switch: dict[str, Any] + + +class HangupAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hangup: bool | SWMLVar + + +class HoldAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hold: int | SWMLVar | dict[str, Any] + + +class PlaybackBGAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + playback_bg: dict[str, Any] + + +class SayAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + say: str + + +class SetGlobalDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + set_global_data: dict[str, Any] + + +class SetMetaDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + set_meta_data: dict[str, Any] + + +class StopAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop: bool | SWMLVar + + +class StopPlaybackBGAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_playback_bg: bool | SWMLVar + + +class ToggleFunctionsAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + toggle_functions: list[dict[str, Any]] + + +class UnsetGlobalDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + unset_global_data: str | dict[str, Any] + + +class UnsetMetaDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + unset_meta_data: str | dict[str, Any] + + +class UserInputAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + user_input: str + + +class ConnectConfig(TypedDict, total=False): + """Dial a SIP URI or phone number. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory"] | Literal["optional"] | Literal["forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + to: str + serial: list[ConnectDeviceSingle] + parallel: list[ConnectDeviceSingle] + serial_parallel: list[list[ConnectDeviceSingle]] + + +class ExecuteConfig(TypedDict, total=False): + """Execute a specified section or URL as a subroutine, and upon completion, return to the current document. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + dest: str + params: dict[str, Any] + meta: dict[str, Any] + on_return: list[SWMLMethod] + result: ExecuteSwitch | list[CondParams] + + +class GotoConfig(TypedDict, total=False): + """Jump to a label within the current section, optionally based on a condition. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + label: str + when: str + max: int | SWMLVar + + +class LiveTranscribeConfig(TypedDict, total=False): + """Start live transcription of the call. The transcription will be sent to the specified webhook URL. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + action: TranscribeAction + + +class LiveTranslateConfig(TypedDict, total=False): + """Start live translation of the call. The translation will be sent to the specified webhook URL. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + action: TranslateAction + + +class JoinRoomConfig(TypedDict, total=False): + """Join a RELAY room. If the room doesn't exist, it creates a new room. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + + +class PromptConfig(TypedDict, total=False): + """Play a prompt and wait for input. The input can be received either as digits from the keypad, + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + play: play_url | list[play_url] | SWMLVar | list[SWMLVar] + volume: float + say_voice: str + say_language: str + say_gender: Literal["male", "female"] + max_digits: int | SWMLVar + terminators: str + digit_timeout: float | SWMLVar + initial_timeout: float | SWMLVar + speech_timeout: float | SWMLVar + speech_end_timeout: float | SWMLVar + speech_language: str + speech_hints: list[str] | list[SWMLVar] + speech_engine: str + status_url: str + + +class ReceiveFaxConfig(TypedDict, total=False): + """Receive a fax being delivered to this call. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + status_url: str + + +class RecordConfig(TypedDict, total=False): + """Record the call audio in the foreground, pausing further SWML execution until recording ends. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + stereo: bool | SWMLVar + format: Literal["wav"] | Literal["mp3"] | Literal["mp4"] + direction: Literal["speak"] | Literal["listen"] + terminators: str + beep: bool | SWMLVar + input_sensitivity: float | SWMLVar + initial_timeout: float | SWMLVar + end_silence_timeout: float | SWMLVar + max_length: float | SWMLVar + status_url: str + + +class RecordCallConfig(TypedDict, total=False): + """Record call in the background. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + control_id: str + stereo: bool | SWMLVar + format: Literal["wav"] | Literal["mp3"] | Literal["mp4"] + direction: Literal["speak"] | Literal["listen"] | Literal["both"] + terminators: str + beep: bool | SWMLVar + input_sensitivity: float | SWMLVar + initial_timeout: float | SWMLVar + end_silence_timeout: float | SWMLVar + max_length: float | SWMLVar + status_url: str + + +class RequestConfig(TypedDict, total=False): + """Send a GET, POST, PUT, or DELETE request to a remote URL. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + url: str + method: Literal["GET"] | Literal["POST"] | Literal["PUT"] | Literal["DELETE"] + headers: dict[str, Any] + body: str | dict[str, Any] + timeout: float | SWMLVar + connect_timeout: float | SWMLVar + save_variables: bool | SWMLVar + + +class SendDigitsConfig(TypedDict, total=False): + """Send digit presses as DTMF tones. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + digits: str + + +class SendFaxConfig(TypedDict, total=False): + """Send a fax. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + document: str + header_info: str + identity: str + status_url: str + + +class SipReferConfig(TypedDict, total=False): + """Send SIP REFER to a SIP call. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + to_uri: str + status_url: str + username: str + password: str + + +class StopRecordCallConfig(TypedDict, total=False): + """Stop an active background recording. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + control_id: str + + +class StopTapConfig(TypedDict, total=False): + """Stop an active tap stream. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + control_id: str + + +class SwitchConfig(TypedDict, total=False): + """Execute different instructions based on a variable's value. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + variable: str + case: dict[str, Any] + default: list[SWMLMethod] + + +class TapConfig(TypedDict, total=False): + """Start background call tap. Media is streamed over Websocket or RTP to customer controlled URI. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + uri: str + control_id: str + direction: Literal["speak"] | Literal["listen"] | Literal["both"] + codec: Literal["PCMU"] | Literal["PCMA"] + rtp_ptime: int | SWMLVar + status_url: str + + +class TransferConfig(TypedDict, total=False): + """Transfer the execution of the script to a different SWML section, URL, or Relay application. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + dest: str + params: dict[str, Any] + meta: dict[str, Any] + + +class PayConfig(TypedDict, total=False): + """Enables secure payment processing during voice calls. When implemented, it manages the entire payment flow + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + payment_connector_url: str + charge_amount: str + currency: str + description: str + input: Literal["dtmf"] + language: str + max_attempts: int | SWMLVar + min_postal_code_length: int | SWMLVar + parameters: list[PayParameters] + payment_method: Literal["credit-card"] + postal_code: bool | str + prompts: list[PayPrompts] + security_code: bool | SWMLVar + status_url: str + timeout: int | SWMLVar + token_type: Literal["one-time"] | Literal["reusable"] + valid_card_types: str + voice: str + + +class DetectMachineConfig(TypedDict, total=False): + """A detection method that combines AMD (Answering Machine Detection) and fax detection. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + detect_message_end: bool | SWMLVar + detectors: str + end_silence_timeout: float | SWMLVar + initial_timeout: float | SWMLVar + machine_ready_timeout: float | SWMLVar + machine_voice_threshold: float | SWMLVar + machine_words_threshold: int | SWMLVar + status_url: str + timeout: float | SWMLVar + tone: Literal["CED"] | Literal["CNG"] + wait: bool | SWMLVar + + +class UserEventConfig(TypedDict, total=False): + """Allows the user to set and send events to the connected client on the call. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + event: dict[str, Any] + + +class _SwmlVerbs: + """The SWML verb methods SwmlBuilder installs at runtime (static view).""" + + def amazon_bedrock(self: _Self, config: AmazonBedrockObject | None = None) -> _Self: + """Creates a new Bedrock AI Agent""" + raise NotImplementedError # installed dynamically at runtime + + def cond(self: _Self, config: Mapping[str, Any] | None = None) -> _Self: + """Execute a sequence of instructions depending on the value of a JavaScript condition.""" + raise NotImplementedError # installed dynamically at runtime + + def connect(self: _Self, config: ConnectConfig | None = None) -> _Self: + """Dial a SIP URI or phone number.""" + raise NotImplementedError # installed dynamically at runtime + + def denoise(self: _Self, config: Mapping[str, Any] | None = None) -> _Self: + """Start noise reduction. You can stop it at any time using `stop_denoise`.""" + raise NotImplementedError # installed dynamically at runtime + + def enter_queue(self: _Self, config: EnterQueueObject | None = None) -> _Self: + """Place the current call in a named queue where it will wait to be connected to an available agent or resource.""" + raise NotImplementedError # installed dynamically at runtime + + def execute(self: _Self, config: ExecuteConfig | None = None) -> _Self: + """Execute a specified section or URL as a subroutine, and upon completion, return to the current document.""" + raise NotImplementedError # installed dynamically at runtime + + def goto(self: _Self, config: GotoConfig | None = None) -> _Self: + """Jump to a label within the current section, optionally based on a condition.""" + raise NotImplementedError # installed dynamically at runtime + + def label(self: _Self, value: str) -> _Self: + """Mark any point of the SWML section with a label so that goto can jump to it.""" + raise NotImplementedError # installed dynamically at runtime + + def live_transcribe( + self: _Self, config: LiveTranscribeConfig | None = None + ) -> _Self: + """Start live transcription of the call. The transcription will be sent to the specified webhook URL.""" + raise NotImplementedError # installed dynamically at runtime + + def live_translate(self: _Self, config: LiveTranslateConfig | None = None) -> _Self: + """Start live translation of the call. The translation will be sent to the specified webhook URL.""" + raise NotImplementedError # installed dynamically at runtime + + def join_room(self: _Self, config: JoinRoomConfig | None = None) -> _Self: + """Join a RELAY room. If the room doesn't exist, it creates a new room.""" + raise NotImplementedError # installed dynamically at runtime + + def join_conference( + self: _Self, config: JoinConferenceObject | None = None + ) -> _Self: + """Join an ad-hoc audio conference started on either the SignalWire or Compatibility API.""" + raise NotImplementedError # installed dynamically at runtime + + def prompt(self: _Self, config: PromptConfig | None = None) -> _Self: + """Play a prompt and wait for input. The input can be received either as digits from the keypad,""" + raise NotImplementedError # installed dynamically at runtime + + def receive_fax(self: _Self, config: ReceiveFaxConfig | None = None) -> _Self: + """Receive a fax being delivered to this call.""" + raise NotImplementedError # installed dynamically at runtime + + def record(self: _Self, config: RecordConfig | None = None) -> _Self: + """Record the call audio in the foreground, pausing further SWML execution until recording ends.""" + raise NotImplementedError # installed dynamically at runtime + + def record_call(self: _Self, config: RecordCallConfig | None = None) -> _Self: + """Record call in the background.""" + raise NotImplementedError # installed dynamically at runtime + + def request(self: _Self, config: RequestConfig | None = None) -> _Self: + """Send a GET, POST, PUT, or DELETE request to a remote URL.""" + raise NotImplementedError # installed dynamically at runtime + + def return_(self: _Self, config: Mapping[str, Any] | None = None) -> _Self: + """Return a value from an execute call or exit the script. The value can be any type.""" + raise NotImplementedError # installed dynamically at runtime + + def send_digits(self: _Self, config: SendDigitsConfig | None = None) -> _Self: + """Send digit presses as DTMF tones.""" + raise NotImplementedError # installed dynamically at runtime + + def send_fax(self: _Self, config: SendFaxConfig | None = None) -> _Self: + """Send a fax.""" + raise NotImplementedError # installed dynamically at runtime + + def send_sms(self: _Self, config: Mapping[str, Any] | None = None) -> _Self: + """Send an outbound SMS or MMS message to a PSTN phone number.""" + raise NotImplementedError # installed dynamically at runtime + + def set(self: _Self, config: Mapping[str, Any] | None = None) -> _Self: + """Set script variables to the specified values.""" + raise NotImplementedError # installed dynamically at runtime + + def sleep(self: _Self, config: Mapping[str, Any] | None = None) -> _Self: + """Pause execution for a specified duration.""" + raise NotImplementedError # installed dynamically at runtime + + def sip_refer(self: _Self, config: SipReferConfig | None = None) -> _Self: + """Send SIP REFER to a SIP call.""" + raise NotImplementedError # installed dynamically at runtime + + def stop_denoise(self: _Self, config: Mapping[str, Any] | None = None) -> _Self: + """Stop noise reduction that was started with denoise.""" + raise NotImplementedError # installed dynamically at runtime + + def stop_record_call( + self: _Self, config: StopRecordCallConfig | None = None + ) -> _Self: + """Stop an active background recording.""" + raise NotImplementedError # installed dynamically at runtime + + def stop_tap(self: _Self, config: StopTapConfig | None = None) -> _Self: + """Stop an active tap stream.""" + raise NotImplementedError # installed dynamically at runtime + + def switch(self: _Self, config: SwitchConfig | None = None) -> _Self: + """Execute different instructions based on a variable's value.""" + raise NotImplementedError # installed dynamically at runtime + + def tap(self: _Self, config: TapConfig | None = None) -> _Self: + """Start background call tap. Media is streamed over Websocket or RTP to customer controlled URI.""" + raise NotImplementedError # installed dynamically at runtime + + def transfer(self: _Self, config: TransferConfig | None = None) -> _Self: + """Transfer the execution of the script to a different SWML section, URL, or Relay application.""" + raise NotImplementedError # installed dynamically at runtime + + def unset(self: _Self, config: Mapping[str, Any] | None = None) -> _Self: + """Unset specified variables. The variables may have been set using the set method""" + raise NotImplementedError # installed dynamically at runtime + + def pay(self: _Self, config: PayConfig | None = None) -> _Self: + """Enables secure payment processing during voice calls. When implemented, it manages the entire payment flow""" + raise NotImplementedError # installed dynamically at runtime + + def detect_machine(self: _Self, config: DetectMachineConfig | None = None) -> _Self: + """A detection method that combines AMD (Answering Machine Detection) and fax detection.""" + raise NotImplementedError # installed dynamically at runtime + + def user_event(self: _Self, config: UserEventConfig | None = None) -> _Self: + """Allows the user to set and send events to the connected client on the call.""" + raise NotImplementedError # installed dynamically at runtime diff --git a/signalwire/signalwire/mcp_gateway/gateway_service.py b/signalwire/signalwire/mcp_gateway/gateway_service.py index 9be43606..f0728098 100644 --- a/signalwire/signalwire/mcp_gateway/gateway_service.py +++ b/signalwire/signalwire/mcp_gateway/gateway_service.py @@ -90,7 +90,7 @@ def __init__(self, config_path: str = "config.json") -> None: ) # Configure security headers - @self.app.after_request + @self.app.after_request # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator def set_security_headers(response: Response) -> Response: response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" @@ -331,7 +331,7 @@ def decorated(*args: Any, **kwargs: Any) -> Any: def _setup_routes(self) -> None: """Set up Flask routes""" - @self.app.route("/health", methods=["GET"]) + @self.app.route("/health", methods=["GET"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator def health() -> Any: """Health check endpoint""" return jsonify( @@ -342,15 +342,15 @@ def health() -> Any: } ) - @self.app.route("/services", methods=["GET"]) + @self.app.route("/services", methods=["GET"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator @self._check_auth def list_services() -> Any: """List available MCP services""" services = self.mcp_manager.list_services() return jsonify(services) - @self.app.route("/services//tools", methods=["GET"]) - @self.limiter.limit(self.rate_config.get("tools_limit", "30 per minute")) + @self.app.route("/services//tools", methods=["GET"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator + @self.limiter.limit(self.rate_config.get("tools_limit", "30 per minute")) # type: ignore[untyped-decorator] # flask-limiter is an optional extra with no stubs installed -> Any decorator @self._check_auth def get_service_tools(service_name: str) -> Any: """Get tools for a specific service""" @@ -366,8 +366,8 @@ def get_service_tools(service_name: str) -> Any: logger.error(f"Error getting tools for {service_name}: {e}") return jsonify({"error": "Service error"}), 500 - @self.app.route("/services//call", methods=["POST"]) - @self.limiter.limit(self.rate_config.get("call_limit", "10 per minute")) + @self.app.route("/services//call", methods=["POST"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator + @self.limiter.limit(self.rate_config.get("call_limit", "10 per minute")) # type: ignore[untyped-decorator] # flask-limiter is an optional extra with no stubs installed -> Any decorator @self._check_auth def call_service_tool(service_name: str) -> Any: """Call a tool on a service""" @@ -482,15 +482,15 @@ def call_service_tool(service_name: str) -> Any: logger.error(f"Error calling tool: {e}") return jsonify({"error": str(e)}), 500 - @self.app.route("/sessions", methods=["GET"]) + @self.app.route("/sessions", methods=["GET"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator @self._check_auth def list_sessions() -> Any: """List active sessions""" sessions = self.session_manager.list_sessions() return jsonify(sessions) - @self.app.route("/sessions/", methods=["DELETE"]) - @self.limiter.limit( + @self.app.route("/sessions/", methods=["DELETE"]) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator + @self.limiter.limit( # type: ignore[untyped-decorator] # flask-limiter is an optional extra with no stubs installed -> Any decorator self.rate_config.get("session_delete_limit", "20 per minute") ) @self._check_auth @@ -510,7 +510,7 @@ def close_session(session_id: str) -> Any: except ValueError as e: return jsonify({"error": str(e)}), 400 - @self.app.errorhandler(Exception) + @self.app.errorhandler(Exception) # type: ignore[untyped-decorator] # flask is an optional extra with no stubs installed -> Any decorator def handle_error(error: Exception) -> Any: logger.error(f"Unhandled error: {error}") return jsonify({"error": "Internal server error"}), 500 diff --git a/signalwire/signalwire/pom/pom.py b/signalwire/signalwire/pom/pom.py index 61c630df..8f612422 100644 --- a/signalwire/signalwire/pom/pom.py +++ b/signalwire/signalwire/pom/pom.py @@ -316,7 +316,7 @@ def from_yaml(yaml_data: str | dict[str, Any]) -> "PromptObjectModel": return PromptObjectModel._from_dict(data) @staticmethod - def _from_dict(data: str | dict | list) -> "PromptObjectModel": + def _from_dict(data: str | dict[str, Any] | list[Any]) -> "PromptObjectModel": """ Internal method to create a PromptObjectModel from a dictionary. Used by both from_json and from_yaml. @@ -331,7 +331,7 @@ def _from_dict(data: str | dict | list) -> "PromptObjectModel": ValueError: If the data is not properly formatted """ - def build_section(d: dict, is_subsection: bool = False) -> Section: + def build_section(d: dict[str, Any], is_subsection: bool = False) -> Section: if not isinstance(d, dict): raise ValueError("Each section must be a dictionary.") if "title" in d and not isinstance(d["title"], str): diff --git a/signalwire/signalwire/pom/pom_tool.py b/signalwire/signalwire/pom/pom_tool.py index b8b60347..8ce85a86 100644 --- a/signalwire/signalwire/pom/pom_tool.py +++ b/signalwire/signalwire/pom/pom_tool.py @@ -21,7 +21,7 @@ from signalwire.pom import PromptObjectModel -def detect_file_format(file_path): +def detect_file_format(file_path: str) -> str: """Detect if the file is JSON or YAML based on extension and content.""" ext = Path(file_path).suffix.lower() @@ -54,7 +54,7 @@ def detect_file_format(file_path): return "json" -def load_pom(file_path): +def load_pom(file_path: str) -> "PromptObjectModel": """Load a POM from a file, auto-detecting the format.""" format_type = detect_file_format(file_path) @@ -67,7 +67,7 @@ def load_pom(file_path): return PromptObjectModel.from_yaml(content) -def render_pom(pom, output_format): +def render_pom(pom: "PromptObjectModel", output_format: str) -> str: """Render the POM in the specified format.""" if output_format == "md": return pom.render_markdown() @@ -80,7 +80,7 @@ def render_pom(pom, output_format): raise ValueError(f"Unsupported output format: {output_format}") -def main(): +def main() -> None: """Main entry point for the POM tool.""" args = docopt(__doc__) diff --git a/signalwire/signalwire/prefabs/concierge.py b/signalwire/signalwire/prefabs/concierge.py index 4731b443..76a0eb5e 100644 --- a/signalwire/signalwire/prefabs/concierge.py +++ b/signalwire/signalwire/prefabs/concierge.py @@ -14,6 +14,10 @@ from signalwire.core.agent_base import AgentBase from signalwire.core.function_result import FunctionResult +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from signalwire.core.post_prompt_generated import PostPrompt, PostPromptData class ConciergeAgent(AgentBase): @@ -250,8 +254,8 @@ def get_directions( def on_summary( self, - summary: dict[str, Any] | None, - raw_data: dict[str, Any] | None = None, + summary: "PostPromptData | None", + raw_data: "PostPrompt | None" = None, ) -> None: """ Process the interaction summary diff --git a/signalwire/signalwire/prefabs/faq_bot.py b/signalwire/signalwire/prefabs/faq_bot.py index a528043e..737db859 100644 --- a/signalwire/signalwire/prefabs/faq_bot.py +++ b/signalwire/signalwire/prefabs/faq_bot.py @@ -14,6 +14,10 @@ from signalwire.core.agent_base import AgentBase from signalwire.core.function_result import FunctionResult +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from signalwire.core.post_prompt_generated import PostPrompt, PostPromptData class FAQBotAgent(AgentBase): @@ -273,8 +277,8 @@ def search_faqs( def on_summary( self, - summary: dict[str, Any] | None, - raw_data: dict[str, Any] | None = None, + summary: "PostPromptData | None", + raw_data: "PostPrompt | None" = None, ) -> None: """ Process the interaction summary diff --git a/signalwire/signalwire/prefabs/receptionist.py b/signalwire/signalwire/prefabs/receptionist.py index dde35a88..ce698a77 100644 --- a/signalwire/signalwire/prefabs/receptionist.py +++ b/signalwire/signalwire/prefabs/receptionist.py @@ -13,6 +13,10 @@ from signalwire.core.agent_base import AgentBase from signalwire.core.function_result import FunctionResult +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from signalwire.core.post_prompt_generated import PostPrompt, PostPromptData class ReceptionistAgent(AgentBase): @@ -256,8 +260,8 @@ def _transfer_call_handler( def on_summary( self, - summary: dict[str, Any] | None, - raw_data: dict[str, Any] | None = None, + summary: "PostPromptData | None", + raw_data: "PostPrompt | None" = None, ) -> None: """ Process the conversation summary diff --git a/signalwire/signalwire/prefabs/survey.py b/signalwire/signalwire/prefabs/survey.py index 52d28e08..29f3c090 100644 --- a/signalwire/signalwire/prefabs/survey.py +++ b/signalwire/signalwire/prefabs/survey.py @@ -14,6 +14,10 @@ from signalwire.core.agent_base import AgentBase from signalwire.core.function_result import FunctionResult +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from signalwire.core.post_prompt_generated import PostPrompt, PostPromptData class SurveyAgent(AgentBase): @@ -347,8 +351,8 @@ def log_response( def on_summary( self, - summary: dict[str, Any] | None, - raw_data: dict[str, Any] | None = None, + summary: "PostPromptData | None", + raw_data: "PostPrompt | None" = None, ) -> None: """ Process the survey results summary diff --git a/signalwire/signalwire/relay/call.py b/signalwire/signalwire/relay/call.py index 23e4d760..a76728b0 100644 --- a/signalwire/signalwire/relay/call.py +++ b/signalwire/signalwire/relay/call.py @@ -122,35 +122,59 @@ def is_done(self) -> bool: return self._done.done() -class PlayAction(Action): - """Handle for an active play operation.""" +class StoppableAction(Action): + """An action that can be stopped. The concrete action sets ``_command_prefix`` + (e.g. ``"play"``) and ``stop`` posts ``calling..stop``. Reused by every + action whose control surface is a ``.stop`` command.""" - def __init__(self, call: Call, control_id: str): - super().__init__( - call, control_id, EVENT_CALL_PLAY, (PLAY_STATE_FINISHED, PLAY_STATE_ERROR) - ) + _command_prefix: str = "" async def stop(self) -> dict[str, Any]: - return await self.call._execute("play.stop", {"control_id": self.control_id}) + return await self.call._execute( + f"{self._command_prefix}.stop", {"control_id": self.control_id} + ) + - async def pause(self) -> dict[str, Any]: - return await self.call._execute("play.pause", {"control_id": self.control_id}) +class PausableAction(StoppableAction): + """A stoppable action that can also pause/resume (record, play, collect).""" + + async def pause(self, behavior: str | None = None) -> dict[str, Any]: + params: dict[str, Any] = {"control_id": self.control_id} + if behavior: + params["behavior"] = behavior + return await self.call._execute(f"{self._command_prefix}.pause", params) async def resume(self) -> dict[str, Any]: - return await self.call._execute("play.resume", {"control_id": self.control_id}) + return await self.call._execute( + f"{self._command_prefix}.resume", {"control_id": self.control_id} + ) + + +class VolumeAction(PausableAction): + """A pausable action that also supports a volume adjustment (play).""" async def volume(self, volume: float) -> dict[str, Any]: return await self.call._execute( - "play.volume", - { - "control_id": self.control_id, - "volume": volume, - }, + f"{self._command_prefix}.volume", + {"control_id": self.control_id, "volume": volume}, ) -class RecordAction(Action): - """Handle for an active record operation.""" +class PlayAction(VolumeAction): + """Handle for an active play operation (stop/pause/resume/volume).""" + + _command_prefix = "play" + + def __init__(self, call: Call, control_id: str): + super().__init__( + call, control_id, EVENT_CALL_PLAY, (PLAY_STATE_FINISHED, PLAY_STATE_ERROR) + ) + + +class RecordAction(PausableAction): + """Handle for an active record operation (stop/pause/resume).""" + + _command_prefix = "record" def __init__(self, call: Call, control_id: str): super().__init__( @@ -160,23 +184,11 @@ def __init__(self, call: Call, control_id: str): (RECORD_STATE_FINISHED, RECORD_STATE_NO_INPUT), ) - async def stop(self) -> dict[str, Any]: - return await self.call._execute("record.stop", {"control_id": self.control_id}) - - async def pause(self, behavior: str | None = None) -> dict[str, Any]: - params: dict[str, Any] = {"control_id": self.control_id} - if behavior: - params["behavior"] = behavior - return await self.call._execute("record.pause", params) - - async def resume(self) -> dict[str, Any]: - return await self.call._execute( - "record.resume", {"control_id": self.control_id} - ) +class DetectAction(StoppableAction): + """Handle for an active detect operation (stop).""" -class DetectAction(Action): - """Handle for an active detect operation.""" + _command_prefix = "detect" def __init__(self, call: Call, control_id: str): super().__init__(call, control_id, EVENT_CALL_DETECT, ("finished", "error")) @@ -189,12 +201,12 @@ def _check_event(self, event: RelayEvent) -> None: if (detect or state in self._terminal_states) and not self._done.done(): self._resolve(event) - async def stop(self) -> dict[str, Any]: - return await self.call._execute("detect.stop", {"control_id": self.control_id}) +class CollectAction(VolumeAction): + """Handle for play_and_collect (stop/volume use the play_and_collect prefix; the + initial-timeout timer is a collect-specific command).""" -class CollectAction(Action): - """Handle for play_and_collect or standalone collect.""" + _command_prefix = "play_and_collect" def __init__(self, call: Call, control_id: str): super().__init__( @@ -215,20 +227,6 @@ def _check_event(self, event: RelayEvent) -> None: else: super()._check_event(event) - async def stop(self) -> dict[str, Any]: - return await self.call._execute( - "play_and_collect.stop", {"control_id": self.control_id} - ) - - async def volume(self, volume: float) -> dict[str, Any]: - return await self.call._execute( - "play_and_collect.volume", - { - "control_id": self.control_id, - "volume": volume, - }, - ) - async def start_input_timers(self) -> dict[str, Any]: """Start the initial_timeout timer on an active collect.""" return await self.call._execute( @@ -236,9 +234,11 @@ async def start_input_timers(self) -> dict[str, Any]: ) -class StandaloneCollectAction(Action): +class StandaloneCollectAction(StoppableAction): """Handle for standalone calling.collect (without play).""" + _command_prefix = "collect" + def __init__(self, call: Call, control_id: str): super().__init__( call, @@ -255,9 +255,6 @@ def _check_event(self, event: RelayEvent) -> None: if (result or state in self._terminal_states) and not self._done.done(): self._resolve(event) - async def stop(self) -> dict[str, Any]: - return await self.call._execute("collect.stop", {"control_id": self.control_id}) - async def start_input_timers(self) -> dict[str, Any]: """Start the initial_timeout timer on an active collect.""" return await self.call._execute( @@ -265,73 +262,62 @@ async def start_input_timers(self) -> dict[str, Any]: ) -class FaxAction(Action): - """Handle for an active send_fax or receive_fax operation.""" +class FaxAction(StoppableAction): + """Handle for an active send_fax or receive_fax operation. The stop prefix + (``send_fax``/``receive_fax``) is set per-instance from the constructor.""" def __init__(self, call: Call, control_id: str, method_prefix: str): super().__init__(call, control_id, EVENT_CALL_FAX, ("finished", "error")) - self._method_prefix = method_prefix - - async def stop(self) -> dict[str, Any]: - return await self.call._execute( - f"{self._method_prefix}.stop", {"control_id": self.control_id} - ) + self._command_prefix = method_prefix -class TapAction(Action): +class TapAction(StoppableAction): """Handle for an active tap operation.""" + _command_prefix = "tap" + def __init__(self, call: Call, control_id: str): super().__init__(call, control_id, EVENT_CALL_TAP, ("finished",)) - async def stop(self) -> dict[str, Any]: - return await self.call._execute("tap.stop", {"control_id": self.control_id}) - -class StreamAction(Action): +class StreamAction(StoppableAction): """Handle for an active stream operation.""" + _command_prefix = "stream" + def __init__(self, call: Call, control_id: str): super().__init__(call, control_id, EVENT_CALL_STREAM, ("finished",)) - async def stop(self) -> dict[str, Any]: - return await self.call._execute("stream.stop", {"control_id": self.control_id}) - -class PayAction(Action): +class PayAction(StoppableAction): """Handle for an active pay operation.""" + _command_prefix = "pay" + def __init__(self, call: Call, control_id: str): super().__init__(call, control_id, EVENT_CALL_PAY, ("finished", "error")) - async def stop(self) -> dict[str, Any]: - return await self.call._execute("pay.stop", {"control_id": self.control_id}) - -class TranscribeAction(Action): +class TranscribeAction(StoppableAction): """Handle for an active transcribe operation.""" + _command_prefix = "transcribe" + def __init__(self, call: Call, control_id: str): super().__init__(call, control_id, EVENT_CALL_TRANSCRIBE, ("finished",)) - async def stop(self) -> dict[str, Any]: - return await self.call._execute( - "transcribe.stop", {"control_id": self.control_id} - ) - -class AIAction(Action): +class AIAction(StoppableAction): """Handle for an active AI agent session.""" + _command_prefix = "ai" + def __init__(self, call: Call, control_id: str): # AI sessions don't have a standard event type with state field — # they end when the call ends or when stopped. We treat "finished" # and "error" as terminal states from calling.call.ai events if any. super().__init__(call, control_id, "calling.call.ai", ("finished", "error")) - async def stop(self) -> dict[str, Any]: - return await self.call._execute("ai.stop", {"control_id": self.control_id}) - # ====================================================================== # Call class diff --git a/signalwire/signalwire/relay/protocol_types_generated.py b/signalwire/signalwire/relay/protocol_types_generated.py new file mode 100644 index 00000000..8756b49a --- /dev/null +++ b/signalwire/signalwire/relay/protocol_types_generated.py @@ -0,0 +1,1722 @@ +# AUTO-GENERATED from porting-sdk/relay-protocol/*.{params,result}.json — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per RELAY method's params (Params) and ack result +# (Result), from the canonical switchblade wire schemas. STATIC-ONLY: +# at runtime each is a plain dict; the wire layer stays untyped and tolerant. +from __future__ import annotations +from typing import Any, TypeAlias, TypedDict + + +class CallingAiHoldParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.ai_hold` (params). Extracted from switchblade `PublicCallAiHoldParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + # non-identifier field 'async': bool | None + call_id: str + node_id: str + prompt: str + swml: bool | None + timeout: str + + +class CallingAiMessageParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.ai_message` (params). Extracted from switchblade `PublicCallAiMessageParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + # non-identifier field 'async': bool | None + call_id: str + global_data: Any + message_text: str + node_id: str + reset: Any + role: str + swml: bool | None + + +class CallingAiUnholdParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.ai_unhold` (params). Extracted from switchblade `PublicCallAiUnholdParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + # non-identifier field 'async': bool | None + call_id: str + node_id: str + prompt: str + swml: bool | None + + +class CallingAmazonBedrockParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.amazon_bedrock` (params). Extracted from switchblade `PublicCallAmazonBedrockParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + SWAIG: Any + # non-identifier field 'async': bool | None + call_id: str + global_data: Any + node_id: str + params: Any + post_prompt: Any + post_prompt_url: str + prompt: Any + swml: bool | None + + +class CallingAnswerParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.answer` (params). Extracted from switchblade `PublicCallAnswerParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + max_duration: int | None + node_id: str + + +class CallingBeginParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.begin` (params). Extracted from switchblade `PublicCallBeginParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + device: dict[str, Any] + max_duration: int | None + node_id: str + region: str + tag: str + + +class CallingBindDigitParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.bind_digit` (params). Extracted from switchblade `PublicCallBindDigitParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + bind_method: str + call_id: str + digits: str + max_triggers: int | None + node_id: str + params: Any + realm: str + swml: bool | None + + +CallingCallParams: TypeAlias = "dict[str, Any]" + + +class CallingClearDigitBindingsParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.clear_digit_bindings` (params). Extracted from switchblade `PublicCallClearDigitBindingsParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + node_id: str + realm: str + swml: bool | None + + +class CallingCollectParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.collect` (params). Extracted from switchblade `PublicCallCollectParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + # non-identifier field 'continue': bool | None + continuous: bool | None + control_id: str + digits: dict[str, Any] + initial_timeout: float | None + node_id: str + partial_results: bool | None + send_start_of_input: bool | None + speech: dict[str, Any] + start_input_timers: bool | None + + +class CallingCollectStartInputTimersParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.collect.start_input_timers` (params). Extracted from switchblade `PublicCallCollectStartInputTimersParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingCollectStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.collect.stop` (params). Extracted from switchblade `PublicCallCollectStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingConnectParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.connect` (params). Extracted from switchblade `PublicCallConnectParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + devices: list[list[dict[str, Any]]] + max_duration: int | None + max_price_per_minute: float | None + node_id: str + ringback: list[dict[str, Any]] + tag: str + + +class CallingDenoiseParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.denoise` (params). Extracted from switchblade `PublicCallDenoiseParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + node_id: str + + +class CallingDenoiseStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.denoise.stop` (params). Extracted from switchblade `PublicCallDenoiseStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + node_id: str + + +class CallingDetectParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.detect` (params). Extracted from switchblade `PublicCallDetectParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + detect: dict[str, Any] + node_id: str + timeout: float | None + + +class CallingDetectStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.detect.stop` (params). Extracted from switchblade `PublicCallDetectStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingDialParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.dial` (params). Extracted from switchblade `PublicCallDialParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + devices: list[list[dict[str, Any]]] + max_price_per_minute: float | None + node_id: str + region: str + tag: str + + +class CallingDisconnectParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.disconnect` (params). Extracted from switchblade `PublicCallDisconnectParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + node_id: str + + +class CallingEchoParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.echo` (params). Extracted from switchblade `PublicCallEchoParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + node_id: str + status_url: str + swml: bool | None + timeout: float | None + + +class CallingEndParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.end` (params). Extracted from switchblade `PublicCallEndParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + node_id: str + reason: str + + +class CallingJoinConferenceParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.join_conference` (params). Extracted from switchblade `PublicCallJoinConferenceParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + acl: str + beep: str + call_id: str + coach: str + end_on_exit: bool | None + max_participants: int | None + muted: bool | None + name: str + node_id: str + record: str + recording_status_callback: str + recording_status_callback_event: str + recording_status_callback_event_type: str + recording_status_callback_method: str + region: str + start_on_enter: bool | None + status_callback: str + status_callback_event: str + status_callback_event_type: str + status_callback_method: str + stream: Any + swml: bool | None + trim: str + wait_url: str + + +class CallingJoinRoomParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.join_room` (params). Extracted from switchblade `PublicCallJoinRoomParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + hagrid_json_api_url: str + hagrid_node_id: str + name: str + node_id: str + status_url: str + swml: bool | None + + +class CallingLeaveConferenceParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.leave_conference` (params). Extracted from switchblade `PublicCallLeaveConferenceParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + # non-identifier field 'async': bool | None + call_id: str + conference_id: str + node_id: str + + +class CallingLeaveRoomParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.leave_room` (params). Extracted from switchblade `PublicCallLeaveRoomParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + # non-identifier field 'async': bool | None + call_id: str + node_id: str + + +class CallingLiveTranscribeParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.live_transcribe` (params). Extracted from switchblade `PublicCallLiveTranscribeParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + action: Any + # non-identifier field 'async': bool | None + call_id: str + node_id: str + swml: bool | None + + +class CallingLiveTranslateParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.live_translate` (params). Extracted from switchblade `PublicCallLiveTranslateParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + action: Any + # non-identifier field 'async': bool | None + call_id: str + node_id: str + status_url: str + swml: bool | None + + +class CallingPassParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.pass` (params). Extracted from switchblade `PublicCallPassParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + node_id: str + + +class CallingPayParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.pay` (params). Extracted from switchblade `PublicCallPayParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + bank_account_type: Any + call_id: str + charge_amount: str + control_id: str + currency: str + description: str + input: Any + language: str + max_attempts: str + min_postal_code_length: str + node_id: str + parameters: list[dict[str, Any]] + payment_connector_url: str + payment_method: Any + postal_code: str + prompts: list[dict[str, Any]] + security_code: str + status_url: str + timeout: str + token_type: Any + valid_card_types: str + voice: str + + +class CallingPayStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.pay.stop` (params). Extracted from switchblade `PublicCallPayStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingPlayParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play` (params). Extracted from switchblade `PublicCallPlayParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + play: list[dict[str, Any]] + volume: float | None + + +class CallingPlayPauseParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play.pause` (params). Extracted from switchblade `PublicCallPlayPauseParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingPlayResumeParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play.resume` (params). Extracted from switchblade `PublicCallPlayResumeParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingPlayStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play.stop` (params). Extracted from switchblade `PublicCallPlayStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingPlayVolumeParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play.volume` (params). Extracted from switchblade `PublicCallPlayVolumeParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + volume: float + + +class CallingPlayAndCollectParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play_and_collect` (params). Extracted from switchblade `PublicCallPlayAndCollectParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + collect: dict[str, Any] + control_id: str + node_id: str + play: list[dict[str, Any]] + volume: float | None + + +class CallingPlayAndCollectStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play_and_collect.stop` (params). Extracted from switchblade `PublicCallPlayAndCollectStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingPlayAndCollectVolumeParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play_and_collect.volume` (params). Extracted from switchblade `PublicCallPlayAndCollectVolumeParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + volume: float + + +class CallingQueueEnterParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.queue.enter` (params). Extracted from switchblade `PublicCallQueueEnterParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + queue_name: str + status_url: str + wait_url: str + + +class CallingQueueLeaveParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.queue.leave` (params). Extracted from switchblade `PublicCallQueueLeaveParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + queue_id: str + queue_name: str + status_url: str + + +class CallingReceiveParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.receive` (params). Extracted from switchblade `PublicCallReceiveParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + context: str + contexts: list[str] + + +class CallingReceiveFaxParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.receive_fax` (params). Extracted from switchblade `PublicCallReceiveFaxParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingReceiveFaxStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.receive_fax.stop` (params). Extracted from switchblade `PublicCallReceiveFaxStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingRecordParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.record` (params). Extracted from switchblade `PublicCallRecordParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + record: dict[str, Any] + + +class CallingRecordPauseParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.record.pause` (params). Extracted from switchblade `PublicCallRecordPauseParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + behavior: str + call_id: str + control_id: str + node_id: str + + +class CallingRecordResumeParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.record.resume` (params). Extracted from switchblade `PublicCallRecordResumeParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingRecordStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.record.stop` (params). Extracted from switchblade `PublicCallRecordStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingReferParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.refer` (params). Extracted from switchblade `PublicCallReferParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + device: dict[str, Any] + node_id: str + + +class CallingSendDigitsParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.send_digits` (params). Extracted from switchblade `PublicCallSendDigitsParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + digits: str + node_id: str + + +class CallingSendFaxParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.send_fax` (params). Extracted from switchblade `PublicCallSendFaxParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + document: str + header_info: str + identity: str + node_id: str + + +class CallingSendFaxStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.send_fax.stop` (params). Extracted from switchblade `PublicCallSendFaxStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingStreamParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.stream` (params). Extracted from switchblade `PublicCallStreamParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + # non-identifier field 'async': bool | None + authorization_bearer_token: str + call_id: str + codec: str + control_id: str + custom_parameters: Any + name: str + node_id: str + status_url: str + status_url_method: str + swml: bool | None + track: str + url: str + + +class CallingStreamStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.stream.stop` (params). Extracted from switchblade `PublicCallStreamStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + # non-identifier field 'async': bool | None + call_id: str + control_id: str + node_id: str + swml: bool | None + + +class CallingTapParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.tap` (params). Extracted from switchblade `PublicCallTapParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + device: dict[str, Any] + node_id: str + tap: dict[str, Any] + + +class CallingTapStopParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.tap.stop` (params). Extracted from switchblade `PublicCallTapStopParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + control_id: str + node_id: str + + +class CallingTransferParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.transfer` (params). Extracted from switchblade `PublicCallTransferParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + dest: str + node_id: str + + +class CallingUserEventParams(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.user_event` (params). Extracted from switchblade `PublicCallUserEventParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + # non-identifier field 'async': bool | None + call_id: str + event: Any + node_id: str + swml: bool | None + + +class MessagingSendParams(TypedDict, total=False): + """Permissive schema for the messaging.send RPC params. Switchblade forwards the JObject as-is to the messaging gateway, so the schema is derived from the Python relay client (``signalwire/relay/client.py:send_message``). At least one of `body` or `media` is required. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + body: str + context: str + from_number: str + media: list[str] + region: str + tags: list[str] + to_number: str + + +class SignalwireConnectParams(TypedDict, total=False): + """Wire schema for the Blade envelope `signalwire.connect` (params). Extracted from switchblade `Messages/ConnectParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + agent: str + authentication: dict[str, Any] + host: str + identity: str + params: dict[str, Any] + protocols: list[dict[str, Any]] + version: dict[str, Any] + + +class SignalwireDisconnectParams(TypedDict, total=False): + """Wire schema for the Blade envelope `signalwire.disconnect` (params). Extracted from switchblade `Messages/DisconnectParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + restart: bool + + +class SignalwireExecuteParams(TypedDict, total=False): + """Wire schema for the Blade envelope `signalwire.execute` (params). Extracted from switchblade `Messages/ExecuteParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + attempted: list[str] + method: str + params: Any + protocol: str + requester_identity: str + requester_nodeid: str + responder_identity: str + responder_nodeid: str + + +class SignalwirePingParams(TypedDict, total=False): + """Wire schema for the Blade envelope `signalwire.ping` (params). Extracted from switchblade `Messages/PingParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + payload: str + timestamp: float | None + + +class SignalwireReauthenticateParams(TypedDict, total=False): + """Wire schema for the Blade envelope `signalwire.reauthenticate` (params). Extracted from switchblade `Messages/ReauthenticateParams.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + authentication: dict[str, Any] + dpop_token: str + + +class CallingAiHoldResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.ai_hold` (result). Extracted from switchblade `PublicCallAiHoldResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingAiMessageResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.ai_message` (result). Extracted from switchblade `PublicCallAiMessageResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingAiUnholdResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.ai_unhold` (result). Extracted from switchblade `PublicCallAiUnholdResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingAmazonBedrockResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.amazon_bedrock` (result). Extracted from switchblade `PublicCallAmazonBedrockResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingAnswerResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.answer` (result). Extracted from switchblade `PublicCallAnswerResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + code: str + data: Any + message: str + + +class CallingBeginResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.begin` (result). Extracted from switchblade `PublicCallBeginResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + message_data: Any + node_id: str + + +class CallingBindDigitResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.bind_digit` (result). Extracted from switchblade `PublicCallBindDigitResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +CallingCallResult: TypeAlias = "dict[str, Any]" + + +class CallingClearDigitBindingsResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.clear_digit_bindings` (result). Extracted from switchblade `PublicCallClearDigitBindingsResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingCollectResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.collect` (result). Extracted from switchblade `PublicCallCollectResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingCollectStartInputTimersResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.collect.start_input_timers` (result). Extracted from switchblade `PublicCallCollectStartInputTimersResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingCollectStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.collect.stop` (result). Extracted from switchblade `PublicCallCollectStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingConnectResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.connect` (result). Extracted from switchblade `PublicCallConnectResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + code: str + data: Any + message: str + message_data: Any + + +class CallingDenoiseResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.denoise` (result). Extracted from switchblade `PublicCallDenoiseResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingDenoiseStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.denoise.stop` (result). Extracted from switchblade `PublicCallDenoiseStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingDetectResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.detect` (result). Extracted from switchblade `PublicCallDetectResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingDetectStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.detect.stop` (result). Extracted from switchblade `PublicCallDetectStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingDialResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.dial` (result). Extracted from switchblade `PublicCallDialResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + code: str + data: Any + message: str + message_data: Any + + +class CallingDisconnectResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.disconnect` (result). Extracted from switchblade `PublicCallDisconnectResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + code: str + data: Any + message: str + + +class CallingEchoResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.echo` (result). Extracted from switchblade `PublicCallEchoResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingEndResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.end` (result). Extracted from switchblade `PublicCallEndResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + code: str + data: Any + message: str + + +class CallingJoinConferenceResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.join_conference` (result). Extracted from switchblade `PublicCallJoinConferenceResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + conference_id: str + data: Any + message: str + + +class CallingJoinRoomResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.join_room` (result). Extracted from switchblade `PublicCallJoinRoomResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingLeaveConferenceResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.leave_conference` (result). Extracted from switchblade `PublicCallLeaveConferenceResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingLeaveRoomResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.leave_room` (result). Extracted from switchblade `PublicCallLeaveRoomResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingLiveTranscribeResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.live_transcribe` (result). Extracted from switchblade `PublicCallLiveTranscribeResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingLiveTranslateResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.live_translate` (result). Extracted from switchblade `PublicCallLiveTranslateResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingPassResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.pass` (result). Extracted from switchblade `PublicCallPassResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingPayResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.pay` (result). Extracted from switchblade `PublicCallPayResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingPayStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.pay.stop` (result). Extracted from switchblade `PublicCallPayStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingPlayPauseResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play.pause` (result). Extracted from switchblade `PublicCallPlayPauseResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingPlayResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play` (result). Extracted from switchblade `PublicCallPlayResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingPlayResumeResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play.resume` (result). Extracted from switchblade `PublicCallPlayResumeResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingPlayStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play.stop` (result). Extracted from switchblade `PublicCallPlayStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingPlayVolumeResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play.volume` (result). Extracted from switchblade `PublicCallPlayVolumeResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingPlayAndCollectResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play_and_collect` (result). Extracted from switchblade `PublicCallPlayAndCollectResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingPlayAndCollectStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play_and_collect.stop` (result). Extracted from switchblade `PublicCallPlayAndCollectStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingPlayAndCollectVolumeResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.play_and_collect.volume` (result). Extracted from switchblade `PublicCallPlayAndCollectVolumeResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingQueueEnterResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.queue.enter` (result). Extracted from switchblade `PublicCallQueueEnterResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingQueueLeaveResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.queue.leave` (result). Extracted from switchblade `PublicCallQueueLeaveResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingReceiveResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.receive` (result). Extracted from switchblade `PublicCallReceiveResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + code: str + message: str + + +class CallingReceiveFaxResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.receive_fax` (result). Extracted from switchblade `PublicCallReceiveFaxResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingReceiveFaxStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.receive_fax.stop` (result). Extracted from switchblade `PublicCallReceiveFaxStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingRecordPauseResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.record.pause` (result). Extracted from switchblade `PublicCallRecordPauseResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingRecordResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.record` (result). Extracted from switchblade `PublicCallRecordResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + url: str + + +class CallingRecordResumeResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.record.resume` (result). Extracted from switchblade `PublicCallRecordResumeResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingRecordStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.record.stop` (result). Extracted from switchblade `PublicCallRecordStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingReferResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.refer` (result). Extracted from switchblade `PublicCallReferResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + code: str + data: Any + message: str + + +class CallingSendDigitsResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.send_digits` (result). Extracted from switchblade `PublicCallSendDigitsResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingSendFaxResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.send_fax` (result). Extracted from switchblade `PublicCallSendFaxResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingSendFaxStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.send_fax.stop` (result). Extracted from switchblade `PublicCallSendFaxStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingStreamResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.stream` (result). Extracted from switchblade `PublicCallStreamResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingStreamStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.stream.stop` (result). Extracted from switchblade `PublicCallStreamStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingTapResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.tap` (result). Extracted from switchblade `PublicCallTapResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + source_device: dict[str, Any] + + +class CallingTapStopResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.tap.stop` (result). Extracted from switchblade `PublicCallTapStopResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + control_id: str + data: Any + message: str + + +class CallingTransferResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.transfer` (result). Extracted from switchblade `PublicCallTransferResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class CallingUserEventResult(TypedDict, total=False): + """Wire schema for the JSON payload of `calling.user_event` (result). Extracted from switchblade `PublicCallUserEventResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + code: str + data: Any + message: str + + +class MessagingSendResult(TypedDict, total=False): + """Permissive schema for the messaging.send RPC response. The message_id from the response is used to route subsequent messaging.state events. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + code: str + message: str + message_id: str + + +class SignalwireConnectResult(TypedDict, total=False): + """Wire schema for the Blade envelope `signalwire.connect` (result). Extracted from switchblade `Messages/ConnectResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + accesses: list[dict[str, Any]] + authorization: dict[str, Any] + authorizations: list[dict[str, Any]] + host: str + ice_servers: list[Any] + identity: str + master_nodeid: str + nodeid: str + protocol: str + protocols: list[dict[str, Any]] + protocols_uncertified: list[str] + result: Any + session_restored: bool + sessionid: str + subscriptions: list[dict[str, Any]] + + +SignalwireDisconnectResult: TypeAlias = "dict[str, Any]" + + +class SignalwireExecuteResult(TypedDict, total=False): + """Wire schema for the Blade envelope `signalwire.execute` (result). Extracted from switchblade `Messages/ExecuteResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + requester_nodeid: str + responder_nodeid: str + result: Any + + +class SignalwirePingResult(TypedDict, total=False): + """Wire schema for the Blade envelope `signalwire.ping` (result). Extracted from switchblade `Messages/PingResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + payload: str + timestamp: float | None + + +class SignalwireReauthenticateResult(TypedDict, total=False): + """Wire schema for the Blade envelope `signalwire.reauthenticate` (result). Extracted from switchblade `Messages/ReauthenticateResult.cs`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + authentication: str + authorization: dict[str, Any] + ice_servers: list[Any] + result: Any diff --git a/signalwire/signalwire/rest/__init__.py b/signalwire/signalwire/rest/__init__.py index 31f0afe0..ef24b995 100644 --- a/signalwire/signalwire/rest/__init__.py +++ b/signalwire/signalwire/rest/__init__.py @@ -11,6 +11,6 @@ from .client import RestClient from ._base import SignalWireRestError -from .call_handler import PhoneCallHandler +from .namespaces.relay_rest_types_generated import PhoneCallHandler __all__ = ["PhoneCallHandler", "RestClient", "SignalWireRestError"] diff --git a/signalwire/signalwire/rest/_base.py b/signalwire/signalwire/rest/_base.py index d73b91a0..5e705079 100644 --- a/signalwire/signalwire/rest/_base.py +++ b/signalwire/signalwire/rest/_base.py @@ -9,16 +9,30 @@ HTTP client infrastructure and base resource classes for the REST client. """ +from typing import Any, Generic, TypeVar, cast + import requests from signalwire.core.logging_config import get_logger logger = get_logger("rest_client") +# CRUD response/request type parameters. Each concrete resource binds these to its +# spec-generated TypedDicts (e.g. CrudResource[ListRoomsResponse, RoomResponse, +# CreateRoomRequest, UpdateRoomRequest]); the signature oracle resolves the +# binding so every resource publishes its real per-operation shapes. Mirrors the +# TS port's CrudResource. +TList = TypeVar("TList") +TItem = TypeVar("TItem") +TCreate = TypeVar("TCreate") +TUpdate = TypeVar("TUpdate") + class SignalWireRestError(Exception): """Raised when the SignalWire REST API returns a non-2xx response.""" - def __init__(self, status_code, body, url, method="GET"): + def __init__( + self, status_code: int, body: Any, url: str, method: str = "GET" + ) -> None: self.status_code = status_code self.body = body self.url = url @@ -30,7 +44,7 @@ def __init__(self, status_code, body, url, method="GET"): class HttpClient: """Thin wrapper around requests.Session with Basic Auth and JSON handling.""" - def __init__(self, project, token, host): + def __init__(self, project: str, token: str, host: str) -> None: self._base_url = f"https://{host}" self._session = requests.Session() self._session.auth = (project, token) @@ -43,13 +57,19 @@ def __init__(self, project, token, host): ) logger.debug("HttpClient initialized", host=host, project=project[:8] + "...") - def _request(self, method, path, body=None, params=None): + def _request( + self, + method: str, + path: str, + body: Any = None, + params: dict[str, Any] | None = None, + ) -> Any: url = self._base_url + path logger.debug("REST request", method=method, path=path) resp = self._session.request(method, url, json=body, params=params) if not resp.ok: try: - err_body = resp.json() + err_body: Any = resp.json() except Exception: err_body = resp.text raise SignalWireRestError(resp.status_code, err_body, path, method) @@ -57,61 +77,109 @@ def _request(self, method, path, body=None, params=None): return {} return resp.json() - def get(self, path, params=None): + def get(self, path: str, params: dict[str, Any] | None = None) -> Any: return self._request("GET", path, params=params) - def post(self, path, body=None, params=None): + def post( + self, path: str, body: Any = None, params: dict[str, Any] | None = None + ) -> Any: return self._request("POST", path, body=body, params=params) - def put(self, path, body=None): + def put(self, path: str, body: Any = None) -> Any: return self._request("PUT", path, body=body) - def patch(self, path, body=None): + def patch(self, path: str, body: Any = None) -> Any: return self._request("PATCH", path, body=body) - def delete(self, path): + def delete(self, path: str) -> Any: return self._request("DELETE", path) class BaseResource: """Base for all namespace/resource classes.""" - def __init__(self, http, base_path): + def __init__(self, http: HttpClient, base_path: str) -> None: self._http = http self._base_path = base_path - def _path(self, *parts): + def _path(self, *parts: Any) -> str: return "/".join([self._base_path] + [str(p) for p in parts]) -class CrudResource(BaseResource): - """Standard CRUD resource with list/create/get/update/delete.""" +class ReadResource(BaseResource, Generic[TList, TItem]): + """Read-only resource with list + get (no create/update/delete). - _update_method = "PATCH" + The shared base for read-only surfaces (e.g. the per-product log resources). + ``CrudResource`` extends this with the write operations, so list/get are + defined once here. + """ + + def list(self, **params: Any) -> TList: + return cast(TList, self._http.get(self._base_path, params=params or None)) - def list(self, **params): - return self._http.get(self._base_path, params=params or None) + def get(self, resource_id: str) -> TItem: + return cast(TItem, self._http.get(self._path(resource_id))) - def create(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) - def get(self, resource_id): - return self._http.get(self._path(resource_id)) +class CrudResource(ReadResource[TList, TItem], Generic[TList, TItem, TCreate, TUpdate]): + """Standard CRUD resource with list/create/get/update/delete. - def update(self, resource_id, /, **kwargs): - # resource_id is positional-only so compat subclasses may name it - # `sid` (Twilio convention) without an LSP override conflict. + Extends ``ReadResource`` (list + get) with create/update/delete. Generic over + the spec-generated response/request types so each concrete resource publishes + its real per-operation shapes (the signature oracle resolves the subclass's + binding). At runtime every method still returns the raw server JSON dict — the + type params are static only. + """ + + _update_method = "PATCH" + + def create(self, **kwargs: Any) -> TItem: + # Honest fallback: the body accepts arbitrary wire fields and at runtime + # is a plain dict. Concrete resources override this with a generated + # CLOSED typed signature (explicit spec fields + an ``extras`` door); the + # class-level ``CrudResource[...]`` binding is what publishes the real + # TCreate shape to the signature oracle, NOT this base method body. (A + # bare ``**kwargs: TCreate`` here is wrong — it would type each kwarg + # VALUE as a whole TCreate — and is what the generated overrides replace.) + return cast(TItem, self._http.post(self._base_path, body=kwargs)) + + def update(self, resource_id: str, /, **kwargs: Any) -> TItem: + # resource_id is positional-only so a subclass may rename it without an LSP + # override conflict. Same contract as ``create``: honest ``**kwargs: Any`` + # fallback; the concrete generated override carries the closed typed shape, the + # binding carries TUpdate for the oracle. method = getattr(self._http, self._update_method.lower()) - return method(self._path(resource_id), body=kwargs) + return cast(TItem, method(self._path(resource_id), body=kwargs)) - def delete(self, resource_id): - return self._http.delete(self._path(resource_id)) + def delete(self, resource_id: str) -> TItem: + return cast(TItem, self._http.delete(self._path(resource_id))) -class CrudWithAddresses(CrudResource): +class CrudWithAddresses(CrudResource[TList, TItem, TCreate, TUpdate]): """CRUD resource that also supports listing addresses.""" - def list_addresses(self, resource_id, **params): + def list_addresses(self, resource_id: str, **params: Any) -> Any: return self._http.get( self._path(resource_id, "addresses"), params=params or None ) + + +class FabricResource(CrudWithAddresses[TList, TItem, TCreate, TUpdate]): + """Standard fabric resource with CRUD + addresses. + + Intermediate generic base — concrete leaf resources bind the four type + parameters. Mirrors the TS port's ``FabricResource``. Lives here (not in the fabric namespace) so the generated + ``fabric_resources_generated`` subclasses can inherit it without a cycle. + """ + + pass + + +class FabricResourcePUT(CrudWithAddresses[TList, TItem, TCreate, TUpdate]): + """Fabric resource that uses PUT for updates. + + Intermediate generic base (see :class:`FabricResource`). + """ + + _update_method = "PUT" diff --git a/signalwire/signalwire/rest/_pagination.py b/signalwire/signalwire/rest/_pagination.py index fcdb517f..88c7bfab 100644 --- a/signalwire/signalwire/rest/_pagination.py +++ b/signalwire/signalwire/rest/_pagination.py @@ -5,12 +5,14 @@ Licensed under the MIT License. See LICENSE file in the project root for full license information. -""" -""" Pagination support for list endpoints that return paged results. """ +from typing import Any + +from ._base import HttpClient + class PaginatedIterator: """Iterates items across paginated API responses. @@ -20,20 +22,26 @@ class PaginatedIterator: print(item) """ - def __init__(self, http, path, params=None, data_key="data"): + def __init__( + self, + http: HttpClient, + path: str, + params: dict[str, Any] | None = None, + data_key: str = "data", + ) -> None: self._http = http self._path = path - self._params = dict(params or {}) + self._params: dict[str, Any] = dict(params or {}) self._data_key = data_key - self._current_page = None - self._items = [] + self._current_page: Any = None + self._items: list[Any] = [] self._index = 0 self._done = False - def __iter__(self): + def __iter__(self) -> "PaginatedIterator": return self - def __next__(self): + def __next__(self) -> Any: while self._index >= len(self._items): if self._done: raise StopIteration @@ -43,7 +51,7 @@ def __next__(self): self._index += 1 return item - def _fetch_next(self): + def _fetch_next(self) -> None: resp = self._http.get(self._path, params=self._params or None) data = resp.get(self._data_key, []) self._items.extend(data) diff --git a/signalwire/signalwire/rest/call_handler.py b/signalwire/signalwire/rest/call_handler.py index 8af7fd02..c4682b62 100644 --- a/signalwire/signalwire/rest/call_handler.py +++ b/signalwire/signalwire/rest/call_handler.py @@ -1,60 +1,21 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. - -PhoneCallHandler — enum of ``call_handler`` values accepted by phone_numbers.update. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Named ``PhoneCallHandler`` (not ``CallHandler``) to avoid colliding with the -RELAY client's inbound-call-handler callback type. - -Setting a phone number's ``call_handler`` + the handler-specific companion -field routes inbound calls and auto-materializes the matching Fabric -resource on the server. See the high-level helpers on -:class:`signalwire.rest.namespaces.phone_numbers.PhoneNumbersResource`. +Deprecated import path. ``PhoneCallHandler`` moved into the spec-generated types module. +``from signalwire.signalwire.rest import PhoneCallHandler`` is the supported import; this +re-export keeps the old DEEP path ``...rest.call_handler import PhoneCallHandler`` working but +emits a DeprecationWarning. PYTHON-ONLY: the surface oracle skips this file. """ -from enum import Enum - - -class PhoneCallHandler(str, Enum): - """``call_handler`` values for ``phone_numbers.update``. - - Each value is a ``str`` subclass, so passing the enum member directly into - ``phone_numbers.update(..., call_handler=PhoneCallHandler.RELAY_SCRIPT)`` - serializes to the wire value without ``.value`` indirection. +import warnings - ================= ============================= ======================= - Enum member Companion field (required) Auto-creates resource - ================= ============================= ======================= - RELAY_SCRIPT call_relay_script_url swml_webhook - LAML_WEBHOOKS call_request_url cxml_webhook - LAML_APPLICATION call_laml_application_id cxml_application - AI_AGENT call_ai_agent_id ai_agent - CALL_FLOW call_flow_id call_flow - RELAY_APPLICATION call_relay_application relay_application - RELAY_TOPIC call_relay_topic (routes via RELAY) - RELAY_CONTEXT call_relay_context (legacy, prefer topic) - RELAY_CONNECTOR (connector config) (internal) - VIDEO_ROOM call_video_room_id (routes to Video API) - DIALOGFLOW call_dialogflow_agent_id (none) - ================= ============================= ======================= +warnings.warn( + "signalwire.signalwire.rest.call_handler is deprecated; use " + "`from signalwire.signalwire.rest import PhoneCallHandler`. This back-compat shim will " + "be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - Note: ``LAML_WEBHOOKS`` (wire value ``laml_webhooks``) produces a **cXML** - handler, not a generic webhook. For SWML, use ``RELAY_SCRIPT``. - """ +from .namespaces.relay_rest_types_generated import PhoneCallHandler # noqa: E402 (re-export after the deprecation warn — intentional) - RELAY_SCRIPT = "relay_script" - LAML_WEBHOOKS = "laml_webhooks" - LAML_APPLICATION = "laml_application" - AI_AGENT = "ai_agent" - CALL_FLOW = "call_flow" - RELAY_APPLICATION = "relay_application" - RELAY_TOPIC = "relay_topic" - RELAY_CONTEXT = "relay_context" - RELAY_CONNECTOR = "relay_connector" - VIDEO_ROOM = "video_room" - DIALOGFLOW = "dialogflow" +__all__ = ["PhoneCallHandler"] diff --git a/signalwire/signalwire/rest/client.py b/signalwire/signalwire/rest/client.py index 221b8b69..077e1758 100644 --- a/signalwire/signalwire/rest/client.py +++ b/signalwire/signalwire/rest/client.py @@ -10,31 +10,12 @@ """ import os + from ._base import HttpClient -from .namespaces.fabric import FabricNamespace -from .namespaces.calling import CallingNamespace -from .namespaces.phone_numbers import PhoneNumbersResource -from .namespaces.addresses import AddressesResource -from .namespaces.queues import QueuesResource -from .namespaces.recordings import RecordingsResource -from .namespaces.number_groups import NumberGroupsResource -from .namespaces.verified_callers import VerifiedCallersResource -from .namespaces.sip_profile import SipProfileResource -from .namespaces.lookup import LookupResource -from .namespaces.short_codes import ShortCodesResource -from .namespaces.imported_numbers import ImportedNumbersResource -from .namespaces.mfa import MfaResource -from .namespaces.registry import RegistryNamespace -from .namespaces.datasphere import DatasphereNamespace -from .namespaces.video import VideoNamespace -from .namespaces.logs import LogsNamespace -from .namespaces.project import ProjectNamespace -from .namespaces.pubsub import PubSubResource -from .namespaces.chat import ChatResource -from .namespaces.compat import CompatNamespace +from .namespaces._client_tree_generated import _GeneratedResourceTree -class RestClient: +class RestClient(_GeneratedResourceTree): """REST client for the SignalWire platform APIs. Usage: @@ -53,10 +34,17 @@ class RestClient: client.calling.play(call_id, play=[...]) client.phone_numbers.search(area_code="512") client.video.rooms.create(name="standup") - client.compat.calls.list() + + The resource object tree (flat resources + namespace containers) is generated from + the specs (``_GeneratedResourceTree._wire_resources``); this class owns only auth. """ - def __init__(self, project=None, token=None, host=None): + def __init__( + self, + project: str | None = None, + token: str | None = None, + host: str | None = None, + ) -> None: project = project or os.environ.get("SIGNALWIRE_PROJECT_ID", "") token = token or os.environ.get("SIGNALWIRE_API_TOKEN", "") host = host or os.environ.get("SIGNALWIRE_SPACE", "") @@ -71,41 +59,5 @@ def __init__(self, project=None, token=None, host=None): self._project = project self._http = HttpClient(project, token, host) - # Fabric API - self.fabric = FabricNamespace(self._http) - - # Calling API - self.calling = CallingNamespace(self._http) - - # Relay REST resources - self.phone_numbers = PhoneNumbersResource(self._http) - self.addresses = AddressesResource(self._http) - self.queues = QueuesResource(self._http) - self.recordings = RecordingsResource(self._http) - self.number_groups = NumberGroupsResource(self._http) - self.verified_callers = VerifiedCallersResource(self._http) - self.sip_profile = SipProfileResource(self._http) - self.lookup = LookupResource(self._http) - self.short_codes = ShortCodesResource(self._http) - self.imported_numbers = ImportedNumbersResource(self._http) - self.mfa = MfaResource(self._http) - self.registry = RegistryNamespace(self._http) - - # Datasphere API - self.datasphere = DatasphereNamespace(self._http) - - # Video API - self.video = VideoNamespace(self._http) - - # Logs - self.logs = LogsNamespace(self._http) - - # Project management - self.project = ProjectNamespace(self._http) - - # PubSub & Chat - self.pubsub = PubSubResource(self._http) - self.chat = ChatResource(self._http) - - # Compatibility (Twilio-compatible) API - self.compat = CompatNamespace(self._http, project) + # Generated resource tree (flat resources + namespace containers). + self._wire_resources(self._http) diff --git a/signalwire/signalwire/rest/namespaces/_client_tree_generated.py b/signalwire/signalwire/rest/namespaces/_client_tree_generated.py new file mode 100644 index 00000000..45c39986 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/_client_tree_generated.py @@ -0,0 +1,175 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/*/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# The SDK client object tree: one namespace container per x-sdk-namespace group +# plus the flat resources, wired from each resource's spec placement. +from __future__ import annotations + +from typing import Any + +from .calling_resources_generated import ( + Calling, +) +from .chat_resources_generated import ( + Chat, +) +from .datasphere_resources_generated import ( + DatasphereDocuments, +) +from .fabric_resources_generated import ( + AiAgents, + CallFlows, + ConferenceRooms, + CxmlApplications, + CxmlScripts, + CxmlWebhooks, + FabricAddresses, + FabricTokens, + FreeswitchConnectors, + GenericResources, + RelayApplications, + SipEndpoints, + SipGateways, + Subscribers, + SwmlScripts, + SwmlWebhooks, +) +from .fax_resources_generated import ( + FaxLogs, +) +from .logs_resources_generated import ( + ConferenceLogs, +) +from .message_resources_generated import ( + MessageLogs, +) +from .project_resources_generated import ( + ProjectTokens, +) +from .pubsub_resources_generated import ( + PubSub, +) +from .relay_rest_resources_generated import ( + Addresses, + ImportedNumbers, + Lookup, + Mfa, + NumberGroups, + PhoneNumbers, + Queues, + Recordings, + RegistryBrands, + RegistryCampaigns, + RegistryNumbers, + RegistryOrders, + ShortCodes, + SipProfile, + VerifiedCallers, +) +from .video_resources_generated import ( + VideoConferenceTokens, + VideoConferences, + VideoRoomRecordings, + VideoRoomSessions, + VideoRoomTokens, + VideoRooms, + VideoStreams, +) +from .voice_resources_generated import ( + VoiceLogs, +) + + +class DatasphereNamespace: + """Generated ``client.datasphere`` namespace.""" + + def __init__(self, http: Any) -> None: + self.documents = DatasphereDocuments(http) + + +class FabricNamespace: + """Generated ``client.fabric`` namespace.""" + + def __init__(self, http: Any) -> None: + self.addresses = FabricAddresses(http) + self.ai_agents = AiAgents(http) + self.call_flows = CallFlows(http) + self.conference_rooms = ConferenceRooms(http) + self.cxml_applications = CxmlApplications(http) + self.cxml_scripts = CxmlScripts(http) + self.cxml_webhooks = CxmlWebhooks(http) + self.freeswitch_connectors = FreeswitchConnectors(http) + self.relay_applications = RelayApplications(http) + self.resources = GenericResources(http) + self.sip_endpoints = SipEndpoints(http) + self.sip_gateways = SipGateways(http) + self.subscribers = Subscribers(http) + self.swml_scripts = SwmlScripts(http) + self.swml_webhooks = SwmlWebhooks(http) + self.tokens = FabricTokens(http) + + +class LogsNamespace: + """Generated ``client.logs`` namespace.""" + + def __init__(self, http: Any) -> None: + self.conferences = ConferenceLogs(http) + self.fax = FaxLogs(http) + self.messages = MessageLogs(http) + self.voice = VoiceLogs(http) + + +class ProjectNamespace: + """Generated ``client.project`` namespace.""" + + def __init__(self, http: Any) -> None: + self.tokens = ProjectTokens(http) + + +class RegistryNamespace: + """Generated ``client.registry`` namespace.""" + + def __init__(self, http: Any) -> None: + self.brands = RegistryBrands(http) + self.campaigns = RegistryCampaigns(http) + self.numbers = RegistryNumbers(http) + self.orders = RegistryOrders(http) + + +class VideoNamespace: + """Generated ``client.video`` namespace.""" + + def __init__(self, http: Any) -> None: + self.conference_tokens = VideoConferenceTokens(http) + self.conferences = VideoConferences(http) + self.room_recordings = VideoRoomRecordings(http) + self.room_sessions = VideoRoomSessions(http) + self.room_tokens = VideoRoomTokens(http) + self.rooms = VideoRooms(http) + self.streams = VideoStreams(http) + + +class _GeneratedResourceTree: + """Generated resource wiring for ``RestClient`` (flat resources + namespaces).""" + + def _wire_resources(self, http: Any) -> None: + self.addresses = Addresses(http) + self.calling = Calling(http) + self.chat = Chat(http) + self.imported_numbers = ImportedNumbers(http) + self.lookup = Lookup(http) + self.mfa = Mfa(http) + self.number_groups = NumberGroups(http) + self.phone_numbers = PhoneNumbers(http) + self.pubsub = PubSub(http) + self.queues = Queues(http) + self.recordings = Recordings(http) + self.short_codes = ShortCodes(http) + self.sip_profile = SipProfile(http) + self.verified_callers = VerifiedCallers(http) + self.datasphere = DatasphereNamespace(http) + self.fabric = FabricNamespace(http) + self.logs = LogsNamespace(http) + self.project = ProjectNamespace(http) + self.registry = RegistryNamespace(http) + self.video = VideoNamespace(http) diff --git a/signalwire/signalwire/rest/namespaces/addresses.py b/signalwire/signalwire/rest/namespaces/addresses.py index 9db569d8..c5040ac1 100644 --- a/signalwire/signalwire/rest/namespaces/addresses.py +++ b/signalwire/signalwire/rest/namespaces/addresses.py @@ -1,31 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Addresses namespace — list, create, get, delete (no update). +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.addresses`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.addresses import AddressesResource`` working +but emits a DeprecationWarning. Prefer ``client.addresses`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource - - -class AddressesResource(BaseResource): - """Address management (no update endpoint).""" - - def __init__(self, http): - super().__init__(http, "/api/relay/rest/addresses") +import warnings - def list(self, **params): - return self._http.get(self._base_path, params=params or None) +warnings.warn( + "signalwire.signalwire.rest.namespaces.addresses is deprecated; use client.addresses. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - def create(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) +from .relay_rest_resources_generated import Addresses # noqa: E402 (re-export after the deprecation warn — intentional) - def get(self, address_id): - return self._http.get(self._path(address_id)) +# Back-compat aliases (old name -> generated bare name): +AddressesResource = Addresses - def delete(self, address_id): - return self._http.delete(self._path(address_id)) +__all__ = ["AddressesResource"] diff --git a/signalwire/signalwire/rest/namespaces/calling.py b/signalwire/signalwire/rest/namespaces/calling.py index 22472b9f..edfb31f7 100644 --- a/signalwire/signalwire/rest/namespaces/calling.py +++ b/signalwire/signalwire/rest/namespaces/calling.py @@ -1,152 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Calling API namespace — REST-based call control via command dispatch. - -All commands are sent as POST /api/calling/calls with a command field. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.calling`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.calling import CallingNamespace`` working +but emits a DeprecationWarning. Prefer ``client.calling`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource - - -class CallingNamespace(BaseResource): - """REST call control — all 37 commands dispatched via single POST endpoint.""" - - def __init__(self, http): - super().__init__(http, "/api/calling/calls") - - def _execute(self, command, call_id=None, **params): - body = {"command": command, "params": params} - if call_id is not None: - body["id"] = call_id - return self._http.post(self._base_path, body=body) - - # Call lifecycle - def dial(self, **params): - return self._execute("dial", **params) - - def update(self, **params): - return self._execute("update", **params) - - def end(self, call_id, **params): - return self._execute("calling.end", call_id, **params) - - def transfer(self, call_id, **params): - return self._execute("calling.transfer", call_id, **params) - - def disconnect(self, call_id, **params): - return self._execute("calling.disconnect", call_id, **params) - - # Play - def play(self, call_id, **params): - return self._execute("calling.play", call_id, **params) - - def play_pause(self, call_id, **params): - return self._execute("calling.play.pause", call_id, **params) - - def play_resume(self, call_id, **params): - return self._execute("calling.play.resume", call_id, **params) - - def play_stop(self, call_id, **params): - return self._execute("calling.play.stop", call_id, **params) - - def play_volume(self, call_id, **params): - return self._execute("calling.play.volume", call_id, **params) - - # Record - def record(self, call_id, **params): - return self._execute("calling.record", call_id, **params) - - def record_pause(self, call_id, **params): - return self._execute("calling.record.pause", call_id, **params) - - def record_resume(self, call_id, **params): - return self._execute("calling.record.resume", call_id, **params) - - def record_stop(self, call_id, **params): - return self._execute("calling.record.stop", call_id, **params) - - # Collect - def collect(self, call_id, **params): - return self._execute("calling.collect", call_id, **params) - - def collect_stop(self, call_id, **params): - return self._execute("calling.collect.stop", call_id, **params) - - def collect_start_input_timers(self, call_id, **params): - return self._execute("calling.collect.start_input_timers", call_id, **params) - - # Detect - def detect(self, call_id, **params): - return self._execute("calling.detect", call_id, **params) - - def detect_stop(self, call_id, **params): - return self._execute("calling.detect.stop", call_id, **params) - - # Tap - def tap(self, call_id, **params): - return self._execute("calling.tap", call_id, **params) - - def tap_stop(self, call_id, **params): - return self._execute("calling.tap.stop", call_id, **params) - - # Stream - def stream(self, call_id, **params): - return self._execute("calling.stream", call_id, **params) - - def stream_stop(self, call_id, **params): - return self._execute("calling.stream.stop", call_id, **params) - - # Denoise - def denoise(self, call_id, **params): - return self._execute("calling.denoise", call_id, **params) - - def denoise_stop(self, call_id, **params): - return self._execute("calling.denoise.stop", call_id, **params) - - # Transcribe - def transcribe(self, call_id, **params): - return self._execute("calling.transcribe", call_id, **params) - - def transcribe_stop(self, call_id, **params): - return self._execute("calling.transcribe.stop", call_id, **params) - - # AI - def ai_message(self, call_id, **params): - return self._execute("calling.ai_message", call_id, **params) - - def ai_hold(self, call_id, **params): - return self._execute("calling.ai_hold", call_id, **params) - - def ai_unhold(self, call_id, **params): - return self._execute("calling.ai_unhold", call_id, **params) - - def ai_stop(self, call_id, **params): - return self._execute("calling.ai.stop", call_id, **params) - - # Live transcribe / translate - def live_transcribe(self, call_id, **params): - return self._execute("calling.live_transcribe", call_id, **params) - - def live_translate(self, call_id, **params): - return self._execute("calling.live_translate", call_id, **params) +import warnings - # Fax - def send_fax_stop(self, call_id, **params): - return self._execute("calling.send_fax.stop", call_id, **params) +warnings.warn( + "signalwire.signalwire.rest.namespaces.calling is deprecated; use client.calling. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - def receive_fax_stop(self, call_id, **params): - return self._execute("calling.receive_fax.stop", call_id, **params) +from .calling_resources_generated import Calling # noqa: E402 (re-export after the deprecation warn — intentional) - # SIP - def refer(self, call_id, **params): - return self._execute("calling.refer", call_id, **params) +# Back-compat aliases (old name -> generated bare name): +CallingNamespace = Calling - # Custom events - def user_event(self, call_id, **params): - return self._execute("calling.user_event", call_id, **params) +__all__ = ["CallingNamespace"] diff --git a/signalwire/signalwire/rest/namespaces/calling_resources_generated.py b/signalwire/signalwire/rest/namespaces/calling_resources_generated.py new file mode 100644 index 00000000..21097767 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/calling_resources_generated.py @@ -0,0 +1,775 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/calling/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, cast +from collections.abc import Mapping + +from .._base import BaseResource + +if TYPE_CHECKING: + from .calling_types_generated import ( + CallAIMessageResetParams, + CallResponse, + HangupReason, + LiveTranscribeStartAction, + LiveTranscribeStopAction, + LiveTranscribeSummarizeAction, + LiveTranslateInjectAction, + LiveTranslateStartAction, + LiveTranslateStopAction, + LiveTranslateSummarizeAction, + SWMLObject, + uuid, + ) + + +class Calling(BaseResource): + """Typed resource for ``/calls`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/calling/calls") + + def dial( + self, + *, + from_: str, + to: str, + caller_id: str | None = None, + fallback_url: str | None = None, + status_url: str | None = None, + status_events: list[ + Literal["answered", "queued", "initiated", "ringing", "ending", "ended"] + ] + | None = None, + url_method: str | None = None, + url: str | None = None, + codecs: list[str] | str | None = None, + swml: SWMLObject | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in { + "from": from_, + "to": to, + "caller_id": caller_id, + "fallback_url": fallback_url, + "status_url": status_url, + "status_events": status_events, + "url_method": url_method, + "url": url, + "codecs": codecs, + "swml": swml, + }.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = {"command": "dial", "params": params} + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + *, + id: uuid, + fallback_url: str | None = None, + status: Literal["canceled", "completed"] | None = None, + status_url: str | None = None, + url: str | None = None, + swml: SWMLObject | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in { + "id": id, + "fallback_url": fallback_url, + "status": status, + "status_url": status_url, + "url": url, + "swml": swml, + }.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = {"command": "update", "params": params} + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def end( + self, + call_id: str, + *, + reason: HangupReason | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"reason": reason}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.end", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def ai_hold( + self, + call_id: str, + *, + timeout: int | None = None, + prompt: str | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in {"timeout": timeout, "prompt": prompt}.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.ai_hold", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def ai_unhold( + self, + call_id: str, + *, + prompt: str | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"prompt": prompt}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.ai_unhold", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def ai_message( + self, + call_id: str, + *, + role: Literal["system", "user", "assistant"] | None = None, + message_text: str | None = None, + reset: CallAIMessageResetParams | None = None, + global_data: dict[str, Any] | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in { + "role": role, + "message_text": message_text, + "reset": reset, + "global_data": global_data, + }.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.ai_message", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def live_transcribe( + self, + call_id: str, + *, + action: LiveTranscribeStartAction + | LiveTranscribeSummarizeAction + | LiveTranscribeStopAction, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"action": action}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.live_transcribe", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def live_translate( + self, + call_id: str, + *, + action: LiveTranslateStartAction + | LiveTranslateSummarizeAction + | LiveTranslateInjectAction + | LiveTranslateStopAction, + status_url: str | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in {"action": action, "status_url": status_url}.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.live_translate", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def transfer( + self, + call_id: str, + *, + dest: str | SWMLObject, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"dest": dest}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.transfer", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def user_event( + self, + call_id: str, + *, + event: dict[str, Any], + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"event": event}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.user_event", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def disconnect( + self, call_id: str, *, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = {} + body: dict[str, Any] = { + "command": "calling.disconnect", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def play( + self, + call_id: str, + *, + play: list[dict[str, Any]], + control_id: str | None = None, + volume: float | None = None, + direction: Literal["listen", "speak", "both"] | None = None, + loop: int | None = None, + status_url: str | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in { + "control_id": control_id, + "play": play, + "volume": volume, + "direction": direction, + "loop": loop, + "status_url": status_url, + }.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.play", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def play_pause( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.play.pause", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def play_resume( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.play.resume", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def play_stop( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.play.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def play_volume( + self, + call_id: str, + *, + control_id: str, + volume: float, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in {"control_id": control_id, "volume": volume}.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.play.volume", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def record( + self, + call_id: str, + *, + control_id: str | None = None, + audio: dict[str, Any] | None = None, + status_url: str | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in { + "control_id": control_id, + "audio": audio, + "status_url": status_url, + }.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.record", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def record_pause( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.record.pause", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def record_resume( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.record.resume", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def record_stop( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.record.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def collect( + self, + call_id: str, + *, + control_id: str | None = None, + initial_timeout: float | None = None, + digits: dict[str, Any] | None = None, + speech: dict[str, Any] | None = None, + continuous: bool | None = None, + partial_results: bool | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in { + "control_id": control_id, + "initial_timeout": initial_timeout, + "digits": digits, + "speech": speech, + "continuous": continuous, + "partial_results": partial_results, + }.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.collect", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def collect_stop( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.collect.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def collect_start_input_timers( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.collect.start_input_timers", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def detect( + self, + call_id: str, + *, + detect: dict[str, Any], + control_id: str | None = None, + timeout: float | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in { + "control_id": control_id, + "detect": detect, + "timeout": timeout, + }.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.detect", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def detect_stop( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.detect.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def tap( + self, + call_id: str, + *, + tap: dict[str, Any], + device: dict[str, Any], + control_id: str | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in {"control_id": control_id, "tap": tap, "device": device}.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.tap", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def tap_stop( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.tap.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def stream( + self, + call_id: str, + *, + url: str, + control_id: str | None = None, + codec: str | None = None, + track: Literal["inbound_track", "outbound_track", "both_tracks"] | None = None, + authorization_bearer_token: str | None = None, + custom_parameters: dict[str, Any] | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in { + "control_id": control_id, + "url": url, + "codec": codec, + "track": track, + "authorization_bearer_token": authorization_bearer_token, + "custom_parameters": custom_parameters, + }.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.stream", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def stream_stop( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.stream.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def denoise( + self, call_id: str, *, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = {} + body: dict[str, Any] = { + "command": "calling.denoise", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def denoise_stop( + self, call_id: str, *, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = {} + body: dict[str, Any] = { + "command": "calling.denoise.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def transcribe( + self, + call_id: str, + *, + control_id: str | None = None, + status_url: str | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in {"control_id": control_id, "status_url": status_url}.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.transcribe", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def transcribe_stop( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.transcribe.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def ai_stop( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.ai.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def send_fax_stop( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.send_fax.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def receive_fax_stop( + self, call_id: str, *, control_id: str, extras: Mapping[str, Any] | None = None + ) -> CallResponse: + params: dict[str, Any] = { + k: v for k, v in {"control_id": control_id}.items() if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.receive_fax.stop", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) + + def refer( + self, + call_id: str, + *, + device: dict[str, Any], + status_url: str | None = None, + extras: Mapping[str, Any] | None = None, + ) -> CallResponse: + params: dict[str, Any] = { + k: v + for k, v in {"device": device, "status_url": status_url}.items() + if v is not None + } + if extras: + params.update(extras) + body: dict[str, Any] = { + "command": "calling.refer", + "params": params, + "id": call_id, + } + return cast("CallResponse", self._http.post(self._base_path, body=body)) diff --git a/signalwire/signalwire/rest/namespaces/calling_types_generated.py b/signalwire/signalwire/rest/namespaces/calling_types_generated.py new file mode 100644 index 00000000..0fdb14cb --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/calling_types_generated.py @@ -0,0 +1,1910 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/calling/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Any, Literal, TypeAlias, TypedDict + + +class AI(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + ai: AIObject + + +class AIObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + global_data: dict[str, Any] + hints: list[str | Hint] + languages: list[Languages] + params: AIParams + post_prompt: AIPostPrompt + post_prompt_url: str + pronounce: list[Pronounce] + prompt: AIPrompt + SWAIG: SWAIG + + +class AIParams(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + acknowledge_interruptions: bool | SWMLVar + ai_model: Literal["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1-nano"] | str + ai_name: str + ai_volume: int | SWMLVar + app_name: str + asr_smart_format: bool | SWMLVar + attention_timeout: AttentionTimeout | Literal[0] | SWMLVar + attention_timeout_prompt: str + asr_diarize: bool | SWMLVar + asr_speaker_affinity: bool | SWMLVar + audible_debug: bool | SWMLVar + audible_latency: bool | SWMLVar + background_file: str + background_file_loops: int | None | SWMLVar + background_file_volume: int | SWMLVar + enable_barge: str | bool | SWMLVar + enable_inner_dialog: bool | SWMLVar + enable_pause: bool | SWMLVar + enable_turn_detection: bool | SWMLVar + barge_match_string: str + barge_min_words: int | SWMLVar + barge_functions: bool | SWMLVar + cache_mode: bool | SWMLVar + conscience: str + convo: list[ConversationMessage] + conversation_id: str + conversation_sliding_window: int | SWMLVar + debug_webhook_level: int | SWMLVar + debug_webhook_url: str + debug: bool | int | SWMLVar + direction: Direction | SWMLVar + digit_terminators: str + digit_timeout: int | SWMLVar + end_of_speech_timeout: int | SWMLVar + enable_accounting: bool | SWMLVar + enable_thinking: bool | SWMLVar + enable_vision: bool | SWMLVar + energy_level: float | SWMLVar + first_word_timeout: int | SWMLVar + function_wait_for_talking: bool | SWMLVar + functions_on_no_response: bool | SWMLVar + hard_stop_prompt: str + hard_stop_time: str | SWMLVar + hold_music: str + hold_on_process: bool | SWMLVar + inactivity_timeout: int | SWMLVar + inner_dialog_model: Literal["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1-nano"] | str + inner_dialog_prompt: str + inner_dialog_synced: bool | SWMLVar + initial_sleep_ms: int | SWMLVar + input_poll_freq: int | SWMLVar + interrupt_on_noise: bool | SWMLVar + interrupt_prompt: str + languages_enabled: bool | SWMLVar + local_tz: str + llm_diarize_aware: bool | SWMLVar + max_emotion: int | SWMLVar + max_response_tokens: int | SWMLVar + openai_asr_engine: str + outbound_attention_timeout: int | SWMLVar + persist_global_data: bool | SWMLVar + pom_format: Literal["markdown", "xml"] + save_conversation: bool | SWMLVar + speech_event_timeout: int | SWMLVar + speech_gen_quick_stops: int | SWMLVar + speech_timeout: int | SWMLVar + speak_when_spoken_to: bool | SWMLVar + start_paused: bool | SWMLVar + static_greeting: str + static_greeting_no_barge: bool | SWMLVar + summary_mode: Literal["string", "original"] | SWMLVar + swaig_allow_settings: bool | SWMLVar + swaig_allow_swml: bool | SWMLVar + swaig_post_conversation: bool | SWMLVar + swaig_set_global_data: bool | SWMLVar + swaig_post_swml_vars: bool | list[str] | SWMLVar + thinking_model: Literal["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1-nano"] | str + transparent_barge: bool | SWMLVar + transparent_barge_max_time: int | SWMLVar + transfer_summary: bool | SWMLVar + turn_detection_timeout: int | SWMLVar + tts_number_format: Literal["international", "national"] + verbose_logs: bool | SWMLVar + video_listening_file: str + video_idle_file: str + video_talking_file: str + vision_model: Literal["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1-nano"] | str + vad_config: str + wait_for_user: bool | SWMLVar + wake_prefix: str + eleven_labs_stability: float | SWMLVar + eleven_labs_similarity: float | SWMLVar + + +AIPostPrompt: TypeAlias = "AIPostPromptText | AIPostPromptPom" + + +class AIPostPromptPom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + pom: list[POM] + + +class AIPostPromptText(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + text: str + + +AIPrompt: TypeAlias = "AIPromptText | AIPromptPom" + + +class AIPromptPom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + pom: list[POM] + contexts: Contexts + + +class AIPromptText(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + text: str + contexts: Contexts + + +Action: TypeAlias = "SWMLAction | ChangeContextAction | ChangeStepAction | ContextSwitchAction | HangupAction | HoldAction | PlaybackBGAction | SayAction | SetGlobalDataAction | SetMetaDataAction | StopAction | StopPlaybackBGAction | ToggleFunctionsAction | UnsetGlobalDataAction | UnsetMetaDataAction | UserInputAction" + + +class AllOfProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + allOf: list[SchemaType] + + +class AmazonBedrock(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + amazon_bedrock: AmazonBedrockObject + + +class AmazonBedrockObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + global_data: dict[str, Any] + params: BedrockParams + post_prompt: BedrockPostPrompt + post_prompt_url: str + prompt: BedrockPrompt + SWAIG: BedrockSWAIG + + +class Answer(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + answer: dict[str, Any] + + +class AnyOfProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + anyOf: list[SchemaType] + + +class ArrayProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["array"] + default: list[Any] + items: SchemaType + + +AttentionTimeout: TypeAlias = "int" + + +class BedrockParams(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + attention_timeout: AttentionTimeout | Literal[0] | SWMLVar + hard_stop_time: str | SWMLVar + inactivity_timeout: int | SWMLVar + video_listening_file: str + video_idle_file: str + video_talking_file: str + hard_stop_prompt: str + + +BedrockPostPrompt: TypeAlias = "dict[str, Any]" + +BedrockPrompt: TypeAlias = "dict[str, Any]" + + +class BedrockSWAIG(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + functions: list[BedrockSWAIGFunction] + defaults: SWAIGDefaults + native_functions: list[SWAIGNativeFunction] + includes: list[SWAIGIncludes] + + +BedrockSWAIGFunction: TypeAlias = "dict[str, Any]" + + +class BooleanProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["boolean"] + default: bool | SWMLVar + + +class CallAIMessageRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.ai_message"] + params: dict[str, Any] + + +class CallAIMessageResetParams(TypedDict, total=False): + """Parameters for resetting the AI conversation state. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + full_reset: bool + user_prompt: str + system_prompt: str + + +class CallCreate422Error(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class CallCreateParamsSWML(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + to: str + caller_id: str + fallback_url: str + status_url: str + status_events: list[ + Literal["answered", "queued", "initiated", "ringing", "ending", "ended"] + ] + url_method: str + codecs: list[str] | str + swml: SWMLObject + + +class CallCreateParamsURL(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + to: str + caller_id: str + fallback_url: str + status_url: str + status_events: list[ + Literal["answered", "queued", "initiated", "ringing", "ending", "ended"] + ] + url_method: str + url: str + + +class CallCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + command: Literal["dial"] + params: CallCreateParamsURL | CallCreateParamsSWML + + +CallDirection: TypeAlias = "Literal['inbound', 'outbound', 'outbound-api']" + + +class CallHangupRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.end"] + params: dict[str, Any] + + +class CallHoldRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.ai_hold"] + params: dict[str, Any] + + +class CallLeg(TypedDict, total=False): + """A Call leg (PSTN, SIP, or WebRTC). + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + # non-identifier field 'from': str + to: str + direction: CallDirection + source: Literal["realtime_api"] + url: str | None + charge: float + created_at: str + charge_details: list[ChargeDetails] + status: CallResponseStatus | None + duration: int | None + duration_ms: int | None + billing_ms: int | None + type: ( + Literal["relay_pstn_call"] + | Literal["relay_sip_call"] + | Literal["relay_webrtc_call"] + ) + parent_id: uuid | None + + +class CallLiveTranscribeRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.live_transcribe"] + params: dict[str, Any] + + +class CallLiveTranslateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.live_translate"] + params: dict[str, Any] + + +CallRequest: TypeAlias = "CallCreateRequest | CallUpdateCurrentCallRequest | CallHangupRequest | CallHoldRequest | CallUnholdRequest | CallAIMessageRequest | CallLiveTranscribeRequest | CallLiveTranslateRequest | CallTransferRequest | CallUserEventRequest | CallDisconnectRequest | CallPlayRequest | CallPlayPauseRequest | CallPlayResumeRequest | CallPlayStopRequest | CallPlayVolumeRequest | CallRecordRequest | CallRecordPauseRequest | CallRecordResumeRequest | CallRecordStopRequest | CallCollectRequest | CallCollectStopRequest | CallCollectStartInputTimersRequest | CallDetectRequest | CallDetectStopRequest | CallTapRequest | CallTapStopRequest | CallStreamRequest | CallStreamStopRequest | CallDenoiseRequest | CallDenoiseStopRequest | CallTranscribeRequest | CallTranscribeStopRequest | CallAIStopRequest | CallSendFaxStopRequest | CallReceiveFaxStopRequest | CallReferRequest" + + +class CallDisconnectRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.disconnect"] + params: dict[str, Any] + + +class CallPlayRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.play"] + params: dict[str, Any] + + +class CallPlayPauseRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.play.pause"] + params: dict[str, Any] + + +class CallPlayResumeRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.play.resume"] + params: dict[str, Any] + + +class CallPlayStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.play.stop"] + params: dict[str, Any] + + +class CallPlayVolumeRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.play.volume"] + params: dict[str, Any] + + +class CallRecordRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.record"] + params: dict[str, Any] + + +class CallRecordPauseRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.record.pause"] + params: dict[str, Any] + + +class CallRecordResumeRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.record.resume"] + params: dict[str, Any] + + +class CallRecordStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.record.stop"] + params: dict[str, Any] + + +class CallCollectRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.collect"] + params: dict[str, Any] + + +class CallCollectStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.collect.stop"] + params: dict[str, Any] + + +class CallCollectStartInputTimersRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.collect.start_input_timers"] + params: dict[str, Any] + + +class CallDetectRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.detect"] + params: dict[str, Any] + + +class CallDetectStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.detect.stop"] + params: dict[str, Any] + + +class CallTapRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.tap"] + params: dict[str, Any] + + +class CallTapStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.tap.stop"] + params: dict[str, Any] + + +class CallStreamRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.stream"] + params: dict[str, Any] + + +class CallStreamStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.stream.stop"] + params: dict[str, Any] + + +class CallDenoiseRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.denoise"] + params: dict[str, Any] + + +class CallDenoiseStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.denoise.stop"] + params: dict[str, Any] + + +class CallTranscribeRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.transcribe"] + params: dict[str, Any] + + +class CallTranscribeStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.transcribe.stop"] + params: dict[str, Any] + + +class CallAIStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.ai.stop"] + params: dict[str, Any] + + +class CallSendFaxStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.send_fax.stop"] + params: dict[str, Any] + + +class CallReceiveFaxStopRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.receive_fax.stop"] + params: dict[str, Any] + + +class CallReferRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.refer"] + params: dict[str, Any] + + +CallResponse: TypeAlias = "CallLeg | FabricDeviceLeg" + +CallResponseStatus: TypeAlias = "Literal['queued', 'initiated', 'created', 'ringing', 'answered', 'ending', 'ended', 'failed', 'canceled', 'completed']" + +CallStatus: TypeAlias = "Literal['created', 'ringing', 'answered', 'ended']" + + +class CallTransferRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.transfer"] + params: dict[str, Any] + + +class CallUnholdRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.ai_unhold"] + params: dict[str, Any] + + +class CallUpdateCurrentCallRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + command: Literal["update"] + params: CallUpdateParamsURL | CallUpdateParamsSWML + + +class CallUpdateParamsSWML(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + fallback_url: str + status: Literal["canceled", "completed"] + status_url: str + swml: SWMLObject + + +class CallUpdateParamsURL(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + fallback_url: str + status: Literal["canceled", "completed"] + status_url: str + url: str + + +class CallUserEventRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + command: Literal["calling.user_event"] + params: dict[str, Any] + + +class ChangeContextAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + change_context: str + + +class ChangeStepAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + change_step: str + + +class ChargeDetails(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + charge: float + + +class Cond(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + cond: list[CondParams] + + +class CondElse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'else': list[SWMLMethod] + + +CondParams: TypeAlias = "CondReg | CondElse" + + +class CondReg(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + when: str + then: list[SWMLMethod] + # non-identifier field 'else': list[SWMLMethod] + + +class Connect(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + connect: ( + ConnectDeviceSingle + | ConnectDeviceSerial + | ConnectDeviceParallel + | ConnectDeviceSerialParallel + ) + + +class ConnectDeviceParallel(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory", "optional", "forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + status_url: str + parallel: list[ConnectDeviceSingle] + + +class ConnectDeviceSerial(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory", "optional", "forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + status_url: str + serial: list[ConnectDeviceSingle] + + +class ConnectDeviceSerialParallel(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory", "optional", "forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + status_url: str + serial_parallel: list[list[ConnectDeviceSingle]] + + +class ConnectDeviceSingle(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory", "optional", "forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + status_url: str + to: str + name: str + codec: str + realtime: bool | SWMLVar + status_url_method: Literal["GET", "POST"] + authorization_bearer_token: str + custom_parameters: dict[str, Any] + + +class ConnectHeaders(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + value: str + + +class ConnectSwitch(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + variable: str + case: dict[str, Any] + default: list[SWMLMethod] + + +class ConstProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + const: dict[str, Any] + + +class ContextPOMSteps(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + step_criteria: str + functions: list[str] + valid_contexts: list[str] + skip_user_turn: bool | SWMLVar + end: bool + valid_steps: list[str] + pom: list[POM] + + +ContextSteps: TypeAlias = "ContextPOMSteps | ContextTextSteps" + + +class ContextSwitchAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + context_switch: dict[str, Any] + + +class ContextTextSteps(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + step_criteria: str + functions: list[str] + valid_contexts: list[str] + skip_user_turn: bool | SWMLVar + end: bool + valid_steps: list[str] + text: str + + +class Contexts(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + default: ContextsObject + + +ContextsObject: TypeAlias = "ContextsPOMObject | ContextsTextObject" + + +class ContextsPOMObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + steps: list[ContextSteps] + isolated: bool + enter_fillers: list[FunctionFillers] + exit_fillers: list[FunctionFillers] + pom: list[POM] + + +class ContextsTextObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + steps: list[ContextSteps] + isolated: bool + enter_fillers: list[FunctionFillers] + exit_fillers: list[FunctionFillers] + text: str + + +class ConversationMessage(TypedDict, total=False): + """A message object representing a single turn in the conversation history. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + role: ConversationRole + content: str + lang: str + + +ConversationRole: TypeAlias = "Literal['user', 'assistant', 'system']" + +CustomTranslationFilter: TypeAlias = "str" + + +class DataMap(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + output: Output + expressions: list[Expression] + webhooks: list[Webhook] + + +class Denoise(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + denoise: dict[str, Any] + + +class DetectMachine(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + detect_machine: dict[str, Any] + + +Direction: TypeAlias = "Literal['inbound', 'outbound']" + + +class EnterQueue(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + enter_queue: EnterQueueObject + + +class EnterQueueObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + queue_name: str + transfer_after_bridge: str | SWMLVar + status_url: str + wait_url: str | SWMLVar + wait_time: int | SWMLVar + + +class Execute(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + execute: dict[str, Any] + + +class ExecuteSwitch(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + variable: str + case: dict[str, Any] + default: list[SWMLMethod] + + +class Expression(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + string: str + pattern: str + output: Output + + +class FabricDeviceLeg(TypedDict, total=False): + """A Fabric subscriber device leg. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + # non-identifier field 'from': str + to: str + direction: CallDirection + source: Literal["realtime_api"] + url: str | None + charge: float + created_at: str + charge_details: list[ChargeDetails] + status: None + type: Literal["fabric_subscriber_device_leg"] + + +FunctionFillers: TypeAlias = "dict[str, Any]" + + +class FunctionParameters(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["object"] + properties: dict[str, Any] + required: list[str] + + +class Goto(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + goto: dict[str, Any] + + +class HangUpHookSWAIGFunction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: Literal["hangup_hook"] + + +class Hangup(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hangup: dict[str, Any] + + +class HangupAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hangup: bool | SWMLVar + + +HangupReason: TypeAlias = ( + "Literal['hangup', 'cancel', 'busy', 'noAnswer', 'decline', 'error']" +) + + +class Hint(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hint: str + pattern: str + replace: str + ignore_case: bool | SWMLVar + + +class HoldAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hold: int | SWMLVar | dict[str, Any] + + +class InjectAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + inject: dict[str, Any] + + +class IntegerProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["integer"] + enum: list[int] + default: int | SWMLVar + + +class JoinConference(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + join_conference: JoinConferenceObject + + +class JoinConferenceObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + muted: bool | SWMLVar + beep: Literal["true", "false", "onEnter", "onExit"] + start_on_enter: bool | SWMLVar + end_on_exit: bool | SWMLVar + wait_url: str | SWMLVar + max_participants: int | SWMLVar + record: Literal["do-not-record", "record-from-start"] + region: str + trim: Literal["trim-silence", "do-not-trim"] + coach: str + status_callback_event: Literal[ + "start", + "end", + "join", + "leave", + "mute", + "hold", + "modify", + "speaker", + "announcement", + ] + status_callback: str + status_callback_method: Literal["GET", "POST"] + recording_status_callback: str + recording_status_callback_method: Literal["GET", "POST"] + recording_status_callback_event: Literal["in-progress", "completed", "absent"] + result: dict[str, Any] | list[CondParams] + + +class JoinRoom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + join_room: dict[str, Any] + + +class Label(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + label: str + + +class LanguageParams(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stability: float | SWMLVar + similarity: float | SWMLVar + + +Languages: TypeAlias = "LanguagesWithSoloFillers | LanguagesWithFillers" + + +class LanguagesWithFillers(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + code: str + voice: str + model: str + emotion: Literal["auto"] + speed: Literal["auto"] + engine: str + params: LanguageParams + function_fillers: list[str] + speech_fillers: list[str] + + +class LanguagesWithSoloFillers(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + code: str + voice: str + model: str + emotion: Literal["auto"] + speed: Literal["auto"] + engine: str + params: LanguageParams + fillers: list[str] + + +class LiveTranscribe(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + live_transcribe: dict[str, Any] + + +class LiveTranscribeStartAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + start: dict[str, Any] + + +LiveTranscribeStopAction: TypeAlias = "Literal['stop']" + + +class LiveTranscribeSummarizeAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + summarize: dict[str, Any] + + +class LiveTranslate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + live_translate: dict[str, Any] + + +class LiveTranslateInjectAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + inject: dict[str, Any] + + +class LiveTranslateStartAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + start: dict[str, Any] + + +LiveTranslateStopAction: TypeAlias = "Literal['stop']" + + +class LiveTranslateSummarizeAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + summarize: dict[str, Any] + + +class NullProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["null"] + description: str + + +class NumberProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["number"] + enum: list[int | float] | list[SWMLVar] + default: int | float | SWMLVar + + +class ObjectProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["object"] + default: dict[str, Any] + properties: dict[str, Any] + required: list[str] + + +class OneOfProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + oneOf: list[SchemaType] + + +class Output(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + response: str + action: list[Action] + + +POM: TypeAlias = "PomSectionBodyContent | PomSectionBulletsContent" + + +class Pay(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + pay: dict[str, Any] + + +class PayParameters(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + value: str + + +PayPromptAction: TypeAlias = "PayPromptSayAction | PayPromptPlayAction" + + +class PayPromptPlayAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["Play"] + phrase: str + + +class PayPromptSayAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["Say"] + phrase: str + + +class PayPrompts(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + actions: list[PayPromptAction] + # non-identifier field 'for': str + attempts: str + card_type: str + error_type: str + + +class Play(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + play: PlayWithURL | PlayWithURLS + + +class PlayWithURL(TypedDict, total=False): + """Play with a single URL + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + auto_answer: bool | SWMLVar + volume: float | SWMLVar + say_voice: str + say_language: str + say_gender: str + status_url: str + url: play_url | SWMLVar + + +class PlayWithURLS(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + auto_answer: bool | SWMLVar + volume: float | SWMLVar + say_voice: str + say_language: str + say_gender: str + status_url: str + urls: list[play_url] | list[SWMLVar] + + +class PlaybackBGAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + playback_bg: dict[str, Any] + + +class PomSectionBodyContent(TypedDict, total=False): + """Content model with body text and optional bullets + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + title: str + subsections: list[POM] + numbered: bool | SWMLVar + numberedBullets: bool | SWMLVar + body: str + bullets: list[str] + + +class PomSectionBulletsContent(TypedDict, total=False): + """Content model with bullets and optional body + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + title: str + subsections: list[POM] + numbered: bool | SWMLVar + numberedBullets: bool | SWMLVar + body: str + bullets: list[str] + + +class Prompt(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + prompt: dict[str, Any] + + +class Pronounce(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + replace: str + # non-identifier field 'with': str + ignore_case: bool | SWMLVar + + +class ReceiveFax(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + receive_fax: dict[str, Any] + + +class Record(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + record: dict[str, Any] + + +class RecordCall(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + record_call: dict[str, Any] + + +class Request(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + request: dict[str, Any] + + +class Return(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'return': dict[str, Any] + + +class SIPRefer(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + sip_refer: dict[str, Any] + + +class SMSWithBody(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + to_number: str + from_number: str + region: str + tags: list[str] + body: str + + +class SMSWithMedia(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + to_number: str + from_number: str + region: str + tags: list[str] + media: list[str] + body: str + + +class SWAIG(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + defaults: SWAIGDefaults + native_functions: list[SWAIGNativeFunction] + includes: list[SWAIGIncludes] + functions: list[SWAIGFunction] + internal_fillers: SWAIGInternalFiller + + +class SWAIGDefaults(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + web_hook_url: str + + +SWAIGFunction: TypeAlias = "UserSWAIGFunction | StartUpHookSWAIGFunction | HangUpHookSWAIGFunction | SummarizeConversationSWAIGFunction" + + +class SWAIGIncludes(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + functions: list[str] + url: str + meta_data: dict[str, Any] + + +class SWAIGInternalFiller(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hangup: FunctionFillers + check_time: FunctionFillers + wait_for_user: FunctionFillers + wait_seconds: FunctionFillers + adjust_response_latency: FunctionFillers + next_step: FunctionFillers + change_context: FunctionFillers + get_visual_input: FunctionFillers + get_ideal_strategy: FunctionFillers + + +SWAIGNativeFunction: TypeAlias = ( + "Literal['check_time', 'wait_seconds', 'wait_for_user', 'adjust_response_latency']" +) + + +class SWMLAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + SWML: SWMLObject + + +SWMLMethod: TypeAlias = "Answer | AI | AmazonBedrock | Cond | Connect | Denoise | EnterQueue | Execute | Goto | Label | LiveTranscribe | LiveTranslate | Hangup | JoinRoom | JoinConference | Play | Prompt | ReceiveFax | Record | RecordCall | Request | Return | SendDigits | SendFax | SendSMS | Set | Sleep | SIPRefer | StopDenoise | StopRecordCall | StopTap | Switch | Tap | Transfer | Unset | Pay | DetectMachine | UserEvent" + + +class SWMLObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + version: Literal["1.0.0"] + sections: Section + + +SWMLVar: TypeAlias = "str" + + +class SayAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + say: str + + +SchemaType: TypeAlias = "StringProperty | IntegerProperty | NumberProperty | BooleanProperty | ArrayProperty | ObjectProperty | NullProperty | OneOfProperty | AllOfProperty | AnyOfProperty | ConstProperty" + + +class Section(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + main: list[SWMLMethod] + + +class SendDigits(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + send_digits: dict[str, Any] + + +class SendFax(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + send_fax: dict[str, Any] + + +class SendSMS(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + send_sms: SMSWithBody | SMSWithMedia + + +class Set(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + set: dict[str, Any] + + +class SetGlobalDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + set_global_data: dict[str, Any] + + +class SetMetaDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + set_meta_data: dict[str, Any] + + +class Sleep(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + sleep: dict[str, Any] | int | SWMLVar + + +SpeechEngine: TypeAlias = "Literal['deepgram', 'google']" + + +class StartAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + start: dict[str, Any] + + +class StartUpHookSWAIGFunction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: Literal["startup_hook"] + + +class StopAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop: bool | SWMLVar + + +class StopDenoise(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_denoise: dict[str, Any] + + +class StopPlaybackBGAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_playback_bg: bool | SWMLVar + + +class StopRecordCall(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_record_call: dict[str, Any] + + +class StopTap(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_tap: dict[str, Any] + + +StringFormat: TypeAlias = "Literal['date_time', 'time', 'date', 'duration', 'email', 'hostname', 'ipv4', 'ipv6', 'uri', 'uuid']" + + +class StringProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["string"] + enum: list[str] + default: str + pattern: str + format: StringFormat + + +class SummarizeAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + summarize: dict[str, Any] + + +SummarizeActionUnion: TypeAlias = "SummarizeAction | Literal['summarize']" + + +class SummarizeConversationSWAIGFunction(TypedDict, total=False): + """An internal reserved function that generates a summary of the conversation and sends any specified properties to the configured webhook after the conversation has ended. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: Literal["summarize_conversation"] + + +class Switch(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + switch: dict[str, Any] + + +class Tap(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + tap: dict[str, Any] + + +class ToggleFunctionsAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + toggle_functions: list[dict[str, Any]] + + +TranscribeAction: TypeAlias = ( + "TranscribeStartAction | Literal['stop'] | TranscribeSummarizeActionUnion" +) + +TranscribeDirection: TypeAlias = "Literal['remote-caller', 'local-caller']" + + +class TranscribeStartAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + start: dict[str, Any] + + +class TranscribeSummarizeAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + summarize: dict[str, Any] + + +TranscribeSummarizeActionUnion: TypeAlias = ( + "TranscribeSummarizeAction | Literal['summarize']" +) + + +class Transfer(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + transfer: dict[str, Any] + + +TranslateAction: TypeAlias = ( + "StartAction | Literal['stop'] | SummarizeActionUnion | InjectAction" +) + +TranslateDirection: TypeAlias = "Literal['remote-caller', 'local-caller']" + +TranslationFilterPreset: TypeAlias = ( + "Literal['polite', 'rude', 'professional', 'shakespeare', 'gen-z']" +) + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode400(TypedDict, total=False): + """The request is invalid. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Bad Request"] + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode404(TypedDict, total=False): + """The server cannot find the requested resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Not Found"] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +class Unset(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + unset: str | list[str] + + +class UnsetGlobalDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + unset_global_data: str | dict[str, Any] + + +class UnsetMetaDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + unset_meta_data: str | dict[str, Any] + + +class UserEvent(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + user_event: dict[str, Any] + + +class UserInputAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + user_input: str + + +class UserSWAIGFunction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: str + + +ValidConfirmMethods: TypeAlias = "Cond | Set | Unset | Hangup | Play | Prompt | Record | RecordCall | StopRecordCall | Tap | StopTap | SendDigits | SendSMS | Denoise | StopDenoise" + + +class Webhook(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + expressions: list[Expression] + error_keys: str | list[str] + url: str + foreach: dict[str, Any] + headers: dict[str, Any] + method: Literal["GET", "POST", "PUT", "DELETE"] + input_args_as_params: bool | SWMLVar + params: dict[str, Any] + require_args: str | list[str] + output: Output + + +play_url: TypeAlias = "str" + +uuid: TypeAlias = "str" + +CallCommandsRequest: TypeAlias = "CallRequest" +CallCommandsResponse: TypeAlias = "CallResponse" diff --git a/signalwire/signalwire/rest/namespaces/chat.py b/signalwire/signalwire/rest/namespaces/chat.py index c515390f..ac8e6b14 100644 --- a/signalwire/signalwire/rest/namespaces/chat.py +++ b/signalwire/signalwire/rest/namespaces/chat.py @@ -1,22 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Chat API namespace — token creation. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.chat`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.chat import ChatResource`` working +but emits a DeprecationWarning. Prefer ``client.chat`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource +import warnings +warnings.warn( + "signalwire.signalwire.rest.namespaces.chat is deprecated; use client.chat. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) -class ChatResource(BaseResource): - """Chat token generation.""" +from .chat_resources_generated import Chat # noqa: E402 (re-export after the deprecation warn — intentional) - def __init__(self, http): - super().__init__(http, "/api/chat/tokens") +# Back-compat aliases (old name -> generated bare name): +ChatResource = Chat - def create_token(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) +__all__ = ["ChatResource"] diff --git a/signalwire/signalwire/rest/namespaces/chat_resources_generated.py b/signalwire/signalwire/rest/namespaces/chat_resources_generated.py new file mode 100644 index 00000000..8a01ac52 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/chat_resources_generated.py @@ -0,0 +1,51 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/chat/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast +from collections.abc import Mapping + +from .._base import BaseResource + +if TYPE_CHECKING: + from .chat_types_generated import ( + ChatChannel, + ChatState, + ChatToken, + ) + + +class Chat(BaseResource): + """Typed resource for ``/tokens`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/chat/tokens") + + def create_token( + self, + *, + ttl: int, + channels: ChatChannel, + member_id: str | None = None, + state: ChatState | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> ChatToken: + body: dict[str, Any] = { + k: v + for k, v in { + "ttl": ttl, + "channels": channels, + "member_id": member_id, + "state": state, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("ChatToken", self._http.post(self._base_path, body=body)) diff --git a/signalwire/signalwire/rest/namespaces/chat_types_generated.py b/signalwire/signalwire/rest/namespaces/chat_types_generated.py new file mode 100644 index 00000000..cf3656ca --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/chat_types_generated.py @@ -0,0 +1,90 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/chat/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Any, Literal, TypeAlias, TypedDict + +ChatChannel: TypeAlias = "dict[str, Any]" + + +class ChatPermissionWithRead(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + read: bool + write: bool + + +class ChatPermissionWithWrite(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + read: bool + write: bool + + +ChatState: TypeAlias = "dict[str, Any]" + + +class ChatToken(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + token: str + + +class ChatToken422Error(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class NewChatToken(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + ttl: int + channels: ChatChannel + member_id: str + state: ChatState + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode400(TypedDict, total=False): + """The request is invalid. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Bad Request"] + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +CreateChatTokenRequest: TypeAlias = "NewChatToken" +CreateChatTokenResponse: TypeAlias = "ChatToken" diff --git a/signalwire/signalwire/rest/namespaces/compat.py b/signalwire/signalwire/rest/namespaces/compat.py deleted file mode 100644 index 6d12b191..00000000 --- a/signalwire/signalwire/rest/namespaces/compat.py +++ /dev/null @@ -1,278 +0,0 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. - -Compatibility API namespace — Twilio-compatible LAML API with AccountSid scoping. -""" - -from .._base import BaseResource, CrudResource - - -class CompatAccounts(BaseResource): - """Compat account/subproject management.""" - - def __init__(self, http): - super().__init__(http, "/api/laml/2010-04-01/Accounts") - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def create(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) - - def get(self, sid): - return self._http.get(self._path(sid)) - - def update(self, sid, /, **kwargs): - return self._http.post(self._path(sid), body=kwargs) - - -class CompatCalls(CrudResource): - """Compat call management with recording and stream sub-resources.""" - - def update(self, sid, /, **kwargs): - return self._http.post(self._path(sid), body=kwargs) - - def start_recording(self, call_sid, **kwargs): - return self._http.post(self._path(call_sid, "Recordings"), body=kwargs) - - def update_recording(self, call_sid, recording_sid, **kwargs): - return self._http.post( - self._path(call_sid, "Recordings", recording_sid), body=kwargs - ) - - def start_stream(self, call_sid, **kwargs): - return self._http.post(self._path(call_sid, "Streams"), body=kwargs) - - def stop_stream(self, call_sid, stream_sid, **kwargs): - return self._http.post(self._path(call_sid, "Streams", stream_sid), body=kwargs) - - -class CompatMessages(CrudResource): - """Compat message management with media sub-resources.""" - - def update(self, sid, /, **kwargs): - return self._http.post(self._path(sid), body=kwargs) - - def list_media(self, message_sid, **params): - return self._http.get(self._path(message_sid, "Media"), params=params or None) - - def get_media(self, message_sid, media_sid): - return self._http.get(self._path(message_sid, "Media", media_sid)) - - def delete_media(self, message_sid, media_sid): - return self._http.delete(self._path(message_sid, "Media", media_sid)) - - -class CompatFaxes(CrudResource): - """Compat fax management with media sub-resources.""" - - def update(self, sid, /, **kwargs): - return self._http.post(self._path(sid), body=kwargs) - - def list_media(self, fax_sid, **params): - return self._http.get(self._path(fax_sid, "Media"), params=params or None) - - def get_media(self, fax_sid, media_sid): - return self._http.get(self._path(fax_sid, "Media", media_sid)) - - def delete_media(self, fax_sid, media_sid): - return self._http.delete(self._path(fax_sid, "Media", media_sid)) - - -class CompatConferences(BaseResource): - """Compat conference management with participants, recordings, and streams.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def get(self, sid): - return self._http.get(self._path(sid)) - - def update(self, sid, /, **kwargs): - return self._http.post(self._path(sid), body=kwargs) - - # Participants - def list_participants(self, conference_sid, **params): - return self._http.get( - self._path(conference_sid, "Participants"), - params=params or None, - ) - - def get_participant(self, conference_sid, call_sid): - return self._http.get(self._path(conference_sid, "Participants", call_sid)) - - def update_participant(self, conference_sid, call_sid, **kwargs): - return self._http.post( - self._path(conference_sid, "Participants", call_sid), - body=kwargs, - ) - - def remove_participant(self, conference_sid, call_sid): - return self._http.delete(self._path(conference_sid, "Participants", call_sid)) - - # Conference recordings - def list_recordings(self, conference_sid, **params): - return self._http.get( - self._path(conference_sid, "Recordings"), - params=params or None, - ) - - def get_recording(self, conference_sid, recording_sid): - return self._http.get(self._path(conference_sid, "Recordings", recording_sid)) - - def update_recording(self, conference_sid, recording_sid, **kwargs): - return self._http.post( - self._path(conference_sid, "Recordings", recording_sid), - body=kwargs, - ) - - def delete_recording(self, conference_sid, recording_sid): - return self._http.delete( - self._path(conference_sid, "Recordings", recording_sid) - ) - - # Conference streams - def start_stream(self, conference_sid, **kwargs): - return self._http.post(self._path(conference_sid, "Streams"), body=kwargs) - - def stop_stream(self, conference_sid, stream_sid, **kwargs): - return self._http.post( - self._path(conference_sid, "Streams", stream_sid), - body=kwargs, - ) - - -class CompatPhoneNumbers(BaseResource): - """Compat phone number management.""" - - def __init__(self, http, base): - super().__init__(http, base) - self._available_base = base.replace( - "/IncomingPhoneNumbers", "/AvailablePhoneNumbers" - ) - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def purchase(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) - - def get(self, sid): - return self._http.get(self._path(sid)) - - def update(self, sid, /, **kwargs): - return self._http.post(self._path(sid), body=kwargs) - - def delete(self, sid): - return self._http.delete(self._path(sid)) - - def import_number(self, **kwargs): - path = self._base_path.replace("/IncomingPhoneNumbers", "/ImportedPhoneNumbers") - return self._http.post(path, body=kwargs) - - def list_available_countries(self, **params): - return self._http.get(self._available_base, params=params or None) - - def search_local(self, country, **params): - return self._http.get( - f"{self._available_base}/{country}/Local", params=params or None - ) - - def search_toll_free(self, country, **params): - return self._http.get( - f"{self._available_base}/{country}/TollFree", params=params or None - ) - - -class CompatApplications(CrudResource): - """Compat application management.""" - - def update(self, sid, /, **kwargs): - return self._http.post(self._path(sid), body=kwargs) - - -class CompatLamlBins(CrudResource): - """Compat cXML/LaML script management.""" - - def update(self, sid, /, **kwargs): - return self._http.post(self._path(sid), body=kwargs) - - -class CompatQueues(CrudResource): - """Compat queue management with members.""" - - def update(self, sid, /, **kwargs): - return self._http.post(self._path(sid), body=kwargs) - - def list_members(self, queue_sid, **params): - return self._http.get(self._path(queue_sid, "Members"), params=params or None) - - def get_member(self, queue_sid, call_sid): - return self._http.get(self._path(queue_sid, "Members", call_sid)) - - def dequeue_member(self, queue_sid, call_sid, **kwargs): - return self._http.post(self._path(queue_sid, "Members", call_sid), body=kwargs) - - -class CompatRecordings(BaseResource): - """Compat recording management.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def get(self, sid): - return self._http.get(self._path(sid)) - - def delete(self, sid): - return self._http.delete(self._path(sid)) - - -class CompatTranscriptions(BaseResource): - """Compat transcription management.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def get(self, sid): - return self._http.get(self._path(sid)) - - def delete(self, sid): - return self._http.delete(self._path(sid)) - - -class CompatTokens(BaseResource): - """Compat API token management.""" - - def create(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) - - def update(self, token_id, **kwargs): - return self._http.patch(self._path(token_id), body=kwargs) - - def delete(self, token_id): - return self._http.delete(self._path(token_id)) - - -class CompatNamespace: - """Twilio-compatible LAML API namespace with AccountSid scoping.""" - - def __init__(self, http, account_sid): - base = f"/api/laml/2010-04-01/Accounts/{account_sid}" - - self.accounts = CompatAccounts(http) - self.calls = CompatCalls(http, f"{base}/Calls") - self.messages = CompatMessages(http, f"{base}/Messages") - self.faxes = CompatFaxes(http, f"{base}/Faxes") - self.conferences = CompatConferences(http, f"{base}/Conferences") - self.phone_numbers = CompatPhoneNumbers(http, f"{base}/IncomingPhoneNumbers") - self.applications = CompatApplications(http, f"{base}/Applications") - self.laml_bins = CompatLamlBins(http, f"{base}/LamlBins") - self.queues = CompatQueues(http, f"{base}/Queues") - self.recordings = CompatRecordings(http, f"{base}/Recordings") - self.transcriptions = CompatTranscriptions(http, f"{base}/Transcriptions") - self.tokens = CompatTokens(http, f"{base}/tokens") diff --git a/signalwire/signalwire/rest/namespaces/datasphere.py b/signalwire/signalwire/rest/namespaces/datasphere.py index a641d899..be997a12 100644 --- a/signalwire/signalwire/rest/namespaces/datasphere.py +++ b/signalwire/signalwire/rest/namespaces/datasphere.py @@ -1,38 +1,22 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Datasphere API namespace — document management and semantic search. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.datasphere`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.datasphere import DatasphereDocuments`` working +but emits a DeprecationWarning. Prefer ``client.datasphere`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import CrudResource - - -class DatasphereDocuments(CrudResource): - """Document management with search and chunk operations.""" - - def __init__(self, http): - super().__init__(http, "/api/datasphere/documents") - - def search(self, **kwargs): - return self._http.post(self._path("search"), body=kwargs) - - def list_chunks(self, document_id, **params): - return self._http.get(self._path(document_id, "chunks"), params=params or None) - - def get_chunk(self, document_id, chunk_id): - return self._http.get(self._path(document_id, "chunks", chunk_id)) - - def delete_chunk(self, document_id, chunk_id): - return self._http.delete(self._path(document_id, "chunks", chunk_id)) +import warnings +warnings.warn( + "signalwire.signalwire.rest.namespaces.datasphere is deprecated; use client.datasphere. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) -class DatasphereNamespace: - """Datasphere API namespace.""" +from .datasphere_resources_generated import DatasphereDocuments # noqa: E402 (re-export after the deprecation warn — intentional) +from ._client_tree_generated import DatasphereNamespace # noqa: E402 (re-export after the deprecation warn — intentional) - def __init__(self, http): - self.documents = DatasphereDocuments(http) +__all__ = ["DatasphereDocuments", "DatasphereNamespace"] diff --git a/signalwire/signalwire/rest/namespaces/datasphere_resources_generated.py b/signalwire/signalwire/rest/namespaces/datasphere_resources_generated.py new file mode 100644 index 00000000..e6716592 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/datasphere_resources_generated.py @@ -0,0 +1,120 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/datasphere/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast +from collections.abc import Mapping + +from .._base import CrudResource + +if TYPE_CHECKING: + from .datasphere_types_generated import ( + ChunkListResponse, + ChunkResponse, + Document, + DocumentCreateRequest, + DocumentListResponse, + DocumentUpdateRequest, + SearchResponse, + docid, + ) + + +class DatasphereDocuments( + CrudResource[ + "DocumentListResponse", + "Document", + "DocumentCreateRequest", + "DocumentUpdateRequest", + ] +): + """Typed resource for ``/documents`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/datasphere/documents") + + def create( # type: ignore[override] + self, + body: DocumentCreateRequest, + *, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> Document: + merged: dict[str, Any] = {**body, **(extras or {}), **kwargs} + return cast("Document", self._http.post(self._base_path, body=merged)) + + def update( + self, + id: str, + /, + *, + tags: list[str] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> Document: + body: dict[str, Any] = { + k: v for k, v in {"tags": tags}.items() if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("Document", self._http.patch(self._path(id), body=body)) + + def search( + self, + *, + query_string: str, + tags: list[str] | None = None, + document_id: docid | None = None, + distance: float | None = None, + count: int | None = None, + language: str | None = None, + pos_to_expand: list[str] | None = None, + max_synonyms: int | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SearchResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "tags": tags, + "document_id": document_id, + "query_string": query_string, + "distance": distance, + "count": count, + "language": language, + "pos_to_expand": pos_to_expand, + "max_synonyms": max_synonyms, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SearchResponse", self._http.post(self._path("search"), body=body)) + + def list_chunks(self, document_id: str, **params: Any) -> ChunkListResponse: + return cast( + "ChunkListResponse", + self._http.get(self._path(document_id, "chunks"), params=params or None), + ) + + def get_chunk( + self, document_id: str, chunk_id: str, **params: Any + ) -> ChunkResponse: + return cast( + "ChunkResponse", + self._http.get( + self._path(document_id, "chunks", chunk_id), params=params or None + ), + ) + + def delete_chunk(self, document_id: str, chunk_id: str) -> dict[str, Any]: + return cast( + "dict[str, Any]", + self._http.delete(self._path(document_id, "chunks", chunk_id)), + ) diff --git a/signalwire/signalwire/rest/namespaces/datasphere_types_generated.py b/signalwire/signalwire/rest/namespaces/datasphere_types_generated.py new file mode 100644 index 00000000..95586e40 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/datasphere_types_generated.py @@ -0,0 +1,240 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/datasphere/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Literal, TypeAlias, TypedDict + + +class Chunk(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + text: str + document_id: docid + + +class ChunkListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[ChunkResponse] + links: ChunkPaginationResponse + + +class ChunkPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class ChunkResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + datasphere_document_id: uuid + project_id: uuid + status: ChunkStatus + tags: list[str] + content: str + created_at: str + updated_at: str + + +ChunkStatus: TypeAlias = "Literal['submitted', 'in_progress', 'completed', 'failed']" + +ChunkingStrategy: TypeAlias = "Literal['sentence', 'paragraph', 'page', 'sliding']" + + +class CreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Document(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: docid + filename: str + status: DocumentStatus + tags: list[str] + chunking_strategy: ChunkingStrategy + max_sentences_per_chunk: int | None + split_newlines: bool | None + overlap_size: int | None + chunk_size: int | None + number_of_chunks: int + chunks_uri: str + created_at: str + updated_at: str + + +DocumentCreatePageRequest: TypeAlias = "DocumentCreateRequestBase" + +DocumentCreateParagraphRequest: TypeAlias = "DocumentCreateRequestBase" + +DocumentCreateRequest: TypeAlias = "DocumentCreateSentenceRequest | DocumentCreateSlidingRequest | DocumentCreatePageRequest | DocumentCreateParagraphRequest" + + +class DocumentCreateRequestBase(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + url: str + tags: list[str] + + +DocumentCreateSentenceRequest: TypeAlias = "DocumentCreateRequestBase" + +DocumentCreateSlidingRequest: TypeAlias = "DocumentCreateRequestBase" + + +class DocumentListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[Document] + links: PaginationResponse + + +class DocumentSearchRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + tags: list[str] + document_id: docid + query_string: str + distance: float + count: int + language: str + pos_to_expand: list[str] + max_synonyms: int + + +DocumentStatus: TypeAlias = "Literal['submitted', 'in_progress', 'completed', 'failed']" + + +class DocumentUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + tags: list[str] + + +class ListStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class PaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SearchResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + chunks: list[Chunk] + + +class SearchStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode400(TypedDict, total=False): + """The request is invalid. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Bad Request"] + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode404(TypedDict, total=False): + """The server cannot find the requested resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Not Found"] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +class UpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +docid: TypeAlias = "str" + +uuid: TypeAlias = "str" + +ListDocumentsResponse: TypeAlias = "DocumentListResponse" +CreateDocumentRequest: TypeAlias = "DocumentCreateRequest" +CreateDocumentResponse: TypeAlias = "Document" +SearchDocumentsRequest: TypeAlias = "DocumentSearchRequest" +SearchDocumentsResponse: TypeAlias = "SearchResponse" +ListDocumentChunksResponse: TypeAlias = "ChunkListResponse" +GetDocumentChunkResponse: TypeAlias = "ChunkResponse" +GetDocumentResponse: TypeAlias = "Document" +UpdateDocumentRequest: TypeAlias = "DocumentUpdateRequest" +UpdateDocumentResponse: TypeAlias = "Document" diff --git a/signalwire/signalwire/rest/namespaces/fabric.py b/signalwire/signalwire/rest/namespaces/fabric.py index 58b4b88a..4bde197d 100644 --- a/signalwire/signalwire/rest/namespaces/fabric.py +++ b/signalwire/signalwire/rest/namespaces/fabric.py @@ -1,243 +1,31 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Licensed under the MIT License. -See LICENSE file in the project root for full license information. - -Fabric API namespace — resource composition, addresses, and tokens. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.fabric`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.fabric import FabricResource`` working +but emits a DeprecationWarning. Prefer ``client.fabric`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ import warnings -from .._base import BaseResource, CrudWithAddresses - - -class FabricResource(CrudWithAddresses): - """Standard fabric resource with CRUD + addresses.""" - - pass - - -class FabricResourcePUT(CrudWithAddresses): - """Fabric resource that uses PUT for updates.""" - - _update_method = "PUT" - - -class AutoMaterializedWebhook(FabricResource): - """Fabric webhook resource that's normally auto-created by phone_numbers.set_*. - - Exposed for backwards compatibility. The binding model for these resources - is on the phone number (see ``phone_numbers.set_swml_webhook`` / - ``set_cxml_webhook``) — setting ``call_handler`` on a phone number - auto-materializes the webhook. Calling ``create`` here produces an orphan - resource that isn't bound to any phone number. - """ - - _auto_helper_name = "phone_numbers.set_*_webhook" - - def create(self, **kwargs): - warnings.warn( - f"Creating a webhook Fabric resource directly produces an orphan not " - f"bound to any phone number. Use {self._auto_helper_name} instead; " - f"it updates the phone number and the server auto-materializes the " - f"resource. See porting-sdk's phone-binding.md.", - DeprecationWarning, - stacklevel=2, - ) - return super().create(**kwargs) - - -class SwmlWebhooksResource(AutoMaterializedWebhook): - _auto_helper_name = "phone_numbers.set_swml_webhook(sid, url=...)" - - -class CxmlWebhooksResource(AutoMaterializedWebhook): - _auto_helper_name = "phone_numbers.set_cxml_webhook(sid, url=...)" - - -class CallFlowsResource(FabricResourcePUT): - """Call flows with version management. - - Note: call_flow (singular) is used in address/version paths per the API spec. - """ - - def list_addresses(self, resource_id, **params): - # API uses singular 'call_flow' for sub-resource paths - path = self._base_path.replace("/call_flows", "/call_flow") - return self._http.get(f"{path}/{resource_id}/addresses", params=params or None) - - def list_versions(self, resource_id, **params): - path = self._base_path.replace("/call_flows", "/call_flow") - return self._http.get(f"{path}/{resource_id}/versions", params=params or None) - - def deploy_version(self, resource_id, **kwargs): - path = self._base_path.replace("/call_flows", "/call_flow") - return self._http.post(f"{path}/{resource_id}/versions", body=kwargs) - - -class ConferenceRoomsResource(FabricResourcePUT): - """Conference rooms — uses singular 'conference_room' for sub-resource paths.""" - - def list_addresses(self, resource_id, **params): - path = self._base_path.replace("/conference_rooms", "/conference_room") - return self._http.get(f"{path}/{resource_id}/addresses", params=params or None) - - -class SubscribersResource(FabricResourcePUT): - """Subscribers with SIP endpoint management.""" - - def list_sip_endpoints(self, subscriber_id, **params): - return self._http.get( - self._path(subscriber_id, "sip_endpoints"), - params=params or None, - ) - - def create_sip_endpoint(self, subscriber_id, **kwargs): - return self._http.post( - self._path(subscriber_id, "sip_endpoints"), - body=kwargs, - ) - - def get_sip_endpoint(self, subscriber_id, endpoint_id): - return self._http.get( - self._path(subscriber_id, "sip_endpoints", endpoint_id), - ) - - def update_sip_endpoint(self, subscriber_id, endpoint_id, **kwargs): - return self._http.patch( - self._path(subscriber_id, "sip_endpoints", endpoint_id), - body=kwargs, - ) - - def delete_sip_endpoint(self, subscriber_id, endpoint_id): - return self._http.delete( - self._path(subscriber_id, "sip_endpoints", endpoint_id), - ) - - -class CxmlApplicationsResource(FabricResourcePUT): - """cXML applications — no create method (read/update/delete only).""" - - def create(self, **kwargs): - raise NotImplementedError("cXML applications cannot be created via this API") - - -class GenericResources(BaseResource): - """Generic resource operations across all fabric resource types.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def get(self, resource_id): - return self._http.get(self._path(resource_id)) - - def delete(self, resource_id): - return self._http.delete(self._path(resource_id)) - - def list_addresses(self, resource_id, **params): - return self._http.get( - self._path(resource_id, "addresses"), - params=params or None, - ) - - def assign_phone_route(self, resource_id, **kwargs): - """Deprecated for the common binding cases. Use ``phone_numbers.set_*`` helpers. - - This endpoint (``POST /api/fabric/resources/{id}/phone_routes``) accepts - only a narrow set of legacy resource types as the attach target. It - **does not work** for ``swml_webhook`` / ``cxml_webhook`` / ``ai_agent`` - bindings — those are configured on the phone number and the Fabric - resource is auto-materialized (see ``phone_numbers.set_swml_webhook`` - etc.). The authoritative list of accepting resource types lives in the - OpenAPI spec; routing here for those types returns 404 or 422. - """ - warnings.warn( - "assign_phone_route does not bind phone numbers to " - "swml_webhook/cxml_webhook/ai_agent resources — those are " - "configured via phone_numbers.set_swml_webhook / set_cxml_webhook " - "/ set_ai_agent. This method applies only to a narrow set of " - "legacy resource types. See porting-sdk's phone-binding.md.", - DeprecationWarning, - stacklevel=2, - ) - return self._http.post(self._path(resource_id, "phone_routes"), body=kwargs) - - def assign_domain_application(self, resource_id, **kwargs): - return self._http.post( - self._path(resource_id, "domain_applications"), body=kwargs - ) - - -class FabricAddresses(BaseResource): - """Read-only fabric addresses.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def get(self, address_id): - return self._http.get(self._path(address_id)) - - -class FabricTokens(BaseResource): - """Subscriber, guest, invite, and embed token creation.""" - - def __init__(self, http): - super().__init__(http, "/api/fabric") - - def create_subscriber_token(self, **kwargs): - return self._http.post(self._path("subscribers", "tokens"), body=kwargs) - - def refresh_subscriber_token(self, **kwargs): - return self._http.post( - self._path("subscribers", "tokens", "refresh"), body=kwargs - ) - - def create_invite_token(self, **kwargs): - return self._http.post(self._path("subscriber", "invites"), body=kwargs) - - def create_guest_token(self, **kwargs): - return self._http.post(self._path("guests", "tokens"), body=kwargs) - - def create_embed_token(self, **kwargs): - return self._http.post(self._path("embeds", "tokens"), body=kwargs) - - -class FabricNamespace: - """Fabric API namespace grouping all resource types.""" - - def __init__(self, http): - base = "/api/fabric/resources" - - # PUT-update resources - self.swml_scripts = FabricResourcePUT(http, f"{base}/swml_scripts") - self.relay_applications = FabricResourcePUT(http, f"{base}/relay_applications") - self.call_flows = CallFlowsResource(http, f"{base}/call_flows") - self.conference_rooms = ConferenceRoomsResource( - http, f"{base}/conference_rooms" - ) - self.freeswitch_connectors = FabricResourcePUT( - http, f"{base}/freeswitch_connectors" - ) - self.subscribers = SubscribersResource(http, f"{base}/subscribers") - self.sip_endpoints = FabricResourcePUT(http, f"{base}/sip_endpoints") - self.cxml_scripts = FabricResourcePUT(http, f"{base}/cxml_scripts") - self.cxml_applications = CxmlApplicationsResource( - http, f"{base}/cxml_applications" - ) - - # PATCH-update resources - # swml_webhooks and cxml_webhooks are normally auto-materialized by - # phone_numbers.set_swml_webhook / set_cxml_webhook. Direct create - # still works for backcompat but emits a DeprecationWarning. - self.swml_webhooks = SwmlWebhooksResource(http, f"{base}/swml_webhooks") - self.ai_agents = FabricResource(http, f"{base}/ai_agents") - self.sip_gateways = FabricResource(http, f"{base}/sip_gateways") - self.cxml_webhooks = CxmlWebhooksResource(http, f"{base}/cxml_webhooks") - - # Special resources - self.resources = GenericResources(http, base) - self.addresses = FabricAddresses(http, "/api/fabric/addresses") - self.tokens = FabricTokens(http) +warnings.warn( + "signalwire.signalwire.rest.namespaces.fabric is deprecated; use client.fabric. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) + +from .._base import FabricResource, FabricResourcePUT # noqa: E402 (re-export after the deprecation warn — intentional) +from .fabric_resources_generated import CallFlows, ConferenceRooms, CxmlApplications, CxmlWebhooks, FabricAddresses, FabricTokens, GenericResources, Subscribers, SwmlWebhooks # noqa: E402 (re-export after the deprecation warn — intentional) +from ._client_tree_generated import FabricNamespace # noqa: E402 (re-export after the deprecation warn — intentional) + +# Back-compat aliases (old name -> generated bare name): +SwmlWebhooksResource = SwmlWebhooks +CxmlWebhooksResource = CxmlWebhooks +CallFlowsResource = CallFlows +ConferenceRoomsResource = ConferenceRooms +SubscribersResource = Subscribers +CxmlApplicationsResource = CxmlApplications + +__all__ = ["CallFlowsResource", "ConferenceRoomsResource", "CxmlApplicationsResource", "CxmlWebhooksResource", "FabricAddresses", "FabricNamespace", "FabricResource", "FabricResourcePUT", "FabricTokens", "GenericResources", "SubscribersResource", "SwmlWebhooksResource"] diff --git a/signalwire/signalwire/rest/namespaces/fabric_resources_generated.py b/signalwire/signalwire/rest/namespaces/fabric_resources_generated.py new file mode 100644 index 00000000..e35ff11f --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/fabric_resources_generated.py @@ -0,0 +1,1463 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/fabric/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, cast +from collections.abc import Mapping + +from .._base import BaseResource, FabricResource, ReadResource + +if TYPE_CHECKING: + from .fabric_types_generated import ( + AIAgentCreateRequest, + AIAgentListResponse, + AIAgentResponse, + AIAgentUpdateRequest, + AIParams, + AIPostPrompt, + AIPostPromptUpdate, + AIPrompt, + AIPromptUpdate, + CXMLScriptCreateRequest, + CXMLScriptListResponse, + CXMLScriptResponse, + CXMLScriptUpdateRequest, + CXMLWebhookCreateRequest, + CXMLWebhookListResponse, + CXMLWebhookResponse, + CXMLWebhookUpdateRequest, + CallFlowAddressListResponse, + CallFlowCreateRequest, + CallFlowListResponse, + CallFlowResponse, + CallFlowUpdateRequest, + CallFlowVersionDeployRequest, + CallFlowVersionDeployResponse, + CallFlowVersionListResponse, + CallHandlerType, + Ciphers, + Codecs, + ConferenceRoomAddressListResponse, + ConferenceRoomCreateRequest, + ConferenceRoomListResponse, + ConferenceRoomResponse, + ConferenceRoomUpdateRequest, + CxmlApplicationAddressListResponse, + CxmlApplicationListResponse, + CxmlApplicationResponse, + DomainApplicationResponse, + EmbedsTokensResponse, + Encryption, + FabricAddress, + FabricAddressesResponse, + FreeswitchConnectorCreateRequest, + FreeswitchConnectorListResponse, + FreeswitchConnectorResponse, + FreeswitchConnectorUpdateRequest, + Hint, + Languages, + Layout, + PhoneRouteResponse, + Pronounce, + RelayApplicationCreateRequest, + RelayApplicationListResponse, + RelayApplicationResponse, + RelayApplicationUpdateRequest, + ResourceAddressListResponse, + ResourceListResponse, + ResourceResponse, + SWAIG, + SWAIGUpdate, + SWMLWebhookCreateRequest, + SWMLWebhookListResponse, + SWMLWebhookResponse, + SWMLWebhookUpdateRequest, + SipEndpointCreateRequest, + SipEndpointListResponse, + SipEndpointResponse, + SipEndpointUpdateRequest, + SipGatewayListResponse, + SipGatewayRequest, + SipGatewayRequestUpdate, + SipGatewayResponse, + SubscriberGuestTokenCreateResponse, + SubscriberInviteTokenCreateResponse, + SubscriberListResponse, + SubscriberRefreshTokenResponse, + SubscriberRequest, + SubscriberResponse, + SubscriberSIPEndpoint, + SubscriberSipEndpointListResponse, + SubscriberTokenResponse, + SwmlScriptCreateRequest, + SwmlScriptListResponse, + SwmlScriptResponse, + SwmlScriptUpdateRequest, + UsedForType, + jwt, + uuid, + ) + + +class FabricAddresses(ReadResource["FabricAddressesResponse", "FabricAddress"]): + """Typed resource for ``/addresses`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/addresses") + + +class GenericResources(BaseResource): + """Typed resource for ``/resources`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources") + + def list(self, **params: Any) -> ResourceListResponse: + return cast( + "ResourceListResponse", + self._http.get(self._base_path, params=params or None), + ) + + def get(self, id: str, **params: Any) -> ResourceResponse: + return cast( + "ResourceResponse", self._http.get(self._path(id), params=params or None) + ) + + def delete(self, id: str) -> dict[str, Any]: + return cast("dict[str, Any]", self._http.delete(self._path(id))) + + def list_addresses(self, id: str, **params: Any) -> ResourceAddressListResponse: + return cast( + "ResourceAddressListResponse", + self._http.get(self._path(id, "addresses"), params=params or None), + ) + + def assign_phone_route( + self, + id: str, + *, + phone_route_id: uuid, + handler: UsedForType, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> PhoneRouteResponse: + body: dict[str, Any] = { + k: v + for k, v in {"phone_route_id": phone_route_id, "handler": handler}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "PhoneRouteResponse", + self._http.post(self._path(id, "phone_routes"), body=body), + ) + + def assign_domain_application( + self, + id: str, + *, + domain_application_id: uuid, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> DomainApplicationResponse: + body: dict[str, Any] = { + k: v + for k, v in {"domain_application_id": domain_application_id}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "DomainApplicationResponse", + self._http.post(self._path(id, "domain_applications"), body=body), + ) + + +class AiAgents( + FabricResource[ + "AIAgentListResponse", + "AIAgentResponse", + "AIAgentCreateRequest", + "AIAgentUpdateRequest", + ] +): + """Typed resource for ``/resources/ai_agents`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/ai_agents") + + def create( # type: ignore[override] + self, + *, + prompt: AIPrompt, + agent_id: uuid, + name: str, + global_data: dict[str, Any] | None = None, + hints: list[str | Hint] | None = None, + languages: list[Languages] | None = None, + params: AIParams | None = None, + post_prompt: AIPostPrompt | None = None, + post_prompt_url: str | None = None, + pronounce: list[Pronounce] | None = None, + SWAIG: SWAIG | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> AIAgentResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "global_data": global_data, + "hints": hints, + "languages": languages, + "params": params, + "post_prompt": post_prompt, + "post_prompt_url": post_prompt_url, + "pronounce": pronounce, + "prompt": prompt, + "SWAIG": SWAIG, + "agent_id": agent_id, + "name": name, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("AIAgentResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + global_data: dict[str, Any] | None = None, + hints: list[str | Hint] | None = None, + languages: list[Languages] | None = None, + params: AIParams | None = None, + post_prompt: AIPostPromptUpdate | None = None, + post_prompt_url: str | None = None, + pronounce: list[Pronounce] | None = None, + prompt: AIPromptUpdate | None = None, + SWAIG: SWAIGUpdate | None = None, + agent_id: uuid | None = None, + name: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> AIAgentResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "global_data": global_data, + "hints": hints, + "languages": languages, + "params": params, + "post_prompt": post_prompt, + "post_prompt_url": post_prompt_url, + "pronounce": pronounce, + "prompt": prompt, + "SWAIG": SWAIG, + "agent_id": agent_id, + "name": name, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("AIAgentResponse", self._http.patch(self._path(id), body=body)) + + +class CallFlows( + FabricResource[ + "CallFlowListResponse", + "CallFlowResponse", + "CallFlowCreateRequest", + "CallFlowUpdateRequest", + ] +): + """Typed resource for ``/resources/call_flows`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/call_flows") + + def create( # type: ignore[override] + self, *, title: str, extras: Mapping[str, Any] | None = None, **kwargs: Any + ) -> CallFlowResponse: + body: dict[str, Any] = { + k: v for k, v in {"title": title}.items() if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("CallFlowResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + title: str | None = None, + document_version: int | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> CallFlowResponse: + body: dict[str, Any] = { + k: v + for k, v in {"title": title, "document_version": document_version}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("CallFlowResponse", self._http.put(self._path(id), body=body)) + + def list_addresses( # type: ignore[override] + self, id: str, **params: Any + ) -> CallFlowAddressListResponse: + return cast( + "CallFlowAddressListResponse", + self._http.get( + f"/api/fabric/resources/call_flow/{id}/addresses", params=params or None + ), + ) + + def list_versions(self, id: str, **params: Any) -> CallFlowVersionListResponse: + return cast( + "CallFlowVersionListResponse", + self._http.get( + f"/api/fabric/resources/call_flow/{id}/versions", params=params or None + ), + ) + + def deploy_version( + self, id: str, body: CallFlowVersionDeployRequest + ) -> CallFlowVersionDeployResponse: + return cast( + "CallFlowVersionDeployResponse", + self._http.post( + f"/api/fabric/resources/call_flow/{id}/versions", body=body + ), + ) + + +class ConferenceRooms( + FabricResource[ + "ConferenceRoomListResponse", + "ConferenceRoomResponse", + "ConferenceRoomCreateRequest", + "ConferenceRoomUpdateRequest", + ] +): + """Typed resource for ``/resources/conference_rooms`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/conference_rooms") + + def create( # type: ignore[override] + self, + *, + name: str, + enable_room_previews: bool, + display_name: str | None = None, + description: str | None = None, + join_from: str | None = None, + join_until: str | None = None, + max_members: int | None = None, + quality: Literal["1080p", "720p"] | None = None, + remove_at: str | None = None, + remove_after_seconds_elapsed: int | None = None, + layout: Layout | None = None, + record_on_start: bool | None = None, + meta: dict[str, Any] | None = None, + sync_audio_video: bool | None = None, + tone_on_entry_and_exit: bool | None = None, + room_join_video_off: bool | None = None, + user_join_video_off: bool | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> ConferenceRoomResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "display_name": display_name, + "description": description, + "join_from": join_from, + "join_until": join_until, + "max_members": max_members, + "quality": quality, + "remove_at": remove_at, + "remove_after_seconds_elapsed": remove_after_seconds_elapsed, + "layout": layout, + "record_on_start": record_on_start, + "enable_room_previews": enable_room_previews, + "meta": meta, + "sync_audio_video": sync_audio_video, + "tone_on_entry_and_exit": tone_on_entry_and_exit, + "room_join_video_off": room_join_video_off, + "user_join_video_off": user_join_video_off, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "ConferenceRoomResponse", self._http.post(self._base_path, body=body) + ) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + display_name: str | None = None, + description: str | None = None, + join_from: str | None = None, + join_until: str | None = None, + max_members: int | None = None, + quality: Literal["1080p", "720p"] | None = None, + remove_at: str | None = None, + remove_after_seconds_elapsed: int | None = None, + layout: Layout | None = None, + record_on_start: bool | None = None, + enable_room_previews: bool | None = None, + meta: dict[str, Any] | None = None, + sync_audio_video: bool | None = None, + tone_on_entry_and_exit: bool | None = None, + room_join_video_off: bool | None = None, + user_join_video_off: bool | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> ConferenceRoomResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "display_name": display_name, + "description": description, + "join_from": join_from, + "join_until": join_until, + "max_members": max_members, + "quality": quality, + "remove_at": remove_at, + "remove_after_seconds_elapsed": remove_after_seconds_elapsed, + "layout": layout, + "record_on_start": record_on_start, + "enable_room_previews": enable_room_previews, + "meta": meta, + "sync_audio_video": sync_audio_video, + "tone_on_entry_and_exit": tone_on_entry_and_exit, + "room_join_video_off": room_join_video_off, + "user_join_video_off": user_join_video_off, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("ConferenceRoomResponse", self._http.put(self._path(id), body=body)) + + def list_addresses( # type: ignore[override] + self, id: str, **params: Any + ) -> ConferenceRoomAddressListResponse: + return cast( + "ConferenceRoomAddressListResponse", + self._http.get( + f"/api/fabric/resources/conference_room/{id}/addresses", + params=params or None, + ), + ) + + +class CxmlApplications(BaseResource): + """Typed resource for ``/resources/cxml_applications`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/cxml_applications") + + def list(self, **params: Any) -> CxmlApplicationListResponse: + return cast( + "CxmlApplicationListResponse", + self._http.get(self._base_path, params=params or None), + ) + + def get(self, id: str, **params: Any) -> CxmlApplicationResponse: + return cast( + "CxmlApplicationResponse", + self._http.get(self._path(id), params=params or None), + ) + + def update( + self, + id: str, + *, + display_name: str | None = None, + account_sid: uuid | None = None, + voice_url: str | None = None, + voice_method: Literal["GET"] | Literal["POST"] | None = None, + voice_fallback_url: str | None = None, + voice_fallback_method: Literal["GET"] | Literal["POST"] | None = None, + status_callback: str | None = None, + status_callback_method: Literal["GET"] | Literal["POST"] | None = None, + sms_url: str | None = None, + sms_method: Literal["GET"] | Literal["POST"] | None = None, + sms_fallback_url: str | None = None, + sms_fallback_method: Literal["GET"] | Literal["POST"] | None = None, + sms_status_callback: str | None = None, + sms_status_callback_method: Literal["GET"] | Literal["POST"] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> CxmlApplicationResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "display_name": display_name, + "account_sid": account_sid, + "voice_url": voice_url, + "voice_method": voice_method, + "voice_fallback_url": voice_fallback_url, + "voice_fallback_method": voice_fallback_method, + "status_callback": status_callback, + "status_callback_method": status_callback_method, + "sms_url": sms_url, + "sms_method": sms_method, + "sms_fallback_url": sms_fallback_url, + "sms_fallback_method": sms_fallback_method, + "sms_status_callback": sms_status_callback, + "sms_status_callback_method": sms_status_callback_method, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "CxmlApplicationResponse", self._http.put(self._path(id), body=body) + ) + + def delete(self, id: str) -> dict[str, Any]: + return cast("dict[str, Any]", self._http.delete(self._path(id))) + + def list_addresses( + self, id: str, **params: Any + ) -> CxmlApplicationAddressListResponse: + return cast( + "CxmlApplicationAddressListResponse", + self._http.get(self._path(id, "addresses"), params=params or None), + ) + + +class CxmlScripts( + FabricResource[ + "CXMLScriptListResponse", + "CXMLScriptResponse", + "CXMLScriptCreateRequest", + "CXMLScriptUpdateRequest", + ] +): + """Typed resource for ``/resources/cxml_scripts`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/cxml_scripts") + + def create( # type: ignore[override] + self, + *, + display_name: str, + contents: str, + status_callback_url: str | None = None, + status_callback_method: Literal["GET"] | Literal["POST"] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> CXMLScriptResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "display_name": display_name, + "contents": contents, + "status_callback_url": status_callback_url, + "status_callback_method": status_callback_method, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("CXMLScriptResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + display_name: str | None = None, + contents: str | None = None, + status_callback_url: str | None = None, + status_callback_method: Literal["GET"] | Literal["POST"] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> CXMLScriptResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "display_name": display_name, + "contents": contents, + "status_callback_url": status_callback_url, + "status_callback_method": status_callback_method, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("CXMLScriptResponse", self._http.put(self._path(id), body=body)) + + +class CxmlWebhooks( + FabricResource[ + "CXMLWebhookListResponse", + "CXMLWebhookResponse", + "CXMLWebhookCreateRequest", + "CXMLWebhookUpdateRequest", + ] +): + """Typed resource for ``/resources/cxml_webhooks`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/cxml_webhooks") + + def create( # type: ignore[override] + self, + *, + primary_request_url: str, + name: str | None = None, + used_for: UsedForType | None = None, + primary_request_method: Literal["GET"] | Literal["POST"] | None = None, + fallback_request_url: str | None = None, + fallback_request_method: Literal["GET"] | Literal["POST"] | None = None, + status_callback_url: str | None = None, + status_callback_method: Literal["GET"] | Literal["POST"] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> CXMLWebhookResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "used_for": used_for, + "primary_request_url": primary_request_url, + "primary_request_method": primary_request_method, + "fallback_request_url": fallback_request_url, + "fallback_request_method": fallback_request_method, + "status_callback_url": status_callback_url, + "status_callback_method": status_callback_method, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("CXMLWebhookResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + used_for: UsedForType | None = None, + primary_request_url: str | None = None, + primary_request_method: Literal["GET"] | Literal["POST"] | None = None, + fallback_request_url: str | None = None, + fallback_request_method: Literal["GET"] | Literal["POST"] | None = None, + status_callback_url: str | None = None, + status_callback_method: Literal["GET"] | Literal["POST"] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> CXMLWebhookResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "used_for": used_for, + "primary_request_url": primary_request_url, + "primary_request_method": primary_request_method, + "fallback_request_url": fallback_request_url, + "fallback_request_method": fallback_request_method, + "status_callback_url": status_callback_url, + "status_callback_method": status_callback_method, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("CXMLWebhookResponse", self._http.patch(self._path(id), body=body)) + + +class FreeswitchConnectors( + FabricResource[ + "FreeswitchConnectorListResponse", + "FreeswitchConnectorResponse", + "FreeswitchConnectorCreateRequest", + "FreeswitchConnectorUpdateRequest", + ] +): + """Typed resource for ``/resources/freeswitch_connectors`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/freeswitch_connectors") + + def create( # type: ignore[override] + self, + *, + name: str, + token: uuid, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> FreeswitchConnectorResponse: + body: dict[str, Any] = { + k: v for k, v in {"name": name, "token": token}.items() if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "FreeswitchConnectorResponse", self._http.post(self._base_path, body=body) + ) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + caller_id: str | None = None, + send_as: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> FreeswitchConnectorResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "caller_id": caller_id, + "send_as": send_as, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "FreeswitchConnectorResponse", self._http.put(self._path(id), body=body) + ) + + +class RelayApplications( + FabricResource[ + "RelayApplicationListResponse", + "RelayApplicationResponse", + "RelayApplicationCreateRequest", + "RelayApplicationUpdateRequest", + ] +): + """Typed resource for ``/resources/relay_applications`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/relay_applications") + + def create( # type: ignore[override] + self, + *, + name: str, + topic: str, + call_status_callback_url: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> RelayApplicationResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "topic": topic, + "call_status_callback_url": call_status_callback_url, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "RelayApplicationResponse", self._http.post(self._base_path, body=body) + ) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + topic: str | None = None, + call_status_callback_url: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> RelayApplicationResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "topic": topic, + "call_status_callback_url": call_status_callback_url, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "RelayApplicationResponse", self._http.put(self._path(id), body=body) + ) + + +class SipEndpoints( + FabricResource[ + "SipEndpointListResponse", + "SipEndpointResponse", + "SipEndpointCreateRequest", + "SipEndpointUpdateRequest", + ] +): + """Typed resource for ``/resources/sip_endpoints`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/sip_endpoints") + + def create( # type: ignore[override] + self, + *, + id: uuid, + username: str, + caller_id: str, + send_as: str, + ciphers: list[Ciphers], + codecs: list[Codecs], + encryption: Encryption, + call_handler: CallHandlerType, + calling_handler_resource_id: uuid | None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SipEndpointResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "id": id, + "username": username, + "caller_id": caller_id, + "send_as": send_as, + "ciphers": ciphers, + "codecs": codecs, + "encryption": encryption, + "call_handler": call_handler, + "calling_handler_resource_id": calling_handler_resource_id, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SipEndpointResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + username: str | None = None, + caller_id: str | None = None, + send_as: str | None = None, + ciphers: list[Ciphers] | None = None, + codecs: list[Codecs] | None = None, + encryption: Encryption | None = None, + call_handler: CallHandlerType | None = None, + calling_handler_resource_id: uuid | None | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SipEndpointResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "username": username, + "caller_id": caller_id, + "send_as": send_as, + "ciphers": ciphers, + "codecs": codecs, + "encryption": encryption, + "call_handler": call_handler, + "calling_handler_resource_id": calling_handler_resource_id, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SipEndpointResponse", self._http.put(self._path(id), body=body)) + + +class SipGateways( + FabricResource[ + "SipGatewayListResponse", + "SipGatewayResponse", + "SipGatewayRequest", + "SipGatewayRequestUpdate", + ] +): + """Typed resource for ``/resources/sip_gateways`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/sip_gateways") + + def create( # type: ignore[override] + self, + *, + name: str, + uri: str, + encryption: Encryption, + ciphers: list[Ciphers], + codecs: list[Codecs], + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SipGatewayResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "uri": uri, + "encryption": encryption, + "ciphers": ciphers, + "codecs": codecs, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SipGatewayResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + uri: str | None = None, + encryption: Encryption | None = None, + ciphers: list[Ciphers] | None = None, + codecs: list[Codecs] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SipGatewayResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "uri": uri, + "encryption": encryption, + "ciphers": ciphers, + "codecs": codecs, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SipGatewayResponse", self._http.patch(self._path(id), body=body)) + + +class Subscribers( + FabricResource[ + "SubscriberListResponse", + "SubscriberResponse", + "SubscriberRequest", + "SubscriberRequest", + ] +): + """Typed resource for ``/resources/subscribers`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/subscribers") + + def create( # type: ignore[override] + self, + *, + email: str, + password: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + display_name: str | None = None, + job_title: str | None = None, + timezone: str | None = None, + country: str | None = None, + region: str | None = None, + company_name: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SubscriberResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "password": password, + "email": email, + "first_name": first_name, + "last_name": last_name, + "display_name": display_name, + "job_title": job_title, + "timezone": timezone, + "country": country, + "region": region, + "company_name": company_name, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SubscriberResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + password: str | None = None, + email: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + display_name: str | None = None, + job_title: str | None = None, + timezone: str | None = None, + country: str | None = None, + region: str | None = None, + company_name: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SubscriberResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "password": password, + "email": email, + "first_name": first_name, + "last_name": last_name, + "display_name": display_name, + "job_title": job_title, + "timezone": timezone, + "country": country, + "region": region, + "company_name": company_name, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SubscriberResponse", self._http.put(self._path(id), body=body)) + + def list_sip_endpoints( + self, fabric_subscriber_id: str, **params: Any + ) -> SubscriberSipEndpointListResponse: + return cast( + "SubscriberSipEndpointListResponse", + self._http.get( + self._path(fabric_subscriber_id, "sip_endpoints"), params=params or None + ), + ) + + def create_sip_endpoint( + self, + fabric_subscriber_id: str, + *, + username: str, + password: str, + caller_id: str | None = None, + send_as: str | None = None, + ciphers: list[Ciphers] | None = None, + codecs: list[Codecs] | None = None, + encryption: Encryption | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SubscriberSIPEndpoint: + body: dict[str, Any] = { + k: v + for k, v in { + "username": username, + "password": password, + "caller_id": caller_id, + "send_as": send_as, + "ciphers": ciphers, + "codecs": codecs, + "encryption": encryption, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "SubscriberSIPEndpoint", + self._http.post( + self._path(fabric_subscriber_id, "sip_endpoints"), body=body + ), + ) + + def get_sip_endpoint( + self, fabric_subscriber_id: str, id: str, **params: Any + ) -> SubscriberSIPEndpoint: + return cast( + "SubscriberSIPEndpoint", + self._http.get( + self._path(fabric_subscriber_id, "sip_endpoints", id), + params=params or None, + ), + ) + + def update_sip_endpoint( + self, + fabric_subscriber_id: str, + id: str, + *, + username: str | None = None, + password: str | None = None, + caller_id: str | None = None, + send_as: str | None = None, + ciphers: list[Ciphers] | None = None, + codecs: list[Codecs] | None = None, + encryption: Encryption | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SubscriberSIPEndpoint: + body: dict[str, Any] = { + k: v + for k, v in { + "username": username, + "password": password, + "caller_id": caller_id, + "send_as": send_as, + "ciphers": ciphers, + "codecs": codecs, + "encryption": encryption, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "SubscriberSIPEndpoint", + self._http.patch( + self._path(fabric_subscriber_id, "sip_endpoints", id), body=body + ), + ) + + def delete_sip_endpoint(self, fabric_subscriber_id: str, id: str) -> dict[str, Any]: + return cast( + "dict[str, Any]", + self._http.delete(self._path(fabric_subscriber_id, "sip_endpoints", id)), + ) + + +class SwmlScripts( + FabricResource[ + "SwmlScriptListResponse", + "SwmlScriptResponse", + "SwmlScriptCreateRequest", + "SwmlScriptUpdateRequest", + ] +): + """Typed resource for ``/resources/swml_scripts`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/swml_scripts") + + def create( # type: ignore[override] + self, + *, + name: str, + contents: str, + status_callback_url: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SwmlScriptResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "contents": contents, + "status_callback_url": status_callback_url, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SwmlScriptResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + display_name: str | None = None, + contents: str | None = None, + status_callback_url: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SwmlScriptResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "display_name": display_name, + "contents": contents, + "status_callback_url": status_callback_url, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SwmlScriptResponse", self._http.put(self._path(id), body=body)) + + +class SwmlWebhooks( + FabricResource[ + "SWMLWebhookListResponse", + "SWMLWebhookResponse", + "SWMLWebhookCreateRequest", + "SWMLWebhookUpdateRequest", + ] +): + """Typed resource for ``/resources/swml_webhooks`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric/resources/swml_webhooks") + + def create( # type: ignore[override] + self, + *, + primary_request_url: str, + name: str | None = None, + used_for: Literal["calling"] | None = None, + primary_request_method: Literal["GET"] | Literal["POST"] | None = None, + fallback_request_url: str | None = None, + fallback_request_method: Literal["GET"] | Literal["POST"] | None = None, + status_callback_url: str | None = None, + status_callback_method: Literal["GET"] | Literal["POST"] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SWMLWebhookResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "used_for": used_for, + "primary_request_url": primary_request_url, + "primary_request_method": primary_request_method, + "fallback_request_url": fallback_request_url, + "fallback_request_method": fallback_request_method, + "status_callback_url": status_callback_url, + "status_callback_method": status_callback_method, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SWMLWebhookResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + used_for: Literal["calling"] | None = None, + primary_request_url: str | None = None, + primary_request_method: Literal["GET"] | Literal["POST"] | None = None, + fallback_request_url: str | None = None, + fallback_request_method: Literal["GET"] | Literal["POST"] | None = None, + status_callback_url: str | None = None, + status_callback_method: Literal["GET"] | Literal["POST"] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SWMLWebhookResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "used_for": used_for, + "primary_request_url": primary_request_url, + "primary_request_method": primary_request_method, + "fallback_request_url": fallback_request_url, + "fallback_request_method": fallback_request_method, + "status_callback_url": status_callback_url, + "status_callback_method": status_callback_method, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SWMLWebhookResponse", self._http.patch(self._path(id), body=body)) + + +class FabricTokens(BaseResource): + """Typed resource for ```` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fabric") + + def create_subscriber_token( + self, + *, + reference: str, + expire_at: int | None = None, + application_id: uuid | None = None, + password: str | None = None, + first_name: str | None = None, + last_name: str | None = None, + display_name: str | None = None, + job_title: str | None = None, + time_zone: str | None = None, + country: str | None = None, + region: str | None = None, + company_name: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SubscriberTokenResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "reference": reference, + "expire_at": expire_at, + "application_id": application_id, + "password": password, + "first_name": first_name, + "last_name": last_name, + "display_name": display_name, + "job_title": job_title, + "time_zone": time_zone, + "country": country, + "region": region, + "company_name": company_name, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "SubscriberTokenResponse", + self._http.post(self._path("subscribers", "tokens"), body=body), + ) + + def refresh_subscriber_token( + self, + *, + refresh_token: jwt, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SubscriberRefreshTokenResponse: + body: dict[str, Any] = { + k: v for k, v in {"refresh_token": refresh_token}.items() if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "SubscriberRefreshTokenResponse", + self._http.post(self._path("subscribers", "tokens", "refresh"), body=body), + ) + + def create_invite_token( + self, + *, + address_id: uuid, + expires_at: int | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SubscriberInviteTokenCreateResponse: + body: dict[str, Any] = { + k: v + for k, v in {"address_id": address_id, "expires_at": expires_at}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "SubscriberInviteTokenCreateResponse", + self._http.post(self._path("subscriber", "invites"), body=body), + ) + + def create_guest_token( + self, + *, + allowed_addresses: list[uuid], + expire_at: int | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SubscriberGuestTokenCreateResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "allowed_addresses": allowed_addresses, + "expire_at": expire_at, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "SubscriberGuestTokenCreateResponse", + self._http.post(self._path("guests", "tokens"), body=body), + ) + + def create_embed_token( + self, *, token: str, extras: Mapping[str, Any] | None = None, **kwargs: Any + ) -> EmbedsTokensResponse: + body: dict[str, Any] = { + k: v for k, v in {"token": token}.items() if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "EmbedsTokensResponse", + self._http.post(self._path("embeds", "tokens"), body=body), + ) diff --git a/signalwire/signalwire/rest/namespaces/fabric_types_generated.py b/signalwire/signalwire/rest/namespaces/fabric_types_generated.py new file mode 100644 index 00000000..4ffbd501 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/fabric_types_generated.py @@ -0,0 +1,3676 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/fabric/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Any, Literal, TypeAlias, TypedDict + + +class AI(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + ai: AIObject + + +class AIAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class AIAgent(TypedDict, total=False): + """An AI Agent configuration that extends the SWML AI object with additional API-specific properties. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + global_data: dict[str, Any] + hints: list[str | Hint] + languages: list[Languages] + params: AIParams + post_prompt: AIPostPrompt + post_prompt_url: str + pronounce: list[Pronounce] + prompt: AIPrompt + SWAIG: SWAIG + agent_id: uuid + name: str + + +class AIAgentAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressApp] + links: AIAddressPaginationResponse + + +class AIAgentCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + global_data: dict[str, Any] + hints: list[str | Hint] + languages: list[Languages] + params: AIParams + post_prompt: AIPostPrompt + post_prompt_url: str + pronounce: list[Pronounce] + prompt: AIPrompt + SWAIG: SWAIG + agent_id: uuid + name: str + + +class AIAgentCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class AIAgentListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[AIAgentResponse] + links: AIAgentPaginationResponse + + +class AIAgentPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class AIAgentResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["ai_agent"] + created_at: str + updated_at: str + ai_agent: AIAgent + + +class AIAgentUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + global_data: dict[str, Any] + hints: list[str | Hint] + languages: list[Languages] + params: AIParams + post_prompt: AIPostPromptUpdate + post_prompt_url: str + pronounce: list[Pronounce] + prompt: AIPromptUpdate + SWAIG: SWAIGUpdate + agent_id: uuid + name: str + + +class AIAgentUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class AIObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + global_data: dict[str, Any] + hints: list[str | Hint] + languages: list[Languages] + params: AIParams + post_prompt: AIPostPrompt + post_prompt_url: str + pronounce: list[Pronounce] + prompt: AIPrompt + SWAIG: SWAIG + + +class AIParams(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + acknowledge_interruptions: bool | SWMLVar + ai_model: Literal["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1-nano"] | str + ai_name: str + ai_volume: int | SWMLVar + app_name: str + asr_smart_format: bool | SWMLVar + attention_timeout: AttentionTimeout | Literal[0] | SWMLVar + attention_timeout_prompt: str + asr_diarize: bool | SWMLVar + asr_speaker_affinity: bool | SWMLVar + audible_debug: bool | SWMLVar + audible_latency: bool | SWMLVar + background_file: str + background_file_loops: int | None | SWMLVar + background_file_volume: int | SWMLVar + enable_barge: str | bool | SWMLVar + enable_inner_dialog: bool | SWMLVar + enable_pause: bool | SWMLVar + enable_turn_detection: bool | SWMLVar + barge_match_string: str + barge_min_words: int | SWMLVar + barge_functions: bool | SWMLVar + cache_mode: bool | SWMLVar + conscience: str + convo: list[ConversationMessage] + conversation_id: str + conversation_sliding_window: int | SWMLVar + debug_webhook_level: int | SWMLVar + debug_webhook_url: str + debug: bool | int | SWMLVar + direction: Direction | SWMLVar + digit_terminators: str + digit_timeout: int | SWMLVar + end_of_speech_timeout: int | SWMLVar + enable_accounting: bool | SWMLVar + enable_thinking: bool | SWMLVar + enable_vision: bool | SWMLVar + energy_level: float | SWMLVar + first_word_timeout: int | SWMLVar + function_wait_for_talking: bool | SWMLVar + functions_on_no_response: bool | SWMLVar + hard_stop_prompt: str + hard_stop_time: str | SWMLVar + hold_music: str + hold_on_process: bool | SWMLVar + inactivity_timeout: int | SWMLVar + inner_dialog_model: Literal["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1-nano"] | str + inner_dialog_prompt: str + inner_dialog_synced: bool | SWMLVar + initial_sleep_ms: int | SWMLVar + input_poll_freq: int | SWMLVar + interrupt_on_noise: bool | SWMLVar + interrupt_prompt: str + languages_enabled: bool | SWMLVar + local_tz: str + llm_diarize_aware: bool | SWMLVar + max_emotion: int | SWMLVar + max_response_tokens: int | SWMLVar + openai_asr_engine: str + outbound_attention_timeout: int | SWMLVar + persist_global_data: bool | SWMLVar + pom_format: Literal["markdown", "xml"] + save_conversation: bool | SWMLVar + speech_event_timeout: int | SWMLVar + speech_gen_quick_stops: int | SWMLVar + speech_timeout: int | SWMLVar + speak_when_spoken_to: bool | SWMLVar + start_paused: bool | SWMLVar + static_greeting: str + static_greeting_no_barge: bool | SWMLVar + summary_mode: Literal["string", "original"] | SWMLVar + swaig_allow_settings: bool | SWMLVar + swaig_allow_swml: bool | SWMLVar + swaig_post_conversation: bool | SWMLVar + swaig_set_global_data: bool | SWMLVar + swaig_post_swml_vars: bool | list[str] | SWMLVar + thinking_model: Literal["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1-nano"] | str + transparent_barge: bool | SWMLVar + transparent_barge_max_time: int | SWMLVar + transfer_summary: bool | SWMLVar + turn_detection_timeout: int | SWMLVar + tts_number_format: Literal["international", "national"] + verbose_logs: bool | SWMLVar + video_listening_file: str + video_idle_file: str + video_talking_file: str + vision_model: Literal["gpt-4o-mini", "gpt-4.1-mini", "gpt-4.1-nano"] | str + vad_config: str + wait_for_user: bool | SWMLVar + wake_prefix: str + eleven_labs_stability: float | SWMLVar + eleven_labs_similarity: float | SWMLVar + + +AIPostPrompt: TypeAlias = "AIPostPromptText | AIPostPromptPom" + + +class AIPostPromptPom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + pom: list[POM] + + +class AIPostPromptPomUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + pom: list[POM] + + +class AIPostPromptText(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + text: str + + +class AIPostPromptTextUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + text: str + + +AIPostPromptUpdate: TypeAlias = "AIPostPromptTextUpdate | AIPostPromptPomUpdate" + +AIPrompt: TypeAlias = "AIPromptText | AIPromptPom" + + +class AIPromptPom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + pom: list[POM] + contexts: Contexts + + +class AIPromptPomUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + pom: list[POM] + contexts: ContextsUpdate + + +class AIPromptText(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + text: str + contexts: Contexts + + +class AIPromptTextUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + max_tokens: int + temperature: float | SWMLVar + top_p: float | SWMLVar + confidence: float | SWMLVar + presence_penalty: float | SWMLVar + frequency_penalty: float | SWMLVar + text: str + contexts: ContextsUpdate + + +AIPromptUpdate: TypeAlias = "AIPromptTextUpdate | AIPromptPomUpdate" + +Action: TypeAlias = "SWMLAction | ChangeContextAction | ChangeStepAction | ContextSwitchAction | HangupAction | HoldAction | PlaybackBGAction | SayAction | SetGlobalDataAction | SetMetaDataAction | StopAction | StopPlaybackBGAction | ToggleFunctionsAction | UnsetGlobalDataAction | UnsetMetaDataAction | UserInputAction" + +AddressChannel: TypeAlias = "AudioChannel | MessagingChannel | VideoChannel" + + +class AllOfProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + allOf: list[SchemaType] + + +class AmazonBedrock(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + amazon_bedrock: AmazonBedrockObject + + +class AmazonBedrockObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + global_data: dict[str, Any] + params: BedrockParams + post_prompt: BedrockPostPrompt + post_prompt_url: str + prompt: BedrockPrompt + SWAIG: BedrockSWAIG + + +class Answer(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + answer: dict[str, Any] + + +class AnyOfProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + anyOf: list[SchemaType] + + +class ArrayProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["array"] + default: list[Any] + items: SchemaType + + +AttentionTimeout: TypeAlias = "int" + + +class AudioChannel(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + audio: str + + +class BedrockParams(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + attention_timeout: AttentionTimeout | Literal[0] | SWMLVar + hard_stop_time: str | SWMLVar + inactivity_timeout: int | SWMLVar + video_listening_file: str + video_idle_file: str + video_talking_file: str + hard_stop_prompt: str + + +BedrockPostPrompt: TypeAlias = "dict[str, Any]" + +BedrockPrompt: TypeAlias = "dict[str, Any]" + + +class BedrockSWAIG(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + functions: list[BedrockSWAIGFunction] + defaults: SWAIGDefaults + native_functions: list[SWAIGNativeFunction] + includes: list[SWAIGIncludes] + + +BedrockSWAIGFunction: TypeAlias = "dict[str, Any]" + + +class BooleanProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["boolean"] + default: bool | SWMLVar + + +class CXMLScript(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + contents: str + request_count: int + last_accessed_at: str | None + request_url: str + script_type: Literal["calling", "messaging"] + display_name: str + status_callback_url: str | None + status_callback_method: Literal["GET"] | Literal["POST"] + + +class CXMLScriptAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressApp] + links: CXMLScriptAddressPaginationResponse + + +class CXMLScriptAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class CXMLScriptCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + display_name: str + contents: str + status_callback_url: str + status_callback_method: Literal["GET"] | Literal["POST"] + + +class CXMLScriptCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class CXMLScriptListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[CXMLScriptResponse] + links: CXMLScriptAddressPaginationResponse + + +class CXMLScriptResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + name: str + type: Literal["cxml_script"] + created_at: str + updated_at: str + cxml_script: CXMLScript + + +class CXMLScriptUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + display_name: str + contents: str + status_callback_url: str + status_callback_method: Literal["GET"] | Literal["POST"] + + +class CXMLScriptUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class CXMLWebhook(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + used_for: UsedForType + primary_request_url: str + primary_request_method: Literal["GET"] | Literal["POST"] + fallback_request_url: str | None + fallback_request_method: Literal["GET"] | Literal["POST"] + status_callback_url: str | None + status_callback_method: Literal["GET"] | Literal["POST"] + + +class CXMLWebhookAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressApp] + links: CXMLWebhookAddressPaginationResponse + + +class CXMLWebhookAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class CXMLWebhookCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + used_for: UsedForType + primary_request_url: str + primary_request_method: Literal["GET"] | Literal["POST"] + fallback_request_url: str + fallback_request_method: Literal["GET"] | Literal["POST"] + status_callback_url: str + status_callback_method: Literal["GET"] | Literal["POST"] + + +class CXMLWebhookCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class CXMLWebhookListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[CXMLWebhookResponse] + links: CXMLWebhookPaginationResponse + + +class CXMLWebhookPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class CXMLWebhookResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["cxml_webhook"] + created_at: str + updated_at: str + cxml_webhook: CXMLWebhook + + +class CXMLWebhookUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + used_for: UsedForType + primary_request_url: str + primary_request_method: Literal["GET"] | Literal["POST"] + fallback_request_url: str + fallback_request_method: Literal["GET"] | Literal["POST"] + status_callback_url: str + status_callback_method: Literal["GET"] | Literal["POST"] + + +class CXMLWebhookUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class CallFlow(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + title: str + flow_data: str + relayml: str + document_version: int + + +class CallFlowAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressApp] + links: CallFlowAddressPaginationResponse + + +class CallFlowAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class CallFlowCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + title: str + + +class CallFlowCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class CallFlowListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + links: CallFlowAddressPaginationResponse + data: list[CallFlowResponse] + + +class CallFlowResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["call_flow"] + created_at: str + updated_at: str + call_flow: CallFlow + + +class CallFlowUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + title: str + document_version: int + + +class CallFlowUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class CallFlowVersion(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + version: str + created_at: str + updated_at: str + flow_data: str + relayml: str + + +class CallFlowVersionDeployByDocumentVersion(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + document_version: int + + +class CallFlowVersionDeployByVersionId(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + call_flow_version_id: uuid + + +CallFlowVersionDeployRequest: TypeAlias = ( + "CallFlowVersionDeployByDocumentVersion | CallFlowVersionDeployByVersionId" +) + + +class CallFlowVersionDeployResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + created_at: str + updated_at: str + document_version: int + flow_data: str + relayml: str + + +class CallFlowVersionListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[CallFlowVersion] + links: CallFlowVersionsPaginationResponse + + +class CallFlowVersionsPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +CallHandlerType: TypeAlias = ( + "Literal['default', 'passthrough', 'block-pstn', 'resource']" +) + +CallStatus: TypeAlias = "Literal['created', 'ringing', 'answered', 'ended']" + + +class ChangeContextAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + change_context: str + + +class ChangeStepAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + change_step: str + + +Ciphers: TypeAlias = "Literal['AEAD_AES_256_GCM_8', 'AES_256_CM_HMAC_SHA1_80', 'AES_CM_128_HMAC_SHA1_80', 'AES_256_CM_HMAC_SHA1_32', 'AES_CM_128_HMAC_SHA1_32']" + +Codecs: TypeAlias = "Literal['PCMU', 'PCMA', 'G722', 'G729', 'OPUS', 'VP8', 'H264']" + + +class Cond(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + cond: list[CondParams] + + +class CondElse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'else': list[SWMLMethod] + + +CondParams: TypeAlias = "CondReg | CondElse" + + +class CondReg(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + when: str + then: list[SWMLMethod] + # non-identifier field 'else': list[SWMLMethod] + + +class ConferenceRoom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + description: str + display_name: str + max_members: int + quality: Literal["1080p", "720p"] + fps: Literal[30, 20] + join_from: str | None + join_until: str | None + remove_at: str | None + remove_after_seconds_elapsed: int | None + layout: Layout + record_on_start: bool + tone_on_entry_and_exit: bool + room_join_video_off: bool + user_join_video_off: bool + enable_room_previews: bool + sync_audio_video: bool | None + meta: dict[str, Any] + prioritize_handraise: bool + + +class ConferenceRoomAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressRoom] + links: ConferenceRoomAddressPaginationResponse + + +class ConferenceRoomAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class ConferenceRoomCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + display_name: str + description: str + join_from: str + join_until: str + max_members: int + quality: Literal["1080p", "720p"] + remove_at: str + remove_after_seconds_elapsed: int + layout: Layout + record_on_start: bool + enable_room_previews: bool + meta: dict[str, Any] + sync_audio_video: bool + tone_on_entry_and_exit: bool + room_join_video_off: bool + user_join_video_off: bool + + +class ConferenceRoomCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class ConferenceRoomListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + links: ConferenceRoomAddressPaginationResponse + data: list[ConferenceRoomResponse] + + +class ConferenceRoomResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["video_room"] + created_at: str + updated_at: str + conference_room: ConferenceRoom + + +class ConferenceRoomUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + display_name: str + description: str + join_from: str + join_until: str + max_members: int + quality: Literal["1080p", "720p"] + remove_at: str + remove_after_seconds_elapsed: int + layout: Layout + record_on_start: bool + enable_room_previews: bool + meta: dict[str, Any] + sync_audio_video: bool + tone_on_entry_and_exit: bool + room_join_video_off: bool + user_join_video_off: bool + + +class ConferenceRoomUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Connect(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + connect: ( + ConnectDeviceSingle + | ConnectDeviceSerial + | ConnectDeviceParallel + | ConnectDeviceSerialParallel + ) + + +class ConnectDeviceParallel(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory", "optional", "forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + status_url: str + parallel: list[ConnectDeviceSingle] + + +class ConnectDeviceSerial(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory", "optional", "forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + status_url: str + serial: list[ConnectDeviceSingle] + + +class ConnectDeviceSerialParallel(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory", "optional", "forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + status_url: str + serial_parallel: list[list[ConnectDeviceSingle]] + + +class ConnectDeviceSingle(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'from': str + headers: list[ConnectHeaders] + codecs: str + webrtc_media: bool | SWMLVar + session_timeout: int | SWMLVar + ringback: list[str] + result: ConnectSwitch | list[CondParams] + timeout: int | SWMLVar + max_duration: int | SWMLVar + answer_on_bridge: bool | SWMLVar + confirm: str | list[ValidConfirmMethods] + confirm_timeout: int | SWMLVar + username: str + password: str + encryption: Literal["mandatory", "optional", "forbidden"] + call_state_url: str + transfer_after_bridge: str | SWMLVar + call_state_events: list[CallStatus] + status_url: str + to: str + name: str + codec: str + realtime: bool | SWMLVar + status_url_method: Literal["GET", "POST"] + authorization_bearer_token: str + custom_parameters: dict[str, Any] + + +class ConnectHeaders(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + value: str + + +class ConnectSwitch(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + variable: str + case: dict[str, Any] + default: list[SWMLMethod] + + +class ConstProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + const: dict[str, Any] + + +class ContextPOMSteps(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + step_criteria: str + functions: list[str] + valid_contexts: list[str] + skip_user_turn: bool | SWMLVar + end: bool + valid_steps: list[str] + pom: list[POM] + + +ContextSteps: TypeAlias = "ContextPOMSteps | ContextTextSteps" + + +class ContextSwitchAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + context_switch: dict[str, Any] + + +class ContextTextSteps(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + step_criteria: str + functions: list[str] + valid_contexts: list[str] + skip_user_turn: bool | SWMLVar + end: bool + valid_steps: list[str] + text: str + + +class Contexts(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + default: ContextsObject + + +ContextsObject: TypeAlias = "ContextsPOMObject | ContextsTextObject" + +ContextsObjectUpdate: TypeAlias = "ContextsPOMObjectUpdate | ContextsTextObjectUpdate" + + +class ContextsPOMObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + steps: list[ContextSteps] + isolated: bool + enter_fillers: list[FunctionFillers] + exit_fillers: list[FunctionFillers] + pom: list[POM] + + +class ContextsPOMObjectUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + steps: list[ContextSteps] + isolated: bool + enter_fillers: list[FunctionFillers] + exit_fillers: list[FunctionFillers] + pom: list[POM] + + +class ContextsTextObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + steps: list[ContextSteps] + isolated: bool + enter_fillers: list[FunctionFillers] + exit_fillers: list[FunctionFillers] + text: str + + +class ContextsTextObjectUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + steps: list[ContextSteps] + isolated: bool + enter_fillers: list[FunctionFillers] + exit_fillers: list[FunctionFillers] + text: str + + +class ContextsUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + default: ContextsObjectUpdate + + +class ConversationMessage(TypedDict, total=False): + """A message object representing a single turn in the conversation history. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + role: ConversationRole + content: str + lang: str + + +ConversationRole: TypeAlias = "Literal['user', 'assistant', 'system']" + +CustomTranslationFilter: TypeAlias = "str" + + +class CxmlApplication(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + friendly_name: str + voice_url: str | None + voice_method: Literal["GET"] | Literal["POST"] + voice_fallback_url: str | None + voice_fallback_method: Literal["GET"] | Literal["POST"] + status_callback: str | None + status_callback_method: Literal["GET"] | Literal["POST"] + sms_url: str | None + sms_method: Literal["GET"] | Literal["POST"] + sms_fallback_url: str | None + sms_fallback_method: Literal["GET"] | Literal["POST"] + sms_status_callback: str | None + sms_status_callback_method: Literal["GET"] | Literal["POST"] + + +class CxmlApplicationAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddress] + links: CxmlApplicationAddressPaginationResponse + + +class CxmlApplicationAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class CxmlApplicationListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[CxmlApplicationResponse] + links: CxmlApplicationPaginationResponse + + +class CxmlApplicationPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class CxmlApplicationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["cxml_application"] + created_at: str + updated_at: str + cxml_application: CxmlApplication + + +class CxmlApplicationUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + display_name: str + account_sid: uuid + voice_url: str + voice_method: Literal["GET"] | Literal["POST"] + voice_fallback_url: str + voice_fallback_method: Literal["GET"] | Literal["POST"] + status_callback: str + status_callback_method: Literal["GET"] | Literal["POST"] + sms_url: str + sms_method: Literal["GET"] | Literal["POST"] + sms_fallback_url: str + sms_fallback_method: Literal["GET"] | Literal["POST"] + sms_status_callback: str + sms_status_callback_method: Literal["GET"] | Literal["POST"] + + +class CxmlApplicationUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class DataMap(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + output: Output + expressions: list[Expression] + webhooks: list[Webhook] + + +class Denoise(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + denoise: dict[str, Any] + + +class DetectMachine(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + detect_machine: dict[str, Any] + + +class DialogFlowPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class DialogflowAgent(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + say_enabled: bool + say: str + voice: str + display_name: str + dialogflow_reference_id: uuid + dialogflow_reference_name: str + + +class DialogflowAgentAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressApp] + links: DialogflowAgentAddressPaginationResponse + + +class DialogflowAgentAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class DialogflowAgentListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[DialogflowAgentResponse] + links: DialogFlowPaginationResponse + + +class DialogflowAgentResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["dialogflow_agent"] + created_at: str + updated_at: str + dialogflow_agent: DialogflowAgent + + +class DialogflowAgentUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + say_enabled: bool + say: str + voice: str + + +class DialogflowAgentUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +Direction: TypeAlias = "Literal['inbound', 'outbound']" + +DisplayTypes: TypeAlias = "Literal['app', 'room', 'call', 'subscriber']" + + +class DomainApplicationAssignRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + domain_application_id: uuid + + +class DomainApplicationCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class DomainApplicationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + display_name: str + cover_url: str + preview_url: str + locked: bool + channels: AddressChannel + created_at: str + type: Literal["app"] + + +class EmbedTokenCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class EmbedsTokensRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + token: str + + +class EmbedsTokensResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + token: str + + +Encryption: TypeAlias = "Literal['required', 'optional', 'default']" + + +class EnterQueue(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + enter_queue: EnterQueueObject + + +class EnterQueueObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + queue_name: str + transfer_after_bridge: str | SWMLVar + status_url: str + wait_url: str | SWMLVar + wait_time: int | SWMLVar + + +class Execute(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + execute: dict[str, Any] + + +class ExecuteSwitch(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + variable: str + case: dict[str, Any] + default: list[SWMLMethod] + + +class Expression(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + string: str + pattern: str + output: Output + + +class FabricAddress(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + display_name: str + cover_url: str + preview_url: str + locked: bool + channels: AddressChannel + created_at: str + type: DisplayTypes + + +class FabricAddressApp(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + display_name: str + cover_url: str + preview_url: str + locked: bool + channels: AddressChannel + created_at: str + type: Literal["app"] + + +class FabricAddressCall(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + display_name: str + cover_url: str + preview_url: str + locked: bool + channels: AddressChannel + created_at: str + type: Literal["call"] + + +class FabricAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class FabricAddressRoom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + display_name: str + cover_url: str + preview_url: str + locked: bool + channels: AddressChannel + created_at: str + type: Literal["room"] + + +class FabricAddressSubscriber(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + display_name: str + cover_url: str + preview_url: str + locked: bool + channels: AddressChannel + created_at: str + type: Literal["subscriber"] + + +class FabricAddressesResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddress] + links: FabricAddressPaginationResponse + + +class FreeswitchConectorPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class FreeswitchConnector(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + caller_id: str | None + send_as: str | None + + +class FreeswitchConnectorAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressCall] + links: FreeswitchConnectorAddressPaginationResponse + + +class FreeswitchConnectorAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class FreeswitchConnectorCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + token: uuid + + +class FreeswitchConnectorCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class FreeswitchConnectorListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + links: FreeswitchConectorPaginationResponse + data: list[FreeswitchConnectorResponse] + + +class FreeswitchConnectorResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["freeswitch_connector"] + created_at: str + updated_at: str + freeswitch_connector: FreeswitchConnector + + +class FreeswitchConnectorUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + caller_id: str + send_as: str + + +class FreeswitchConnectorUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +FunctionFillers: TypeAlias = "dict[str, Any]" + +FunctionFillersUpdate: TypeAlias = "dict[str, Any]" + + +class FunctionParameters(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["object"] + properties: dict[str, Any] + required: list[str] + + +class Goto(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + goto: dict[str, Any] + + +class GuestTokenCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class HangUpHookSWAIGFunction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: Literal["hangup_hook"] + + +class Hangup(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hangup: dict[str, Any] + + +class HangupAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hangup: bool | SWMLVar + + +class Hint(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hint: str + pattern: str + replace: str + ignore_case: bool | SWMLVar + + +class HoldAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hold: int | SWMLVar | dict[str, Any] + + +class InjectAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + inject: dict[str, Any] + + +class IntegerProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["integer"] + enum: list[int] + default: int | SWMLVar + + +class InviteTokenCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class JoinConference(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + join_conference: JoinConferenceObject + + +class JoinConferenceObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + muted: bool | SWMLVar + beep: Literal["true", "false", "onEnter", "onExit"] + start_on_enter: bool | SWMLVar + end_on_exit: bool | SWMLVar + wait_url: str | SWMLVar + max_participants: int | SWMLVar + record: Literal["do-not-record", "record-from-start"] + region: str + trim: Literal["trim-silence", "do-not-trim"] + coach: str + status_callback_event: Literal[ + "start", + "end", + "join", + "leave", + "mute", + "hold", + "modify", + "speaker", + "announcement", + ] + status_callback: str + status_callback_method: Literal["GET", "POST"] + recording_status_callback: str + recording_status_callback_method: Literal["GET", "POST"] + recording_status_callback_event: Literal["in-progress", "completed", "absent"] + result: dict[str, Any] | list[CondParams] + + +class JoinRoom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + join_room: dict[str, Any] + + +class Label(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + label: str + + +class LanguageParams(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stability: float | SWMLVar + similarity: float | SWMLVar + + +Languages: TypeAlias = "LanguagesWithSoloFillers | LanguagesWithFillers" + + +class LanguagesWithFillers(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + code: str + voice: str + model: str + emotion: Literal["auto"] + speed: Literal["auto"] + engine: str + params: LanguageParams + function_fillers: list[str] + speech_fillers: list[str] + + +class LanguagesWithSoloFillers(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + code: str + voice: str + model: str + emotion: Literal["auto"] + speed: Literal["auto"] + engine: str + params: LanguageParams + fillers: list[str] + + +Layout: TypeAlias = "Literal['grid-responsive', 'grid-responsive-mobile', 'highlight-1-responsive', '1x1', '2x1', '2x2', '5up', '3x3', '4x4', '5x5', '6x6', '8x8', '10x10']" + + +class LiveTranscribe(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + live_transcribe: dict[str, Any] + + +class LiveTranslate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + live_translate: dict[str, Any] + + +class MessagingChannel(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + messaging: str + + +class NullProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["null"] + description: str + + +class NumberProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["number"] + enum: list[int | float] | list[SWMLVar] + default: int | float | SWMLVar + + +class ObjectProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["object"] + default: dict[str, Any] + properties: dict[str, Any] + required: list[str] + + +class OneOfProperty(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + oneOf: list[SchemaType] + + +class Output(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + response: str + action: list[Action] + + +POM: TypeAlias = "PomSectionBodyContent | PomSectionBulletsContent" + + +class Pay(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + pay: dict[str, Any] + + +class PayParameters(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + value: str + + +PayPromptAction: TypeAlias = "PayPromptSayAction | PayPromptPlayAction" + + +class PayPromptPlayAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["Play"] + phrase: str + + +class PayPromptSayAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + type: Literal["Say"] + phrase: str + + +class PayPrompts(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + actions: list[PayPromptAction] + # non-identifier field 'for': str + attempts: str + card_type: str + error_type: str + + +class PhoneRouteAssignRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + phone_route_id: uuid + handler: UsedForType + + +class PhoneRouteCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class PhoneRouteResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + display_name: str + cover_url: str + preview_url: str + locked: bool + channels: AddressChannel + created_at: str + type: Literal["app"] + + +class Play(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + play: PlayWithURL | PlayWithURLS + + +class PlayWithURL(TypedDict, total=False): + """Play with a single URL + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + auto_answer: bool | SWMLVar + volume: float | SWMLVar + say_voice: str + say_language: str + say_gender: str + status_url: str + url: play_url | SWMLVar + + +class PlayWithURLS(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + auto_answer: bool | SWMLVar + volume: float | SWMLVar + say_voice: str + say_language: str + say_gender: str + status_url: str + urls: list[play_url] | list[SWMLVar] + + +class PlaybackBGAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + playback_bg: dict[str, Any] + + +class PomSectionBodyContent(TypedDict, total=False): + """Content model with body text and optional bullets + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + title: str + subsections: list[POM] + numbered: bool | SWMLVar + numberedBullets: bool | SWMLVar + body: str + bullets: list[str] + + +class PomSectionBulletsContent(TypedDict, total=False): + """Content model with bullets and optional body + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + title: str + subsections: list[POM] + numbered: bool | SWMLVar + numberedBullets: bool | SWMLVar + body: str + bullets: list[str] + + +class Prompt(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + prompt: dict[str, Any] + + +class Pronounce(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + replace: str + # non-identifier field 'with': str + ignore_case: bool | SWMLVar + + +class ReceiveFax(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + receive_fax: dict[str, Any] + + +class Record(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + record: dict[str, Any] + + +class RecordCall(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + record_call: dict[str, Any] + + +class RefreshTokenStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class RelayApplication(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + topic: str + call_status_callback_url: str | None + + +class RelayApplicationAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressApp] + links: RelayApplicationAddressPaginationResponse + + +class RelayApplicationAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class RelayApplicationCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + topic: str + call_status_callback_url: str + + +class RelayApplicationCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class RelayApplicationListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[RelayApplicationResponse] + links: RelayApplicationAddressPaginationResponse + + +class RelayApplicationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["relay_application"] + created_at: str + updated_at: str + relay_application: RelayApplication + + +class RelayApplicationUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + topic: str + call_status_callback_url: str + + +class RelayApplicationUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Request(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + request: dict[str, Any] + + +class ResourceAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddress] + links: ResourceAddressPaginationResponse + + +class ResourceAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class ResourceListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[ResourceResponse] + links: ResourcePaginationResponse + + +class ResourcePaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +ResourceResponse: TypeAlias = "ResourceResponseAI | ResourceResponseCallFlow | ResourceResponseCXMLWebhook | ResourceResponseCXMLScript | ResourceResponseCXMLApplication | ResourceResponseDialogFlowAgent | ResourceResponseFSConnector | ResourceResponseRelayApp | ResourceResponseSipEndpoint | ResourceResponseSipGateway | ResourceResponseSubscriber | ResourceResponseSWMLWebhook | ResourceResponseSWMLScript | ResourceResponseConferenceRoom" + + +class ResourceResponseAI(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["ai_agent"] + ai_agent: AIAgent + + +class ResourceResponseCXMLApplication(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["cxml_application"] + cxml_application: CxmlApplication + + +class ResourceResponseCXMLScript(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["cxml_script"] + cxml_script: CXMLScript + + +class ResourceResponseCXMLWebhook(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["cxml_webhook"] + cxml_webhook: CXMLWebhook + + +class ResourceResponseCallFlow(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["call_flow"] + call_flow: CallFlow + + +class ResourceResponseConferenceRoom(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["swml_script"] + conference_room: ConferenceRoom + + +class ResourceResponseDialogFlowAgent(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["dialogflow_agent"] + dialogflow_agent: DialogflowAgent + + +class ResourceResponseFSConnector(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["freeswitch_connector"] + freeswitch_connector: FreeswitchConnector + + +class ResourceResponseRelayApp(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["relay_application"] + relay_application: RelayApplication + + +class ResourceResponseSWMLScript(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["swml_script"] + swml_script: SwmlScript + + +class ResourceResponseSWMLWebhook(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["swml_webhook"] + swml_webhook: SWMLWebhook + + +class ResourceResponseSipEndpoint(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["sip_endpoint"] + sip_endpoint: SipEndpoint + + +class ResourceResponseSipGateway(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["sip_gateway"] + sip_gateway: SipGateway + + +class ResourceResponseSubscriber(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + created_at: str + updated_at: str + type: Literal["subscriber"] + subscriber: Subscriber + + +class ResourceSipEndpointAssignRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + sip_endpoint_id: uuid + + +class ResourceSipEndpointCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class ResourceSipEndpointResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + type: Literal["call"] + cover_url: str | None + preview_url: str | None + channels: AddressChannel + + +class ResourceSipEndpointUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class ResourceSubSipEndpointCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Return(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + # non-identifier field 'return': dict[str, Any] + + +class SIPRefer(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + sip_refer: dict[str, Any] + + +class SMSWithBody(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + to_number: str + from_number: str + region: str + tags: list[str] + body: str + + +class SMSWithMedia(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + to_number: str + from_number: str + region: str + tags: list[str] + media: list[str] + body: str + + +class SWAIG(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + defaults: SWAIGDefaults + native_functions: list[SWAIGNativeFunction] + includes: list[SWAIGIncludes] + functions: list[SWAIGFunction] + internal_fillers: SWAIGInternalFiller + + +class SWAIGDefaults(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + web_hook_url: str + + +SWAIGFunction: TypeAlias = "UserSWAIGFunction | StartUpHookSWAIGFunction | HangUpHookSWAIGFunction | SummarizeConversationSWAIGFunction" + + +class SWAIGIncludes(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + functions: list[str] + url: str + meta_data: dict[str, Any] + + +class SWAIGInternalFiller(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hangup: FunctionFillers + check_time: FunctionFillers + wait_for_user: FunctionFillers + wait_seconds: FunctionFillers + adjust_response_latency: FunctionFillers + next_step: FunctionFillers + change_context: FunctionFillers + get_visual_input: FunctionFillers + get_ideal_strategy: FunctionFillers + + +class SWAIGInternalFillerUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + hangup: FunctionFillersUpdate + check_time: FunctionFillersUpdate + wait_for_user: FunctionFillersUpdate + wait_seconds: FunctionFillersUpdate + adjust_response_latency: FunctionFillersUpdate + next_step: FunctionFillersUpdate + change_context: FunctionFillersUpdate + get_visual_input: FunctionFillersUpdate + get_ideal_strategy: FunctionFillersUpdate + + +SWAIGNativeFunction: TypeAlias = ( + "Literal['check_time', 'wait_seconds', 'wait_for_user', 'adjust_response_latency']" +) + + +class SWAIGUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + defaults: SWAIGDefaults + native_functions: list[SWAIGNativeFunction] + includes: list[SWAIGIncludes] + functions: list[SWAIGFunction] + internal_fillers: SWAIGInternalFillerUpdate + + +class SWMLAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + SWML: SWMLObject + + +SWMLMethod: TypeAlias = "Answer | AI | AmazonBedrock | Cond | Connect | Denoise | EnterQueue | Execute | Goto | Label | LiveTranscribe | LiveTranslate | Hangup | JoinRoom | JoinConference | Play | Prompt | ReceiveFax | Record | RecordCall | Request | Return | SendDigits | SendFax | SendSMS | Set | Sleep | SIPRefer | StopDenoise | StopRecordCall | StopTap | Switch | Tap | Transfer | Unset | Pay | DetectMachine | UserEvent" + + +class SWMLObject(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + version: Literal["1.0.0"] + sections: Section + + +class SWMLScriptAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressApp] + links: SWMLScriptAddressPaginationResponse + + +class SWMLScriptAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +SWMLVar: TypeAlias = "str" + + +class SWMLWebhook(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + name: str + used_for: Literal["calling"] + primary_request_url: str + primary_request_method: Literal["GET"] | Literal["POST"] + fallback_request_url: str | None + fallback_request_method: Literal["GET"] | Literal["POST"] + status_callback_url: str | None + status_callback_method: Literal["GET"] | Literal["POST"] + + +class SWMLWebhookAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressApp] + links: SWMLWebhookAddressPaginationResponse + + +class SWMLWebhookAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SWMLWebhookCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + used_for: Literal["calling"] + primary_request_url: str + primary_request_method: Literal["GET"] | Literal["POST"] + fallback_request_url: str + fallback_request_method: Literal["GET"] | Literal["POST"] + status_callback_url: str + status_callback_method: Literal["GET"] | Literal["POST"] + + +class SWMLWebhookListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[SWMLWebhookResponse] + links: SWMLWebhookPaginationResponse + + +class SWMLWebhookPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SWMLWebhookResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["swml_webhook"] + created_at: str + updated_at: str + swml_webhook: SWMLWebhook + + +class SWMLWebhookUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + used_for: Literal["calling"] + primary_request_url: str + primary_request_method: Literal["GET"] | Literal["POST"] + fallback_request_url: str + fallback_request_method: Literal["GET"] | Literal["POST"] + status_callback_url: str + status_callback_method: Literal["GET"] | Literal["POST"] + + +class SayAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + say: str + + +SchemaType: TypeAlias = "StringProperty | IntegerProperty | NumberProperty | BooleanProperty | ArrayProperty | ObjectProperty | NullProperty | OneOfProperty | AllOfProperty | AnyOfProperty | ConstProperty" + + +class Section(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + main: list[SWMLMethod] + + +class SendDigits(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + send_digits: dict[str, Any] + + +class SendFax(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + send_fax: dict[str, Any] + + +class SendSMS(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + send_sms: SMSWithBody | SMSWithMedia + + +class Set(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + set: dict[str, Any] + + +class SetGlobalDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + set_global_data: dict[str, Any] + + +class SetMetaDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + set_meta_data: dict[str, Any] + + +class SipEndpoint(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + username: str + caller_id: str + send_as: str + ciphers: list[Ciphers] + codecs: list[Codecs] + encryption: Encryption + call_handler: CallHandlerType + calling_handler_resource_id: uuid | None + + +class SipEndpointAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressCall] + links: SipEndpointAddressPaginationResponse + + +class SipEndpointAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SipEndpointCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + username: str + caller_id: str + send_as: str + ciphers: list[Ciphers] + codecs: list[Codecs] + encryption: Encryption + call_handler: CallHandlerType + calling_handler_resource_id: uuid | None + + +class SipEndpointCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class SipEndpointListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[SipEndpointResponse] + links: SipEndpointPaginationResponse + + +class SipEndpointPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SipEndpointResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["sip_endpoint"] + created_at: str + updated_at: str + sip_endpoint: SipEndpoint + + +class SipEndpointUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + username: str + caller_id: str + send_as: str + ciphers: list[Ciphers] + codecs: list[Codecs] + encryption: Encryption + call_handler: CallHandlerType + calling_handler_resource_id: uuid | None + + +class SipEndpointUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class SipGateway(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: str + uri: str + name: str + ciphers: list[Ciphers] + codecs: list[Codecs] + encryption: Encryption + + +class SipGatewayAddressListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressCall] + links: SipGatewayAddressPaginationResponse + + +class SipGatewayAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SipGatewayCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class SipGatewayListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[SipGatewayResponse] + links: SipGatewayPaginationResponse + + +class SipGatewayPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SipGatewayRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + uri: str + encryption: Encryption + ciphers: list[Ciphers] + codecs: list[Codecs] + + +class SipGatewayRequestUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + uri: str + encryption: Encryption + ciphers: list[Ciphers] + codecs: list[Codecs] + + +class SipGatewayResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: str + project_id: str + display_name: str + type: Literal["sip_gateway"] + created_at: str + updated_at: str + sip_gateway: SipGateway + + +class Sleep(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + sleep: dict[str, Any] | int | SWMLVar + + +SpeechEngine: TypeAlias = "Literal['deepgram', 'google']" + + +class StartAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + start: dict[str, Any] + + +class StartUpHookSWAIGFunction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: Literal["startup_hook"] + + +class StopAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop: bool | SWMLVar + + +class StopDenoise(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_denoise: dict[str, Any] + + +class StopPlaybackBGAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_playback_bg: bool | SWMLVar + + +class StopRecordCall(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_record_call: dict[str, Any] + + +class StopTap(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + stop_tap: dict[str, Any] + + +StringFormat: TypeAlias = "Literal['date_time', 'time', 'date', 'duration', 'email', 'hostname', 'ipv4', 'ipv6', 'uri', 'uuid']" + + +class StringProperty(TypedDict, total=False): + """Base interface for all property types + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + nullable: bool | SWMLVar + type: Literal["string"] + enum: list[str] + default: str + pattern: str + format: StringFormat + + +class Subscriber(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + email: str + first_name: str + last_name: str + display_name: str + job_title: str + timezone: str + country: str + region: str + company_name: str + + +class SubscriberAddressPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SubscriberAddressesResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[FabricAddressSubscriber] + links: SubscriberAddressPaginationResponse + + +class SubscriberCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class SubscriberGuestTokenCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + allowed_addresses: list[uuid] + expire_at: int + + +class SubscriberGuestTokenCreateResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + token: jwt + refresh_token: jwt + + +class SubscriberInviteTokenCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + address_id: uuid + expires_at: int + + +class SubscriberInviteTokenCreateResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + token: jwt + + +class SubscriberListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[SubscriberResponse] + links: SubscriberPaginationResponse + + +class SubscriberPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SubscriberRefreshTokenRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + refresh_token: jwt + + +class SubscriberRefreshTokenResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + token: jwt + refresh_token: jwt + + +class SubscriberRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + password: str + email: str + first_name: str + last_name: str + display_name: str + job_title: str + timezone: str + country: str + region: str + company_name: str + + +class SubscriberResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: str + project_id: str + display_name: str + type: Literal["subscriber"] + created_at: str + updated_at: str + subscriber: Subscriber + + +class SubscriberSIPEndpoint(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + username: str + caller_id: str + send_as: str + ciphers: list[Ciphers] + codecs: list[Codecs] + encryption: Encryption + + +class SubscriberSipEndpointListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[SubscriberSIPEndpoint] + links: SubscriberSipEndpointPaginationResponse + + +class SubscriberSipEndpointPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SubscriberSipEndpointRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + username: str + password: str + caller_id: str + send_as: str + ciphers: list[Ciphers] + codecs: list[Codecs] + encryption: Encryption + + +class SubscriberSipEndpointRequestUpdate(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + username: str + password: str + caller_id: str + send_as: str + ciphers: list[Ciphers] + codecs: list[Codecs] + encryption: Encryption + + +class SubscriberTokenRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + reference: str + expire_at: int + application_id: uuid + password: str + first_name: str + last_name: str + display_name: str + job_title: str + time_zone: str + country: str + region: str + company_name: str + + +class SubscriberTokenResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + subscriber_id: uuid + token: jwt + refresh_token: jwt + + +class SubscriberTokenStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class SubscriberUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class SummarizeAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + summarize: dict[str, Any] + + +SummarizeActionUnion: TypeAlias = "SummarizeAction | Literal['summarize']" + + +class SummarizeConversationSWAIGFunction(TypedDict, total=False): + """An internal reserved function that generates a summary of the conversation and sends any specified properties to the configured webhook after the conversation has ended. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: Literal["summarize_conversation"] + + +class Switch(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + switch: dict[str, Any] + + +class SwmlScript(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + contents: str + request_url: str + display_name: str + status_callback_url: str + status_callback_method: Literal["POST"] + + +class SwmlScriptCreateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + name: str + contents: str + status_callback_url: str + + +class SwmlScriptCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class SwmlScriptListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + data: list[SwmlScriptResponse] + links: SwmlScriptPaginationresponse + + +class SwmlScriptPaginationresponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class SwmlScriptResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + project_id: uuid + display_name: str + type: Literal["swml_script"] + created_at: str + updated_at: str + swml_script: SwmlScript + + +class SwmlScriptUpdateRequest(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + display_name: str + contents: str + status_callback_url: str + + +class SwmlScriptUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class SwmlWebhookCreateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class SwmlWebhookUpdateStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Tap(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + tap: dict[str, Any] + + +class ToggleFunctionsAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + toggle_functions: list[dict[str, Any]] + + +TranscribeAction: TypeAlias = ( + "TranscribeStartAction | Literal['stop'] | TranscribeSummarizeActionUnion" +) + +TranscribeDirection: TypeAlias = "Literal['remote-caller', 'local-caller']" + + +class TranscribeStartAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + start: dict[str, Any] + + +class TranscribeSummarizeAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + summarize: dict[str, Any] + + +TranscribeSummarizeActionUnion: TypeAlias = ( + "TranscribeSummarizeAction | Literal['summarize']" +) + + +class Transfer(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + transfer: dict[str, Any] + + +TranslateAction: TypeAlias = ( + "StartAction | Literal['stop'] | SummarizeActionUnion | InjectAction" +) + +TranslateDirection: TypeAlias = "Literal['remote-caller', 'local-caller']" + +TranslationFilterPreset: TypeAlias = ( + "Literal['polite', 'rude', 'professional', 'shakespeare', 'gen-z']" +) + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode403(TypedDict, total=False): + """Access is forbidden. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Forbidden"] + + +class Types_StatusCodes_StatusCode404(TypedDict, total=False): + """The server cannot find the requested resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Not Found"] + + +class Types_StatusCodes_StatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +class Unset(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + unset: str | list[str] + + +class UnsetGlobalDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + unset_global_data: str | dict[str, Any] + + +class UnsetMetaDataAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + unset_meta_data: str | dict[str, Any] + + +UsedForType: TypeAlias = "Literal['calling', 'messaging']" + + +class UserEvent(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + user_event: dict[str, Any] + + +class UserInputAction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + user_input: str + + +class UserSWAIGFunction(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + purpose: str + parameters: FunctionParameters + fillers: FunctionFillers + argument: FunctionParameters + active: bool | SWMLVar + meta_data: dict[str, Any] + meta_data_token: str + data_map: DataMap + skip_fillers: bool | SWMLVar + web_hook_url: str + wait_file: str + wait_file_loops: int | str + wait_for_fillers: bool | SWMLVar + function: str + + +ValidConfirmMethods: TypeAlias = "Cond | Set | Unset | Hangup | Play | Prompt | Record | RecordCall | StopRecordCall | Tap | StopTap | SendDigits | SendSMS | Denoise | StopDenoise" + + +class VideoChannel(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + video: str + + +class Webhook(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + expressions: list[Expression] + error_keys: str | list[str] + url: str + foreach: dict[str, Any] + headers: dict[str, Any] + method: Literal["GET", "POST", "PUT", "DELETE"] + input_args_as_params: bool | SWMLVar + params: dict[str, Any] + require_args: str | list[str] + output: Output + + +jwt: TypeAlias = "str" + +play_url: TypeAlias = "str" + +uuid: TypeAlias = "str" + +ListFabricAddressesResponse: TypeAlias = "FabricAddressesResponse" +GetFabricAddressResponse: TypeAlias = "FabricAddress" +CreateEmbedsTokenRequest: TypeAlias = "EmbedsTokensRequest" +CreateEmbedsTokenResponse: TypeAlias = "EmbedsTokensResponse" +CreateSubscriberGuestTokenRequest: TypeAlias = "SubscriberGuestTokenCreateRequest" +CreateSubscriberGuestTokenResponse: TypeAlias = "SubscriberGuestTokenCreateResponse" +ListResourcesResponse: TypeAlias = "ResourceListResponse" +ListAiAgentsResponse: TypeAlias = "AIAgentListResponse" +CreateAiAgentRequest: TypeAlias = "AIAgentCreateRequest" +CreateAiAgentResponse: TypeAlias = "AIAgentResponse" +ListAiAgentAddressesResponse: TypeAlias = "AIAgentAddressListResponse" +GetAiAgentResponse: TypeAlias = "AIAgentResponse" +UpdateAiAgentRequest: TypeAlias = "AIAgentUpdateRequest" +UpdateAiAgentResponse: TypeAlias = "AIAgentResponse" +ListCallFlowAddressesResponse: TypeAlias = "CallFlowAddressListResponse" +ListCallFlowVersionsResponse: TypeAlias = "CallFlowVersionListResponse" +DeployCallFlowVersionRequest: TypeAlias = "CallFlowVersionDeployRequest" +DeployCallFlowVersionResponse: TypeAlias = "CallFlowVersionDeployResponse" +ListCallFlowsResponse: TypeAlias = "CallFlowListResponse" +CreateCallFlowRequest: TypeAlias = "CallFlowCreateRequest" +CreateCallFlowResponse: TypeAlias = "CallFlowResponse" +GetCallFlowResponse: TypeAlias = "CallFlowResponse" +UpdateCallFlowRequest: TypeAlias = "CallFlowUpdateRequest" +UpdateCallFlowResponse: TypeAlias = "CallFlowResponse" +ListConferenceRoomAddressesResponse: TypeAlias = "ConferenceRoomAddressListResponse" +ListConferenceRoomsResponse: TypeAlias = "ConferenceRoomListResponse" +CreateConferenceRoomRequest: TypeAlias = "ConferenceRoomCreateRequest" +CreateConferenceRoomResponse: TypeAlias = "ConferenceRoomResponse" +GetConferenceRoomResponse: TypeAlias = "ConferenceRoomResponse" +UpdateConferenceRoomRequest: TypeAlias = "ConferenceRoomUpdateRequest" +UpdateConferenceRoomResponse: TypeAlias = "ConferenceRoomResponse" +ListCxmlApplicationsResponse: TypeAlias = "CxmlApplicationListResponse" +GetCxmlApplicationResponse: TypeAlias = "CxmlApplicationResponse" +UpdateCxmlApplicationRequest: TypeAlias = "CxmlApplicationUpdateRequest" +UpdateCxmlApplicationResponse: TypeAlias = "CxmlApplicationResponse" +ListCxmlApplicationAddressesResponse: TypeAlias = "CxmlApplicationAddressListResponse" +ListCxmlScriptsResponse: TypeAlias = "CXMLScriptListResponse" +CreateCxmlScriptRequest: TypeAlias = "CXMLScriptCreateRequest" +CreateCxmlScriptResponse: TypeAlias = "CXMLScriptResponse" +GetCxmlScriptResponse: TypeAlias = "CXMLScriptResponse" +UpdateCxmlScriptRequest: TypeAlias = "CXMLScriptUpdateRequest" +UpdateCxmlScriptResponse: TypeAlias = "CXMLScriptResponse" +ListCxmlScriptAddressesResponse: TypeAlias = "CXMLScriptAddressListResponse" +ListCxmlWebhooksResponse: TypeAlias = "CXMLWebhookListResponse" +CreateCxmlWebhookRequest: TypeAlias = "CXMLWebhookCreateRequest" +CreateCxmlWebhookResponse: TypeAlias = "CXMLWebhookResponse" +ListCxmlWebhookAddressesResponse: TypeAlias = "CXMLWebhookAddressListResponse" +GetCxmlWebhookResponse: TypeAlias = "CXMLWebhookResponse" +UpdateCxmlWebhookRequest: TypeAlias = "CXMLWebhookUpdateRequest" +UpdateCxmlWebhookResponse: TypeAlias = "CXMLWebhookResponse" +ListDialogflowAgentsResponse: TypeAlias = "DialogflowAgentListResponse" +GetDialogflowAgentResponse: TypeAlias = "DialogflowAgentResponse" +UpdateDialogflowAgentRequest: TypeAlias = "DialogflowAgentUpdateRequest" +UpdateDialogflowAgentResponse: TypeAlias = "DialogflowAgentResponse" +ListDialogflowAgentAddressesResponse: TypeAlias = "DialogflowAgentAddressListResponse" +ListFreeswitchConnectorsResponse: TypeAlias = "FreeswitchConnectorListResponse" +CreateFreeswitchConnectorRequest: TypeAlias = "FreeswitchConnectorCreateRequest" +CreateFreeswitchConnectorResponse: TypeAlias = "FreeswitchConnectorResponse" +GetFreeswitchConnectorResponse: TypeAlias = "FreeswitchConnectorResponse" +UpdateFreeswitchConnectorRequest: TypeAlias = "FreeswitchConnectorUpdateRequest" +UpdateFreeswitchConnectorResponse: TypeAlias = "FreeswitchConnectorResponse" +ListFreeswitchConnectorAddressesResponse: TypeAlias = ( + "FreeswitchConnectorAddressListResponse" +) +ListRelayApplicationsResponse: TypeAlias = "RelayApplicationListResponse" +CreateRelayApplicationRequest: TypeAlias = "RelayApplicationCreateRequest" +CreateRelayApplicationResponse: TypeAlias = "RelayApplicationResponse" +GetRelayApplicationResponse: TypeAlias = "RelayApplicationResponse" +UpdateRelayApplicationRequest: TypeAlias = "RelayApplicationUpdateRequest" +UpdateRelayApplicationResponse: TypeAlias = "RelayApplicationResponse" +ListRelayApplicationAddressesResponse: TypeAlias = "RelayApplicationAddressListResponse" +ListSipEndpointsResponse: TypeAlias = "list[SipEndpointListResponse]" +CreateSipEndpointRequest: TypeAlias = "SipEndpointCreateRequest" +CreateSipEndpointResponse: TypeAlias = "SipEndpointResponse" +AssignResourceSipEndpointRequest: TypeAlias = "ResourceSipEndpointAssignRequest" +AssignResourceSipEndpointResponse: TypeAlias = "ResourceSipEndpointResponse" +GetSipEndpointResponse: TypeAlias = "SipEndpointResponse" +UpdateSipEndpointRequest: TypeAlias = "SipEndpointUpdateRequest" +UpdateSipEndpointResponse: TypeAlias = "SipEndpointResponse" +ListSipEndpointAddressesResponse: TypeAlias = "SipEndpointAddressListResponse" +ListSipGatewaysResponse: TypeAlias = "SipGatewayListResponse" +CreateSipGatewayRequest: TypeAlias = "SipGatewayRequest" +CreateSipGatewayResponse: TypeAlias = "SipGatewayResponse" +ListSipGatewayAddressesResponse: TypeAlias = "SipGatewayAddressListResponse" +GetSipGatewayResponse: TypeAlias = "SipGatewayResponse" +UpdateSipGatewayRequest: TypeAlias = "SipGatewayRequestUpdate" +UpdateSipGatewayResponse: TypeAlias = "SipGatewayResponse" +ListSubscribersResponse: TypeAlias = "SubscriberListResponse" +CreateSubscriberRequest: TypeAlias = "SubscriberRequest" +CreateSubscriberResponse: TypeAlias = "SubscriberResponse" +ListSubscriberSipEndpointsResponse: TypeAlias = "SubscriberSipEndpointListResponse" +CreateSubscriberSipEndpointRequest: TypeAlias = "SubscriberSipEndpointRequest" +CreateSubscriberSipEndpointResponse: TypeAlias = "SubscriberSIPEndpoint" +GetSubscriberSipEndpointResponse: TypeAlias = "SubscriberSIPEndpoint" +UpdateSubscriberSipEndpointRequest: TypeAlias = "SubscriberSipEndpointRequestUpdate" +UpdateSubscriberSipEndpointResponse: TypeAlias = "SubscriberSIPEndpoint" +GetSubscriberResponse: TypeAlias = "SubscriberResponse" +UpdateSubscriberRequest: TypeAlias = "SubscriberRequest" +UpdateSubscriberResponse: TypeAlias = "SubscriberResponse" +ListSubscriberAddressesResponse: TypeAlias = "list[SubscriberAddressesResponse]" +ListSwmlScriptsResponse: TypeAlias = "list[SwmlScriptListResponse]" +CreateSwmlScriptRequest: TypeAlias = "SwmlScriptCreateRequest" +CreateSwmlScriptResponse: TypeAlias = "SwmlScriptResponse" +GetSwmlScriptResponse: TypeAlias = "SwmlScriptResponse" +UpdateSwmlScriptRequest: TypeAlias = "SwmlScriptUpdateRequest" +UpdateSwmlScriptResponse: TypeAlias = "SwmlScriptResponse" +ListSwmlScriptAddressesResponse: TypeAlias = "SWMLScriptAddressListResponse" +ListSwmlWebhooksResponse: TypeAlias = "SWMLWebhookListResponse" +CreateSwmlWebhookRequest: TypeAlias = "SWMLWebhookCreateRequest" +CreateSwmlWebhookResponse: TypeAlias = "SWMLWebhookResponse" +GetSwmlWebhookResponse: TypeAlias = "SWMLWebhookResponse" +UpdateSwmlWebhookRequest: TypeAlias = "SWMLWebhookUpdateRequest" +UpdateSwmlWebhookResponse: TypeAlias = "SWMLWebhookResponse" +ListSwmlWebhookAddressesResponse: TypeAlias = "SWMLWebhookAddressListResponse" +GetResourceResponse: TypeAlias = "ResourceResponse" +ListResourceAddressesResponse: TypeAlias = "ResourceAddressListResponse" +AssignResourceDomainApplicationRequest: TypeAlias = "DomainApplicationAssignRequest" +AssignResourceDomainApplicationResponse: TypeAlias = "DomainApplicationResponse" +AssignResourcePhoneRouteRequest: TypeAlias = "PhoneRouteAssignRequest" +AssignResourcePhoneRouteResponse: TypeAlias = "PhoneRouteResponse" +CreateSubscriberInviteTokenRequest: TypeAlias = "SubscriberInviteTokenCreateRequest" +CreateSubscriberInviteTokenResponse: TypeAlias = "SubscriberInviteTokenCreateResponse" +CreateSubscriberTokenRequest: TypeAlias = "SubscriberTokenRequest" +CreateSubscriberTokenResponse: TypeAlias = "SubscriberTokenResponse" +RefreshSubscriberTokenRequest: TypeAlias = "SubscriberRefreshTokenRequest" +RefreshSubscriberTokenResponse: TypeAlias = "SubscriberRefreshTokenResponse" diff --git a/signalwire/signalwire/rest/namespaces/fax_resources_generated.py b/signalwire/signalwire/rest/namespaces/fax_resources_generated.py new file mode 100644 index 00000000..db42c485 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/fax_resources_generated.py @@ -0,0 +1,24 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/fax/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from .._base import ReadResource + +if TYPE_CHECKING: + from .fax_types_generated import ( + LogListResponse, + LogResponse, + ) + + +class FaxLogs(ReadResource["LogListResponse", "LogResponse"]): + """Typed resource for ``/logs`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/fax/logs") diff --git a/signalwire/signalwire/rest/namespaces/fax_types_generated.py b/signalwire/signalwire/rest/namespaces/fax_types_generated.py new file mode 100644 index 00000000..4737802d --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/fax_types_generated.py @@ -0,0 +1,173 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/fax/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Literal, TypeAlias, TypedDict + + +class ChargeDetail(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + charge: float + + +class FaxLog(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + # non-identifier field 'from': str | None + to: str | None + status: Literal[ + "queued", + "initiated", + "ringing", + "in-progress", + "busy", + "failed", + "no-answer", + "canceled", + "completed", + ] + direction: Literal["inbound", "outbound-api", "outbound-dial"] | None + source: Literal["laml"] + type: Literal["laml_call"] + url: str + remote_station: str | None + charge: float + number_of_pages: int | None + quality: Literal["fine", "standard", "superfine"] | None + charge_details: list[ChargeDetail] + created_at: str + error_code: str | None + error_message: str | None + + +class FaxLogShowStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class FaxLogsListStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class LogListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + links: LogPaginationResponse + data: list[FaxLog] + + +class LogPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class LogResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: uuid + # non-identifier field 'from': str | None + to: str | None + status: Literal[ + "queued", + "initiated", + "ringing", + "in-progress", + "busy", + "failed", + "no-answer", + "canceled", + "completed", + ] + direction: Literal["inbound", "outbound-api", "outbound-dial"] | None + source: Literal["laml"] + type: Literal["laml_call"] + url: str + remote_station: str | None + charge: float + number_of_pages: int | None + quality: Literal["fine", "standard", "superfine"] | None + charge_details: list[ChargeDetail] + created_at: str + error_code: str | None + error_message: str | None + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode400(TypedDict, total=False): + """The request is invalid. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Bad Request"] + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode404(TypedDict, total=False): + """The server cannot find the requested resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Not Found"] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +uuid: TypeAlias = "str" + +ListFaxLogsResponse: TypeAlias = "LogListResponse" +GetFaxLogResponse: TypeAlias = "LogResponse" diff --git a/signalwire/signalwire/rest/namespaces/imported_numbers.py b/signalwire/signalwire/rest/namespaces/imported_numbers.py index 7c038ad9..0fc1d58e 100644 --- a/signalwire/signalwire/rest/namespaces/imported_numbers.py +++ b/signalwire/signalwire/rest/namespaces/imported_numbers.py @@ -1,22 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Imported Phone Numbers namespace — create only. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.imported_numbers`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.imported_numbers import ImportedNumbersResource`` working +but emits a DeprecationWarning. Prefer ``client.imported_numbers`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource +import warnings +warnings.warn( + "signalwire.signalwire.rest.namespaces.imported_numbers is deprecated; use client.imported_numbers. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) -class ImportedNumbersResource(BaseResource): - """Import externally-hosted phone numbers.""" +from .relay_rest_resources_generated import ImportedNumbers # noqa: E402 (re-export after the deprecation warn — intentional) - def __init__(self, http): - super().__init__(http, "/api/relay/rest/imported_phone_numbers") +# Back-compat aliases (old name -> generated bare name): +ImportedNumbersResource = ImportedNumbers - def create(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) +__all__ = ["ImportedNumbersResource"] diff --git a/signalwire/signalwire/rest/namespaces/logs.py b/signalwire/signalwire/rest/namespaces/logs.py index 965b17dc..fb82aad8 100644 --- a/signalwire/signalwire/rest/namespaces/logs.py +++ b/signalwire/signalwire/rest/namespaces/logs.py @@ -1,62 +1,25 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Logs namespace — message, voice, fax, and conference logs (read-only). +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.logs`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.logs import MessageLogs`` working +but emits a DeprecationWarning. Prefer ``client.logs`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource - - -class MessageLogs(BaseResource): - """Message log queries.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def get(self, log_id): - return self._http.get(self._path(log_id)) - - -class VoiceLogs(BaseResource): - """Voice log queries.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def get(self, log_id): - return self._http.get(self._path(log_id)) - - def list_events(self, log_id, **params): - return self._http.get(self._path(log_id, "events"), params=params or None) - - -class FaxLogs(BaseResource): - """Fax log queries.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def get(self, log_id): - return self._http.get(self._path(log_id)) - - -class ConferenceLogs(BaseResource): - """Conference log queries.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) +import warnings +warnings.warn( + "signalwire.signalwire.rest.namespaces.logs is deprecated; use client.logs. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) -class LogsNamespace: - """Logs API namespace.""" +from .message_resources_generated import MessageLogs # noqa: E402 (re-export after the deprecation warn — intentional) +from .voice_resources_generated import VoiceLogs # noqa: E402 (re-export after the deprecation warn — intentional) +from .fax_resources_generated import FaxLogs # noqa: E402 (re-export after the deprecation warn — intentional) +from .logs_resources_generated import ConferenceLogs # noqa: E402 (re-export after the deprecation warn — intentional) +from ._client_tree_generated import LogsNamespace # noqa: E402 (re-export after the deprecation warn — intentional) - def __init__(self, http): - self.messages = MessageLogs(http, "/api/messaging/logs") - self.voice = VoiceLogs(http, "/api/voice/logs") - self.fax = FaxLogs(http, "/api/fax/logs") - self.conferences = ConferenceLogs(http, "/api/logs/conferences") +__all__ = ["ConferenceLogs", "FaxLogs", "LogsNamespace", "MessageLogs", "VoiceLogs"] diff --git a/signalwire/signalwire/rest/namespaces/logs_resources_generated.py b/signalwire/signalwire/rest/namespaces/logs_resources_generated.py new file mode 100644 index 00000000..6f440e64 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/logs_resources_generated.py @@ -0,0 +1,29 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/logs/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from .._base import BaseResource + +if TYPE_CHECKING: + from .logs_types_generated import ( + ConferencesResponse, + ) + + +class ConferenceLogs(BaseResource): + """Typed resource for ``/conferences`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/logs/conferences") + + def list(self, **params: Any) -> ConferencesResponse: + return cast( + "ConferencesResponse", + self._http.get(self._base_path, params=params or None), + ) diff --git a/signalwire/signalwire/rest/namespaces/logs_types_generated.py b/signalwire/signalwire/rest/namespaces/logs_types_generated.py new file mode 100644 index 00000000..15541c97 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/logs_types_generated.py @@ -0,0 +1,150 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/logs/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Literal, TypeAlias, TypedDict + + +class ChargeDetails(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + description: str + charge: str + + +class ConferenceLogPaginationLinks(TypedDict, total=False): + """Pagination links for conference log list responses. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + self: str + first: str + next: str + prev: str + + +class ConferenceLogsStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class ConferencesResponse(TypedDict, total=False): + """Response containing a list of conferences. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: ConferenceLogPaginationLinks + data: list[CxmlConference | RelayConference | VideoRoomSessionConference] + + +class CxmlConference(TypedDict, total=False): + """Core conference object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + created_at: str + project_id: uuid + region: str + name: str | None + status: str | None + max_size: int | None + current_participants: int + updated_at: str + type: Literal["cxml_conference"] + + +class RelayConference(TypedDict, total=False): + """Core conference object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + created_at: str + project_id: uuid + region: str + name: str | None + status: str | None + max_size: int | None + current_participants: int + updated_at: str + type: Literal["relay_conference"] + recording_url: str | None + recording_duration: int | None + recording_file_size: int | None + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +class VideoRoomSessionConference(TypedDict, total=False): + """Core conference object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + created_at: str + source: str + type: Literal["video_conference_session", "video_room_session"] + url: str + room_name: str | None + status: str | None + locked: bool + started_at: str | None + ended_at: str | None + charge: str + charge_details: list[ChargeDetails] + + +uuid: TypeAlias = "str" + +ListConferencesResponse: TypeAlias = "ConferencesResponse" diff --git a/signalwire/signalwire/rest/namespaces/lookup.py b/signalwire/signalwire/rest/namespaces/lookup.py index 0118a613..0cdc490a 100644 --- a/signalwire/signalwire/rest/namespaces/lookup.py +++ b/signalwire/signalwire/rest/namespaces/lookup.py @@ -1,22 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Phone Number Lookup namespace. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.lookup`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.lookup import LookupResource`` working +but emits a DeprecationWarning. Prefer ``client.lookup`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource +import warnings +warnings.warn( + "signalwire.signalwire.rest.namespaces.lookup is deprecated; use client.lookup. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) -class LookupResource(BaseResource): - """Phone number lookup (carrier, CNAM).""" +from .relay_rest_resources_generated import Lookup # noqa: E402 (re-export after the deprecation warn — intentional) - def __init__(self, http): - super().__init__(http, "/api/relay/rest/lookup") +# Back-compat aliases (old name -> generated bare name): +LookupResource = Lookup - def phone_number(self, e164, **params): - return self._http.get(self._path("phone_number", e164), params=params or None) +__all__ = ["LookupResource"] diff --git a/signalwire/signalwire/rest/namespaces/message_resources_generated.py b/signalwire/signalwire/rest/namespaces/message_resources_generated.py new file mode 100644 index 00000000..9535ed9f --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/message_resources_generated.py @@ -0,0 +1,24 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/message/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from .._base import ReadResource + +if TYPE_CHECKING: + from .message_types_generated import ( + LogListResponse, + LogRetrieveResponse, + ) + + +class MessageLogs(ReadResource["LogListResponse", "LogRetrieveResponse"]): + """Typed resource for ``/logs`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/messaging/logs") diff --git a/signalwire/signalwire/rest/namespaces/message_types_generated.py b/signalwire/signalwire/rest/namespaces/message_types_generated.py new file mode 100644 index 00000000..c9db3d47 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/message_types_generated.py @@ -0,0 +1,167 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/message/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Literal, TypeAlias, TypedDict + + +class ChargeDetail(TypedDict, total=False): + """Details on charges associated with this log. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + charge: float + + +class LogListResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + links: LogPaginationResponse + data: list[MessageLog] + + +class LogPaginationResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + self: str + first: str + next: str + prev: str + + +class LogRetrieveResponse(TypedDict, total=False): + """Response model for message log retrieve endpoint + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + # non-identifier field 'from': str + to: str + status: Literal[ + "queued", "initiated", "delivered", "sent", "received", "undelivered", "failed" + ] + direction: Literal[ + "inbound", "outbound", "outbound-api", "outbound-call", "outbound-reply" + ] + kind: Literal["sms", "mms"] + source: Literal["realtime_api", "laml"] + type: Literal["relay_message", "laml_message"] + url: str | None + number_of_segments: int + charge: float + charge_details: list[ChargeDetail] + created_at: str + + +class MessageLog(TypedDict, total=False): + """Message log entry with all activity details + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + # non-identifier field 'from': str + to: str + status: Literal[ + "queued", "initiated", "delivered", "sent", "received", "undelivered", "failed" + ] + direction: Literal[ + "inbound", "outbound", "outbound-api", "outbound-call", "outbound-reply" + ] + kind: Literal["sms", "mms"] + source: Literal["realtime_api", "laml"] + type: Literal["relay_message", "laml_message"] + url: str | None + number_of_segments: int + charge: float + charge_details: list[ChargeDetail] + created_at: str + + +class MessageLogShowStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class MessageLogsListStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode400(TypedDict, total=False): + """The request is invalid. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Bad Request"] + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode404(TypedDict, total=False): + """The server cannot find the requested resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Not Found"] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +uuid: TypeAlias = "str" + +ListMessageLogsResponse: TypeAlias = "LogListResponse" +GetMessageLogResponse: TypeAlias = "LogRetrieveResponse" diff --git a/signalwire/signalwire/rest/namespaces/mfa.py b/signalwire/signalwire/rest/namespaces/mfa.py index 3763f4a7..c3edba8f 100644 --- a/signalwire/signalwire/rest/namespaces/mfa.py +++ b/signalwire/signalwire/rest/namespaces/mfa.py @@ -1,28 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -MFA (Multi-Factor Authentication) namespace. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.mfa`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.mfa import MfaResource`` working +but emits a DeprecationWarning. Prefer ``client.mfa`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource - - -class MfaResource(BaseResource): - """Multi-factor authentication via SMS or phone call.""" +import warnings - def __init__(self, http): - super().__init__(http, "/api/relay/rest/mfa") +warnings.warn( + "signalwire.signalwire.rest.namespaces.mfa is deprecated; use client.mfa. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - def sms(self, **kwargs): - return self._http.post(self._path("sms"), body=kwargs) +from .relay_rest_resources_generated import Mfa # noqa: E402 (re-export after the deprecation warn — intentional) - def call(self, **kwargs): - return self._http.post(self._path("call"), body=kwargs) +# Back-compat aliases (old name -> generated bare name): +MfaResource = Mfa - def verify(self, request_id, **kwargs): - return self._http.post(self._path(request_id, "verify"), body=kwargs) +__all__ = ["MfaResource"] diff --git a/signalwire/signalwire/rest/namespaces/number_groups.py b/signalwire/signalwire/rest/namespaces/number_groups.py index dde7e05c..7012bc9e 100644 --- a/signalwire/signalwire/rest/namespaces/number_groups.py +++ b/signalwire/signalwire/rest/namespaces/number_groups.py @@ -1,43 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Licensed under the MIT License. -See LICENSE file in the project root for full license information. - -Number Groups namespace — CRUD + membership management. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.number_groups`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.number_groups import NumberGroupsResource`` working +but emits a DeprecationWarning. Prefer ``client.number_groups`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import CrudResource - - -class NumberGroupsResource(CrudResource): - """Number group management with membership operations.""" - - _update_method = "PUT" - - def __init__(self, http): - super().__init__(http, "/api/relay/rest/number_groups") +import warnings - def list_memberships(self, group_id, **params): - return self._http.get( - self._path(group_id, "number_group_memberships"), - params=params or None, - ) +warnings.warn( + "signalwire.signalwire.rest.namespaces.number_groups is deprecated; use client.number_groups. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - def add_membership(self, group_id, **kwargs): - return self._http.post( - self._path(group_id, "number_group_memberships"), - body=kwargs, - ) +from .relay_rest_resources_generated import NumberGroups # noqa: E402 (re-export after the deprecation warn — intentional) - def get_membership(self, membership_id): - return self._http.get( - f"/api/relay/rest/number_group_memberships/{membership_id}" - ) +# Back-compat aliases (old name -> generated bare name): +NumberGroupsResource = NumberGroups - def delete_membership(self, membership_id): - return self._http.delete( - f"/api/relay/rest/number_group_memberships/{membership_id}" - ) +__all__ = ["NumberGroupsResource"] diff --git a/signalwire/signalwire/rest/namespaces/phone_numbers.py b/signalwire/signalwire/rest/namespaces/phone_numbers.py index 1d7bfc8f..ca272136 100644 --- a/signalwire/signalwire/rest/namespaces/phone_numbers.py +++ b/signalwire/signalwire/rest/namespaces/phone_numbers.py @@ -1,161 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Phone Numbers namespace — list, search, purchase, get, update, release, bind. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.phone_numbers`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.phone_numbers import PhoneNumbersResource`` working +but emits a DeprecationWarning. Prefer ``client.phone_numbers`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from typing import cast - -from .._base import CrudResource -from ..call_handler import PhoneCallHandler - - -class PhoneNumbersResource(CrudResource): - """Phone number management. - - Supports the standard CRUD surface plus typed helpers for binding an - inbound call to a handler (SWML webhook, cXML webhook, AI agent, call - flow, RELAY application/topic). The binding model is: set - ``call_handler`` + the handler-specific companion field on the phone - number; the server auto-materializes the matching Fabric resource. - See :mod:`signalwire.rest.call_handler` for the enum, and the - porting-sdk's ``phone-binding.md`` for the full model. - """ - - _update_method = "PUT" - - def __init__(self, http): - super().__init__(http, "/api/relay/rest/phone_numbers") - - def search(self, **params): - return self._http.get(self._path("search"), params=params or None) - - # -- Typed binding helpers ------------------------------------------- - # - # Each helper is a one-line wrapper over ``update`` with the right - # ``call_handler`` value and companion field already set. Pass through - # extra kwargs for cases the helper doesn't name explicitly (e.g. - # ``call_fallback_url`` on cXML webhooks). - - def set_swml_webhook(self, resource_id: str, url: str, **extra) -> dict: - """Route inbound calls to an SWML webhook URL. - - Your backend returns an SWML document per call. The server - auto-creates a ``swml_webhook`` Fabric resource keyed off this URL. - """ - return cast( - dict, - self.update( - resource_id, - call_handler=PhoneCallHandler.RELAY_SCRIPT.value, - call_relay_script_url=url, - **extra, - ), - ) - - def set_cxml_webhook( - self, - resource_id: str, - url: str, - fallback_url: str | None = None, - status_callback_url: str | None = None, - **extra, - ) -> dict: - """Route inbound calls to a cXML (Twilio-compat / LAML) webhook. - - Despite the wire value ``laml_webhooks`` being plural, this creates - a single ``cxml_webhook`` Fabric resource. ``fallback_url`` is used - when the primary URL fails; ``status_callback_url`` receives call - status updates. - """ - body = { - "call_handler": PhoneCallHandler.LAML_WEBHOOKS.value, - "call_request_url": url, - } - if fallback_url is not None: - body["call_fallback_url"] = fallback_url - if status_callback_url is not None: - body["call_status_callback_url"] = status_callback_url - body.update(extra) - return cast(dict, self.update(resource_id, **body)) - - def set_cxml_application( - self, resource_id: str, application_id: str, **extra - ) -> dict: - """Route inbound calls to an existing cXML application by ID.""" - return cast( - dict, - self.update( - resource_id, - call_handler=PhoneCallHandler.LAML_APPLICATION.value, - call_laml_application_id=application_id, - **extra, - ), - ) - - def set_ai_agent(self, resource_id: str, agent_id: str, **extra) -> dict: - """Route inbound calls to an AI Agent Fabric resource by ID.""" - return cast( - dict, - self.update( - resource_id, - call_handler=PhoneCallHandler.AI_AGENT.value, - call_ai_agent_id=agent_id, - **extra, - ), - ) +import warnings - def set_call_flow( - self, - resource_id: str, - flow_id: str, - version: str | None = None, - **extra, - ) -> dict: - """Route inbound calls to a Call Flow by ID. +warnings.warn( + "signalwire.signalwire.rest.namespaces.phone_numbers is deprecated; use client.phone_numbers. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - ``version`` accepts ``"working_copy"`` or ``"current_deployed"`` - (server default when omitted). - """ - body = { - "call_handler": PhoneCallHandler.CALL_FLOW.value, - "call_flow_id": flow_id, - } - if version is not None: - body["call_flow_version"] = version - body.update(extra) - return cast(dict, self.update(resource_id, **body)) +from .relay_rest_resources_generated import PhoneNumbers # noqa: E402 (re-export after the deprecation warn — intentional) - def set_relay_application(self, resource_id: str, name: str, **extra) -> dict: - """Route inbound calls to a named RELAY application.""" - return cast( - dict, - self.update( - resource_id, - call_handler=PhoneCallHandler.RELAY_APPLICATION.value, - call_relay_application=name, - **extra, - ), - ) +# Back-compat aliases (old name -> generated bare name): +PhoneNumbersResource = PhoneNumbers - def set_relay_topic( - self, - resource_id: str, - topic: str, - status_callback_url: str | None = None, - **extra, - ) -> dict: - """Route inbound calls to a RELAY topic (client subscription).""" - body = { - "call_handler": PhoneCallHandler.RELAY_TOPIC.value, - "call_relay_topic": topic, - } - if status_callback_url is not None: - body["call_relay_topic_status_callback_url"] = status_callback_url - body.update(extra) - return cast(dict, self.update(resource_id, **body)) +__all__ = ["PhoneNumbersResource"] diff --git a/signalwire/signalwire/rest/namespaces/project.py b/signalwire/signalwire/rest/namespaces/project.py index c67d9a7b..07c1caea 100644 --- a/signalwire/signalwire/rest/namespaces/project.py +++ b/signalwire/signalwire/rest/namespaces/project.py @@ -1,35 +1,22 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Project API namespace — API token management. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.project`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.project import ProjectTokens`` working +but emits a DeprecationWarning. Prefer ``client.project`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource - - -class ProjectTokens(BaseResource): - """Project API token management.""" - - def __init__(self, http): - super().__init__(http, "/api/project/tokens") - - def create(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) - - def update(self, token_id, **kwargs): - return self._http.patch(self._path(token_id), body=kwargs) - - def delete(self, token_id): - return self._http.delete(self._path(token_id)) +import warnings +warnings.warn( + "signalwire.signalwire.rest.namespaces.project is deprecated; use client.project. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) -class ProjectNamespace: - """Project API namespace.""" +from .project_resources_generated import ProjectTokens # noqa: E402 (re-export after the deprecation warn — intentional) +from ._client_tree_generated import ProjectNamespace # noqa: E402 (re-export after the deprecation warn — intentional) - def __init__(self, http): - self.tokens = ProjectTokens(http) +__all__ = ["ProjectNamespace", "ProjectTokens"] diff --git a/signalwire/signalwire/rest/namespaces/project_resources_generated.py b/signalwire/signalwire/rest/namespaces/project_resources_generated.py new file mode 100644 index 00000000..e8108ec7 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/project_resources_generated.py @@ -0,0 +1,70 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/project/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast +from collections.abc import Mapping + +from .._base import BaseResource + +if TYPE_CHECKING: + from .project_types_generated import ( + TokenPermission, + TokenResponse, + ) + + +class ProjectTokens(BaseResource): + """Typed resource for ``/tokens`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/project/tokens") + + def create( + self, + *, + name: str, + permissions: list[TokenPermission], + subproject_id: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> TokenResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "permissions": permissions, + "subproject_id": subproject_id, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("TokenResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + token_id: str, + *, + name: str | None = None, + permissions: list[TokenPermission] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> TokenResponse: + body: dict[str, Any] = { + k: v + for k, v in {"name": name, "permissions": permissions}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("TokenResponse", self._http.patch(self._path(token_id), body=body)) + + def delete(self, token_id: str) -> dict[str, Any]: + return cast("dict[str, Any]", self._http.delete(self._path(token_id))) diff --git a/signalwire/signalwire/rest/namespaces/project_types_generated.py b/signalwire/signalwire/rest/namespaces/project_types_generated.py new file mode 100644 index 00000000..7b14c524 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/project_types_generated.py @@ -0,0 +1,101 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/project/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Literal, TypeAlias, TypedDict + + +class CreateTokenRequest(TypedDict, total=False): + """Request body for creating a new API Token. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + permissions: list[TokenPermission] + subproject_id: str + + +TokenPermission: TypeAlias = "Literal['calling', 'chat', 'datasphere', 'fax', 'management', 'messaging', 'numbers', 'pubsub', 'storage', 'tasking', 'video']" + + +class TokenResponse(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + id: str + name: str + permissions: list[TokenPermission] + token: str + + +class TokenStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode404(TypedDict, total=False): + """The server cannot find the requested resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Not Found"] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +class UpdateTokenRequest(TypedDict, total=False): + """Request body for updating an API Token. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + permissions: list[TokenPermission] + + +CreateTokenResponse: TypeAlias = "TokenResponse" +UpdateTokenResponse: TypeAlias = "TokenResponse" diff --git a/signalwire/signalwire/rest/namespaces/pubsub.py b/signalwire/signalwire/rest/namespaces/pubsub.py index 1fb41816..cb813123 100644 --- a/signalwire/signalwire/rest/namespaces/pubsub.py +++ b/signalwire/signalwire/rest/namespaces/pubsub.py @@ -1,22 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -PubSub API namespace — token creation. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.pubsub`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.pubsub import PubSubResource`` working +but emits a DeprecationWarning. Prefer ``client.pubsub`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource +import warnings +warnings.warn( + "signalwire.signalwire.rest.namespaces.pubsub is deprecated; use client.pubsub. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) -class PubSubResource(BaseResource): - """PubSub token generation.""" +from .pubsub_resources_generated import PubSub # noqa: E402 (re-export after the deprecation warn — intentional) - def __init__(self, http): - super().__init__(http, "/api/pubsub/tokens") +# Back-compat aliases (old name -> generated bare name): +PubSubResource = PubSub - def create_token(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) +__all__ = ["PubSubResource"] diff --git a/signalwire/signalwire/rest/namespaces/pubsub_resources_generated.py b/signalwire/signalwire/rest/namespaces/pubsub_resources_generated.py new file mode 100644 index 00000000..772bfdea --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/pubsub_resources_generated.py @@ -0,0 +1,51 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/pubsub/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast +from collections.abc import Mapping + +from .._base import BaseResource + +if TYPE_CHECKING: + from .pubsub_types_generated import ( + PubSubChannels, + PubSubState, + PubSubToken, + ) + + +class PubSub(BaseResource): + """Typed resource for ``/tokens`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/pubsub/tokens") + + def create_token( + self, + *, + ttl: int, + channels: PubSubChannels, + member_id: str | None = None, + state: PubSubState | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> PubSubToken: + body: dict[str, Any] = { + k: v + for k, v in { + "ttl": ttl, + "channels": channels, + "member_id": member_id, + "state": state, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("PubSubToken", self._http.post(self._base_path, body=body)) diff --git a/signalwire/signalwire/rest/namespaces/pubsub_types_generated.py b/signalwire/signalwire/rest/namespaces/pubsub_types_generated.py new file mode 100644 index 00000000..2b6045e6 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/pubsub_types_generated.py @@ -0,0 +1,101 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/pubsub/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Any, Literal, TypeAlias, TypedDict + + +class NewPubSubToken(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + ttl: int + channels: PubSubChannels + member_id: str + state: PubSubState + + +PubSubChannels: TypeAlias = "dict[str, Any]" + + +class PubSubPermissionWithRead(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + read: bool + write: bool + + +class PubSubPermissionWithWrite(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + read: bool + write: bool + + +PubSubState: TypeAlias = "dict[str, Any]" + + +class PubSubToken(TypedDict, total=False): + """Open shape: extra server keys permitted; not validated at runtime.""" + + token: str + + +class PubSubToken422Error(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode400(TypedDict, total=False): + """The request is invalid. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Bad Request"] + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +CreateTokenRequest: TypeAlias = "NewPubSubToken" +CreateTokenResponse: TypeAlias = "PubSubToken" diff --git a/signalwire/signalwire/rest/namespaces/queues.py b/signalwire/signalwire/rest/namespaces/queues.py index 9d86ae99..4bc457a1 100644 --- a/signalwire/signalwire/rest/namespaces/queues.py +++ b/signalwire/signalwire/rest/namespaces/queues.py @@ -1,30 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Queues namespace — CRUD + member management. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.queues`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.queues import QueuesResource`` working +but emits a DeprecationWarning. Prefer ``client.queues`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import CrudResource - - -class QueuesResource(CrudResource): - """Queue management with member operations.""" - - _update_method = "PUT" +import warnings - def __init__(self, http): - super().__init__(http, "/api/relay/rest/queues") +warnings.warn( + "signalwire.signalwire.rest.namespaces.queues is deprecated; use client.queues. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - def list_members(self, queue_id, **params): - return self._http.get(self._path(queue_id, "members"), params=params or None) +from .relay_rest_resources_generated import Queues # noqa: E402 (re-export after the deprecation warn — intentional) - def get_next_member(self, queue_id): - return self._http.get(self._path(queue_id, "members", "next")) +# Back-compat aliases (old name -> generated bare name): +QueuesResource = Queues - def get_member(self, queue_id, member_id): - return self._http.get(self._path(queue_id, "members", member_id)) +__all__ = ["QueuesResource"] diff --git a/signalwire/signalwire/rest/namespaces/recordings.py b/signalwire/signalwire/rest/namespaces/recordings.py index 5f322ec3..31a7befd 100644 --- a/signalwire/signalwire/rest/namespaces/recordings.py +++ b/signalwire/signalwire/rest/namespaces/recordings.py @@ -1,28 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Recordings namespace — list, get, delete (no create/update). +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.recordings`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.recordings import RecordingsResource`` working +but emits a DeprecationWarning. Prefer ``client.recordings`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource - - -class RecordingsResource(BaseResource): - """Recording management (read-only + delete).""" +import warnings - def __init__(self, http): - super().__init__(http, "/api/relay/rest/recordings") +warnings.warn( + "signalwire.signalwire.rest.namespaces.recordings is deprecated; use client.recordings. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - def list(self, **params): - return self._http.get(self._base_path, params=params or None) +from .relay_rest_resources_generated import Recordings # noqa: E402 (re-export after the deprecation warn — intentional) - def get(self, recording_id): - return self._http.get(self._path(recording_id)) +# Back-compat aliases (old name -> generated bare name): +RecordingsResource = Recordings - def delete(self, recording_id): - return self._http.delete(self._path(recording_id)) +__all__ = ["RecordingsResource"] diff --git a/signalwire/signalwire/rest/namespaces/registry.py b/signalwire/signalwire/rest/namespaces/registry.py index 801962ec..04cbf42d 100644 --- a/signalwire/signalwire/rest/namespaces/registry.py +++ b/signalwire/signalwire/rest/namespaces/registry.py @@ -1,75 +1,22 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -10DLC Campaign Registry namespace — brands, campaigns, orders, numbers. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.registry`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.registry import RegistryBrands`` working +but emits a DeprecationWarning. Prefer ``client.registry`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource - - -class RegistryBrands(BaseResource): - """10DLC brand management.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def create(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) - - def get(self, brand_id): - return self._http.get(self._path(brand_id)) - - def list_campaigns(self, brand_id, **params): - return self._http.get(self._path(brand_id, "campaigns"), params=params or None) - - def create_campaign(self, brand_id, **kwargs): - return self._http.post(self._path(brand_id, "campaigns"), body=kwargs) - - -class RegistryCampaigns(BaseResource): - """10DLC campaign management.""" - - def get(self, campaign_id): - return self._http.get(self._path(campaign_id)) - - def update(self, campaign_id, **kwargs): - return self._http.put(self._path(campaign_id), body=kwargs) - - def list_numbers(self, campaign_id, **params): - return self._http.get(self._path(campaign_id, "numbers"), params=params or None) - - def list_orders(self, campaign_id, **params): - return self._http.get(self._path(campaign_id, "orders"), params=params or None) - - def create_order(self, campaign_id, **kwargs): - return self._http.post(self._path(campaign_id, "orders"), body=kwargs) - - -class RegistryOrders(BaseResource): - """10DLC assignment order management.""" - - def get(self, order_id): - return self._http.get(self._path(order_id)) - - -class RegistryNumbers(BaseResource): - """10DLC number assignment management.""" - - def delete(self, number_id): - return self._http.delete(self._path(number_id)) +import warnings +warnings.warn( + "signalwire.signalwire.rest.namespaces.registry is deprecated; use client.registry. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) -class RegistryNamespace: - """10DLC Campaign Registry namespace.""" +from .relay_rest_resources_generated import RegistryBrands, RegistryCampaigns, RegistryNumbers, RegistryOrders # noqa: E402 (re-export after the deprecation warn — intentional) +from ._client_tree_generated import RegistryNamespace # noqa: E402 (re-export after the deprecation warn — intentional) - def __init__(self, http): - base = "/api/relay/rest/registry/beta" - self.brands = RegistryBrands(http, f"{base}/brands") - self.campaigns = RegistryCampaigns(http, f"{base}/campaigns") - self.orders = RegistryOrders(http, f"{base}/orders") - self.numbers = RegistryNumbers(http, f"{base}/numbers") +__all__ = ["RegistryBrands", "RegistryCampaigns", "RegistryNamespace", "RegistryNumbers", "RegistryOrders"] diff --git a/signalwire/signalwire/rest/namespaces/relay_rest_resources_generated.py b/signalwire/signalwire/rest/namespaces/relay_rest_resources_generated.py new file mode 100644 index 00000000..2222dcc2 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/relay_rest_resources_generated.py @@ -0,0 +1,941 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/relay-rest/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, cast +from collections.abc import Mapping + +from .._base import BaseResource, CrudResource + +if TYPE_CHECKING: + from .relay_rest_types_generated import ( + AddressListResponse, + AddressResponse, + AddressType, + AssignedNumberListResponse, + AvailablePhoneNumbersResponse, + BrandListResponse, + BrandResponse, + CampaignListResponse, + CampaignResponse, + CreateCspBrandRequest, + CreateManagedBrandRequest, + CreateManagedCampaignRequest, + CreateNumberGroupRequest, + CreatePartnerCampaignRequest, + CreateQueueRequest, + CreateVerifiedCallerIDRequest, + HttpMethod, + MfaResponse, + MfaVerifyResponse, + NumberGroupListResponse, + NumberGroupMembershipListResponse, + NumberGroupMembershipResponse, + NumberGroupResponse, + OrderListResponse, + OrderResponse, + PhoneNumberCallHandlerRequest, + PhoneNumberListResponse, + PhoneNumberLookupResponse, + PhoneNumberMessageHandler, + PhoneNumberResponse, + PurchasePhoneNumberRequest, + QueueListResponse, + QueueMemberListResponse, + QueueMemberResponse, + QueueResponse, + RecordingListResponse, + ShortCodeListResponse, + ShortCodeMessageHandler, + ShortCodeResponse, + SipProfileResponse, + UpdateNumberGroupRequest, + UpdatePhoneNumberRequest, + UpdateQueueRequest, + UpdateVerifiedCallerIDRequest, + VerifiedCallerIDListResponse, + VerifiedCallerIDResponse, + uuid, + ) + + +class Addresses(BaseResource): + """Typed resource for ``/addresses`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/addresses") + + def list(self, **params: Any) -> AddressListResponse: + return cast( + "AddressListResponse", + self._http.get(self._base_path, params=params or None), + ) + + def create( + self, + *, + label: str, + country: str, + first_name: str, + last_name: str, + street_number: str, + street_name: str, + city: str, + state: str, + postal_code: str, + address_type: AddressType | None = None, + address_number: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> AddressResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "label": label, + "country": country, + "first_name": first_name, + "last_name": last_name, + "street_number": street_number, + "street_name": street_name, + "address_type": address_type, + "address_number": address_number, + "city": city, + "state": state, + "postal_code": postal_code, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("AddressResponse", self._http.post(self._base_path, body=body)) + + def get(self, id: str, **params: Any) -> AddressResponse: + return cast( + "AddressResponse", self._http.get(self._path(id), params=params or None) + ) + + def delete(self, id: str) -> dict[str, Any]: + return cast("dict[str, Any]", self._http.delete(self._path(id))) + + +class ImportedNumbers(BaseResource): + """Typed resource for ``/imported_phone_numbers`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/imported_phone_numbers") + + def create( + self, + *, + number: str, + number_type: Literal["longcode", "tollfree"], + capabilities: list[Literal["sms", "voice", "fax", "mms"]] | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> PhoneNumberResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "number": number, + "number_type": number_type, + "capabilities": capabilities, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("PhoneNumberResponse", self._http.post(self._base_path, body=body)) + + +class Lookup(BaseResource): + """Typed resource for ``/lookup`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/lookup") + + def phone_number( + self, e164_number: str, **params: Any + ) -> PhoneNumberLookupResponse: + return cast( + "PhoneNumberLookupResponse", + self._http.get( + self._path("phone_number", e164_number), params=params or None + ), + ) + + +class Mfa(BaseResource): + """Typed resource for ``/mfa`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/mfa") + + def sms( + self, + *, + to: str, + from_: str | None = None, + message: str | None = None, + token_length: int | None = None, + valid_for: int | None = None, + max_attempts: int | None = None, + allow_alphas: bool | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> MfaResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "to": to, + "from": from_, + "message": message, + "token_length": token_length, + "valid_for": valid_for, + "max_attempts": max_attempts, + "allow_alphas": allow_alphas, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("MfaResponse", self._http.post(self._path("sms"), body=body)) + + def call( + self, + *, + to: str, + from_: str | None = None, + message: str | None = None, + token_length: int | None = None, + valid_for: int | None = None, + max_attempts: int | None = None, + allow_alphas: bool | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> MfaResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "to": to, + "from": from_, + "message": message, + "token_length": token_length, + "valid_for": valid_for, + "max_attempts": max_attempts, + "allow_alphas": allow_alphas, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("MfaResponse", self._http.post(self._path("call"), body=body)) + + def verify( + self, + mfa_request_id: str, + *, + token: str, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> MfaVerifyResponse: + body: dict[str, Any] = { + k: v for k, v in {"token": token}.items() if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "MfaVerifyResponse", + self._http.post(self._path(mfa_request_id, "verify"), body=body), + ) + + +class NumberGroups( + CrudResource[ + "NumberGroupListResponse", + "NumberGroupResponse", + "CreateNumberGroupRequest", + "UpdateNumberGroupRequest", + ] +): + """Typed resource for ``/number_groups`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/number_groups") + + def create( # type: ignore[override] + self, + *, + name: str, + sticky_sender: bool | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> NumberGroupResponse: + body: dict[str, Any] = { + k: v + for k, v in {"name": name, "sticky_sender": sticky_sender}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("NumberGroupResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + sticky_sender: bool | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> NumberGroupResponse: + body: dict[str, Any] = { + k: v + for k, v in {"name": name, "sticky_sender": sticky_sender}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("NumberGroupResponse", self._http.put(self._path(id), body=body)) + + def list_memberships( + self, number_group_id: str, **params: Any + ) -> NumberGroupMembershipListResponse: + return cast( + "NumberGroupMembershipListResponse", + self._http.get( + self._path(number_group_id, "number_group_memberships"), + params=params or None, + ), + ) + + def add_membership( + self, + number_group_id: str, + *, + phone_number_id: uuid, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> NumberGroupMembershipResponse: + body: dict[str, Any] = { + k: v + for k, v in {"phone_number_id": phone_number_id}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "NumberGroupMembershipResponse", + self._http.post( + self._path(number_group_id, "number_group_memberships"), body=body + ), + ) + + def get_membership(self, id: str, **params: Any) -> NumberGroupMembershipResponse: + return cast( + "NumberGroupMembershipResponse", + self._http.get( + f"/api/relay/rest/number_group_memberships/{id}", params=params or None + ), + ) + + def delete_membership(self, id: str) -> dict[str, Any]: + return cast( + "dict[str, Any]", + self._http.delete(f"/api/relay/rest/number_group_memberships/{id}"), + ) + + +class PhoneNumbers( + CrudResource[ + "PhoneNumberListResponse", + "PhoneNumberResponse", + "PurchasePhoneNumberRequest", + "UpdatePhoneNumberRequest", + ] +): + """Typed resource for ``/phone_numbers`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/phone_numbers") + + def create( # type: ignore[override] + self, *, number: str, extras: Mapping[str, Any] | None = None, **kwargs: Any + ) -> PhoneNumberResponse: + body: dict[str, Any] = { + k: v for k, v in {"number": number}.items() if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("PhoneNumberResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + call_handler: PhoneNumberCallHandlerRequest | None = None, + call_receive_mode: str | None = None, + call_request_url: str | None = None, + call_request_method: Literal["GET", "POST"] | None = None, + call_fallback_url: str | None = None, + call_fallback_method: Literal["GET", "POST"] | None = None, + call_status_callback_url: str | None = None, + call_status_callback_method: Literal["GET", "POST"] | None = None, + call_laml_application_id: str | None = None, + call_dialogflow_agent_id: str | None = None, + call_relay_topic: str | None = None, + call_relay_topic_status_callback_url: str | None = None, + call_relay_script_url: str | None = None, + call_relay_context: str | None = None, + call_relay_context_status_callback_url: str | None = None, + call_relay_application: str | None = None, + call_relay_connector_id: str | None = None, + call_sip_endpoint_id: str | None = None, + call_verto_resource: str | None = None, + call_video_room_id: uuid | None = None, + call_ai_agent_id: uuid | None = None, + call_flow_id: uuid | None = None, + call_flow_version: Literal["working_copy", "current_deployed"] | None = None, + message_handler: PhoneNumberMessageHandler | None = None, + message_request_url: str | None = None, + message_request_method: Literal["GET", "POST"] | None = None, + message_fallback_url: str | None = None, + message_fallback_method: Literal["GET", "POST"] | None = None, + message_laml_application_id: str | None = None, + message_relay_topic: str | None = None, + message_relay_context: str | None = None, + message_relay_application: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> PhoneNumberResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "call_handler": call_handler, + "call_receive_mode": call_receive_mode, + "call_request_url": call_request_url, + "call_request_method": call_request_method, + "call_fallback_url": call_fallback_url, + "call_fallback_method": call_fallback_method, + "call_status_callback_url": call_status_callback_url, + "call_status_callback_method": call_status_callback_method, + "call_laml_application_id": call_laml_application_id, + "call_dialogflow_agent_id": call_dialogflow_agent_id, + "call_relay_topic": call_relay_topic, + "call_relay_topic_status_callback_url": call_relay_topic_status_callback_url, + "call_relay_script_url": call_relay_script_url, + "call_relay_context": call_relay_context, + "call_relay_context_status_callback_url": call_relay_context_status_callback_url, + "call_relay_application": call_relay_application, + "call_relay_connector_id": call_relay_connector_id, + "call_sip_endpoint_id": call_sip_endpoint_id, + "call_verto_resource": call_verto_resource, + "call_video_room_id": call_video_room_id, + "call_ai_agent_id": call_ai_agent_id, + "call_flow_id": call_flow_id, + "call_flow_version": call_flow_version, + "message_handler": message_handler, + "message_request_url": message_request_url, + "message_request_method": message_request_method, + "message_fallback_url": message_fallback_url, + "message_fallback_method": message_fallback_method, + "message_laml_application_id": message_laml_application_id, + "message_relay_topic": message_relay_topic, + "message_relay_context": message_relay_context, + "message_relay_application": message_relay_application, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("PhoneNumberResponse", self._http.put(self._path(id), body=body)) + + def search(self, **params: Any) -> AvailablePhoneNumbersResponse: + return cast( + "AvailablePhoneNumbersResponse", + self._http.get(self._path("search"), params=params or None), + ) + + def set_swml_webhook( + self, resource_id: str, url: str, **extra: Any + ) -> PhoneNumberResponse: + body: dict[str, Any] = {"call_handler": "relay_script"} + body["call_relay_script_url"] = url + body.update(extra) + return self.update(resource_id, **body) + + def set_cxml_webhook( + self, + resource_id: str, + url: str, + fallback_url: str | None = None, + status_callback_url: str | None = None, + **extra: Any, + ) -> PhoneNumberResponse: + body: dict[str, Any] = {"call_handler": "laml_webhooks"} + body["call_request_url"] = url + if fallback_url is not None: + body["call_fallback_url"] = fallback_url + if status_callback_url is not None: + body["call_status_callback_url"] = status_callback_url + body.update(extra) + return self.update(resource_id, **body) + + def set_cxml_application( + self, resource_id: str, application_id: str, **extra: Any + ) -> PhoneNumberResponse: + body: dict[str, Any] = {"call_handler": "laml_application"} + body["call_laml_application_id"] = application_id + body.update(extra) + return self.update(resource_id, **body) + + def set_ai_agent( + self, resource_id: str, agent_id: uuid, **extra: Any + ) -> PhoneNumberResponse: + body: dict[str, Any] = {"call_handler": "ai_agent"} + body["call_ai_agent_id"] = agent_id + body.update(extra) + return self.update(resource_id, **body) + + def set_call_flow( + self, + resource_id: str, + flow_id: uuid, + version: Literal["working_copy", "current_deployed"] | None = None, + **extra: Any, + ) -> PhoneNumberResponse: + body: dict[str, Any] = {"call_handler": "call_flow"} + body["call_flow_id"] = flow_id + if version is not None: + body["call_flow_version"] = version + body.update(extra) + return self.update(resource_id, **body) + + def set_relay_application( + self, resource_id: str, name: str, **extra: Any + ) -> PhoneNumberResponse: + body: dict[str, Any] = {"call_handler": "relay_application"} + body["call_relay_application"] = name + body.update(extra) + return self.update(resource_id, **body) + + def set_relay_topic( + self, + resource_id: str, + topic: str, + status_callback_url: str | None = None, + **extra: Any, + ) -> PhoneNumberResponse: + body: dict[str, Any] = {"call_handler": "relay_topic"} + body["call_relay_topic"] = topic + if status_callback_url is not None: + body["call_relay_topic_status_callback_url"] = status_callback_url + body.update(extra) + return self.update(resource_id, **body) + + +class Queues( + CrudResource[ + "QueueListResponse", "QueueResponse", "CreateQueueRequest", "UpdateQueueRequest" + ] +): + """Typed resource for ``/queues`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/queues") + + def create( + self, + *, + name: str | None = None, + max_size: int | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> QueueResponse: + body: dict[str, Any] = { + k: v + for k, v in {"name": name, "max_size": max_size}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("QueueResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + max_size: int | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> QueueResponse: + body: dict[str, Any] = { + k: v + for k, v in {"name": name, "max_size": max_size}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("QueueResponse", self._http.put(self._path(id), body=body)) + + def list_members(self, queue_id: str, **params: Any) -> QueueMemberListResponse: + return cast( + "QueueMemberListResponse", + self._http.get(self._path(queue_id, "members"), params=params or None), + ) + + def get_next_member(self, queue_id: str, **params: Any) -> QueueMemberResponse: + return cast( + "QueueMemberResponse", + self._http.get( + self._path(queue_id, "members", "next"), params=params or None + ), + ) + + def get_member(self, queue_id: str, id: str, **params: Any) -> QueueMemberResponse: + return cast( + "QueueMemberResponse", + self._http.get(self._path(queue_id, "members", id), params=params or None), + ) + + +class Recordings(BaseResource): + """Typed resource for ``/recordings`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/recordings") + + def list(self, **params: Any) -> RecordingListResponse: + return cast( + "RecordingListResponse", + self._http.get(self._base_path, params=params or None), + ) + + def get(self, id: str, **params: Any) -> dict[str, Any]: + return cast( + "dict[str, Any]", self._http.get(self._path(id), params=params or None) + ) + + def delete(self, id: str) -> dict[str, Any]: + return cast("dict[str, Any]", self._http.delete(self._path(id))) + + +class RegistryBrands(BaseResource): + """Typed resource for ``/registry/beta/brands`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/registry/beta/brands") + + def list(self, **params: Any) -> BrandListResponse: + return cast( + "BrandListResponse", self._http.get(self._base_path, params=params or None) + ) + + def create( + self, body: CreateManagedBrandRequest | CreateCspBrandRequest + ) -> BrandResponse: + return cast("BrandResponse", self._http.post(self._base_path, body=body)) + + def get(self, id: str, **params: Any) -> BrandResponse: + return cast( + "BrandResponse", self._http.get(self._path(id), params=params or None) + ) + + def list_campaigns(self, id: str, **params: Any) -> CampaignListResponse: + return cast( + "CampaignListResponse", + self._http.get(self._path(id, "campaigns"), params=params or None), + ) + + def create_campaign( + self, id: str, body: CreateManagedCampaignRequest | CreatePartnerCampaignRequest + ) -> CampaignResponse: + return cast( + "CampaignResponse", self._http.post(self._path(id, "campaigns"), body=body) + ) + + +class RegistryCampaigns(BaseResource): + """Typed resource for ``/registry/beta/campaigns`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/registry/beta/campaigns") + + def get(self, id: str, **params: Any) -> CampaignResponse: + return cast( + "CampaignResponse", self._http.get(self._path(id), params=params or None) + ) + + def update( + self, + id: str, + *, + name: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> CampaignResponse: + body: dict[str, Any] = { + k: v for k, v in {"name": name}.items() if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("CampaignResponse", self._http.put(self._path(id), body=body)) + + def list_numbers(self, id: str, **params: Any) -> AssignedNumberListResponse: + return cast( + "AssignedNumberListResponse", + self._http.get(self._path(id, "numbers"), params=params or None), + ) + + def list_orders(self, id: str, **params: Any) -> OrderListResponse: + return cast( + "OrderListResponse", + self._http.get(self._path(id, "orders"), params=params or None), + ) + + def create_order( + self, + id: str, + *, + phone_numbers: list[str] | None = None, + status_callback_url: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> OrderResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "phone_numbers": phone_numbers, + "status_callback_url": status_callback_url, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "OrderResponse", self._http.post(self._path(id, "orders"), body=body) + ) + + +class RegistryNumbers(BaseResource): + """Typed resource for ``/registry/beta/numbers`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/registry/beta/numbers") + + def delete(self, id: str) -> dict[str, Any]: + return cast("dict[str, Any]", self._http.delete(self._path(id))) + + +class RegistryOrders(BaseResource): + """Typed resource for ``/registry/beta/orders`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/registry/beta/orders") + + def get(self, id: str, **params: Any) -> OrderResponse: + return cast( + "OrderResponse", self._http.get(self._path(id), params=params or None) + ) + + +class ShortCodes(BaseResource): + """Typed resource for ``/short_codes`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/short_codes") + + def list(self, **params: Any) -> ShortCodeListResponse: + return cast( + "ShortCodeListResponse", + self._http.get(self._base_path, params=params or None), + ) + + def get(self, id: str, **params: Any) -> ShortCodeResponse: + return cast( + "ShortCodeResponse", self._http.get(self._path(id), params=params or None) + ) + + def update( + self, + id: str, + *, + name: str, + message_handler: ShortCodeMessageHandler, + message_request_url: str | None = None, + message_request_method: HttpMethod | None = None, + message_fallback_url: str | None = None, + message_fallback_method: HttpMethod | None = None, + message_laml_application_id: uuid | None = None, + message_relay_context: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> ShortCodeResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "message_handler": message_handler, + "message_request_url": message_request_url, + "message_request_method": message_request_method, + "message_fallback_url": message_fallback_url, + "message_fallback_method": message_fallback_method, + "message_laml_application_id": message_laml_application_id, + "message_relay_context": message_relay_context, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("ShortCodeResponse", self._http.put(self._path(id), body=body)) + + +class SipProfile(BaseResource): + """Typed resource for ``/sip_profile`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/sip_profile") + + def get(self, **params: Any) -> SipProfileResponse: + return cast( + "SipProfileResponse", self._http.get(self._base_path, params=params or None) + ) + + def update( + self, + *, + domain_identifier: str | None = None, + default_codecs: list[str] | None = None, + default_ciphers: list[str] | None = None, + default_encryption: Literal["required", "optional"] | None = None, + default_send_as: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> SipProfileResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "domain_identifier": domain_identifier, + "default_codecs": default_codecs, + "default_ciphers": default_ciphers, + "default_encryption": default_encryption, + "default_send_as": default_send_as, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("SipProfileResponse", self._http.put(self._base_path, body=body)) + + +class VerifiedCallers( + CrudResource[ + "VerifiedCallerIDListResponse", + "VerifiedCallerIDResponse", + "CreateVerifiedCallerIDRequest", + "UpdateVerifiedCallerIDRequest", + ] +): + """Typed resource for ``/verified_caller_ids`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/relay/rest/verified_caller_ids") + + def create( # type: ignore[override] + self, + *, + number: str, + name: str | None = None, + extension: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> VerifiedCallerIDResponse: + body: dict[str, Any] = { + k: v + for k, v in {"number": number, "name": name, "extension": extension}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "VerifiedCallerIDResponse", self._http.post(self._base_path, body=body) + ) + + def update( + self, + id: str, + /, + *, + name: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> VerifiedCallerIDResponse: + body: dict[str, Any] = { + k: v for k, v in {"name": name}.items() if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "VerifiedCallerIDResponse", self._http.put(self._path(id), body=body) + ) + + def redial_verification(self, id: str) -> VerifiedCallerIDResponse: + return cast( + "VerifiedCallerIDResponse", self._http.post(self._path(id, "verification")) + ) + + def submit_verification( + self, + id: str, + *, + verification_code: str, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> VerifiedCallerIDResponse: + body: dict[str, Any] = { + k: v + for k, v in {"verification_code": verification_code}.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast( + "VerifiedCallerIDResponse", + self._http.put(self._path(id, "verification"), body=body), + ) diff --git a/signalwire/signalwire/rest/namespaces/relay_rest_types_generated.py b/signalwire/signalwire/rest/namespaces/relay_rest_types_generated.py new file mode 100644 index 00000000..d7c0ccb8 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/relay_rest_types_generated.py @@ -0,0 +1,1780 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/relay-rest/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from enum import Enum +from typing import Literal, TypeAlias, TypedDict + + +class AddNumberGroupMembershipRequest(TypedDict, total=False): + """Request body for adding a phone number to a number group. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + phone_number_id: uuid + + +class Address(TypedDict, total=False): + """Address model representing a physical address for regulatory compliance. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + label: str + country: str + first_name: str + last_name: str + street_number: str + street_name: str + address_type: AddressType | None + address_number: str | None + city: str + state: str + postal_code: str + zip_code: str + + +class AddressListResponse(TypedDict, total=False): + """Response containing a list of addresses. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[Address] + + +class AddressResponse(TypedDict, total=False): + """Response containing a single address. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + label: str + country: str + first_name: str + last_name: str + street_number: str + street_name: str + address_type: AddressType | None + address_number: str | None + city: str + state: str + postal_code: str + zip_code: str + + +AddressType: TypeAlias = "Literal['Apartment', 'Basement', 'Building', 'Department', 'Floor', 'Office', 'Penthouse', 'Suite', 'Trailer', 'Unit']" + + +class AssignedNumber(TypedDict, total=False): + """Assigned number model for campaign registration. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + state: str + campaign_id: uuid + phone_number: AssignedPhoneNumber + created_at: str + updated_at: str + + +class AssignedNumberListResponse(TypedDict, total=False): + """Response containing a list of assigned numbers. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[AssignedNumber] + + +class AssignedPhoneNumber(TypedDict, total=False): + """Phone number details in an assignment. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + name: str + number: str + status_callback_url: str + + +class AvailablePhoneNumber(TypedDict, total=False): + """Available phone number for purchase. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + number: str + region: str + city: str + rate_center: str + lata: str + capabilities: PhoneNumberCapabilities + + +class AvailablePhoneNumbersResponse(TypedDict, total=False): + """Response containing available phone numbers for purchase. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[AvailablePhoneNumber] + + +class Brand(TypedDict, total=False): + """Brand model for 10DLC registration. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + state: str + name: str + company_name: str + contact_email: str + contact_phone: str + ein_issuing_country: str + legal_entity_type: str + ein: str + company_address: str + company_vertical: str + company_website: str + csp_brand_reference: str + csp_self_registered: bool + status_callback_url: str + created_at: str + updated_at: str + + +class BrandListResponse(TypedDict, total=False): + """Response containing a list of brands. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[Brand] + + +class BrandResponse(TypedDict, total=False): + """Response containing a single brand. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + state: str + name: str + company_name: str + contact_email: str + contact_phone: str + ein_issuing_country: str + legal_entity_type: str + ein: str + company_address: str + company_vertical: str + company_website: str + csp_brand_reference: str + csp_self_registered: bool + status_callback_url: str + created_at: str + updated_at: str + + +CallReceiveMode: TypeAlias = "Literal['voice', 'fax']" + + +class Campaign(TypedDict, total=False): + """Campaign model for 10DLC registration. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + name: str + state: str + sms_use_case: str + sub_use_cases: list[str] + campaign_verify_token: str + description: str + sample1: str + sample2: str + sample3: str + sample4: str + sample5: str + dynamic_templates: str + message_flow: str + opt_in_message: str + opt_out_message: str + help_message: str + opt_in_keywords: str + opt_out_keywords: str + help_keywords: str + number_pooling_required: bool + number_pooling_per_campaign: str + direct_lending: bool + embedded_link: bool + embedded_phone: bool + age_gated_content: bool + lead_generation: bool + csp_campaign_reference: str + status_callback_url: str + created_at: str + updated_at: str + + +class CampaignListResponse(TypedDict, total=False): + """Response containing a list of campaigns. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[Campaign] + + +class CampaignResponse(TypedDict, total=False): + """Response containing a single campaign. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + name: str + state: str + sms_use_case: str + sub_use_cases: list[str] + campaign_verify_token: str + description: str + sample1: str + sample2: str + sample3: str + sample4: str + sample5: str + dynamic_templates: str + message_flow: str + opt_in_message: str + opt_out_message: str + help_message: str + opt_in_keywords: str + opt_out_keywords: str + help_keywords: str + number_pooling_required: bool + number_pooling_per_campaign: str + direct_lending: bool + embedded_link: bool + embedded_phone: bool + age_gated_content: bool + lead_generation: bool + csp_campaign_reference: str + status_callback_url: str + created_at: str + updated_at: str + + +class CarrierLookupInfo(TypedDict, total=False): + """Carrier lookup information. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + lrn: str + spid: str + ocn: str + lata: str + city: str + state: str + jurisdiction: str + lec: str + linetype: str + + +class CnamInfo(TypedDict, total=False): + """Caller ID (CNAM) information. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + caller_id: str + + +CompanyVertical: TypeAlias = "Literal['AGRICULTURE', 'COMMUNICATION', 'CONSTRUCTION', 'EDUCATION', 'ENERGY', 'ENTERTAINMENT', 'FINANCIAL', 'GAMBLING', 'GOVERNMENT', 'HEALTHCARE', 'HOSPITALITY', 'HUMAN_RESOURCES', 'INSURANCE', 'LEGAL', 'MANUFACTURING', 'NGO', 'POLITICAL', 'POSTAL', 'PROFESSIONAL', 'REAL_ESTATE', 'RETAIL', 'TECHNOLOGY', 'TRANSPORTATION']" + + +class CreateAddressRequest(TypedDict, total=False): + """Request body for creating an address. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + label: str + country: str + first_name: str + last_name: str + street_number: str + street_name: str + address_type: AddressType + address_number: str + city: str + state: str + postal_code: str + + +class CreateCspBrandRequest(TypedDict, total=False): + """Request body for importing a self-registered CSP brand. Use this when you have already registered your brand directly with TCR. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + csp_self_registered: Literal[True] + name: str + csp_brand_reference: str + status_callback_url: str + + +class CreateDomainApplicationRequest(TypedDict, total=False): + """Request body for creating a domain application. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + identifier: str + user: str + ip_auth_enabled: bool + ip_auth: list[str] + encryption: Literal["optional", "required", "forbidden"] + codecs: list[str] + ciphers: list[str] + call_handler: DomainAppCallHandlerRequest + call_relay_topic: str + call_relay_topic_status_callback_url: str + call_relay_application: str + call_request_url: str + call_request_method: Literal["GET", "POST"] + call_fallback_url: str + call_fallback_method: Literal["GET", "POST"] + call_status_callback_url: str + call_status_callback_method: Literal["GET", "POST"] + call_laml_application_id: str + call_video_room_id: uuid + call_relay_script_url: str + call_dialogflow_agent_id: uuid + call_ai_agent_id: uuid + call_flow_id: uuid + call_flow_version: Literal["working_copy", "current_deployed"] + call_relay_context: str + call_relay_context_status_callback_url: str + + +class CreateManagedBrandRequest(TypedDict, total=False): + """Request body for registering a new managed brand for 10DLC registration. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + company_name: str + contact_email: str + contact_phone: str + ein_issuing_country: str + legal_entity_type: LegalEntityType + ein: str + company_address: str + company_vertical: CompanyVertical + company_website: str + status_callback_url: str + + +class CreateManagedCampaignRequest(TypedDict, total=False): + """Request body for creating a managed campaign. Used when the brand is a managed (non-CSP) brand. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + brand_id: uuid + sms_use_case: str + sub_use_cases: list[str] + campaign_verify_token: str + description: str + sample1: str + sample2: str + sample3: str + sample4: str + sample5: str + dynamic_messages: str + message_flow: str + opt_in_message: str + opt_out_message: str + help_message: str + opt_in_keywords: str + opt_out_keywords: str + help_keywords: str + number_pooling_required: bool + number_pooling_per_campaign: str + direct_lending: bool + embedded_link: bool + embedded_phone: bool + age_gated_content: bool + lead_generation: bool + terms_and_conditions: bool + status_callback_url: str + + +class CreateNumberGroupRequest(TypedDict, total=False): + """Request body for creating a number group. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + sticky_sender: bool + + +class CreateOrderRequest(TypedDict, total=False): + """Request body for creating an order. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + phone_numbers: list[str] + status_callback_url: str + + +class CreatePartnerCampaignRequest(TypedDict, total=False): + """Request body for creating a partner/CSP campaign. Used when the brand is a CSP (self-registered) brand. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + brand_id: uuid + csp_campaign_reference: str + status_callback_url: str + + +class CreateQueueRequest(TypedDict, total=False): + """Request body for creating a queue. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + max_size: int + + +class CreateSipEndpointRequest(TypedDict, total=False): + """Request body for creating a SIP endpoint. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + username: str + password: str + caller_id: str + send_as: str + ciphers: list[str] + codecs: list[str] + encryption: Literal["default", "required", "optional"] + call_handler: Literal[ + "relay_context", + "relay_topic", + "relay_application", + "relay_connector", + "relay_script", + "laml_webhooks", + "laml_application", + "dialogflow", + "video_room", + "call_flow", + "ai_agent", + ] + call_request_url: str + call_request_method: Literal["GET", "POST"] + call_fallback_url: str + call_fallback_method: Literal["GET", "POST"] + call_status_callback_url: str + call_status_callback_method: Literal["GET", "POST"] + call_laml_application_id: str + call_dialogflow_agent_id: str + call_relay_topic: str + call_relay_topic_status_callback_url: str + call_relay_context: str + call_relay_context_status_callback_url: str + call_relay_application: str + call_video_room_id: str + call_flow_id: str + call_flow_version: str + call_ai_agent_id: str + call_relay_script_url: str + + +class CreateVerifiedCallerIDRequest(TypedDict, total=False): + """Request body for creating a verified caller ID. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + number: str + name: str + extension: str + + +DomainAppCallHandler: TypeAlias = "Literal['relay_topic', 'relay_application', 'laml_webhooks', 'laml_application', 'video_room', 'relay_script', 'dialogflow', 'ai_agent', 'call_flow', 'relay_context', 'relay_connector', 'fabric_subscriber', 'sip_gateway', 'call_queue']" + +DomainAppCallHandlerRequest: TypeAlias = "Literal['relay_topic', 'relay_application', 'laml_webhooks', 'laml_application', 'video_room', 'relay_script', 'dialogflow', 'ai_agent', 'call_flow', 'relay_context']" + + +class DomainApplication(TypedDict, total=False): + """Domain application model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + type: str + domain: str + name: str | None + identifier: str + user: str + ip_auth_enabled: bool + ip_auth: list[str] + call_handler: DomainAppCallHandler | None + calling_handler_resource_id: uuid | None + call_relay_topic: str | None + call_relay_topic_status_callback_url: str | None + call_relay_context: str | None + call_relay_context_status_callback_url: str | None + call_request_url: str | None + call_request_method: Literal["GET", "POST"] | None + call_fallback_url: str | None + call_fallback_method: Literal["GET", "POST"] | None + call_status_callback_url: str | None + call_status_callback_method: Literal["GET", "POST"] | None + call_laml_application_id: str | None + call_video_room_id: uuid | None + call_relay_script_url: str | None + encryption: Literal["optional", "required", "forbidden"] + codecs: list[str] + ciphers: list[str] + + +class DomainApplicationListResponse(TypedDict, total=False): + """Response containing a list of domain applications. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[DomainApplication] + + +class DomainApplicationResponse(TypedDict, total=False): + """Response containing a single domain application. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + type: str + domain: str + name: str | None + identifier: str + user: str + ip_auth_enabled: bool + ip_auth: list[str] + call_handler: DomainAppCallHandler | None + calling_handler_resource_id: uuid | None + call_relay_topic: str | None + call_relay_topic_status_callback_url: str | None + call_relay_context: str | None + call_relay_context_status_callback_url: str | None + call_request_url: str | None + call_request_method: Literal["GET", "POST"] | None + call_fallback_url: str | None + call_fallback_method: Literal["GET", "POST"] | None + call_status_callback_url: str | None + call_status_callback_method: Literal["GET", "POST"] | None + call_laml_application_id: str | None + call_video_room_id: uuid | None + call_relay_script_url: str | None + encryption: Literal["optional", "required", "forbidden"] + codecs: list[str] + ciphers: list[str] + + +HttpMethod: TypeAlias = "Literal['GET', 'POST']" + + +class ImportPhoneNumberRequest(TypedDict, total=False): + """Request body for importing a phone number. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + number: str + number_type: Literal["longcode", "tollfree"] + capabilities: list[Literal["sms", "voice", "fax", "mms"]] + + +LegalEntityType: TypeAlias = ( + "Literal['PRIVATE_PROFIT', 'PUBLIC_PROFIT', 'NON_PROFIT', 'GOVERNMENT']" +) + + +class MembershipPhoneNumber(TypedDict, total=False): + """Phone number representation within a membership. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + name: str + number: str + capabilities: list[str] + + +class MfaRequest(TypedDict, total=False): + """MFA request model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + to: str + # non-identifier field 'from': str + message: str + token_length: int + valid_for: int + max_attempts: int + allow_alphas: bool + + +class MfaResponse(TypedDict, total=False): + """MFA response model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + success: bool + to: str + channel: str + + +class MfaVerifyRequest(TypedDict, total=False): + """MFA verification request model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + token: str + + +class MfaVerifyResponse(TypedDict, total=False): + """MFA verification response model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + success: bool + + +class NumberGroup(TypedDict, total=False): + """Number group model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + name: str + sticky_sender: bool + phone_number_count: int + + +class NumberGroupListResponse(TypedDict, total=False): + """Response containing a list of number groups. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[NumberGroup] + + +class NumberGroupMembership(TypedDict, total=False): + """Number group membership model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + number_group_id: uuid + phone_number: MembershipPhoneNumber + created_at: str + updated_at: str + + +class NumberGroupMembershipListResponse(TypedDict, total=False): + """Response containing a list of number group memberships. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[NumberGroupMembership] + + +class NumberGroupMembershipResponse(TypedDict, total=False): + """Response containing a single number group membership. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + number_group_id: uuid + phone_number: MembershipPhoneNumber + created_at: str + updated_at: str + + +class NumberGroupResponse(TypedDict, total=False): + """Response containing a single number group. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + name: str + sticky_sender: bool + phone_number_count: int + + +class Order(TypedDict, total=False): + """Order model for campaign registry operations. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + state: str + processed_at: str + created_at: str + updated_at: str + status_callback_url: str + + +class OrderListResponse(TypedDict, total=False): + """Response containing a list of orders. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[Order] + + +class OrderResponse(TypedDict, total=False): + """Response containing a single order. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + state: str + processed_at: str + created_at: str + updated_at: str + status_callback_url: str + + +class PaginationLinks(TypedDict, total=False): + """Pagination links for list responses. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + self: str + first: str + next: str + prev: str + + +class PhoneNumber(TypedDict, total=False): + """Phone number model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + number: str + name: str | None + capabilities: list[PhoneNumberCapability] + number_type: PhoneNumberType + e911_address_id: uuid | None + created_at: str + updated_at: str + next_billed_at: str | None + call_handler: PhoneNumberCallHandler | None + calling_handler_resource_id: uuid | None + call_receive_mode: CallReceiveMode + call_request_url: str | None + call_request_method: HttpMethod | None + call_fallback_url: str | None + call_fallback_method: HttpMethod | None + call_status_callback_url: str | None + call_status_callback_method: HttpMethod | None + call_laml_application_id: str | None + call_dialogflow_agent_id: str | None + call_relay_topic: str | None + call_relay_topic_status_callback_url: str | None + call_relay_script_url: str | None + call_relay_context: str | None + call_relay_context_status_callback_url: str | None + call_relay_application: str | None + call_relay_connector_id: str | None + call_sip_endpoint_id: uuid | None + call_verto_resource: str | None + call_video_room_id: uuid | None + message_handler: PhoneNumberMessageHandler | None + messaging_handler_resource_id: uuid | None + message_request_url: str | None + message_request_method: HttpMethod | None + message_fallback_url: str | None + message_fallback_method: HttpMethod | None + message_laml_application_id: str | None + message_relay_topic: str | None + message_relay_context: str | None + country_code: str | None + + +PhoneNumberCallHandler: TypeAlias = "Literal['relay_context', 'relay_topic', 'relay_script', 'relay_application', 'relay_connector', 'relay_sip_endpoint', 'relay_verto_endpoint', 'laml_webhooks', 'laml_application', 'dialogflow', 'video_room', 'call_flow', 'ai_agent', 'fabric_subscriber', 'sip_gateway', 'call_queue']" + +PhoneNumberCallHandlerRequest: TypeAlias = "Literal['relay_context', 'relay_topic', 'relay_script', 'relay_application', 'relay_connector', 'relay_sip_endpoint', 'relay_verto_endpoint', 'laml_webhooks', 'laml_application', 'dialogflow', 'video_room', 'ai_agent', 'call_flow']" + + +class PhoneNumberCapabilities(TypedDict, total=False): + """Phone number capabilities. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + voice: bool + sms: bool + mms: bool + fax: bool + + +PhoneNumberCapability: TypeAlias = "Literal['voice', 'sms', 'mms', 'fax']" + + +class PhoneNumberListResponse(TypedDict, total=False): + """Response containing a list of phone numbers. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[PhoneNumber] + + +class PhoneNumberLookupResponse(TypedDict, total=False): + """Response containing phone number lookup result. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + country_code_number: int + national_number: str + possible_number: bool + valid_number: bool + national_number_formatted: str + international_number_formatted: str + e164: str + location: str + country_code: str + timezones: list[str] + number_type: str + carrier: CarrierLookupInfo + cnam: CnamInfo + + +PhoneNumberMessageHandler: TypeAlias = "Literal['relay_context', 'relay_topic', 'relay_application', 'laml_webhooks', 'laml_application']" + + +class PhoneNumberResponse(TypedDict, total=False): + """Response containing a single phone number. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + number: str + name: str | None + capabilities: list[PhoneNumberCapability] + number_type: PhoneNumberType + e911_address_id: uuid | None + created_at: str + updated_at: str + next_billed_at: str | None + call_handler: PhoneNumberCallHandler | None + calling_handler_resource_id: uuid | None + call_receive_mode: CallReceiveMode + call_request_url: str | None + call_request_method: HttpMethod | None + call_fallback_url: str | None + call_fallback_method: HttpMethod | None + call_status_callback_url: str | None + call_status_callback_method: HttpMethod | None + call_laml_application_id: str | None + call_dialogflow_agent_id: str | None + call_relay_topic: str | None + call_relay_topic_status_callback_url: str | None + call_relay_script_url: str | None + call_relay_context: str | None + call_relay_context_status_callback_url: str | None + call_relay_application: str | None + call_relay_connector_id: str | None + call_sip_endpoint_id: uuid | None + call_verto_resource: str | None + call_video_room_id: uuid | None + message_handler: PhoneNumberMessageHandler | None + messaging_handler_resource_id: uuid | None + message_request_url: str | None + message_request_method: HttpMethod | None + message_fallback_url: str | None + message_fallback_method: HttpMethod | None + message_laml_application_id: str | None + message_relay_topic: str | None + message_relay_context: str | None + country_code: str | None + + +PhoneNumberType: TypeAlias = "Literal['toll-free', 'longcode']" + + +class PstnRecording(TypedDict, total=False): + """Recording from a PSTN call leg. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + project_id: uuid + created_at: str + updated_at: str + duration_in_seconds: int + error_code: str + price: float + price_unit: str + status: str + url: str + stereo: bool + byte_size: int + track: str + relay_pstn_leg_id: uuid + + +class PurchasePhoneNumberRequest(TypedDict, total=False): + """Request body for purchasing a phone number. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + number: str + + +class Queue(TypedDict, total=False): + """Queue model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + project_id: uuid + friendly_name: str + max_size: int + current_size: int + average_wait_time: int + uri: str + date_created: str + date_updated: str + + +class QueueListResponse(TypedDict, total=False): + """Response containing a list of queues. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[Queue] + + +class QueueMember(TypedDict, total=False): + """Queue member model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: uuid + project_id: str + queue_id: str + position: int + uri: str + wait_time: int + date_enqueued: str + + +class QueueMemberListResponse(TypedDict, total=False): + """Response containing a list of queue members. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[QueueMember] + + +class QueueMemberResponse(TypedDict, total=False): + """Response containing a single queue member. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: uuid + project_id: str + queue_id: str + position: int + uri: str + wait_time: int + date_enqueued: str + + +class QueueResponse(TypedDict, total=False): + """Response containing a single queue. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + project_id: uuid + friendly_name: str + max_size: int + current_size: int + average_wait_time: int + uri: str + date_created: str + date_updated: str + + +Recording: TypeAlias = "PstnRecording | SipRecording | WebRtcRecording" + + +class RecordingListResponse(TypedDict, total=False): + """Response containing a list of recordings. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[Recording] + + +class ShortCode(TypedDict, total=False): + """Short code model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + name: str | None + number: str + capabilities: list[ShortCodeCapability] + number_type: Literal["shortcode"] + code_type: ShortCodeType + country_code: str + created_at: str + updated_at: str + next_billed_at: str | None + lease_duration: str | None + message_handler: ShortCodeMessageHandler | None + message_request_url: str | None + message_request_method: HttpMethod | None + message_fallback_url: str | None + message_fallback_method: HttpMethod | None + message_laml_application_id: uuid | None + message_relay_context: str | None + + +ShortCodeCapability: TypeAlias = "Literal['sms', 'mms']" + + +class ShortCodeListResponse(TypedDict, total=False): + """Response containing a list of short codes. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[ShortCode] + + +ShortCodeMessageHandler: TypeAlias = ( + "Literal['relay_context', 'laml_webhooks', 'laml_application']" +) + + +class ShortCodeResponse(TypedDict, total=False): + """Response containing a single short code. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + name: str | None + number: str + capabilities: list[ShortCodeCapability] + number_type: Literal["shortcode"] + code_type: ShortCodeType + country_code: str + created_at: str + updated_at: str + next_billed_at: str | None + lease_duration: str | None + message_handler: ShortCodeMessageHandler | None + message_request_url: str | None + message_request_method: HttpMethod | None + message_fallback_url: str | None + message_fallback_method: HttpMethod | None + message_laml_application_id: uuid | None + message_relay_context: str | None + + +ShortCodeType: TypeAlias = "Literal['vanity', 'random']" + + +class SipEndpoint(TypedDict, total=False): + """SIP endpoint model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + id: uuid + username: str + caller_id: str | None + send_as: str + ciphers: list[str] + codecs: list[str] + encryption: Literal["default", "required", "optional"] + call_handler: SipEndpointCallHandler | None + calling_handler_resource_id: uuid | None + call_request_url: str | None + call_request_method: Literal["GET", "POST"] | None + call_fallback_url: str | None + call_fallback_method: Literal["GET", "POST"] | None + call_status_callback_url: str | None + call_status_callback_method: Literal["GET", "POST"] | None + call_laml_application_id: str | None + call_dialogflow_agent_id: str | None + call_relay_topic: str | None + call_relay_topic_status_callback_url: str | None + call_relay_context: str | None + call_relay_context_status_callback_url: str | None + call_relay_application: str | None + call_video_room_id: uuid | None + call_relay_script_url: str | None + + +SipEndpointCallHandler: TypeAlias = "Literal['relay_context', 'relay_topic', 'relay_application', 'relay_connector', 'relay_script', 'laml_webhooks', 'laml_application', 'dialogflow', 'video_room', 'call_flow', 'ai_agent']" + + +class SipEndpointListResponse(TypedDict, total=False): + """Response containing a list of SIP endpoints. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[SipEndpoint] + + +class SipEndpointResponse(TypedDict, total=False): + """Response containing a single SIP endpoint. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + id: uuid + username: str + caller_id: str | None + send_as: str + ciphers: list[str] + codecs: list[str] + encryption: Literal["default", "required", "optional"] + call_handler: SipEndpointCallHandler | None + calling_handler_resource_id: uuid | None + call_request_url: str | None + call_request_method: Literal["GET", "POST"] | None + call_fallback_url: str | None + call_fallback_method: Literal["GET", "POST"] | None + call_status_callback_url: str | None + call_status_callback_method: Literal["GET", "POST"] | None + call_laml_application_id: str | None + call_dialogflow_agent_id: str | None + call_relay_topic: str | None + call_relay_topic_status_callback_url: str | None + call_relay_context: str | None + call_relay_context_status_callback_url: str | None + call_relay_application: str | None + call_video_room_id: uuid | None + call_relay_script_url: str | None + + +class SipProfileResponse(TypedDict, total=False): + """Response containing the SIP profile. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + domain: str + domain_identifier: str + default_codecs: list[str] + default_ciphers: list[str] + default_encryption: Literal["required", "optional"] + default_send_as: str + + +class SipRecording(TypedDict, total=False): + """Recording from a SIP call leg. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + project_id: uuid + created_at: str + updated_at: str + duration_in_seconds: int + error_code: str + price: float + price_unit: str + status: str + url: str + stereo: bool + byte_size: int + track: str + relay_sip_leg_id: uuid + + +class Types_StatusCodes_SpaceApiErrorItem(TypedDict, total=False): + """Details about a specific validation error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + detail: str + status: str + title: str + code: str + + +class Types_StatusCodes_StatusCode400(TypedDict, total=False): + """The request is invalid. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Bad Request"] + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode404(TypedDict, total=False): + """The server cannot find the requested resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Not Found"] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +class Types_StatusCodes_ValidationError(TypedDict, total=False): + """The request failed validation. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_SpaceApiErrorItem] + + +class UpdateCampaignRequest(TypedDict, total=False): + """Request body for updating a campaign. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + + +class UpdateDomainApplicationRequest(TypedDict, total=False): + """Request body for updating a domain application. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + identifier: str + user: str + ip_auth_enabled: bool + ip_auth: list[str] + encryption: Literal["optional", "required", "forbidden"] + codecs: list[str] + ciphers: list[str] + call_handler: DomainAppCallHandlerRequest + call_relay_topic: str + call_relay_topic_status_callback_url: str + call_relay_application: str + call_request_url: str + call_request_method: Literal["GET", "POST"] + call_fallback_url: str + call_fallback_method: Literal["GET", "POST"] + call_status_callback_url: str + call_status_callback_method: Literal["GET", "POST"] + call_laml_application_id: str + call_video_room_id: uuid + call_relay_script_url: str + call_dialogflow_agent_id: uuid + call_ai_agent_id: uuid + call_flow_id: uuid + call_flow_version: Literal["working_copy", "current_deployed"] + call_relay_context: str + call_relay_context_status_callback_url: str + + +class UpdateNumberGroupRequest(TypedDict, total=False): + """Request body for updating a number group. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + sticky_sender: bool + + +class UpdatePhoneNumberRequest(TypedDict, total=False): + """Request body for updating a phone number. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + call_handler: PhoneNumberCallHandlerRequest + call_receive_mode: str + call_request_url: str + call_request_method: Literal["GET", "POST"] + call_fallback_url: str + call_fallback_method: Literal["GET", "POST"] + call_status_callback_url: str + call_status_callback_method: Literal["GET", "POST"] + call_laml_application_id: str + call_dialogflow_agent_id: str + call_relay_topic: str + call_relay_topic_status_callback_url: str + call_relay_script_url: str + call_relay_context: str + call_relay_context_status_callback_url: str + call_relay_application: str + call_relay_connector_id: str + call_sip_endpoint_id: str + call_verto_resource: str + call_video_room_id: uuid + call_ai_agent_id: uuid + call_flow_id: uuid + call_flow_version: Literal["working_copy", "current_deployed"] + message_handler: PhoneNumberMessageHandler + message_request_url: str + message_request_method: Literal["GET", "POST"] + message_fallback_url: str + message_fallback_method: Literal["GET", "POST"] + message_laml_application_id: str + message_relay_topic: str + message_relay_context: str + message_relay_application: str + + +class UpdateQueueRequest(TypedDict, total=False): + """Request body for updating a queue. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + max_size: int + + +class UpdateShortCodeRequest(TypedDict, total=False): + """Request body for updating a short code. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + message_handler: ShortCodeMessageHandler + message_request_url: str + message_request_method: HttpMethod + message_fallback_url: str + message_fallback_method: HttpMethod + message_laml_application_id: uuid + message_relay_context: str + + +class UpdateSipEndpointRequest(TypedDict, total=False): + """Request body for updating a SIP endpoint. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + username: str + password: str + caller_id: str + send_as: str + ciphers: list[str] + codecs: list[str] + encryption: Literal["default", "required", "optional"] + call_handler: Literal[ + "relay_context", + "relay_topic", + "relay_application", + "relay_connector", + "relay_script", + "laml_webhooks", + "laml_application", + "dialogflow", + "video_room", + "call_flow", + "ai_agent", + ] + call_request_url: str + call_request_method: Literal["GET", "POST"] + call_fallback_url: str + call_fallback_method: Literal["GET", "POST"] + call_status_callback_url: str + call_status_callback_method: Literal["GET", "POST"] + call_laml_application_id: str + call_dialogflow_agent_id: str + call_relay_topic: str + call_relay_topic_status_callback_url: str + call_relay_context: str + call_relay_context_status_callback_url: str + call_relay_application: str + call_video_room_id: str + call_flow_id: str + call_flow_version: str + call_ai_agent_id: str + call_relay_script_url: str + + +class UpdateSipProfileRequest(TypedDict, total=False): + """Request body for updating the SIP profile. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + domain_identifier: str + default_codecs: list[str] + default_ciphers: list[str] + default_encryption: Literal["required", "optional"] + default_send_as: str + + +class UpdateVerifiedCallerIDRequest(TypedDict, total=False): + """Request body for updating a verified caller ID. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + + +class VerifiedCallerID(TypedDict, total=False): + """Verified caller ID model. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + id: uuid + number: str + name: str + extension: str + verified: bool + verified_at: str + status: Literal["Verified", "Awaiting Verification"] + + +class VerifiedCallerIDListResponse(TypedDict, total=False): + """Response containing a list of verified caller IDs. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[VerifiedCallerID] + + +class VerifiedCallerIDResponse(TypedDict, total=False): + """Response containing a single verified caller ID. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + id: uuid + number: str + name: str + extension: str + verified: bool + verified_at: str + status: Literal["Verified", "Awaiting Verification"] + + +class VerifyCallerIDRequest(TypedDict, total=False): + """Request body for verifying a caller ID. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + verification_code: str + + +class WebRtcRecording(TypedDict, total=False): + """Recording from a WebRTC call leg. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + project_id: uuid + created_at: str + updated_at: str + duration_in_seconds: int + error_code: str + price: float + price_unit: str + status: str + url: str + stereo: bool + byte_size: int + track: str + relay_webrtc_leg_id: uuid + + +uuid: TypeAlias = "str" + +ListAddressesResponse: TypeAlias = "AddressListResponse" +CreateAddressResponse: TypeAlias = "AddressResponse" +GetAddressResponse: TypeAlias = "AddressResponse" +ListDomainApplicationsResponse: TypeAlias = "DomainApplicationListResponse" +CreateDomainApplicationResponse: TypeAlias = "DomainApplicationResponse" +RetrieveDomainApplicationResponse: TypeAlias = "DomainApplicationResponse" +UpdateDomainApplicationResponse: TypeAlias = "DomainApplicationResponse" +ListSipEndpointsResponse: TypeAlias = "SipEndpointListResponse" +CreateSipEndpointResponse: TypeAlias = "SipEndpointResponse" +RetrieveSipEndpointResponse: TypeAlias = "SipEndpointResponse" +UpdateSipEndpointResponse: TypeAlias = "SipEndpointResponse" +CreateImportedPhoneNumberRequest: TypeAlias = "ImportPhoneNumberRequest" +CreateImportedPhoneNumberResponse: TypeAlias = "PhoneNumberResponse" +LookupPhoneNumberResponse: TypeAlias = "PhoneNumberLookupResponse" +RequestMfaCallRequest: TypeAlias = "MfaRequest" +RequestMfaCallResponse: TypeAlias = "MfaResponse" +RequestMfaSmsRequest: TypeAlias = "MfaRequest" +RequestMfaSmsResponse: TypeAlias = "MfaResponse" +VerifyMfaTokenRequest: TypeAlias = "MfaVerifyRequest" +VerifyMfaTokenResponse: TypeAlias = "MfaVerifyResponse" +RetrieveNumberGroupMembershipResponse: TypeAlias = "NumberGroupMembershipResponse" +ListNumberGroupsResponse: TypeAlias = "NumberGroupListResponse" +CreateNumberGroupResponse: TypeAlias = "NumberGroupResponse" +ListNumberGroupMembershipsResponse: TypeAlias = "NumberGroupMembershipListResponse" +CreateNumberGroupMembershipRequest: TypeAlias = "AddNumberGroupMembershipRequest" +CreateNumberGroupMembershipResponse: TypeAlias = "NumberGroupMembershipResponse" +RetrieveNumberGroupResponse: TypeAlias = "NumberGroupResponse" +UpdateNumberGroupResponse: TypeAlias = "NumberGroupResponse" +ListPhoneNumbersResponse: TypeAlias = "PhoneNumberListResponse" +PurchasePhoneNumberResponse: TypeAlias = "PhoneNumberResponse" +SearchAvailablePhoneNumbersResponse: TypeAlias = "AvailablePhoneNumbersResponse" +RetrievePhoneNumberResponse: TypeAlias = "PhoneNumberResponse" +UpdatePhoneNumberResponse: TypeAlias = "PhoneNumberResponse" +ListQueuesResponse: TypeAlias = "QueueListResponse" +CreateQueueResponse: TypeAlias = "QueueResponse" +GetQueueResponse: TypeAlias = "QueueResponse" +UpdateQueueResponse: TypeAlias = "QueueResponse" +ListQueueMembersResponse: TypeAlias = "QueueMemberListResponse" +RetrieveNextQueueMemberResponse: TypeAlias = "QueueMemberResponse" +RetrieveQueueMemberResponse: TypeAlias = "QueueMemberResponse" +ListRecordingsResponse: TypeAlias = "RecordingListResponse" +GetRecordingResponse: TypeAlias = "PstnRecording | SipRecording | WebRtcRecording" +ListBrandsResponse: TypeAlias = "BrandListResponse" +CreateBrandRequest: TypeAlias = "CreateManagedBrandRequest | CreateCspBrandRequest" +CreateBrandResponse: TypeAlias = "BrandResponse" +RetrieveBrandResponse: TypeAlias = "BrandResponse" +ListCampaignsResponse: TypeAlias = "CampaignListResponse" +CreateCampaignRequest: TypeAlias = ( + "CreateManagedCampaignRequest | CreatePartnerCampaignRequest" +) +CreateCampaignResponse: TypeAlias = "CampaignResponse" +RetrieveCampaignResponse: TypeAlias = "CampaignResponse" +UpdateCampaignResponse: TypeAlias = "CampaignResponse" +ListNumberAssignmentsResponse: TypeAlias = "AssignedNumberListResponse" +ListOrdersResponse: TypeAlias = "OrderListResponse" +CreateOrderResponse: TypeAlias = "OrderResponse" +RetrieveOrderResponse: TypeAlias = "OrderResponse" +ListShortCodesResponse: TypeAlias = "ShortCodeListResponse" +RetrieveShortCodeResponse: TypeAlias = "ShortCodeResponse" +UpdateShortCodeResponse: TypeAlias = "ShortCodeResponse" +RetrieveSipProfileResponse: TypeAlias = "SipProfileResponse" +UpdateSipProfileResponse: TypeAlias = "SipProfileResponse" +ListVerifiedCallerIdsResponse: TypeAlias = "VerifiedCallerIDListResponse" +CreateVerifiedCallerIdRequest: TypeAlias = "CreateVerifiedCallerIDRequest" +CreateVerifiedCallerIdResponse: TypeAlias = "VerifiedCallerIDResponse" +RetrieveVerifiedCallerIdResponse: TypeAlias = "VerifiedCallerIDResponse" +UpdateVerifiedCallerIdRequest: TypeAlias = "UpdateVerifiedCallerIDRequest" +UpdateVerifiedCallerIdResponse: TypeAlias = "VerifiedCallerIDResponse" +RedialVerificationCallResponse: TypeAlias = "VerifiedCallerIDResponse" +ValidateVerificationCodeRequest: TypeAlias = "VerifyCallerIDRequest" +ValidateVerificationCodeResponse: TypeAlias = "VerifiedCallerIDResponse" + + +class PhoneCallHandler(str, Enum): + RELAY_CONTEXT = "relay_context" + RELAY_TOPIC = "relay_topic" + RELAY_SCRIPT = "relay_script" + RELAY_APPLICATION = "relay_application" + RELAY_CONNECTOR = "relay_connector" + RELAY_SIP_ENDPOINT = "relay_sip_endpoint" + RELAY_VERTO_ENDPOINT = "relay_verto_endpoint" + LAML_WEBHOOKS = "laml_webhooks" + LAML_APPLICATION = "laml_application" + DIALOGFLOW = "dialogflow" + VIDEO_ROOM = "video_room" + AI_AGENT = "ai_agent" + CALL_FLOW = "call_flow" diff --git a/signalwire/signalwire/rest/namespaces/short_codes.py b/signalwire/signalwire/rest/namespaces/short_codes.py index 8e1c55da..081d0846 100644 --- a/signalwire/signalwire/rest/namespaces/short_codes.py +++ b/signalwire/signalwire/rest/namespaces/short_codes.py @@ -1,28 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Short Codes namespace — list, get, update (no create/delete). +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.short_codes`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.short_codes import ShortCodesResource`` working +but emits a DeprecationWarning. Prefer ``client.short_codes`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource - - -class ShortCodesResource(BaseResource): - """Short code management (read + update only).""" +import warnings - def __init__(self, http): - super().__init__(http, "/api/relay/rest/short_codes") +warnings.warn( + "signalwire.signalwire.rest.namespaces.short_codes is deprecated; use client.short_codes. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - def list(self, **params): - return self._http.get(self._base_path, params=params or None) +from .relay_rest_resources_generated import ShortCodes # noqa: E402 (re-export after the deprecation warn — intentional) - def get(self, short_code_id): - return self._http.get(self._path(short_code_id)) +# Back-compat aliases (old name -> generated bare name): +ShortCodesResource = ShortCodes - def update(self, short_code_id, **kwargs): - return self._http.put(self._path(short_code_id), body=kwargs) +__all__ = ["ShortCodesResource"] diff --git a/signalwire/signalwire/rest/namespaces/sip_profile.py b/signalwire/signalwire/rest/namespaces/sip_profile.py index f896fbbf..3d0e6a1d 100644 --- a/signalwire/signalwire/rest/namespaces/sip_profile.py +++ b/signalwire/signalwire/rest/namespaces/sip_profile.py @@ -1,25 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Licensed under the MIT License. -See LICENSE file in the project root for full license information. - -SIP Profile namespace — get and update project SIP profile. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.sip_profile`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.sip_profile import SipProfileResource`` working +but emits a DeprecationWarning. Prefer ``client.sip_profile`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource - +import warnings -class SipProfileResource(BaseResource): - """Project SIP profile (singleton resource).""" +warnings.warn( + "signalwire.signalwire.rest.namespaces.sip_profile is deprecated; use client.sip_profile. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - def __init__(self, http): - super().__init__(http, "/api/relay/rest/sip_profile") +from .relay_rest_resources_generated import SipProfile # noqa: E402 (re-export after the deprecation warn — intentional) - def get(self): - return self._http.get(self._base_path) +# Back-compat aliases (old name -> generated bare name): +SipProfileResource = SipProfile - def update(self, **kwargs): - return self._http.put(self._base_path, body=kwargs) +__all__ = ["SipProfileResource"] diff --git a/signalwire/signalwire/rest/namespaces/swml_webhooks_types_generated.py b/signalwire/signalwire/rest/namespaces/swml_webhooks_types_generated.py new file mode 100644 index 00000000..00c5abe6 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/swml_webhooks_types_generated.py @@ -0,0 +1,143 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/swml-webhooks/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Any, TypedDict + + +class SwaigRequestData(TypedDict, total=False): + """Body POSTed to a SWAIG function's web_hook_url when the AI invokes it. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + ai_session_id: str + app_name: str + project_id: str + space_id: str + action: str + function: str + argument: SwaigArgument + meta_data: dict[str, Any] + conversation_id: str + content_type: str + version: str + + +class SwaigArgument(TypedDict, total=False): + """The arguments the AI extracted for a SWAIG function call. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + parsed: list[dict[str, Any]] + raw: str + substituted: str + + +class PostPromptData(TypedDict, total=False): + """Body POSTed to post_prompt_url at the end of an AI call — the calling.call.ai.complete event envelope. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + event_type: str + event_channel: str + timestamp: float + project_id: str + space_id: str + params: PostPromptParams + + +class PostPromptParams(TypedDict, total=False): + """The AI-completion payload carried by a post-prompt webhook. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + ai_session_id: str + summary: str + post_prompt_result: dict[str, Any] | str + end_reason: str + conversation: list[PostPromptConversationTurn] + function_calls: list[PostPromptFunctionCall] + + +class PostPromptConversationTurn(TypedDict, total=False): + """A single turn in a post-prompt conversation transcript. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + role: str + content: str + + +class PostPromptFunctionCall(TypedDict, total=False): + """A function the AI called during the conversation, as reported post-prompt. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + function: str + params: dict[str, Any] + result: dict[str, Any] + + +class SwmlRequestData(TypedDict, total=False): + """Body POSTed to a dynamic-SWML request handler. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call: SwmlRequestCall + vars: dict[str, Any] + envs: dict[str, Any] + params: dict[str, Any] + + +class SwmlRequestCall(TypedDict, total=False): + """The call object embedded in a dynamic-SWML request. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + call_id: str + node_id: str + segment_id: str + project_id: str + space_id: str + call_state: str + direction: str + type: str + # non-identifier field 'from': str + to: str + from_number: str + to_number: str + headers: list[dict[str, Any]] + + +class SignalWireErrorBody(TypedDict, total=False): + """Error body returned by the Compatibility REST API (single-error form). Source: signalwire/docs specs/compatibility-api/_shared/errors.tsp (CompatibilityErrorResponse). + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + code: int + message: str + more_info: str + status: int diff --git a/signalwire/signalwire/rest/namespaces/verified_callers.py b/signalwire/signalwire/rest/namespaces/verified_callers.py index dc11924f..9e96d424 100644 --- a/signalwire/signalwire/rest/namespaces/verified_callers.py +++ b/signalwire/signalwire/rest/namespaces/verified_callers.py @@ -1,27 +1,24 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Verified Caller IDs namespace — CRUD + verification flow. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.verified_callers`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.verified_callers import VerifiedCallersResource`` working +but emits a DeprecationWarning. Prefer ``client.verified_callers`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import CrudResource - - -class VerifiedCallersResource(CrudResource): - """Verified caller ID management with verification flow.""" +import warnings - _update_method = "PUT" +warnings.warn( + "signalwire.signalwire.rest.namespaces.verified_callers is deprecated; use client.verified_callers. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) - def __init__(self, http): - super().__init__(http, "/api/relay/rest/verified_caller_ids") +from .relay_rest_resources_generated import VerifiedCallers # noqa: E402 (re-export after the deprecation warn — intentional) - def redial_verification(self, caller_id): - return self._http.post(self._path(caller_id, "verification")) +# Back-compat aliases (old name -> generated bare name): +VerifiedCallersResource = VerifiedCallers - def submit_verification(self, caller_id, **kwargs): - return self._http.put(self._path(caller_id, "verification"), body=kwargs) +__all__ = ["VerifiedCallersResource"] diff --git a/signalwire/signalwire/rest/namespaces/video.py b/signalwire/signalwire/rest/namespaces/video.py index e56f6973..e4aae7d9 100644 --- a/signalwire/signalwire/rest/namespaces/video.py +++ b/signalwire/signalwire/rest/namespaces/video.py @@ -1,127 +1,22 @@ -""" -Copyright (c) 2025 SignalWire - -This file is part of the SignalWire SDK. - -Licensed under the MIT License. -See LICENSE file in the project root for full license information. +"""Back-compat shim — DO NOT add to other ports. x-sdk-back-compat-shim -Video API namespace — rooms, sessions, recordings, conferences, tokens, streams. +Deprecated import path. The REST layer is spec-generated; these symbols moved out of +``namespaces.video`` (the ``*Resource``/``*Namespace`` suffixes were dropped). This thin +re-export keeps ``from signalwire.signalwire.rest.namespaces.video import VideoRooms`` working +but emits a DeprecationWarning. Prefer ``client.video`` (no import needed). PYTHON-ONLY: the +surface oracle skips this file, so no other port implements these. """ -from .._base import BaseResource, CrudResource - - -class VideoRooms(CrudResource): - """Video room management with streams.""" - - _update_method = "PUT" - - def list_streams(self, room_id, **params): - return self._http.get(self._path(room_id, "streams"), params=params or None) - - def create_stream(self, room_id, **kwargs): - return self._http.post(self._path(room_id, "streams"), body=kwargs) - - -class VideoRoomTokens(BaseResource): - """Video room token generation.""" - - def create(self, **kwargs): - return self._http.post(self._base_path, body=kwargs) - - -class VideoRoomSessions(BaseResource): - """Video room session management.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def get(self, session_id): - return self._http.get(self._path(session_id)) - - def list_events(self, session_id, **params): - return self._http.get(self._path(session_id, "events"), params=params or None) - - def list_members(self, session_id, **params): - return self._http.get(self._path(session_id, "members"), params=params or None) - - def list_recordings(self, session_id, **params): - return self._http.get( - self._path(session_id, "recordings"), params=params or None - ) - - -class VideoRoomRecordings(BaseResource): - """Video room recording management.""" - - def list(self, **params): - return self._http.get(self._base_path, params=params or None) - - def get(self, recording_id): - return self._http.get(self._path(recording_id)) - - def delete(self, recording_id): - return self._http.delete(self._path(recording_id)) - - def list_events(self, recording_id, **params): - return self._http.get(self._path(recording_id, "events"), params=params or None) - - -class VideoConferences(CrudResource): - """Video conference management with tokens and streams.""" - - _update_method = "PUT" - - def list_conference_tokens(self, conference_id, **params): - return self._http.get( - self._path(conference_id, "conference_tokens"), - params=params or None, - ) - - def list_streams(self, conference_id, **params): - return self._http.get( - self._path(conference_id, "streams"), params=params or None - ) - - def create_stream(self, conference_id, **kwargs): - return self._http.post(self._path(conference_id, "streams"), body=kwargs) - - -class VideoConferenceTokens(BaseResource): - """Video conference token management.""" - - def get(self, token_id): - return self._http.get(self._path(token_id)) - - def reset(self, token_id): - return self._http.post(self._path(token_id, "reset")) - - -class VideoStreams(BaseResource): - """Video stream management.""" - - def get(self, stream_id): - return self._http.get(self._path(stream_id)) - - def update(self, stream_id, **kwargs): - return self._http.put(self._path(stream_id), body=kwargs) - - def delete(self, stream_id): - return self._http.delete(self._path(stream_id)) +import warnings +warnings.warn( + "signalwire.signalwire.rest.namespaces.video is deprecated; use client.video. " + "This back-compat shim will be removed in a future release.", + DeprecationWarning, + stacklevel=2, +) -class VideoNamespace: - """Video API namespace.""" +from .video_resources_generated import VideoConferenceTokens, VideoConferences, VideoRoomRecordings, VideoRoomSessions, VideoRoomTokens, VideoRooms, VideoStreams # noqa: E402 (re-export after the deprecation warn — intentional) +from ._client_tree_generated import VideoNamespace # noqa: E402 (re-export after the deprecation warn — intentional) - def __init__(self, http): - base = "/api/video" - self.rooms = VideoRooms(http, f"{base}/rooms") - self.room_tokens = VideoRoomTokens(http, f"{base}/room_tokens") - self.room_sessions = VideoRoomSessions(http, f"{base}/room_sessions") - self.room_recordings = VideoRoomRecordings(http, f"{base}/room_recordings") - self.conferences = VideoConferences(http, f"{base}/conferences") - self.conference_tokens = VideoConferenceTokens( - http, f"{base}/conference_tokens" - ) - self.streams = VideoStreams(http, f"{base}/streams") +__all__ = ["VideoConferenceTokens", "VideoConferences", "VideoNamespace", "VideoRoomRecordings", "VideoRoomSessions", "VideoRoomTokens", "VideoRooms", "VideoStreams"] diff --git a/signalwire/signalwire/rest/namespaces/video_resources_generated.py b/signalwire/signalwire/rest/namespaces/video_resources_generated.py new file mode 100644 index 00000000..dbe5df0d --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/video_resources_generated.py @@ -0,0 +1,491 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/video/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast +from collections.abc import Mapping + +from .._base import BaseResource, CrudResource, ReadResource + +if TYPE_CHECKING: + from .video_types_generated import ( + Conference, + ConferenceSize, + ConferenceToken, + CreateConferenceRequest, + CreateRoomRequest, + JoinAsType, + ListConferenceTokensResponse, + ListConferencesResponse, + ListRoomRecordingEventsResponse, + ListRoomRecordingsResponse, + ListRoomSessionEventsResponse, + ListRoomSessionMembersResponse, + ListRoomSessionRecordingsResponse, + ListRoomSessionsResponse, + ListRoomsResponse, + ListStreamsResponse, + MediaAllowedType, + RoomLayout, + RoomRecording, + RoomResponse, + RoomSessionSummary, + RoomTokenPermission, + RoomTokenResponse, + Stream, + UpdateConferenceRequest, + UpdateRoomRequest, + VideoLayout, + VideoQuality, + ) + + +class VideoConferenceTokens(BaseResource): + """Typed resource for ``/conference_tokens`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/video/conference_tokens") + + def get(self, id: str, **params: Any) -> ConferenceToken: + return cast( + "ConferenceToken", self._http.get(self._path(id), params=params or None) + ) + + def reset(self, id: str) -> ConferenceToken: + return cast("ConferenceToken", self._http.post(self._path(id, "reset"))) + + +class VideoConferences( + CrudResource[ + "ListConferencesResponse", + "Conference", + "CreateConferenceRequest", + "UpdateConferenceRequest", + ] +): + """Typed resource for ``/conferences`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/video/conferences") + + def create( # type: ignore[override] + self, + *, + display_name: str, + name: str | None = None, + description: str | None = None, + join_from: str | None = None, + join_until: str | None = None, + quality: VideoQuality | None = None, + layout: VideoLayout | None = None, + size: ConferenceSize | None = None, + record_on_start: bool | None = None, + enable_room_previews: bool | None = None, + enable_chat: bool | None = None, + dark_primary: str | None = None, + dark_background: str | None = None, + dark_foreground: str | None = None, + dark_success: str | None = None, + dark_negative: str | None = None, + light_primary: str | None = None, + light_background: str | None = None, + light_foreground: str | None = None, + light_success: str | None = None, + light_negative: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> Conference: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "display_name": display_name, + "description": description, + "join_from": join_from, + "join_until": join_until, + "quality": quality, + "layout": layout, + "size": size, + "record_on_start": record_on_start, + "enable_room_previews": enable_room_previews, + "enable_chat": enable_chat, + "dark_primary": dark_primary, + "dark_background": dark_background, + "dark_foreground": dark_foreground, + "dark_success": dark_success, + "dark_negative": dark_negative, + "light_primary": light_primary, + "light_background": light_background, + "light_foreground": light_foreground, + "light_success": light_success, + "light_negative": light_negative, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("Conference", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + display_name: str | None = None, + description: str | None = None, + join_from: str | None = None, + join_until: str | None = None, + quality: VideoQuality | None = None, + layout: VideoLayout | None = None, + size: ConferenceSize | None = None, + record_on_start: bool | None = None, + tone_on_entry_and_exit: bool | None = None, + room_join_video_off: bool | None = None, + user_join_video_off: bool | None = None, + enable_room_previews: bool | None = None, + enable_chat: bool | None = None, + dark_primary: str | None = None, + dark_background: str | None = None, + dark_foreground: str | None = None, + dark_success: str | None = None, + dark_negative: str | None = None, + light_primary: str | None = None, + light_background: str | None = None, + light_foreground: str | None = None, + light_success: str | None = None, + light_negative: str | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> Conference: + body: dict[str, Any] = { + k: v + for k, v in { + "display_name": display_name, + "description": description, + "join_from": join_from, + "join_until": join_until, + "quality": quality, + "layout": layout, + "size": size, + "record_on_start": record_on_start, + "tone_on_entry_and_exit": tone_on_entry_and_exit, + "room_join_video_off": room_join_video_off, + "user_join_video_off": user_join_video_off, + "enable_room_previews": enable_room_previews, + "enable_chat": enable_chat, + "dark_primary": dark_primary, + "dark_background": dark_background, + "dark_foreground": dark_foreground, + "dark_success": dark_success, + "dark_negative": dark_negative, + "light_primary": light_primary, + "light_background": light_background, + "light_foreground": light_foreground, + "light_success": light_success, + "light_negative": light_negative, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("Conference", self._http.put(self._path(id), body=body)) + + def list_conference_tokens( + self, id: str, **params: Any + ) -> ListConferenceTokensResponse: + return cast( + "ListConferenceTokensResponse", + self._http.get(self._path(id, "conference_tokens"), params=params or None), + ) + + def list_streams(self, id: str, **params: Any) -> ListStreamsResponse: + return cast( + "ListStreamsResponse", + self._http.get(self._path(id, "streams"), params=params or None), + ) + + def create_stream( + self, + id: str, + *, + url: str, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> Stream: + body: dict[str, Any] = {k: v for k, v in {"url": url}.items() if v is not None} + if extras: + body.update(extras) + body.update(kwargs) + return cast("Stream", self._http.post(self._path(id, "streams"), body=body)) + + +class VideoRoomRecordings(BaseResource): + """Typed resource for ``/room_recordings`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/video/room_recordings") + + def list(self, **params: Any) -> ListRoomRecordingsResponse: + return cast( + "ListRoomRecordingsResponse", + self._http.get(self._base_path, params=params or None), + ) + + def get(self, id: str, **params: Any) -> RoomRecording: + return cast( + "RoomRecording", self._http.get(self._path(id), params=params or None) + ) + + def delete(self, id: str) -> dict[str, Any]: + return cast("dict[str, Any]", self._http.delete(self._path(id))) + + def list_events(self, id: str, **params: Any) -> ListRoomRecordingEventsResponse: + return cast( + "ListRoomRecordingEventsResponse", + self._http.get(self._path(id, "events"), params=params or None), + ) + + +class VideoRoomSessions(ReadResource["ListRoomSessionsResponse", "RoomSessionSummary"]): + """Typed resource for ``/room_sessions`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/video/room_sessions") + + def list_events(self, id: str, **params: Any) -> ListRoomSessionEventsResponse: + return cast( + "ListRoomSessionEventsResponse", + self._http.get(self._path(id, "events"), params=params or None), + ) + + def list_members(self, id: str, **params: Any) -> ListRoomSessionMembersResponse: + return cast( + "ListRoomSessionMembersResponse", + self._http.get(self._path(id, "members"), params=params or None), + ) + + def list_recordings( + self, id: str, **params: Any + ) -> ListRoomSessionRecordingsResponse: + return cast( + "ListRoomSessionRecordingsResponse", + self._http.get(self._path(id, "recordings"), params=params or None), + ) + + +class VideoRoomTokens(BaseResource): + """Typed resource for ``/room_tokens`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/video/room_tokens") + + def create( + self, + *, + room_name: str, + user_name: str | None = None, + permissions: list[RoomTokenPermission] | None = None, + join_from: str | None = None, + join_until: str | None = None, + remove_at: str | None = None, + remove_after_seconds_elapsed: int | None = None, + join_audio_muted: bool | None = None, + join_video_muted: bool | None = None, + auto_create_room: bool | None = None, + enable_room_previews: bool | None = None, + room_display_name: str | None = None, + end_room_session_on_leave: bool | None = None, + join_as: JoinAsType | None = None, + media_allowed: MediaAllowedType | None = None, + room_meta: dict[str, Any] | None = None, + meta: dict[str, Any] | None = None, + sync_audio_video: bool | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> RoomTokenResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "room_name": room_name, + "user_name": user_name, + "permissions": permissions, + "join_from": join_from, + "join_until": join_until, + "remove_at": remove_at, + "remove_after_seconds_elapsed": remove_after_seconds_elapsed, + "join_audio_muted": join_audio_muted, + "join_video_muted": join_video_muted, + "auto_create_room": auto_create_room, + "enable_room_previews": enable_room_previews, + "room_display_name": room_display_name, + "end_room_session_on_leave": end_room_session_on_leave, + "join_as": join_as, + "media_allowed": media_allowed, + "room_meta": room_meta, + "meta": meta, + "sync_audio_video": sync_audio_video, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("RoomTokenResponse", self._http.post(self._base_path, body=body)) + + +class VideoRooms( + CrudResource[ + "ListRoomsResponse", "RoomResponse", "CreateRoomRequest", "UpdateRoomRequest" + ] +): + """Typed resource for ``/rooms`` (generated).""" + + _update_method = "PUT" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/video/rooms") + + def create( # type: ignore[override] + self, + *, + name: str, + display_name: str | None = None, + description: str | None = None, + max_members: int | None = None, + quality: VideoQuality | None = None, + join_from: str | None = None, + join_until: str | None = None, + remove_at: str | None = None, + remove_after_seconds_elapsed: int | None = None, + layout: RoomLayout | None = None, + record_on_start: bool | None = None, + enable_room_previews: bool | None = None, + meta: dict[str, Any] | None = None, + sync_audio_video: bool | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> RoomResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "name": name, + "display_name": display_name, + "description": description, + "max_members": max_members, + "quality": quality, + "join_from": join_from, + "join_until": join_until, + "remove_at": remove_at, + "remove_after_seconds_elapsed": remove_after_seconds_elapsed, + "layout": layout, + "record_on_start": record_on_start, + "enable_room_previews": enable_room_previews, + "meta": meta, + "sync_audio_video": sync_audio_video, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("RoomResponse", self._http.post(self._base_path, body=body)) + + def update( + self, + id: str, + /, + *, + display_name: str | None = None, + description: str | None = None, + max_members: int | None = None, + quality: VideoQuality | None = None, + join_from: str | None = None, + join_until: str | None = None, + remove_at: str | None = None, + remove_after_seconds_elapsed: int | None = None, + layout: RoomLayout | None = None, + record_on_start: bool | None = None, + enable_room_previews: bool | None = None, + meta: dict[str, Any] | None = None, + sync_audio_video: bool | None = None, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> RoomResponse: + body: dict[str, Any] = { + k: v + for k, v in { + "display_name": display_name, + "description": description, + "max_members": max_members, + "quality": quality, + "join_from": join_from, + "join_until": join_until, + "remove_at": remove_at, + "remove_after_seconds_elapsed": remove_after_seconds_elapsed, + "layout": layout, + "record_on_start": record_on_start, + "enable_room_previews": enable_room_previews, + "meta": meta, + "sync_audio_video": sync_audio_video, + }.items() + if v is not None + } + if extras: + body.update(extras) + body.update(kwargs) + return cast("RoomResponse", self._http.put(self._path(id), body=body)) + + def list_streams(self, id: str, **params: Any) -> ListStreamsResponse: + return cast( + "ListStreamsResponse", + self._http.get(self._path(id, "streams"), params=params or None), + ) + + def create_stream( + self, + id: str, + *, + url: str, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> Stream: + body: dict[str, Any] = {k: v for k, v in {"url": url}.items() if v is not None} + if extras: + body.update(extras) + body.update(kwargs) + return cast("Stream", self._http.post(self._path(id, "streams"), body=body)) + + +class VideoStreams(BaseResource): + """Typed resource for ``/streams`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/video/streams") + + def get(self, id: str, **params: Any) -> Stream: + return cast("Stream", self._http.get(self._path(id), params=params or None)) + + def update( + self, + id: str, + *, + url: str, + extras: Mapping[str, Any] | None = None, + **kwargs: Any, + ) -> Stream: + body: dict[str, Any] = {k: v for k, v in {"url": url}.items() if v is not None} + if extras: + body.update(extras) + body.update(kwargs) + return cast("Stream", self._http.put(self._path(id), body=body)) + + def delete(self, id: str) -> dict[str, Any]: + return cast("dict[str, Any]", self._http.delete(self._path(id))) diff --git a/signalwire/signalwire/rest/namespaces/video_types_generated.py b/signalwire/signalwire/rest/namespaces/video_types_generated.py new file mode 100644 index 00000000..0318480e --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/video_types_generated.py @@ -0,0 +1,746 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/video/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Any, Literal, TypeAlias, TypedDict + + +class ActiveSession(TypedDict, total=False): + """Active session information for a room. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + room_id: str + name: str + display_name: str + join_from: str + join_until: str + remove_at: str + remove_after_seconds_elapsed: int + layout: str + max_members: int + fps: VideoFps + quality: VideoQuality + start_time: str + end_time: str + duration: int + status: RoomSessionStatus + record_on_start: bool + enable_room_previews: bool + preview_url: str + audio_video_sync: bool + + +class ChargeDetail(TypedDict, total=False): + """Charge detail item for logs. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + charge: float + + +class Conference(TypedDict, total=False): + """Video conference response object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + name: str + display_name: str | None + description: str | None + join_from: str | None + join_until: str | None + quality: VideoQuality + layout: VideoLayout + size: ConferenceSize | None + record_on_start: bool + tone_on_entry_and_exit: bool + user_join_video_off: bool + room_join_video_off: bool + enable_chat: bool + enable_room_previews: bool | None + dark_primary: str | None + dark_background: str | None + dark_foreground: str | None + dark_success: str | None + dark_negative: str | None + light_primary: str | None + light_background: str | None + light_foreground: str | None + light_success: str | None + light_negative: str | None + meta: dict[str, Any] | None + created_at: str + updated_at: str + active_session: ActiveSession + + +ConferenceSize: TypeAlias = "Literal['small', 'medium', 'large']" + + +class ConferenceToken(TypedDict, total=False): + """A conference token object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + name: str | None + token: str + scopes: list[str] + + +class CreateConferenceRequest(TypedDict, total=False): + """Request body for creating a conference. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + display_name: str + description: str + join_from: str + join_until: str + quality: VideoQuality + layout: VideoLayout + size: ConferenceSize + record_on_start: bool + enable_room_previews: bool + enable_chat: bool + dark_primary: str + dark_background: str + dark_foreground: str + dark_success: str + dark_negative: str + light_primary: str + light_background: str + light_foreground: str + light_success: str + light_negative: str + + +class CreateRoomRequest(TypedDict, total=False): + """Request body for creating a room. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + name: str + display_name: str + description: str + max_members: int + quality: VideoQuality + join_from: str + join_until: str + remove_at: str + remove_after_seconds_elapsed: int + layout: RoomLayout + record_on_start: bool + enable_room_previews: bool + meta: dict[str, Any] + sync_audio_video: bool + + +class CreateRoomTokenRequest(TypedDict, total=False): + """Request body for creating a room token. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + room_name: str + user_name: str + permissions: list[RoomTokenPermission] + join_from: str + join_until: str + remove_at: str + remove_after_seconds_elapsed: int + join_audio_muted: bool + join_video_muted: bool + auto_create_room: bool + enable_room_previews: bool + room_display_name: str + end_room_session_on_leave: bool + join_as: JoinAsType + media_allowed: MediaAllowedType + room_meta: dict[str, Any] + meta: dict[str, Any] + sync_audio_video: bool + + +class CreateStreamRequest(TypedDict, total=False): + """Request body for creating a stream. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + url: str + + +class DiscardedLog(TypedDict, total=False): + """A discarded/deleted video log entry. Returned when the log has been deleted. Only present when `include_deleted` is `true`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + discarded_at: str + created_at: str + + +JoinAsType: TypeAlias = "Literal['audience', 'member']" + + +class ListConferenceTokensResponse(TypedDict, total=False): + """List conference tokens response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[ConferenceToken] + + +class ListConferencesResponse(TypedDict, total=False): + """List conferences response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[Conference] + + +class ListLogsResponse(TypedDict, total=False): + """List logs response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[VideoLog] + + +class ListRoomRecordingEventsResponse(TypedDict, total=False): + """List room recording events response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[RoomSessionEvent] + + +class ListRoomRecordingsResponse(TypedDict, total=False): + """List room recordings response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[RoomRecording] + + +class ListRoomSessionEventsResponse(TypedDict, total=False): + """List room session events response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[RoomSessionEvent] + + +class ListRoomSessionMembersResponse(TypedDict, total=False): + """List room session members response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[RoomSessionMember] + + +class ListRoomSessionRecordingsResponse(TypedDict, total=False): + """List room session recordings response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[RoomRecording] + + +class ListRoomSessionsResponse(TypedDict, total=False): + """List room sessions response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[RoomSession] + + +class ListRoomsResponse(TypedDict, total=False): + """List rooms response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[RoomResponse] + + +class ListStreamsResponse(TypedDict, total=False): + """List streams response. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: PaginationLinks + data: list[Stream] + + +class Log(TypedDict, total=False): + """Log object representing a video activity entry. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + source: LogSource + type: LogType + url: str + room_name: str | None + status: LogStatus | None + locked: bool + started_at: str | None + ended_at: str | None + charge: float + created_at: str + charge_details: list[ChargeDetail] + + +LogSource: TypeAlias = "Literal['realtime_api']" + +LogStatus: TypeAlias = "Literal['in-progress', 'completed']" + +LogType: TypeAlias = "Literal['video_room_session', 'video_conference_session']" + +MediaAllowedType: TypeAlias = "Literal['all', 'video-only', 'audio-only']" + + +class PaginationLinks(TypedDict, total=False): + """Pagination links for list responses. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + self: str + first: str + next: str + prev: str + + +RoomLayout: TypeAlias = "Literal['grid-responsive', 'grid-responsive-mobile', 'highlight-1-responsive', '1x1', '2x1', '2x2', '5up', '3x3', '4x4', '5x5', '6x6', '8x8', '10x10']" + + +class RoomRecording(TypedDict, total=False): + """Room recording response object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + room_session_id: str + status: RoomRecordingStatus | None + started_at: str | None + finished_at: str | None + duration: int | None + size_in_bytes: int | None + format: str | None + cost_in_dollars: float + uri: str | None + created_at: str + updated_at: str + + +RoomRecordingStatus: TypeAlias = ( + "Literal['recording', 'paused', 'processing', 'completed']" +) + + +class RoomResponse(TypedDict, total=False): + """Room response object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + name: str + display_name: str | None + description: str | None + max_members: int + quality: VideoQuality + fps: int + join_from: str | None + join_until: str | None + remove_at: str | None + remove_after_seconds_elapsed: int | None + layout: RoomLayout + record_on_start: bool + tone_on_entry_and_exit: bool + room_join_video_off: bool + user_join_video_off: bool + enable_room_previews: bool | None + sync_audio_video: bool | None + meta: dict[str, Any] | None + prioritize_handraise: bool + active_session: ActiveSession + created_at: str + updated_at: str + + +class RoomSession(TypedDict, total=False): + """Room session response object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + room_id: str | None + name: str | None + display_name: str | None + max_members: int | None + quality: VideoQuality | None + fps: VideoFps | None + join_from: str | None + join_until: str | None + remove_at: str | None + remove_after_seconds_elapsed: int | None + layout: str | None + record_on_start: bool + tone_on_entry_and_exit: bool + room_join_video_off: bool + user_join_video_off: bool + locked: bool + start_time: str | None + end_time: str | None + duration: int | None + status: RoomSessionStatus | None + created_at: str + updated_at: str + preview_url: str | None + prioritize_handraise: bool | None + sync_audio_video: bool | None + cost_in_dollars: float + enable_room_previews: bool + locked_cover: str + + +class RoomSessionEvent(TypedDict, total=False): + """Room session event response object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + project_id: str + room_id: str + room_session_id: str + room_recording_id: str + room_participant_id: str + level: str + name: str + payload: dict[str, Any] + created_at: str + + +class RoomSessionMember(TypedDict, total=False): + """Room session member response object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + room_session_id: str + name: str | None + join_time: str | None + leave_time: str | None + duration: int | None + cost_in_dollars: float + + +RoomSessionStatus: TypeAlias = "Literal['in-progress', 'completed']" + + +class RoomSessionSummary(TypedDict, total=False): + """Room session summary, returned by the show endpoint. Omits list-only fields. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + room_id: str | None + name: str | None + display_name: str | None + max_members: int | None + quality: VideoQuality | None + fps: VideoFps | None + join_from: str | None + join_until: str | None + remove_at: str | None + remove_after_seconds_elapsed: int | None + layout: str | None + record_on_start: bool + tone_on_entry_and_exit: bool + room_join_video_off: bool + user_join_video_off: bool + locked: bool + start_time: str | None + end_time: str | None + duration: int | None + status: RoomSessionStatus | None + created_at: str + updated_at: str + preview_url: str | None + prioritize_handraise: bool | None + sync_audio_video: bool | None + + +RoomTokenPermission: TypeAlias = "Literal['room.member.audio_mute', 'room.member.audio_unmute', 'room.member.video_mute', 'room.member.video_unmute', 'room.member.deaf', 'room.member.undeaf', 'room.member.set_input_volume', 'room.member.set_output_volume', 'room.member.set_input_sensitivity', 'room.member.set_position', 'room.member.set_meta', 'room.member.raisehand', 'room.member.lowerhand', 'room.member.remove', 'room.member.promote', 'room.member.demote', 'room.hide_video_muted', 'room.list_available_layouts', 'room.lock', 'room.playback', 'room.playback_seek', 'room.prioritize_handraise', 'room.recording', 'room.set_layout', 'room.set_position', 'room.set_meta', 'room.show_video_muted', 'room.stream', 'room.unlock', 'room.self.audio_mute', 'room.self.audio_unmute', 'room.self.video_mute', 'room.self.video_unmute', 'room.self.deaf', 'room.self.undeaf', 'room.self.set_input_volume', 'room.self.set_output_volume', 'room.self.set_input_sensitivity', 'room.self.set_position', 'room.self.set_meta', 'room.self.raisehand', 'room.self.lowerhand', 'room.self.screenshare', 'room.self.additional_source']" + + +class RoomTokenResponse(TypedDict, total=False): + """Room token response object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + token: str + + +class Stream(TypedDict, total=False): + """A video stream object. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: str + url: str | None + stream_type: str | None + width: int | None + height: int | None + fps: int | None + created_at: str + updated_at: str + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode400(TypedDict, total=False): + """The request is invalid. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Bad Request"] + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode403(TypedDict, total=False): + """Access is forbidden. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Forbidden"] + + +class Types_StatusCodes_StatusCode404(TypedDict, total=False): + """The server cannot find the requested resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Not Found"] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +class UpdateConferenceRequest(TypedDict, total=False): + """Request body for updating a conference. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + display_name: str + description: str + join_from: str + join_until: str + quality: VideoQuality + layout: VideoLayout + size: ConferenceSize + record_on_start: bool + tone_on_entry_and_exit: bool + room_join_video_off: bool + user_join_video_off: bool + enable_room_previews: bool + enable_chat: bool + dark_primary: str + dark_background: str + dark_foreground: str + dark_success: str + dark_negative: str + light_primary: str + light_background: str + light_foreground: str + light_success: str + light_negative: str + + +class UpdateRoomRequest(TypedDict, total=False): + """Request body for updating a room. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + display_name: str + description: str + max_members: int + quality: VideoQuality + join_from: str + join_until: str + remove_at: str + remove_after_seconds_elapsed: int + layout: RoomLayout + record_on_start: bool + enable_room_previews: bool + meta: dict[str, Any] + sync_audio_video: bool + + +class UpdateStreamRequest(TypedDict, total=False): + """Request body for updating a stream. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + url: str + + +VideoFps: TypeAlias = "Literal[20, 30]" + +VideoLayout: TypeAlias = "Literal['grid-responsive', 'grid-responsive-mobile', 'highlight-1-responsive', '1x1', '2x1', '2x2', '5up', '3x3', '4x4', '5x5', '6x6', '8x8', '10x10']" + +VideoLog: TypeAlias = "Log | DiscardedLog" + +VideoQuality: TypeAlias = "Literal['720p', '1080p']" + + +class VideoStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +GetConferenceTokenResponse: TypeAlias = "ConferenceToken" +ResetConferenceTokenResponse: TypeAlias = "ConferenceToken" +CreateVideoConferenceRequest: TypeAlias = "CreateConferenceRequest" +CreateVideoConferenceResponse: TypeAlias = "Conference" +ListVideoConferencesResponse: TypeAlias = "ListConferencesResponse" +GetVideoConferenceResponse: TypeAlias = "Conference" +UpdateVideoConferenceRequest: TypeAlias = "UpdateConferenceRequest" +UpdateVideoConferenceResponse: TypeAlias = "Conference" +ListConferenceStreamsResponse: TypeAlias = "ListStreamsResponse" +CreateConferenceStreamRequest: TypeAlias = "CreateStreamRequest" +CreateConferenceStreamResponse: TypeAlias = "Stream" +GetLogResponse: TypeAlias = "VideoLog" +GetRoomRecordingResponse: TypeAlias = "RoomRecording" +GetRoomSessionResponse: TypeAlias = "RoomSessionSummary" +CreateRoomTokenResponse: TypeAlias = "RoomTokenResponse" +CreateRoomResponse: TypeAlias = "RoomResponse" +GetRoomResponse: TypeAlias = "RoomResponse" +UpdateRoomResponse: TypeAlias = "RoomResponse" +ListRoomStreamsResponse: TypeAlias = "ListStreamsResponse" +CreateRoomStreamRequest: TypeAlias = "CreateStreamRequest" +CreateRoomStreamResponse: TypeAlias = "Stream" +GetRoomByNameResponse: TypeAlias = "RoomResponse" +GetStreamResponse: TypeAlias = "Stream" +UpdateStreamResponse: TypeAlias = "Stream" diff --git a/signalwire/signalwire/rest/namespaces/voice_resources_generated.py b/signalwire/signalwire/rest/namespaces/voice_resources_generated.py new file mode 100644 index 00000000..7b9210df --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/voice_resources_generated.py @@ -0,0 +1,31 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/voice/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One typed CRUD subclass per full-CRUD resource: closed typed create/update params +# (explicit spec fields) + an ``extras`` escape hatch and a ``**kwargs`` tail for +# unknown / reserved-word wire fields, bound to the resource's spec types. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from .._base import ReadResource + +if TYPE_CHECKING: + from .voice_types_generated import ( + LogEventsListResponse, + LogListResponse, + VoiceLog, + ) + + +class VoiceLogs(ReadResource["LogListResponse", "VoiceLog"]): + """Typed resource for ``/logs`` (generated).""" + + def __init__(self, http: Any) -> None: + super().__init__(http, "/api/voice/logs") + + def list_events(self, id: str, **params: Any) -> LogEventsListResponse: + return cast( + "LogEventsListResponse", + self._http.get(self._path(id, "events"), params=params or None), + ) diff --git a/signalwire/signalwire/rest/namespaces/voice_types_generated.py b/signalwire/signalwire/rest/namespaces/voice_types_generated.py new file mode 100644 index 00000000..4e5c2b62 --- /dev/null +++ b/signalwire/signalwire/rest/namespaces/voice_types_generated.py @@ -0,0 +1,265 @@ +# AUTO-GENERATED from porting-sdk/rest-apis/voice/openapi.yaml — DO NOT EDIT. +# Regenerate: python3 porting-sdk/scripts/generate_python_rest_types.py +# +# One TypedDict per components/schemas entry + per-operation Request/Response +# aliases. TypedDicts are STATIC-ONLY: at runtime each is a plain dict, so a +# differently-shaped server response is returned unchanged and never raises. +from __future__ import annotations +from typing import Any, Literal, TypeAlias, TypedDict + + +class ChargeDetail(TypedDict, total=False): + """Details on charges associated with this log. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + description: str + charge: float + + +class DialogflowVoiceLog(TypedDict, total=False): + """Voice log for Dialogflow call types. Returned when `type` is `dialogflow_call`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + # non-identifier field 'from': str + to: str + source: VoiceSources + charge: float + charge_details: list[ChargeDetail] + created_at: str + type: Literal["dialogflow_call"] + url: None + status: VoiceLogStatus + duration: int | None + + +class DiscardedVoiceLog(TypedDict, total=False): + """A discarded/deleted voice log entry. Returned when the log has been deleted. Only present when `include_deleted` is `true`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + discarded_at: str + created_at: str + + +class FabricVoiceLog(TypedDict, total=False): + """Voice log for Fabric Subscriber Device call types. Returned when `type` is `fabric_subscriber_device_leg`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + # non-identifier field 'from': str + to: str + source: VoiceSources + charge: float + charge_details: list[ChargeDetail] + created_at: str + type: Literal["fabric_subscriber_device_leg"] + url: None + direction: VoiceDirection + status: VoiceLogStatus | None + + +class LogEvent(TypedDict, total=False): + """Event entry for a voice log + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + event_at: str + level: Literal["info", "warn", "error", "debug"] + name: str + details: dict[str, Any] + project_id: uuid + log_id: uuid + + +class LogEventsListResponse(TypedDict, total=False): + """Response model for log events list endpoint + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + data: list[LogEvent] + + +class LogListResponse(TypedDict, total=False): + """Response model for voice log list endpoint + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + links: LogPaginationResponse + data: list[VoiceLog] + + +class LogPaginationResponse(TypedDict, total=False): + """Pagination links for voice log list responses + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + self: str + first: str + next: str + prev: str + + +class RelayVoiceLog(TypedDict, total=False): + """Voice log for Compatibility and Relay call types. Returned when `type` is `laml_call`, `relay_pstn_call`, `relay_sip_call`, or `relay_webrtc_call`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + # non-identifier field 'from': str + to: str + source: VoiceSources + charge: float + charge_details: list[ChargeDetail] + created_at: str + type: RelayVoiceType + url: str | None + direction: VoiceDirection + status: VoiceLogStatus + duration: int | None + duration_ms: int | None + billing_ms: int | None + parent_id: str | None + + +RelayVoiceType: TypeAlias = ( + "Literal['laml_call', 'relay_pstn_call', 'relay_sip_call', 'relay_webrtc_call']" +) + + +class Types_StatusCodes_RestApiErrorItem(TypedDict, total=False): + """Details about a specific error. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + type: str + code: str + message: str + attribute: str | None + url: str + + +class Types_StatusCodes_StatusCode400(TypedDict, total=False): + """The request is invalid. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Bad Request"] + + +class Types_StatusCodes_StatusCode401(TypedDict, total=False): + """Access is unauthorized. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Unauthorized"] + + +class Types_StatusCodes_StatusCode404(TypedDict, total=False): + """The server cannot find the requested resource. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Not Found"] + + +class Types_StatusCodes_StatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +class Types_StatusCodes_StatusCode500(TypedDict, total=False): + """An internal server error occurred. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + error: Literal["Internal Server Error"] + + +class VideoRoomVoiceLog(TypedDict, total=False): + """Voice log for audio legs in a Video Room. Returned when `type` is `video_room_pstn_leg` or `video_room_sip_leg`. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + id: uuid + # non-identifier field 'from': str + to: str + source: VoiceSources + charge: float + charge_details: list[ChargeDetail] + created_at: str + type: VideoRoomVoiceType + url: None + direction: VoiceDirection + status: VoiceLogStatus + duration: int | None + duration_ms: int | None + + +VideoRoomVoiceType: TypeAlias = "Literal['video_room_pstn_leg', 'video_room_sip_leg']" + +VoiceDirection: TypeAlias = ( + "Literal['inbound', 'outbound', 'outbound-api', 'outbound-dial']" +) + +VoiceLog: TypeAlias = "RelayVoiceLog | VideoRoomVoiceLog | DialogflowVoiceLog | FabricVoiceLog | DiscardedVoiceLog" + +VoiceLogStatus: TypeAlias = "Literal['queued', 'initiated', 'ringing', 'in-progress', 'busy', 'failed', 'no-answer', 'canceled', 'completed', 'ended', 'answered', 'created', 'ending', 'joined']" + + +class VoiceLogsListStatusCode422(TypedDict, total=False): + """The request contains invalid parameters. See errors for details. + + Open shape: extra server keys are permitted and partial payloads are valid; + not validated at runtime (a TypedDict is a plain ``dict``). + """ + + errors: list[Types_StatusCodes_RestApiErrorItem] + + +VoiceSources: TypeAlias = "Literal['dialogflow', 'laml', 'realtime_api']" + +uuid: TypeAlias = "str" + +ListVoiceLogsResponse: TypeAlias = "LogListResponse" +GetVoiceLogResponse: TypeAlias = "VoiceLog" +ListVoiceLogEventsResponse: TypeAlias = "LogEventsListResponse" diff --git a/signalwire/signalwire/search/document_processor.py b/signalwire/signalwire/search/document_processor.py index 6e6c40fd..40969219 100644 --- a/signalwire/signalwire/search/document_processor.py +++ b/signalwire/signalwire/search/document_processor.py @@ -310,8 +310,8 @@ def _extract_html(self, file_path: str) -> str: file_path, encoding="utf-8" ) as file: soup = BeautifulSoup(file, "html.parser") - # bs4 is untyped (ignore_missing_imports); get_text() returns str. - return cast(str, soup.get_text(separator="\n")) + text: str = soup.get_text(separator="\n") + return text except Exception as e: return json.dumps({"error": f"Error processing HTML: {e}"}) @@ -325,7 +325,8 @@ def _extract_markdown(self, file_path: str) -> str: if markdown is not None and BeautifulSoup is not None: html = markdown.markdown(content) soup = BeautifulSoup(html, "html.parser") - return cast(str, soup.get_text(separator="\n")) + md_text: str = soup.get_text(separator="\n") + return md_text # Fallback to raw markdown return content except Exception as e: diff --git a/signalwire/signalwire/skills/google_maps/skill.py b/signalwire/signalwire/skills/google_maps/skill.py index 196a1544..8f83c8ed 100644 --- a/signalwire/signalwire/skills/google_maps/skill.py +++ b/signalwire/signalwire/skills/google_maps/skill.py @@ -135,7 +135,7 @@ def _debug_json(label: str, data: Any) -> None: class GoogleMapsClient: - def __init__(self, api_key): + def __init__(self, api_key: str): self.api_key = api_key def validate_address( @@ -289,7 +289,7 @@ def _autocomplete_search( autocomplete_url = ( "https://maps.googleapis.com/maps/api/place/autocomplete/json" ) - params = {"input": input_text, "key": self.api_key} + params: dict[str, Any] = {"input": input_text, "key": self.api_key} # Soft bias toward pickup area (no strictbounds — far destinations still work) if bias_lat is not None and bias_lng is not None: params["location"] = f"{bias_lat},{bias_lng}" @@ -386,7 +386,13 @@ def _get_place_details(self, place_id: str) -> dict[str, Any] | None: "business_name": name if is_business else "", } - def compute_route(self, origin_lat, origin_lng, dest_lat, dest_lng): + def compute_route( + self, + origin_lat: float, + origin_lng: float, + dest_lat: float, + dest_lng: float, + ) -> dict[str, Any] | None: """Compute route using Google Routes API. Returns: {"distance_meters": int, "duration_seconds": int} or None @@ -586,12 +592,22 @@ def _compute_route_handler( dest_lat = args.get("dest_lat") dest_lng = args.get("dest_lng") - if None in (origin_lat, origin_lng, dest_lat, dest_lng): + if ( + origin_lat is None + or origin_lng is None + or dest_lat is None + or dest_lng is None + ): return FunctionResult( "All four coordinates are required: origin_lat, origin_lng, dest_lat, dest_lng." ) - result = self.client.compute_route(origin_lat, origin_lng, dest_lat, dest_lng) + result = self.client.compute_route( + float(origin_lat), + float(origin_lng), + float(dest_lat), + float(dest_lng), + ) if not result: return FunctionResult( "I couldn't compute a route between those locations. Please verify the coordinates." diff --git a/signalwire/signalwire/skills/registry.py b/signalwire/signalwire/skills/registry.py index 7a23639f..e5a98782 100644 --- a/signalwire/signalwire/skills/registry.py +++ b/signalwire/signalwire/skills/registry.py @@ -314,14 +314,20 @@ def get_all_skills_schema(self) -> dict[str, dict[str, Any]]: } } """ - skills_schema = {} + skills_schema: dict[str, dict[str, Any]] = {} # Load entry points first self._load_entry_points() # Helper function to add skill to schema - def add_skill_to_schema(skill_class, source): + def add_skill_to_schema( + skill_class: type[SkillBase], source: str + ) -> None: try: + skill_name = skill_class.SKILL_NAME + if skill_name is None: + return + # Get parameter schema try: parameter_schema = skill_class.get_parameter_schema() @@ -329,8 +335,8 @@ def add_skill_to_schema(skill_class, source): # Skill doesn't implement get_parameter_schema yet parameter_schema = {} - skills_schema[skill_class.SKILL_NAME] = { - "name": skill_class.SKILL_NAME, + skills_schema[skill_name] = { + "name": skill_name, "description": skill_class.SKILL_DESCRIPTION, "version": getattr(skill_class, "SKILL_VERSION", "1.0.0"), "supports_multiple_instances": getattr( diff --git a/signalwire/signalwire/skills/spider/skill.py b/signalwire/signalwire/skills/spider/skill.py index 77b030e6..75c366a1 100644 --- a/signalwire/signalwire/skills/spider/skill.py +++ b/signalwire/signalwire/skills/spider/skill.py @@ -11,7 +11,7 @@ import re import collections -from typing import Any, ClassVar, cast +from typing import Any, ClassVar, TYPE_CHECKING, cast from urllib.parse import urljoin, urlparse import requests from lxml import html @@ -20,6 +20,9 @@ from signalwire.core.skill_base import SkillBase from signalwire.core.function_result import FunctionResult +if TYPE_CHECKING: + from signalwire.core.agent_base import AgentBase + class SpiderSkill(SkillBase): """Fast web scraping skill optimized for speed and token efficiency.""" @@ -145,7 +148,7 @@ def get_parameter_schema(cls) -> dict[str, dict[str, Any]]: ) return schema - def __init__(self, agent, params: dict[str, Any]): + def __init__(self, agent: "AgentBase", params: dict[str, Any]): """Initialize the spider skill with configuration parameters.""" super().__init__(agent, params) @@ -519,7 +522,7 @@ def _crawl_site_handler( return FunctionResult("Max pages must be at least 1") # Simple breadth-first crawl - visited: set = set() + visited: set[Any] = set[Any]() to_visit = [(start_url, 0)] # (url, depth) results = [] diff --git a/signalwire/signalwire/utils/__init__.py b/signalwire/signalwire/utils/__init__.py index 07370e68..e13127ec 100644 --- a/signalwire/signalwire/utils/__init__.py +++ b/signalwire/signalwire/utils/__init__.py @@ -7,8 +7,6 @@ See LICENSE file in the project root for full license information. """ -from typing import cast - from .schema_utils import SchemaUtils from .url_validator import validate_url from signalwire.core.logging_config import get_execution_mode @@ -21,9 +19,7 @@ def is_serverless_mode() -> bool: Returns: bool: True if in serverless mode, False if in server mode """ - # get_execution_mode() is currently untyped (-> Any), so the comparison - # leaks Any; the equality is a genuine bool. Narrow it explicitly. - return cast(str, get_execution_mode()) != "server" + return get_execution_mode() != "server" __all__ = ["SchemaUtils", "get_execution_mode", "is_serverless_mode", "validate_url"] diff --git a/signalwire/signalwire/web/web_service.py b/signalwire/signalwire/web/web_service.py index 0a34553c..880f8fa4 100644 --- a/signalwire/signalwire/web/web_service.py +++ b/signalwire/signalwire/web/web_service.py @@ -348,8 +348,8 @@ async def health() -> dict[str, Any]: "directory_browsing": self.enable_directory_browsing, } - @self.app.get("/") - async def root(): + @self.app.get("/", response_model=None) + async def root() -> "Response | dict[str, Any]": """Root endpoint showing available directories""" html = """ @@ -420,7 +420,7 @@ async def serve_file( ), route: str = route, directory: str = directory, - ): + ) -> "Response": """Serve files with security checks""" if security: self._get_current_username(credentials) diff --git a/tests/conftest.py b/tests/conftest.py index d8f18f50..224b2543 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,9 +5,7 @@ Licensed under the MIT License. See LICENSE file in the project root for full license information. -""" -""" Pytest configuration and shared fixtures for SignalWire AI Agents tests """ @@ -16,9 +14,10 @@ import pytest import tempfile import shutil +from collections.abc import Iterator from pathlib import Path from unittest.mock import Mock, patch, MagicMock -from typing import Dict, Any, Optional, List +from typing import Any import json import uuid from datetime import datetime @@ -28,7 +27,7 @@ sys.path.insert(0, str(project_root)) # Import the main classes we'll be testing -from signalwire import AgentBase +from signalwire.core.agent_base import AgentBase from signalwire.core.function_result import FunctionResult from signalwire.core.swaig_function import SWAIGFunction from signalwire.core.data_map import DataMap @@ -37,7 +36,7 @@ @pytest.fixture(scope="session") -def test_data_dir(): +def test_data_dir() -> Iterator[Path]: """Create a temporary directory for test data""" temp_dir = tempfile.mkdtemp(prefix="signalwire_tests_") yield Path(temp_dir) @@ -45,7 +44,7 @@ def test_data_dir(): @pytest.fixture -def mock_env_vars(): +def mock_env_vars() -> Iterator[dict[str, str]]: """Mock environment variables for testing""" env_vars = { "SIGNALWIRE_PROJECT_ID": "test-project-id", @@ -67,7 +66,7 @@ def mock_env_vars(): @pytest.fixture -def sample_agent_config(): +def sample_agent_config() -> dict[str, Any]: """Sample agent configuration for testing""" return { 'name': 'test_agent', @@ -81,7 +80,7 @@ def sample_agent_config(): @pytest.fixture -def mock_agent(mock_env_vars): +def mock_agent(mock_env_vars: dict[str, str]) -> AgentBase: """Create a mock agent for testing (schema validation disabled)""" with pytest.MonkeyPatch().context() as m: # Mock uvicorn to prevent actual server startup @@ -106,28 +105,29 @@ def mock_agent(mock_env_vars): @pytest.fixture -def sample_swaig_function(): +def sample_swaig_function() -> Any: """Sample SWAIG function for testing""" - def test_handler(param1: str, param2: int = 42): + def test_handler(param1: str, param2: int = 42) -> dict[str, Any]: return {"result": f"Processed {param1} with {param2}"} + parameters: dict[str, Any] = { + "type": "object", + "properties": { + "param1": {"type": "string", "description": "First parameter"}, + "param2": {"type": "integer", "description": "Second parameter", "default": 42} + }, + "required": ["param1"] + } return SWAIGFunction( name="test_function", description="A test function", - parameters={ - "type": "object", - "properties": { - "param1": {"type": "string", "description": "First parameter"}, - "param2": {"type": "integer", "description": "Second parameter", "default": 42} - }, - "required": ["param1"] - }, + parameters=parameters, handler=test_handler ) @pytest.fixture -def sample_post_data(): +def sample_post_data() -> dict[str, Any]: """Sample POST data that would come from SignalWire""" return { "function": "test_function", @@ -158,7 +158,7 @@ def sample_post_data(): @pytest.fixture -def mock_fastapi_request(): +def mock_fastapi_request() -> Any: """Create a mock FastAPI request for testing""" request = Mock() request.method = "POST" @@ -171,7 +171,7 @@ def mock_fastapi_request(): @pytest.fixture -def mock_session_manager(): +def mock_session_manager() -> Any: """Mock session manager for testing""" session_manager = Mock() session_manager.create_tool_token.return_value = "test-token-123" @@ -180,7 +180,7 @@ def mock_session_manager(): @pytest.fixture -def sample_swml_document(): +def sample_swml_document() -> dict[str, Any]: """Sample SWML document structure""" return { "version": "1.0.0", @@ -203,7 +203,7 @@ def sample_swml_document(): @pytest.fixture -def mock_skill(): +def mock_skill() -> type: """Mock skill for testing skill manager""" from signalwire.core.skill_base import SkillBase @@ -212,10 +212,10 @@ class MockSkill(SkillBase): SKILL_DESCRIPTION = "A mock skill for testing" SKILL_VERSION = "1.0.0" - def setup(self): + def setup(self) -> bool: return True - def register_tools(self): + def register_tools(self) -> None: self.agent.define_tool( name="mock_tool", description="A mock tool", @@ -227,7 +227,7 @@ def register_tools(self): @pytest.fixture -def temp_state_file(test_data_dir): +def temp_state_file(test_data_dir: Path) -> Iterator[Any]: """Temporary state file for testing state management""" state_file = test_data_dir / "test_state.json" yield state_file @@ -236,7 +236,7 @@ def temp_state_file(test_data_dir): @pytest.fixture -def sample_contexts(): +def sample_contexts() -> Any: """Sample contexts for testing context system""" return [ { @@ -254,7 +254,7 @@ def sample_contexts(): @pytest.fixture -def mock_swml_service(): +def mock_swml_service() -> Any: """Create a mock SWML service for testing (schema validation disabled)""" service = SWMLService( name="test_service", @@ -268,7 +268,7 @@ def mock_swml_service(): @pytest.fixture -def mock_swaig_function(): +def mock_swaig_function() -> Any: """Create a mock SWAIG function for testing""" return { "function": "test_function", @@ -285,7 +285,7 @@ def mock_swaig_function(): @pytest.fixture -def mock_post_data(): +def mock_post_data() -> Any: """Create mock POST data for webhook testing""" return { "call_id": "test-call-123", @@ -303,7 +303,7 @@ def mock_post_data(): @pytest.fixture -def sample_swml_response(): +def sample_swml_response() -> Any: """Sample SWML response for testing""" return { "version": "1.0.0", @@ -323,7 +323,7 @@ def sample_swml_response(): # Pytest hooks for better test organization -def pytest_configure(config): +def pytest_configure(config: "pytest.Config") -> None: """Configure pytest with custom markers""" config.addinivalue_line( "markers", "unit: mark test as a unit test" @@ -339,7 +339,7 @@ def pytest_configure(config): ) -def pytest_collection_modifyitems(config, items): +def pytest_collection_modifyitems(config: "pytest.Config", items: "list[pytest.Item]") -> None: """Automatically mark tests based on their location""" for item in items: # Mark tests in unit/ directory as unit tests @@ -351,7 +351,7 @@ def pytest_collection_modifyitems(config, items): item.add_marker(pytest.mark.integration) # Mark tests that use network fixtures as network tests - if any(fixture in item.fixturenames for fixture in ["requests_mock", "httpx_mock"]): + if any(fixture in getattr(item, "fixturenames", []) for fixture in ["requests_mock", "httpx_mock"]): item.add_marker(pytest.mark.network) @@ -360,7 +360,7 @@ class TestUtils: """Utility functions for tests""" @staticmethod - def create_mock_response(status_code: int = 200, json_data: Optional[Dict] = None): + def create_mock_response(status_code: int = 200, json_data: "dict[str, Any] | None" = None) -> Any: """Create a mock HTTP response""" response = Mock() response.status_code = status_code @@ -369,7 +369,7 @@ def create_mock_response(status_code: int = 200, json_data: Optional[Dict] = Non return response @staticmethod - def assert_swml_structure(swml_dict: Dict[str, Any]): + def assert_swml_structure(swml_dict: "dict[str, Any]") -> None: """Assert that a dictionary has valid SWML structure""" assert "version" in swml_dict assert "sections" in swml_dict @@ -377,7 +377,7 @@ def assert_swml_structure(swml_dict: Dict[str, Any]): assert isinstance(swml_dict["sections"]["main"], list) @staticmethod - def assert_swaig_function_structure(func_dict: Dict[str, Any]): + def assert_swaig_function_structure(func_dict: "dict[str, Any]") -> None: """Assert that a dictionary has valid SWAIG function structure""" required_fields = ["function", "purpose", "argument"] for field in required_fields: @@ -385,6 +385,6 @@ def assert_swaig_function_structure(func_dict: Dict[str, Any]): @pytest.fixture -def test_utils(): +def test_utils() -> type: """Provide test utilities""" return TestUtils \ No newline at end of file diff --git a/tests/integration/relay/test_relay_live.py b/tests/integration/relay/test_relay_live.py index f5c475c1..35dacae8 100644 --- a/tests/integration/relay/test_relay_live.py +++ b/tests/integration/relay/test_relay_live.py @@ -1,3 +1,4 @@ +from typing import Any """Live integration tests for RELAY client. Skipped by default — requires real credentials in environment variables: @@ -37,15 +38,15 @@ @skip_no_creds class TestRelayLive: - def setup_method(self): + def setup_method(self) -> None: _active_clients.clear() - def teardown_method(self): + def teardown_method(self) -> None: _active_clients.clear() @pytest.mark.asyncio - async def test_connect_disconnect(self): - kwargs = {"project": _PROJECT, "token": _TOKEN} + async def test_connect_disconnect(self) -> None: + kwargs: dict[str, Any] = {"project": _PROJECT, "token": _TOKEN} if _HOST: kwargs["host"] = _HOST client = RelayClient(**kwargs) @@ -58,8 +59,8 @@ async def test_connect_disconnect(self): assert not client._connected @pytest.mark.asyncio - async def test_protocol_string(self): - kwargs = {"project": _PROJECT, "token": _TOKEN} + async def test_protocol_string(self) -> None: + kwargs: dict[str, Any] = {"project": _PROJECT, "token": _TOKEN} if _HOST: kwargs["host"] = _HOST client = RelayClient(**kwargs) @@ -71,9 +72,9 @@ async def test_protocol_string(self): await client.disconnect() @pytest.mark.asyncio - async def test_ping(self): + async def test_ping(self) -> None: """Verify the server sends pings and we respond correctly.""" - kwargs = {"project": _PROJECT, "token": _TOKEN} + kwargs: dict[str, Any] = {"project": _PROJECT, "token": _TOKEN} if _HOST: kwargs["host"] = _HOST client = RelayClient(**kwargs) @@ -87,8 +88,8 @@ async def test_ping(self): @skip_no_phone @pytest.mark.asyncio - async def test_dial_and_hangup(self): - kwargs = {"project": _PROJECT, "token": _TOKEN} + async def test_dial_and_hangup(self) -> None: + kwargs: dict[str, Any] = {"project": _PROJECT, "token": _TOKEN} if _HOST: kwargs["host"] = _HOST client = RelayClient(**kwargs) @@ -102,9 +103,9 @@ async def test_dial_and_hangup(self): # Wait briefly then hang up await asyncio.sleep(2) - try: + try: # noqa: SIM105 - best-effort cleanup; contextlib.suppress is not clearer here await call.hangup() - except Exception: + except Exception: # noqa: S110 - call may have already ended; cleanup is best-effort pass # Call may have already ended await client.disconnect() diff --git a/tests/test_examples.py b/tests/test_examples.py index 957d723a..c28a9726 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,3 +1,4 @@ +from typing import Any, cast #!/usr/bin/env python3 """ Test suite for all examples in the examples/ directory. @@ -25,7 +26,7 @@ EXAMPLES_DIR = REPO_ROOT / "examples" -def run_swaig_test(agent_path: Path, *args, timeout: int = 30) -> tuple: +def run_swaig_test(agent_path: Path, *args: str, timeout: int = 30) -> tuple[int, str, str]: """ Run swaig-test on an agent file and return (returncode, stdout, stderr). """ @@ -37,7 +38,7 @@ def run_swaig_test(agent_path: Path, *args, timeout: int = 30) -> tuple: return -1, "", "Timeout expired" -def get_swml_json(agent_path: Path) -> dict: +def get_swml_json(agent_path: Path) -> dict[str, Any]: """ Get SWML JSON output from an agent file. """ @@ -45,12 +46,12 @@ def get_swml_json(agent_path: Path) -> dict: if returncode != 0: pytest.fail(f"swaig-test failed for {agent_path}:\nstderr: {stderr}\nstdout: {stdout}") try: - return json.loads(stdout) + return cast("dict[str, Any]", json.loads(stdout)) except json.JSONDecodeError as e: pytest.fail(f"Invalid JSON from {agent_path}: {e}\nOutput: {stdout}") -def list_tools(agent_path: Path) -> list: +def list_tools(agent_path: Path) -> list[str]: """ List tools available in an agent. """ @@ -110,7 +111,7 @@ class TestBasicAgentExamples: "declarative_agent.py", "faq_bot_agent.py", ]) - def test_basic_agents_load(self, agent_file): + def test_basic_agents_load(self, agent_file: str) -> None: """Test basic agent examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -123,7 +124,7 @@ def test_basic_agents_load(self, agent_file): "simple_static_agent.py", "declarative_agent.py", ]) - def test_basic_agents_generate_valid_swml(self, agent_file): + def test_basic_agents_generate_valid_swml(self, agent_file: str) -> None: """Test basic agents generate valid SWML.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -143,7 +144,7 @@ class TestContextsExamples: "info_gatherer_example.py", "dynamic_info_gatherer_example.py", ]) - def test_contexts_agents_load(self, agent_file): + def test_contexts_agents_load(self, agent_file: str) -> None: """Test context-based agents can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -151,7 +152,7 @@ def test_contexts_agents_load(self, agent_file): returncode, stdout, stderr = run_swaig_test(agent_path, "--list-tools") assert returncode == 0, f"Failed to load {agent_file}:\nstderr: {stderr}\nstdout: {stdout}" - def test_survey_agent_multi_class(self): + def test_survey_agent_multi_class(self) -> None: """Test survey_agent_example.py with explicit agent class.""" agent_path = EXAMPLES_DIR / "survey_agent_example.py" if not agent_path.exists(): @@ -167,7 +168,7 @@ class TestDataMapExamples: @pytest.mark.parametrize("agent_file", [ "data_map_demo.py", ]) - def test_datamap_agents_load(self, agent_file): + def test_datamap_agents_load(self, agent_file: str) -> None: """Test DataMap agents can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -185,7 +186,7 @@ class TestSkillsExamples: "skills_demo.py", "wikipedia_demo.py", ]) - def test_skills_agents_load(self, agent_file): + def test_skills_agents_load(self, agent_file: str) -> None: """Test skills agents can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -207,7 +208,7 @@ class TestWebSearchExamples: @pytest.mark.parametrize("agent_file", [ "web_search_multi_instance_demo.py", ]) - def test_web_search_agents_load(self, agent_file): + def test_web_search_agents_load(self, agent_file: str) -> None: """Test web search agents can be loaded (may skip if no API keys).""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -226,7 +227,7 @@ class TestDatasphereExamples: "datasphere_serverless_demo.py", "datasphere_multi_instance_demo.py", ]) - def test_datasphere_agents_load(self, agent_file): + def test_datasphere_agents_load(self, agent_file: str) -> None: """Test Datasphere agents can be loaded (may skip if no credentials).""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -244,7 +245,7 @@ class TestSWAIGFeaturesExamples: @pytest.mark.parametrize("agent_file", [ "swaig_features_agent.py", ]) - def test_swaig_features_agents_load(self, agent_file): + def test_swaig_features_agents_load(self, agent_file: str) -> None: """Test SWAIG feature agents can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -261,7 +262,7 @@ class TestSWMLServiceExamples: @pytest.mark.parametrize("agent_file", [ "swml_service_routing_example.py", ]) - def test_swml_service_agents_load(self, agent_file): + def test_swml_service_agents_load(self, agent_file: str) -> None: """Test SWML service examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -279,7 +280,7 @@ class TestDeploymentExamples: "kubernetes_ready_agent.py", "custom_path_agent.py", ]) - def test_deployment_agents_load(self, agent_file): + def test_deployment_agents_load(self, agent_file: str) -> None: """Test deployment examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -293,7 +294,7 @@ def test_deployment_agents_load(self, agent_file): class TestMultiAgentExamples: """Test multi-agent examples.""" - def test_multi_agent_server_load(self): + def test_multi_agent_server_load(self) -> None: """Test multi-agent server can be loaded.""" agent_path = EXAMPLES_DIR / "multi_agent_server.py" if not agent_path.exists(): @@ -304,7 +305,7 @@ def test_multi_agent_server_load(self): assert returncode == 0 or "multiple" in stdout.lower() or "agent" in stdout.lower(), \ f"Failed to list agents:\nstderr: {stderr}\nstdout: {stdout}" - def test_multi_endpoint_agent_load(self): + def test_multi_endpoint_agent_load(self) -> None: """Test multi-endpoint agent can be loaded.""" agent_path = EXAMPLES_DIR / "multi_endpoint_agent.py" if not agent_path.exists(): @@ -320,7 +321,7 @@ class TestPrefabExamples: "concierge_agent_example.py", "receptionist_agent_example.py", ]) - def test_prefab_agents_load(self, agent_file): + def test_prefab_agents_load(self, agent_file: str) -> None: """Test prefab examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -335,7 +336,7 @@ class TestDynamicConfigExamples: @pytest.mark.parametrize("agent_file", [ "comprehensive_dynamic_agent.py", ]) - def test_dynamic_config_agents_load(self, agent_file): + def test_dynamic_config_agents_load(self, agent_file: str) -> None: """Test dynamic config examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -352,7 +353,7 @@ class TestAuthExamples: @pytest.mark.parametrize("agent_file", [ "env_auth_test.py", ]) - def test_auth_agents_load(self, agent_file): + def test_auth_agents_load(self, agent_file: str) -> None: """Test auth examples can be loaded.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -371,7 +372,7 @@ class TestSearchExamples: "sigmond_native_search.py", "sigmond_remote_search.py", ]) - def test_search_agents_load(self, agent_file): + def test_search_agents_load(self, agent_file: str) -> None: """Test search agents can be loaded (may skip if no index).""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -391,7 +392,7 @@ class TestBedrockExamples: "bedrock_agent_test.py", "bedrock_server_test.py", ]) - def test_bedrock_agents_load(self, agent_file): + def test_bedrock_agents_load(self, agent_file: str) -> None: """Test Bedrock agents can be loaded (skip if no AWS credentials).""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -409,7 +410,7 @@ def test_bedrock_agents_load(self, agent_file): class TestSpecialExamples: """Test special/edge case examples.""" - def test_search_server_standalone(self): + def test_search_server_standalone(self) -> None: """Test search server standalone (not an agent, may fail gracefully).""" agent_path = EXAMPLES_DIR / "search_server_standalone.py" if not agent_path.exists(): @@ -421,7 +422,7 @@ def test_search_server_standalone(self): if "not an agent" in stderr.lower() or "no agent" in stderr.lower(): pytest.skip("search_server_standalone.py is not an agent file") - def test_lambda_handler(self): + def test_lambda_handler(self) -> None: """Test lambda handler example.""" agent_path = EXAMPLES_DIR / "test_lambda_handler.py" if not agent_path.exists(): @@ -449,7 +450,7 @@ class TestSWMLGeneration: "swaig_features_agent.py", "declarative_agent.py", ]) - def test_swml_has_ai_section(self, agent_file): + def test_swml_has_ai_section(self, agent_file: str) -> None: """Test SWML has AI configuration.""" agent_path = EXAMPLES_DIR / agent_file if not agent_path.exists(): @@ -471,7 +472,7 @@ def test_swml_has_ai_section(self, agent_file): class TestToolsPresence: """Test that specific agents have expected tools.""" - def test_simple_agent_has_tools(self): + def test_simple_agent_has_tools(self) -> None: """Test simple_agent has expected tools.""" agent_path = EXAMPLES_DIR / "simple_agent.py" if not agent_path.exists(): @@ -482,7 +483,7 @@ def test_simple_agent_has_tools(self): assert "get_time" in tools, f"Missing get_time tool. Found: {tools}" assert "get_weather" in tools, f"Missing get_weather tool. Found: {tools}" - def test_swaig_features_agent_has_tools(self): + def test_swaig_features_agent_has_tools(self) -> None: """Test swaig_features_agent has expected tools.""" agent_path = EXAMPLES_DIR / "swaig_features_agent.py" if not agent_path.exists(): @@ -497,7 +498,7 @@ class TestServerlessSimulation: """Test serverless environment simulation.""" @pytest.mark.parametrize("platform", ["lambda", "cloud_function"]) - def test_serverless_swml_generation(self, platform): + def test_serverless_swml_generation(self, platform: str) -> None: """Test SWML generation in serverless simulation.""" agent_path = EXAMPLES_DIR / "simple_agent.py" if not agent_path.exists(): diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py index 609ea045..f67de834 100644 --- a/tests/test_mcp_integration.py +++ b/tests/test_mcp_integration.py @@ -1,9 +1,10 @@ +from typing import Any #!/usr/bin/env python3 """Tests for MCP server endpoint and add_mcp_server configuration.""" import json import pytest -from signalwire import AgentBase +from signalwire.core.agent_base import AgentBase from signalwire.core.function_result import FunctionResult from signalwire.core.mixins.mcp_server_mixin import MCPServerMixin @@ -11,7 +12,7 @@ class TestMCPServerMixin: """Test the MCP server mixin directly""" - def _make_agent(self): + def _make_agent(self) -> "AgentBase": """Create an agent with MCP server enabled and a tool""" agent = AgentBase(name="test-mcp", route="/test") agent._mcp_server_enabled = True @@ -19,7 +20,7 @@ def _make_agent(self): # Register a tool manually for testing from signalwire.core.swaig_function import SWAIGFunction - def weather_handler(agent_self, args, raw): + def weather_handler(agent_self: Any, args: dict[str, Any], raw: dict[str, Any]) -> Any: return FunctionResult(f"72F sunny in {args.get('location', 'unknown')}") func = SWAIGFunction( @@ -35,7 +36,7 @@ def weather_handler(agent_self, args, raw): return agent - def test_build_tool_list(self): + def test_build_tool_list(self) -> None: """Tools are converted to MCP format correctly""" agent = self._make_agent() tools = agent._build_mcp_tool_list() @@ -47,7 +48,7 @@ def test_build_tool_list(self): assert tools[0]["inputSchema"]["type"] == "object" assert "location" in tools[0]["inputSchema"].get("properties", {}) - def test_initialize_handshake(self): + def test_initialize_handshake(self) -> None: """Initialize returns protocol version and capabilities""" agent = self._make_agent() resp = agent._handle_mcp_request({ @@ -67,7 +68,7 @@ def test_initialize_handshake(self): assert resp["result"]["protocolVersion"] == "2025-06-18" assert "tools" in resp["result"]["capabilities"] - def test_initialized_notification(self): + def test_initialized_notification(self) -> None: """notifications/initialized returns empty result""" agent = self._make_agent() resp = agent._handle_mcp_request({ @@ -77,7 +78,7 @@ def test_initialized_notification(self): assert "result" in resp - def test_tools_list(self): + def test_tools_list(self) -> None: """tools/list returns registered tools in MCP format""" agent = self._make_agent() resp = agent._handle_mcp_request({ @@ -92,7 +93,7 @@ def test_tools_list(self): assert len(tools) == 1 assert tools[0]["name"] == "get_weather" - def test_tools_call(self): + def test_tools_call(self) -> None: """tools/call invokes the handler and returns content""" agent = self._make_agent() resp = agent._handle_mcp_request({ @@ -112,7 +113,7 @@ def test_tools_call(self): assert content[0]["type"] == "text" assert "Orlando" in content[0]["text"] - def test_tools_call_unknown(self): + def test_tools_call_unknown(self) -> None: """tools/call with unknown tool returns error""" agent = self._make_agent() resp = agent._handle_mcp_request({ @@ -126,7 +127,7 @@ def test_tools_call_unknown(self): assert resp["error"]["code"] == -32602 assert "nonexistent" in resp["error"]["message"] - def test_unknown_method(self): + def test_unknown_method(self) -> None: """Unknown method returns method not found error""" agent = self._make_agent() resp = agent._handle_mcp_request({ @@ -139,7 +140,7 @@ def test_unknown_method(self): assert "error" in resp assert resp["error"]["code"] == -32601 - def test_ping(self): + def test_ping(self) -> None: """ping returns empty result""" agent = self._make_agent() resp = agent._handle_mcp_request({ @@ -150,7 +151,7 @@ def test_ping(self): assert "result" in resp - def test_invalid_jsonrpc_version(self): + def test_invalid_jsonrpc_version(self) -> None: """Non-2.0 version returns error""" agent = self._make_agent() resp = agent._handle_mcp_request({ @@ -166,7 +167,7 @@ def test_invalid_jsonrpc_version(self): class TestAddMCPServer: """Test the add_mcp_server config method""" - def test_add_mcp_server_basic(self): + def test_add_mcp_server_basic(self) -> None: """Basic MCP server config""" agent = AgentBase(name="test", route="/test") agent.add_mcp_server("https://mcp.example.com/tools") @@ -174,7 +175,7 @@ def test_add_mcp_server_basic(self): assert len(agent._mcp_servers) == 1 assert agent._mcp_servers[0]["url"] == "https://mcp.example.com/tools" - def test_add_mcp_server_with_headers(self): + def test_add_mcp_server_with_headers(self) -> None: """MCP server with auth headers""" agent = AgentBase(name="test", route="/test") agent.add_mcp_server( @@ -184,7 +185,7 @@ def test_add_mcp_server_with_headers(self): assert agent._mcp_servers[0]["headers"]["Authorization"] == "Bearer sk-xxx" - def test_add_mcp_server_with_resources(self): + def test_add_mcp_server_with_resources(self) -> None: """MCP server with resources enabled""" agent = AgentBase(name="test", route="/test") agent.add_mcp_server( @@ -196,7 +197,7 @@ def test_add_mcp_server_with_resources(self): assert agent._mcp_servers[0]["resources"] == True assert agent._mcp_servers[0]["resource_vars"]["caller_id"] == "${caller_id_number}" - def test_add_multiple_servers(self): + def test_add_multiple_servers(self) -> None: """Multiple MCP servers""" agent = AgentBase(name="test", route="/test") agent.add_mcp_server("https://mcp1.example.com") @@ -204,14 +205,14 @@ def test_add_multiple_servers(self): assert len(agent._mcp_servers) == 2 - def test_method_chaining(self): + def test_method_chaining(self) -> None: """add_mcp_server returns self for chaining""" agent = AgentBase(name="test", route="/test") result = agent.add_mcp_server("https://mcp.example.com") assert result is agent - def test_enable_mcp_server(self): + def test_enable_mcp_server(self) -> None: """enable_mcp_server sets the flag""" agent = AgentBase(name="test", route="/test") assert agent._mcp_server_enabled == False diff --git a/tests/unit/cli/test_agent_loader.py b/tests/unit/cli/test_agent_loader.py index 8ab129c2..6dba820e 100644 --- a/tests/unit/cli/test_agent_loader.py +++ b/tests/unit/cli/test_agent_loader.py @@ -26,6 +26,7 @@ import importlib import importlib.util import textwrap +from collections.abc import Iterator # noqa: E402 from pathlib import Path from unittest.mock import Mock, patch, MagicMock, PropertyMock @@ -41,16 +42,16 @@ class _MockSWMLService: name = "mock-service" route = "/mock" - def serve(self, *a, **kw): + def serve(self, *a: object, **kw: object) -> None: pass - def run(self, *a, **kw): + def run(self, *a: object, **kw: object) -> None: pass class _MockAgentBase(_MockSWMLService): """Minimal stand-in for AgentBase (inherits from SWMLService stand-in).""" - _tool_registry = {} + _tool_registry: dict[str, object] = {} class _MockServiceCapture: @@ -68,8 +69,8 @@ class _MockServiceCapture: _needs_stub = _svc_loader_mod not in sys.modules if _needs_stub: _mod = types.ModuleType(_svc_loader_mod) - _mod.ServiceCapture = _MockServiceCapture - _mod.load_agent_from_file = lambda *a, **kw: None + _mod.ServiceCapture = _MockServiceCapture # type: ignore[attr-defined] + _mod.load_agent_from_file = lambda *a, **kw: None # type: ignore[attr-defined] sys.modules[_svc_loader_mod] = _mod # Now import the module under test -- the try/except at module level will @@ -90,7 +91,7 @@ class _MockServiceCapture: # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) -def _patch_module_globals(): +def _patch_module_globals() -> Iterator[None]: """Ensure every test sees our mock classes and the AVAILABLE flags set.""" with patch.object(agent_loader, "SWMLService", _MockSWMLService), \ patch.object(agent_loader, "AgentBase", _MockAgentBase), \ @@ -100,7 +101,7 @@ def _patch_module_globals(): yield -def _write_py(tmp_path, filename, code): +def _write_py(tmp_path: Path, filename: str, code: str) -> str: """Helper: write a Python file into tmp_path and return its path string.""" p = tmp_path / filename p.write_text(textwrap.dedent(code)) @@ -114,24 +115,24 @@ def _write_py(tmp_path, filename, code): class TestDiscoverServicesInFile: """Tests for the public discover_services_in_file function.""" - def test_raises_when_swml_not_available(self, tmp_path): + def test_raises_when_swml_not_available(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "svc.py", "x = 1\n") with patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False): with pytest.raises(ImportError, match="SWMLService not available"): agent_loader.discover_services_in_file(path) - def test_file_not_found(self, tmp_path): + def test_file_not_found(self, tmp_path: Path) -> None: fake = str(tmp_path / "no_such_file.py") with pytest.raises(FileNotFoundError): agent_loader.discover_services_in_file(fake) - def test_non_python_file(self, tmp_path): + def test_non_python_file(self, tmp_path: Path) -> None: path = tmp_path / "data.txt" path.write_text("hello") with pytest.raises(ValueError, match="Python file"): agent_loader.discover_services_in_file(str(path)) - def test_finds_instance(self, tmp_path): + def test_finds_instance(self, tmp_path: Path) -> None: """A module-level SWMLService instance should be discovered.""" code = """\ class Svc: @@ -149,15 +150,17 @@ class Svc: orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.my_instance = instance + module.my_instance = instance # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -166,7 +169,7 @@ def fake_exec(module): names = [r["name"] for r in results] assert "my_instance" in names - def test_finds_subclass(self, tmp_path): + def test_finds_subclass(self, tmp_path: Path) -> None: """A module-level SWMLService subclass should be discovered.""" code = "x = 1\n" path = _write_py(tmp_path, "mymod2.py", code) @@ -177,15 +180,17 @@ class MySvcClass(_MockSWMLService): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.MySvcClass = MySvcClass + module.MySvcClass = MySvcClass # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -204,7 +209,7 @@ def fake_exec(module): class TestDiscoverAgentsInFile: """Tests for the backward-compat discover_agents_in_file wrapper.""" - def test_filters_to_agents_only(self, tmp_path): + def test_filters_to_agents_only(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "agents.py", code) @@ -219,16 +224,18 @@ def test_filters_to_agents_only(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.agent_inst = agent_inst - module.svc_inst = svc_inst + module.agent_inst = agent_inst # type: ignore[attr-defined] + module.svc_inst = svc_inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -239,7 +246,7 @@ def fake_exec(module): for r in results: assert r["is_agent"] is True - def test_empty_when_no_agents(self, tmp_path): + def test_empty_when_no_agents(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "empty.py", code) results = agent_loader.discover_agents_in_file(path) @@ -253,17 +260,17 @@ def test_empty_when_no_agents(self, tmp_path): class TestDiscoverServicesImpl: """Tests for the internal _discover_services_impl.""" - def test_file_not_found(self, tmp_path): + def test_file_not_found(self, tmp_path: Path) -> None: with pytest.raises(FileNotFoundError, match="Service file not found"): agent_loader._discover_services_impl(str(tmp_path / "nope.py")) - def test_non_py_raises_value_error(self, tmp_path): + def test_non_py_raises_value_error(self, tmp_path: Path) -> None: f = tmp_path / "data.json" f.write_text("{}") with pytest.raises(ValueError, match="Python file"): agent_loader._discover_services_impl(str(f)) - def test_sys_path_cleaned_on_success(self, tmp_path): + def test_sys_path_cleaned_on_success(self, tmp_path: Path) -> None: """Module directory should be removed from sys.path after success.""" path = _write_py(tmp_path, "ok.py", "x = 1\n") module_dir = str(tmp_path.resolve()) @@ -273,7 +280,7 @@ def test_sys_path_cleaned_on_success(self, tmp_path): agent_loader._discover_services_impl(path) assert module_dir not in sys.path - def test_sys_path_cleaned_on_error(self, tmp_path): + def test_sys_path_cleaned_on_error(self, tmp_path: Path) -> None: """Module directory should be removed from sys.path even on import error.""" path = _write_py(tmp_path, "bad.py", "raise RuntimeError('boom')\n") module_dir = str(tmp_path.resolve()) @@ -283,12 +290,12 @@ def test_sys_path_cleaned_on_error(self, tmp_path): agent_loader._discover_services_impl(path) assert module_dir not in sys.path - def test_import_error_wrapped(self, tmp_path): + def test_import_error_wrapped(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "bad2.py", "import nonexistent_module_xyz\n") with pytest.raises(ImportError, match="Failed to load service module"): agent_loader._discover_services_impl(path) - def test_instance_attributes(self, tmp_path): + def test_instance_attributes(self, tmp_path: Path) -> None: """Discovered instance dicts should have expected keys and values.""" code = "x = 1\n" path = _write_py(tmp_path, "inst.py", code) @@ -299,15 +306,17 @@ def test_instance_attributes(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.the_service = inst + module.the_service = inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -322,7 +331,7 @@ def fake_exec(module): assert entry["class_name"] == "_MockSWMLService" assert entry["object"] is inst - def test_class_not_duplicated_when_instance_exists(self, tmp_path): + def test_class_not_duplicated_when_instance_exists(self, tmp_path: Path) -> None: """If an instance of a class is found, the class itself should not be listed separately.""" code = "x = 1\n" path = _write_py(tmp_path, "dup.py", code) @@ -337,16 +346,18 @@ class MySpecialSvc(_MockSWMLService): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.MySpecialSvc = MySpecialSvc - module.sp = inst + module.MySpecialSvc = MySpecialSvc # type: ignore[attr-defined] + module.sp = inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -355,7 +366,7 @@ def fake_exec(module): class_entries = [r for r in results if r["name"] == "MySpecialSvc" and r["type"] == "class"] assert len(class_entries) == 0 # should be deduplicated - def test_agent_instance_has_is_agent_true(self, tmp_path): + def test_agent_instance_has_is_agent_true(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "ag.py", code) @@ -365,15 +376,17 @@ def test_agent_instance_has_is_agent_true(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.agent = inst + module.agent = inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -384,7 +397,7 @@ def fake_exec(module): assert agent_entries[0]["is_agent"] is True assert agent_entries[0]["has_tools"] is True - def test_empty_module_returns_empty_list(self, tmp_path): + def test_empty_module_returns_empty_list(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "empty2.py", "x = 42\n") results = agent_loader._discover_services_impl(path) assert results == [] @@ -397,13 +410,13 @@ def test_empty_module_returns_empty_list(self, tmp_path): class TestLoadServiceFromFile: """Tests for the public load_service_from_file function.""" - def test_raises_when_swml_not_available(self, tmp_path): + def test_raises_when_swml_not_available(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "s.py", "x = 1\n") with patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False): with pytest.raises(ImportError, match="SWMLService not available"): agent_loader.load_service_from_file(path) - def test_delegates_to_impl(self, tmp_path): + def test_delegates_to_impl(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "s2.py", "x = 1\n") sentinel = _MockSWMLService() with patch.object(agent_loader, "_load_service_impl", return_value=sentinel) as m: @@ -419,13 +432,13 @@ def test_delegates_to_impl(self, tmp_path): class TestLoadAgentFromFile: """Tests for the public load_agent_from_file function.""" - def test_raises_when_agent_base_not_available(self, tmp_path): + def test_raises_when_agent_base_not_available(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "a.py", "x = 1\n") with patch.object(agent_loader, "AGENT_BASE_AVAILABLE", False): with pytest.raises(ImportError, match="AgentBase not available"): agent_loader.load_agent_from_file(path) - def test_delegates_to_impl_with_prefer_route_false(self, tmp_path): + def test_delegates_to_impl_with_prefer_route_false(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "a2.py", "x = 1\n") sentinel = _MockAgentBase() with patch.object(agent_loader, "_load_service_impl", return_value=sentinel) as m: @@ -443,24 +456,24 @@ class TestLoadServiceImpl: # --- basic validation ------------------------------------------------ - def test_file_not_found(self, tmp_path): + def test_file_not_found(self, tmp_path: Path) -> None: with pytest.raises(FileNotFoundError, match="Service file not found"): agent_loader._load_service_impl(str(tmp_path / "gone.py")) - def test_non_py_file(self, tmp_path): + def test_non_py_file(self, tmp_path: Path) -> None: f = tmp_path / "data.txt" f.write_text("hi") with pytest.raises(ValueError, match="Python file"): agent_loader._load_service_impl(str(f)) - def test_import_error_in_module(self, tmp_path): + def test_import_error_in_module(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "bad.py", "raise RuntimeError('kaboom')\n") with pytest.raises(ImportError, match="Failed to load service module"): agent_loader._load_service_impl(path) # --- prefer_route=True path ------------------------------------------ - def test_prefer_route_finds_instance_by_route(self, tmp_path): + def test_prefer_route_finds_instance_by_route(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "r1.py", code) @@ -470,28 +483,30 @@ def test_prefer_route_finds_instance_by_route(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.svc = inst + module.svc = inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): result = agent_loader._load_service_impl(path, "/my-route", prefer_route=True) assert result is inst - def test_prefer_route_not_found_raises(self, tmp_path): + def test_prefer_route_not_found_raises(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "r2.py", code) with pytest.raises(ValueError, match="No service found with route"): agent_loader._load_service_impl(path, "/nowhere", prefer_route=True) - def test_prefer_route_fallback_to_class_name(self, tmp_path): + def test_prefer_route_fallback_to_class_name(self, tmp_path: Path) -> None: """When the identifier doesn't match any route but matches a class name as attribute.""" code = "x = 1\n" path = _write_py(tmp_path, "r3.py", code) @@ -502,15 +517,17 @@ def test_prefer_route_fallback_to_class_name(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.MySvc = inst # attribute name matches identifier + module.MySvc = inst # type: ignore[attr-defined] # attribute name matches identifier - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -519,7 +536,7 @@ def fake_exec(module): # --- prefer_route=False (class-name) path ---------------------------- - def test_class_name_finds_instance(self, tmp_path): + def test_class_name_finds_instance(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "cn1.py", code) @@ -529,70 +546,76 @@ def test_class_name_finds_instance(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.MySvc = inst + module.MySvc = inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): result = agent_loader._load_service_impl(path, "MySvc", prefer_route=False) assert result is inst - def test_class_name_not_found_raises(self, tmp_path): + def test_class_name_not_found_raises(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "cn2.py", code) with pytest.raises(ValueError, match="not found in"): agent_loader._load_service_impl(path, "NoSuchClass", prefer_route=False) - def test_class_name_not_valid_service(self, tmp_path): + def test_class_name_not_valid_service(self, tmp_path: Path) -> None: """Identifier exists in module but is not a SWMLService.""" code = "x = 1\n" path = _write_py(tmp_path, "cn3.py", code) orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.NotAService = "just a string" + module.NotAService = "just a string" # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): with pytest.raises(ValueError, match="not a valid SWMLService"): agent_loader._load_service_impl(path, "NotAService", prefer_route=False) - def test_class_name_instantiates_class(self, tmp_path): + def test_class_name_instantiates_class(self, tmp_path: Path) -> None: """When the identifier is a SWMLService subclass, it should be instantiated.""" code = "x = 1\n" path = _write_py(tmp_path, "cn4.py", code) class MySvcClass(_MockSWMLService): - def __init__(self): + def __init__(self) -> None: self.name = "inst-svc" self.route = "/inst" orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.MySvcClass = MySvcClass + module.MySvcClass = MySvcClass # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -601,7 +624,7 @@ def fake_exec(module): # --- Strategy 1: 'agent' / 'service' variable ----------------------- - def test_strategy1_finds_agent_variable(self, tmp_path): + def test_strategy1_finds_agent_variable(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "s1a.py", code) @@ -611,22 +634,24 @@ def test_strategy1_finds_agent_variable(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.agent = inst + module.agent = inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): result = agent_loader._load_service_impl(path) assert result is inst - def test_strategy1_finds_service_variable(self, tmp_path): + def test_strategy1_finds_service_variable(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "s1b.py", code) @@ -636,15 +661,17 @@ def test_strategy1_finds_service_variable(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.service = inst + module.service = inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -653,7 +680,7 @@ def fake_exec(module): # --- Strategy 2: any SWMLService instance ---------------------------- - def test_strategy2_single_instance(self, tmp_path): + def test_strategy2_single_instance(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "s2a.py", code) @@ -663,22 +690,24 @@ def test_strategy2_single_instance(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.my_svc = inst + module.my_svc = inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): result = agent_loader._load_service_impl(path) assert result is inst - def test_strategy2_multiple_instances_prefers_agent(self, tmp_path): + def test_strategy2_multiple_instances_prefers_agent(self, tmp_path: Path) -> None: """When multiple instances exist, prefer one named 'agent'.""" code = "x = 1\n" path = _write_py(tmp_path, "s2b.py", code) @@ -693,16 +722,18 @@ def test_strategy2_multiple_instances_prefers_agent(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.other_svc = inst1 - module.agent = inst2 + module.other_svc = inst1 # type: ignore[attr-defined] + module.agent = inst2 # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -710,7 +741,7 @@ def fake_exec(module): # Strategy 1 should find 'agent' before Strategy 2 even runs assert result is inst2 - def test_strategy2_multiple_instances_uses_first_when_no_preferred_name(self, tmp_path): + def test_strategy2_multiple_instances_uses_first_when_no_preferred_name(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "s2c.py", code) @@ -724,16 +755,18 @@ def test_strategy2_multiple_instances_uses_first_when_no_preferred_name(self, tm orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.alpha_svc = inst1 - module.beta_svc = inst2 + module.alpha_svc = inst1 # type: ignore[attr-defined] + module.beta_svc = inst2 # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -743,85 +776,91 @@ def fake_exec(module): # --- Strategy 3: subclass instantiation ------------------------------ - def test_strategy3_single_class(self, tmp_path): + def test_strategy3_single_class(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "s3a.py", code) class SingleSvc(_MockSWMLService): - def __init__(self): + def __init__(self) -> None: self.name = "single" self.route = "/single" orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.SingleSvc = SingleSvc + module.SingleSvc = SingleSvc # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): result = agent_loader._load_service_impl(path) assert isinstance(result, SingleSvc) - def test_strategy3_multiple_classes_raises(self, tmp_path): + def test_strategy3_multiple_classes_raises(self, tmp_path: Path) -> None: """Multiple service classes without an identifier should raise ValueError.""" code = "x = 1\n" path = _write_py(tmp_path, "s3b.py", code) class SvcA(_MockSWMLService): - def __init__(self): + def __init__(self) -> None: self.name = "a" self.route = "/a" class SvcB(_MockSWMLService): - def __init__(self): + def __init__(self) -> None: self.name = "b" self.route = "/b" orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.SvcA = SvcA - module.SvcB = SvcB + module.SvcA = SvcA # type: ignore[attr-defined] + module.SvcB = SvcB # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): with pytest.raises(ValueError, match="Multiple service classes found"): agent_loader._load_service_impl(path) - def test_strategy3_class_instantiation_failure_prints_warning(self, tmp_path, capsys): + def test_strategy3_class_instantiation_failure_prints_warning(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: """If the single class can't be instantiated, a warning is printed.""" code = "x = 1\n" path = _write_py(tmp_path, "s3c.py", code) class BadSvc(_MockSWMLService): - def __init__(self): + def __init__(self) -> None: raise TypeError("cannot init") orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.BadSvc = BadSvc + module.BadSvc = BadSvc # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -833,7 +872,7 @@ def fake_exec(module): # --- Strategy 4: main() function ------------------------------------ - def test_strategy4_main_returns_service(self, tmp_path): + def test_strategy4_main_returns_service(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "s4a.py", code) @@ -843,22 +882,24 @@ def test_strategy4_main_returns_service(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.main = lambda: inst + module.main = lambda: inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): result = agent_loader._load_service_impl(path) assert result is inst - def test_strategy4_main_captured_via_serve(self, tmp_path): + def test_strategy4_main_captured_via_serve(self, tmp_path: Path) -> None: """main() calls serve() which we intercept to capture the service.""" code = "x = 1\n" path = _write_py(tmp_path, "s4b.py", code) @@ -867,45 +908,49 @@ def test_strategy4_main_captured_via_serve(self, tmp_path): inst.name = "captured" inst.route = "/captured" - def fake_main(): + def fake_main() -> None: inst.serve() # should be intercepted orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.main = fake_main + module.main = fake_main # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): result = agent_loader._load_service_impl(path) assert result is inst - def test_strategy4_main_exception_prints_warning(self, tmp_path, capsys): + def test_strategy4_main_exception_prints_warning(self, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: """If main() raises, a warning is printed and we fall through.""" code = "x = 1\n" path = _write_py(tmp_path, "s4c.py", code) - def bad_main(): + def bad_main() -> None: raise RuntimeError("main failed") orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.main = bad_main + module.main = bad_main # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -917,14 +962,14 @@ def fake_exec(module): # --- no service found ------------------------------------------------ - def test_no_service_raises(self, tmp_path): + def test_no_service_raises(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "nothing.py", "x = 1\n") with pytest.raises(ValueError, match="No service found"): agent_loader._load_service_impl(path) # --- sys.path management in load impl -------------------------------- - def test_load_impl_cleans_sys_path(self, tmp_path): + def test_load_impl_cleans_sys_path(self, tmp_path: Path) -> None: code = "x = 1\n" path = _write_py(tmp_path, "clean.py", code) @@ -938,15 +983,17 @@ def test_load_impl_cleans_sys_path(self, tmp_path): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.agent = inst + module.agent = inst # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -962,19 +1009,19 @@ def fake_exec(module): class TestModuleFallbacks: """Tests verifying behaviour when base classes are not importable.""" - def test_discover_services_raises_with_swml_unavailable(self, tmp_path): + def test_discover_services_raises_with_swml_unavailable(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "f1.py", "x = 1\n") with patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False): with pytest.raises(ImportError): agent_loader.discover_services_in_file(path) - def test_load_service_raises_with_swml_unavailable(self, tmp_path): + def test_load_service_raises_with_swml_unavailable(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "f2.py", "x = 1\n") with patch.object(agent_loader, "SWML_SERVICE_AVAILABLE", False): with pytest.raises(ImportError): agent_loader.load_service_from_file(path) - def test_load_agent_raises_with_agent_base_unavailable(self, tmp_path): + def test_load_agent_raises_with_agent_base_unavailable(self, tmp_path: Path) -> None: path = _write_py(tmp_path, "f3.py", "x = 1\n") with patch.object(agent_loader, "AGENT_BASE_AVAILABLE", False): with pytest.raises(ImportError): @@ -988,87 +1035,93 @@ def test_load_agent_raises_with_agent_base_unavailable(self, tmp_path): class TestEdgeCases: """Miscellaneous edge cases.""" - def test_prefer_route_tries_class_instantiation_for_route(self, tmp_path): + def test_prefer_route_tries_class_instantiation_for_route(self, tmp_path: Path) -> None: """If no existing instance matches the route, _load_service_impl tries instantiating classes and checking their routes.""" code = "x = 1\n" path = _write_py(tmp_path, "edge1.py", code) class RoutedSvc(_MockSWMLService): - def __init__(self): + def __init__(self) -> None: self.name = "routed" self.route = "/special" orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.RoutedSvc = RoutedSvc + module.RoutedSvc = RoutedSvc # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): result = agent_loader._load_service_impl(path, "/special", prefer_route=True) assert isinstance(result, RoutedSvc) - def test_class_name_path_instantiation_error(self, tmp_path): + def test_class_name_path_instantiation_error(self, tmp_path: Path) -> None: """When prefer_route=False and the class cannot be instantiated, raise ValueError.""" code = "x = 1\n" path = _write_py(tmp_path, "edge2.py", code) class BadClass(_MockSWMLService): - def __init__(self): + def __init__(self) -> None: raise RuntimeError("no init") orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.BadClass = BadClass + module.BadClass = BadClass # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): with pytest.raises(ValueError, match="Failed to instantiate"): agent_loader._load_service_impl(path, "BadClass", prefer_route=False) - def test_strategy3_skips_when_module_has_main(self, tmp_path): + def test_strategy3_skips_when_module_has_main(self, tmp_path: Path) -> None: """Strategy 3 (class discovery) is skipped when module has main().""" code = "x = 1\n" path = _write_py(tmp_path, "edge3.py", code) called = [] - def my_main(): + def my_main() -> None: called.append(True) class UnusedSvc(_MockSWMLService): - def __init__(self): + def __init__(self) -> None: self.name = "unused" self.route = "/unused" orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.main = my_main - module.UnusedSvc = UnusedSvc + module.main = my_main # type: ignore[attr-defined] + module.UnusedSvc = UnusedSvc # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): @@ -1078,7 +1131,7 @@ def fake_exec(module): # main() should have been called (Strategy 4) assert len(called) == 1 - def test_discover_services_class_with_exception_in_info(self, tmp_path): + def test_discover_services_class_with_exception_in_info(self, tmp_path: Path) -> None: """If getting class info raises, the exception path in _discover_services_impl still records the class.""" code = "x = 1\n" @@ -1090,15 +1143,17 @@ class TrickySvc(_MockSWMLService): orig_exec = importlib.util.spec_from_file_location - def patched_spec(*args, **kwargs): - spec = orig_exec(*args, **kwargs) + def patched_spec(*args: object, **kwargs: object) -> importlib.machinery.ModuleSpec: + spec = orig_exec(*args, **kwargs) # type: ignore[arg-type] + assert spec is not None + assert spec.loader is not None real_exec = spec.loader.exec_module - def fake_exec(module): + def fake_exec(module: types.ModuleType) -> None: real_exec(module) - module.TrickySvc = TrickySvc + module.TrickySvc = TrickySvc # type: ignore[attr-defined] - spec.loader.exec_module = fake_exec + spec.loader.exec_module = fake_exec # type: ignore[method-assign] return spec with patch("importlib.util.spec_from_file_location", side_effect=patched_spec): diff --git a/tests/unit/cli/test_build_search.py b/tests/unit/cli/test_build_search.py index db1a2b1a..4d0316f0 100644 --- a/tests/unit/cli/test_build_search.py +++ b/tests/unit/cli/test_build_search.py @@ -16,6 +16,7 @@ import types import json from pathlib import Path +from typing import Any # noqa: E402 from unittest.mock import Mock, patch, MagicMock, call from io import StringIO import argparse @@ -24,7 +25,7 @@ # The build_search.py code does local imports from these modules, so @patch needs # them to exist in sys.modules for the patch target to resolve. # Only insert stubs for modules that truly can't be imported. -def _ensure_mock_module(module_path, attrs=None): +def _ensure_mock_module(module_path: str, attrs: dict[str, object] | None = None) -> None: """Register a fake module in sys.modules if the real one isn't importable.""" try: __import__(module_path) @@ -61,7 +62,7 @@ class TestBuildSearchMain: @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs']) - def test_basic_build_command(self, mock_builder_class): + def test_basic_build_command(self, mock_builder_class: MagicMock) -> None: """Test basic build command with minimal arguments""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -112,7 +113,7 @@ def test_basic_build_command(self, mock_builder_class): '--verbose', '--validate' ]) - def test_full_build_command(self, mock_builder_class): + def test_full_build_command(self, mock_builder_class: MagicMock) -> None: """Test build command with all arguments""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -163,15 +164,15 @@ def test_full_build_command(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', 'README.md']) - def test_mixed_sources(self, mock_builder_class): + def test_mixed_sources(self, mock_builder_class: MagicMock) -> None: """Test build command with mixed file and directory sources""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - def mock_exists(self): + def mock_exists(self: Path) -> bool: return str(self) in ['./docs', 'README.md'] - - def mock_is_file(self): + + def mock_is_file(self: Path) -> bool: return str(self) == 'README.md' with patch('pathlib.Path.exists', mock_exists), \ @@ -186,7 +187,7 @@ def mock_is_file(self): assert args['output_file'] == 'sources.swsearch' @patch('sys.argv', ['sw-search', './nonexistent']) - def test_nonexistent_source(self): + def test_nonexistent_source(self) -> None: """Test handling of nonexistent sources""" with patch('pathlib.Path.exists', return_value=False), \ patch('builtins.print') as mock_print, \ @@ -199,7 +200,7 @@ def test_nonexistent_source(self): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', './missing']) - def test_partial_valid_sources(self, mock_builder_class): + def test_partial_valid_sources(self, mock_builder_class: MagicMock) -> None: """Test handling when some sources are invalid""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -209,13 +210,13 @@ def test_partial_valid_sources(self, mock_builder_class): docs_path.exists.return_value = True docs_path.is_file.return_value = False docs_path.name = 'docs' - docs_path.__str__ = lambda self: './docs' + docs_path.__str__ = lambda self: './docs' # type: ignore[method-assign, assignment, misc] missing_path = Mock() missing_path.exists.return_value = False - missing_path.__str__ = lambda self: './missing' + missing_path.__str__ = lambda self: './missing' # type: ignore[method-assign, assignment, misc] - def mock_path_constructor(path_str): + def mock_path_constructor(path_str: object) -> Mock: if str(path_str) == './docs': return docs_path elif str(path_str) == './missing': @@ -224,7 +225,7 @@ def mock_path_constructor(path_str): # Default mock for other paths mock_path = Mock() mock_path.exists.return_value = True - mock_path.__str__ = lambda self: str(path_str) + mock_path.__str__ = lambda self: str(path_str) # type: ignore[method-assign, assignment, misc] return mock_path # Patch Path where it was imported in build_search module @@ -243,7 +244,7 @@ def mock_path_constructor(path_str): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './missing1', './missing2']) - def test_all_invalid_sources(self, mock_builder_class): + def test_all_invalid_sources(self, mock_builder_class: MagicMock) -> None: """Test handling when all sources are invalid""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -259,7 +260,7 @@ def test_all_invalid_sources(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--output', 'test']) - def test_output_extension_handling(self, mock_builder_class): + def test_output_extension_handling(self, mock_builder_class: MagicMock) -> None: """Test automatic addition of .swsearch extension""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -275,7 +276,7 @@ def test_output_extension_handling(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs']) - def test_keyboard_interrupt(self, mock_builder_class): + def test_keyboard_interrupt(self, mock_builder_class: MagicMock) -> None: """Test handling of keyboard interrupt""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -293,7 +294,7 @@ def test_keyboard_interrupt(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs']) - def test_build_error(self, mock_builder_class): + def test_build_error(self, mock_builder_class: MagicMock) -> None: """Test handling of build errors""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -311,7 +312,7 @@ def test_build_error(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--validate']) - def test_validation_failure(self, mock_builder_class): + def test_validation_failure(self, mock_builder_class: MagicMock) -> None: """Test handling of validation failure""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -336,7 +337,7 @@ class TestValidateCommand: """Test the validate command functionality""" @patch('argparse.ArgumentParser') - def test_validate_nonexistent_file(self, mock_parser_class): + def test_validate_nonexistent_file(self, mock_parser_class: MagicMock) -> None: """Test validation of nonexistent file""" # Mock argument parser mock_parser = Mock() @@ -360,7 +361,7 @@ class TestSearchCommand: """Test the search command functionality""" @patch('sys.argv', ['search', 'test.swsearch', 'test query']) - def test_basic_search(self): + def test_basic_search(self) -> None: """Test basic search command""" mock_engine = Mock() mock_engine.get_stats.return_value = {'total_chunks': 100, 'total_files': 10} @@ -395,7 +396,7 @@ def test_basic_search(self): '--verbose', '--json' ]) - def test_full_search_command(self): + def test_full_search_command(self) -> None: """Test search command with all options""" mock_engine = Mock() mock_engine.get_stats.return_value = {'total_chunks': 100, 'total_files': 10} @@ -425,7 +426,7 @@ def test_full_search_command(self): assert '"query": "test query"' in printed_output @patch('sys.argv', ['search', 'test.swsearch', 'test query', '--no-content']) - def test_search_no_content(self): + def test_search_no_content(self) -> None: """Test search command with no content output""" mock_engine = Mock() mock_engine.get_stats.return_value = {'total_chunks': 100, 'total_files': 10} @@ -454,7 +455,7 @@ def test_search_no_content(self): assert 'Test content that should not be shown' not in printed_output @patch('sys.argv', ['search', 'test.swsearch', 'test query']) - def test_search_no_results(self): + def test_search_no_results(self) -> None: """Test search command with no results""" mock_engine = Mock() mock_engine.get_stats.return_value = {'total_chunks': 100, 'total_files': 10} @@ -477,7 +478,7 @@ def test_search_no_results(self): mock_print.assert_any_call("No results found for 'test query'") @patch('sys.argv', ['search', 'nonexistent.swsearch', 'query']) - def test_search_nonexistent_file(self): + def test_search_nonexistent_file(self) -> None: """Test search with nonexistent index file""" with patch('pathlib.Path.exists', return_value=False), \ patch('builtins.print') as mock_print, \ @@ -489,7 +490,7 @@ def test_search_nonexistent_file(self): mock_print.assert_any_call("Error: Index file does not exist: nonexistent.swsearch") @patch('sys.argv', ['search', 'test.swsearch', 'query']) - def test_search_import_error(self): + def test_search_import_error(self) -> None: """Test search with missing dependencies""" with patch('pathlib.Path.exists', return_value=True), \ patch('builtins.print') as mock_print, \ @@ -503,7 +504,7 @@ def test_search_import_error(self): mock_print.assert_any_call("Error: Search functionality not available. Install with: pip install signalwire-sdk[search]") @patch('sys.argv', ['search', 'test.swsearch', 'query']) - def test_search_engine_error(self): + def test_search_engine_error(self) -> None: """Test search engine initialization error""" with patch('pathlib.Path.exists', return_value=True), \ patch('builtins.print') as mock_print, \ @@ -521,14 +522,14 @@ class TestConsoleEntryPoint: @patch('signalwire.cli.build_search.main') @patch('sys.argv', ['sw-search', './docs']) - def test_console_entry_main(self, mock_main): + def test_console_entry_main(self, mock_main: MagicMock) -> None: """Test console entry point calls main for build command""" console_entry_point() mock_main.assert_called_once() @patch('signalwire.cli.build_search.validate_command') @patch('sys.argv', ['sw-search', 'validate', 'test.swsearch']) - def test_console_entry_validate(self, mock_validate): + def test_console_entry_validate(self, mock_validate: MagicMock) -> None: """Test console entry point calls validate_command""" console_entry_point() mock_validate.assert_called_once() @@ -537,7 +538,7 @@ def test_console_entry_validate(self, mock_validate): @patch('signalwire.cli.build_search.search_command') @patch('sys.argv', ['sw-search', 'search', 'test.swsearch', 'query']) - def test_console_entry_search(self, mock_search): + def test_console_entry_search(self, mock_search: MagicMock) -> None: """Test console entry point calls search_command""" console_entry_point() mock_search.assert_called_once() @@ -550,7 +551,7 @@ class TestArgumentParsing: @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--chunking-strategy', 'sentence', '--split-newlines', '2']) - def test_sentence_chunking_with_newlines(self, mock_builder_class): + def test_sentence_chunking_with_newlines(self, mock_builder_class: MagicMock) -> None: """Test sentence chunking with split newlines parameter""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -579,7 +580,7 @@ def test_sentence_chunking_with_newlines(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--chunking-strategy', 'paragraph']) - def test_paragraph_chunking(self, mock_builder_class): + def test_paragraph_chunking(self, mock_builder_class: MagicMock) -> None: """Test paragraph chunking strategy""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -608,7 +609,7 @@ def test_paragraph_chunking(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--chunking-strategy', 'page']) - def test_page_chunking(self, mock_builder_class): + def test_page_chunking(self, mock_builder_class: MagicMock) -> None: """Test page chunking strategy""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -641,7 +642,7 @@ class TestVerboseOutput: @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'sliding']) - def test_verbose_sliding_output(self, mock_builder_class): + def test_verbose_sliding_output(self, mock_builder_class: MagicMock) -> None: """Test verbose output for sliding window strategy""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -660,7 +661,7 @@ def test_verbose_sliding_output(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'sentence', '--split-newlines', '3']) - def test_verbose_sentence_output(self, mock_builder_class): + def test_verbose_sentence_output(self, mock_builder_class: MagicMock) -> None: """Test verbose output for sentence strategy with newlines""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -683,7 +684,7 @@ class TestErrorHandlingEdgeCases: @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--verbose']) - def test_verbose_error_with_traceback(self, mock_builder_class): + def test_verbose_error_with_traceback(self, mock_builder_class: MagicMock) -> None: """Test verbose error output includes traceback""" mock_builder_class.side_effect = Exception("Detailed error") @@ -699,7 +700,7 @@ def test_verbose_error_with_traceback(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['validate', 'test.swsearch', '--verbose']) - def test_validate_verbose_error_with_traceback(self, mock_builder_class): + def test_validate_verbose_error_with_traceback(self, mock_builder_class: MagicMock) -> None: """Test verbose validation error includes traceback""" mock_builder_class.side_effect = Exception("Validation detailed error") @@ -713,7 +714,7 @@ def test_validate_verbose_error_with_traceback(self, mock_builder_class): mock_traceback.assert_called_once() @patch('sys.argv', ['search', 'test.swsearch', 'query', '--verbose']) - def test_search_verbose_error_with_traceback(self): + def test_search_verbose_error_with_traceback(self) -> None: """Test verbose search error includes traceback""" with patch('pathlib.Path.exists', return_value=True), \ patch('builtins.print') as mock_print, \ @@ -740,7 +741,7 @@ class TestConsoleEntryPointExtended: @patch('builtins.print') @patch('sys.argv', ['sw-search', '--help']) - def test_console_entry_help_flag(self, mock_print): + def test_console_entry_help_flag(self, mock_print: MagicMock) -> None: """Test --help flag shows help text without importing heavy modules.""" console_entry_point() printed = ''.join(str(c.args[0]) for c in mock_print.call_args_list if c.args) @@ -748,7 +749,7 @@ def test_console_entry_help_flag(self, mock_print): @patch('builtins.print') @patch('sys.argv', ['sw-search', '-h']) - def test_console_entry_help_short_flag(self, mock_print): + def test_console_entry_help_short_flag(self, mock_print: MagicMock) -> None: """Test -h flag shows help text.""" console_entry_point() printed = ''.join(str(c.args[0]) for c in mock_print.call_args_list if c.args) @@ -756,7 +757,7 @@ def test_console_entry_help_short_flag(self, mock_print): @patch('signalwire.cli.build_search.remote_command') @patch('sys.argv', ['sw-search', 'remote', 'http://localhost:8001', 'query']) - def test_console_entry_remote(self, mock_remote): + def test_console_entry_remote(self, mock_remote: MagicMock) -> None: """Test console entry point routes to remote_command.""" console_entry_point() mock_remote.assert_called_once() @@ -764,7 +765,7 @@ def test_console_entry_remote(self, mock_remote): @patch('signalwire.cli.build_search.migrate_command') @patch('sys.argv', ['sw-search', 'migrate', 'test.swsearch', '--info']) - def test_console_entry_migrate(self, mock_migrate): + def test_console_entry_migrate(self, mock_migrate: MagicMock) -> None: """Test console entry point routes to migrate_command.""" console_entry_point() mock_migrate.assert_called_once() @@ -775,7 +776,7 @@ class TestMainPgvectorBackend: """Tests for pgvector backend handling in main().""" @patch('sys.argv', ['sw-search', './docs', '--backend', 'pgvector']) - def test_pgvector_requires_connection_string(self): + def test_pgvector_requires_connection_string(self) -> None: """--backend pgvector without --connection-string should exit.""" with patch('pathlib.Path.exists', return_value=True), \ patch('builtins.print') as mock_print, \ @@ -792,7 +793,7 @@ def test_pgvector_requires_connection_string(self): '--backend', 'pgvector', '--connection-string', 'postgresql://user:pass@localhost/db', ]) - def test_pgvector_default_output_single_source(self, mock_builder_class): + def test_pgvector_default_output_single_source(self, mock_builder_class: MagicMock) -> None: """pgvector single source should use source name as collection name.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -813,7 +814,7 @@ def test_pgvector_default_output_single_source(self, mock_builder_class): '--backend', 'pgvector', '--connection-string', 'postgresql://u:p@localhost/db', ]) - def test_pgvector_default_output_multi_source(self, mock_builder_class): + def test_pgvector_default_output_multi_source(self, mock_builder_class: MagicMock) -> None: """pgvector with multiple sources defaults to 'documents' collection.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -832,7 +833,7 @@ def test_pgvector_default_output_multi_source(self, mock_builder_class): '--backend', 'pgvector', '--connection-string', 'postgresql://u:p@localhost/db', ]) - def test_pgvector_success_message(self, mock_builder_class): + def test_pgvector_success_message(self, mock_builder_class: MagicMock) -> None: """pgvector success path prints collection info.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -850,7 +851,7 @@ class TestMainOutputConflict: """Tests for --output and --output-dir conflict detection.""" @patch('sys.argv', ['sw-search', './docs', '--output', 'out.swsearch', '--output-dir', './dir']) - def test_output_and_output_dir_conflict(self): + def test_output_and_output_dir_conflict(self) -> None: """Specifying both --output and --output-dir should error.""" with patch('pathlib.Path.exists', return_value=True), \ patch('builtins.print') as mock_print, \ @@ -865,7 +866,7 @@ class TestMainJsonOutputFormat: @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--output-format', 'json']) - def test_json_format_default_output_name(self, mock_builder_class): + def test_json_format_default_output_name(self, mock_builder_class: MagicMock) -> None: """JSON format without explicit output should default to chunks.json.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -887,7 +888,7 @@ def test_json_format_default_output_name(self, mock_builder_class): '--backend', 'pgvector', '--connection-string', 'postgresql://u:p@localhost/db', ]) - def test_json_format_ignores_backend_warning(self, mock_builder_class): + def test_json_format_ignores_backend_warning(self, mock_builder_class: MagicMock) -> None: """JSON format with non-sqlite backend should warn.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -909,7 +910,7 @@ def test_json_format_ignores_backend_warning(self, mock_builder_class): '--output-format', 'json', '--output', 'my_chunks', ]) - def test_json_format_output_gets_json_extension(self, mock_builder_class): + def test_json_format_output_gets_json_extension(self, mock_builder_class: MagicMock) -> None: """JSON format output without .json suffix gets one appended via Path.with_suffix.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -935,7 +936,7 @@ def test_json_format_output_gets_json_extension(self, mock_builder_class): '--output-format', 'json', '--output-dir', '/tmp/test_chunks_out', ]) - def test_json_format_output_dir_mode(self, mock_builder_class): + def test_json_format_output_dir_mode(self, mock_builder_class: MagicMock) -> None: """JSON format with --output-dir should process without error.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -956,7 +957,7 @@ class TestMainOutputDirIndexFormat: @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--output-dir', '/tmp/idx_out']) - def test_output_dir_single_source_sqlite(self, mock_builder_class): + def test_output_dir_single_source_sqlite(self, mock_builder_class: MagicMock) -> None: """Index format with --output-dir and single source auto-names the file.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -974,7 +975,7 @@ def test_output_dir_single_source_sqlite(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './a', './b', '--output-dir', '/tmp/idx_out']) - def test_output_dir_multi_source_sqlite(self, mock_builder_class): + def test_output_dir_multi_source_sqlite(self, mock_builder_class: MagicMock) -> None: """Index format with --output-dir and multiple sources uses 'combined'.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -995,7 +996,7 @@ class TestMainModelAlias: @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--model', 'base']) - def test_model_alias_base(self, mock_builder_class): + def test_model_alias_base(self, mock_builder_class: MagicMock) -> None: """Model alias 'base' should resolve to the full model name.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -1011,7 +1012,7 @@ def test_model_alias_base(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--model', 'large']) - def test_model_alias_large(self, mock_builder_class): + def test_model_alias_large(self, mock_builder_class: MagicMock) -> None: """Model alias 'large' should resolve correctly.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -1027,7 +1028,7 @@ def test_model_alias_large(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--model', 'custom-org/my-model']) - def test_model_full_name_passthrough(self, mock_builder_class): + def test_model_full_name_passthrough(self, mock_builder_class: MagicMock) -> None: """Full model name that is not an alias should pass through unchanged.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -1047,7 +1048,7 @@ class TestMainVerboseStrategies: @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'paragraph']) - def test_verbose_paragraph(self, mock_builder_class): + def test_verbose_paragraph(self, mock_builder_class: MagicMock) -> None: mock_builder = Mock() mock_builder_class.return_value = mock_builder with patch('pathlib.Path.exists', return_value=True), \ @@ -1060,7 +1061,7 @@ def test_verbose_paragraph(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'page']) - def test_verbose_page(self, mock_builder_class): + def test_verbose_page(self, mock_builder_class: MagicMock) -> None: mock_builder = Mock() mock_builder_class.return_value = mock_builder with patch('pathlib.Path.exists', return_value=True), \ @@ -1073,7 +1074,7 @@ def test_verbose_page(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'semantic']) - def test_verbose_semantic(self, mock_builder_class): + def test_verbose_semantic(self, mock_builder_class: MagicMock) -> None: mock_builder = Mock() mock_builder_class.return_value = mock_builder with patch('pathlib.Path.exists', return_value=True), \ @@ -1086,7 +1087,7 @@ def test_verbose_semantic(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'topic']) - def test_verbose_topic(self, mock_builder_class): + def test_verbose_topic(self, mock_builder_class: MagicMock) -> None: mock_builder = Mock() mock_builder_class.return_value = mock_builder with patch('pathlib.Path.exists', return_value=True), \ @@ -1099,7 +1100,7 @@ def test_verbose_topic(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'qa']) - def test_verbose_qa(self, mock_builder_class): + def test_verbose_qa(self, mock_builder_class: MagicMock) -> None: mock_builder = Mock() mock_builder_class.return_value = mock_builder with patch('pathlib.Path.exists', return_value=True), \ @@ -1112,7 +1113,7 @@ def test_verbose_qa(self, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs', '--verbose', '--chunking-strategy', 'sentence']) - def test_verbose_sentence_no_newlines(self, mock_builder_class): + def test_verbose_sentence_no_newlines(self, mock_builder_class: MagicMock) -> None: """Sentence strategy without split-newlines should not print newline line.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -1134,15 +1135,15 @@ class TestMainSingleFileSource: @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', 'README.md']) - def test_single_file_source_names_output(self, mock_builder_class): + def test_single_file_source_names_output(self, mock_builder_class: MagicMock) -> None: """Single file source should name output after file stem.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder - def mock_exists(self): + def mock_exists(self: Path) -> bool: return True - def mock_is_file(self): + def mock_is_file(self: Path) -> bool: return str(self) == 'README.md' with patch('pathlib.Path.exists', mock_exists), \ @@ -1161,7 +1162,7 @@ class TestMainIndexCreationCheck: @patch('signalwire.search.index_builder.IndexBuilder') @patch('sys.argv', ['sw-search', './docs']) - def test_index_file_not_created(self, mock_builder_class): + def test_index_file_not_created(self, mock_builder_class: MagicMock) -> None: """If output file is not created, should exit with error.""" mock_builder = Mock() mock_builder_class.return_value = mock_builder @@ -1185,7 +1186,7 @@ class TestValidateCommandExtended: @patch('signalwire.search.index_builder.IndexBuilder') @patch('argparse.ArgumentParser') - def test_validate_success(self, mock_parser_class, mock_builder_class): + def test_validate_success(self, mock_parser_class: MagicMock, mock_builder_class: MagicMock) -> None: """Successful validation prints valid message.""" mock_parser = Mock() mock_parser_class.return_value = mock_parser @@ -1213,7 +1214,7 @@ def test_validate_success(self, mock_parser_class, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('argparse.ArgumentParser') - def test_validate_success_verbose(self, mock_parser_class, mock_builder_class): + def test_validate_success_verbose(self, mock_parser_class: MagicMock, mock_builder_class: MagicMock) -> None: """Successful verbose validation prints configuration details.""" mock_parser = Mock() mock_parser_class.return_value = mock_parser @@ -1240,7 +1241,7 @@ def test_validate_success_verbose(self, mock_parser_class, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('argparse.ArgumentParser') - def test_validate_failure(self, mock_parser_class, mock_builder_class): + def test_validate_failure(self, mock_parser_class: MagicMock, mock_builder_class: MagicMock) -> None: """Failed validation should exit with code 1.""" mock_parser = Mock() mock_parser_class.return_value = mock_parser @@ -1268,7 +1269,7 @@ def test_validate_failure(self, mock_parser_class, mock_builder_class): @patch('signalwire.search.index_builder.IndexBuilder') @patch('argparse.ArgumentParser') - def test_validate_exception(self, mock_parser_class, mock_builder_class): + def test_validate_exception(self, mock_parser_class: MagicMock, mock_builder_class: MagicMock) -> None: """Exception during validation should exit with code 1.""" mock_parser = Mock() mock_parser_class.return_value = mock_parser @@ -1291,7 +1292,7 @@ class TestSearchCommandExtended: """Additional tests for search_command.""" @patch('sys.argv', ['search', 'test.swsearch']) - def test_search_no_query_no_shell(self): + def test_search_no_query_no_shell(self) -> None: """Missing query without --shell should exit.""" with patch('pathlib.Path.exists', return_value=True), \ patch('builtins.print') as mock_print, \ @@ -1301,7 +1302,7 @@ def test_search_no_query_no_shell(self): mock_print.assert_any_call("Error: Query is required unless using --shell mode") @patch('sys.argv', ['search', 'test.swsearch', 'q', '--keyword-weight', '1.5']) - def test_search_keyword_weight_too_high(self): + def test_search_keyword_weight_too_high(self) -> None: """keyword-weight > 1.0 should exit.""" with patch('pathlib.Path.exists', return_value=True), \ patch('builtins.print') as mock_print, \ @@ -1311,7 +1312,7 @@ def test_search_keyword_weight_too_high(self): mock_print.assert_any_call("Error: --keyword-weight must be between 0.0 and 1.0") @patch('sys.argv', ['search', 'test.swsearch', 'q', '--keyword-weight', '-0.1']) - def test_search_keyword_weight_negative(self): + def test_search_keyword_weight_negative(self) -> None: """keyword-weight < 0.0 should exit.""" with patch('pathlib.Path.exists', return_value=True), \ patch('builtins.print') as mock_print, \ @@ -1324,7 +1325,7 @@ def test_search_keyword_weight_negative(self): 'search', 'coll', 'q', '--backend', 'pgvector', ]) - def test_search_pgvector_requires_connection_string(self): + def test_search_pgvector_requires_connection_string(self) -> None: """pgvector backend without connection string should exit.""" with patch('builtins.print') as mock_print, \ pytest.raises(SystemExit) as exc_info: @@ -1335,7 +1336,7 @@ def test_search_pgvector_requires_connection_string(self): ) @patch('sys.argv', ['search', 'test.swsearch', 'q', '--model', 'mini']) - def test_search_model_alias_resolved(self): + def test_search_model_alias_resolved(self) -> None: """Model alias in search should resolve to full name.""" mock_engine = Mock() mock_engine.get_stats.return_value = { @@ -1360,7 +1361,7 @@ def test_search_model_alias_resolved(self): assert call_kw['model'] == 'sentence-transformers/all-MiniLM-L6-v2' @patch('sys.argv', ['search', 'test.swsearch', 'q', '--json', '--no-content']) - def test_search_json_no_content(self): + def test_search_json_no_content(self) -> None: """JSON output with --no-content should omit content field.""" mock_engine = Mock() mock_engine.get_stats.return_value = {'total_chunks': 10, 'total_files': 1} @@ -1388,7 +1389,7 @@ def test_search_json_no_content(self): assert 'Hidden content' not in printed_text @patch('sys.argv', ['search', 'test.swsearch', 'test query', '--tags', 'docs']) - def test_search_no_results_with_tags(self): + def test_search_no_results_with_tags(self) -> None: """No results with tags should mention tags in output.""" mock_engine = Mock() mock_engine.get_stats.return_value = {'total_chunks': 10, 'total_files': 1} @@ -1407,7 +1408,7 @@ def test_search_no_results_with_tags(self): assert any('tags' in s.lower() for s in printed) @patch('sys.argv', ['search', 'test.swsearch', 'q']) - def test_search_result_with_line_numbers_and_tags(self): + def test_search_result_with_line_numbers_and_tags(self) -> None: """Results with line_start and tags metadata should display them.""" mock_engine = Mock() mock_engine.get_stats.return_value = {'total_chunks': 10, 'total_files': 1} @@ -1437,7 +1438,7 @@ def test_search_result_with_line_numbers_and_tags(self): assert any('Tags: api, docs' in s for s in printed) @patch('sys.argv', ['search', 'test.swsearch', 'q']) - def test_search_long_content_truncated(self): + def test_search_long_content_truncated(self) -> None: """Content longer than 500 chars should be truncated in non-verbose mode.""" long_content = 'A' * 600 mock_engine = Mock() @@ -1466,7 +1467,7 @@ class TestMigrateCommand: """Tests for migrate_command.""" @patch('sys.argv', ['migrate', '--info', 'test.swsearch']) - def test_migrate_info_success(self): + def test_migrate_info_success(self) -> None: """--info flag should display index information.""" mock_migrator = Mock() mock_migrator.get_index_info.return_value = { @@ -1488,7 +1489,7 @@ def test_migrate_info_success(self): mock_print.assert_any_call(" Total chunks: 100") @patch('sys.argv', ['migrate', '--info']) - def test_migrate_info_no_source(self): + def test_migrate_info_no_source(self) -> None: """--info without source should exit.""" with patch('builtins.print') as mock_print, \ pytest.raises(SystemExit) as exc_info: @@ -1497,7 +1498,7 @@ def test_migrate_info_no_source(self): mock_print.assert_any_call("Error: Source index required with --info") @patch('sys.argv', ['migrate']) - def test_migrate_no_source(self): + def test_migrate_no_source(self) -> None: """No source should exit.""" with patch('builtins.print') as mock_print, \ pytest.raises(SystemExit) as exc_info: @@ -1506,7 +1507,7 @@ def test_migrate_no_source(self): mock_print.assert_any_call("Error: Source index required for migration") @patch('sys.argv', ['migrate', 'test.swsearch']) - def test_migrate_no_direction(self): + def test_migrate_no_direction(self) -> None: """No migration direction should exit.""" with patch('builtins.print') as mock_print, \ pytest.raises(SystemExit) as exc_info: @@ -1517,7 +1518,7 @@ def test_migrate_no_direction(self): ) @patch('sys.argv', ['migrate', 'test.swsearch', '--to-pgvector']) - def test_migrate_to_pgvector_no_connection_string(self): + def test_migrate_to_pgvector_no_connection_string(self) -> None: """to-pgvector without connection string should exit.""" mock_migrator = Mock() with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ @@ -1533,7 +1534,7 @@ def test_migrate_to_pgvector_no_connection_string(self): 'migrate', 'test.swsearch', '--to-pgvector', '--connection-string', 'postgresql://u:p@localhost/db', ]) - def test_migrate_to_pgvector_no_collection_name(self): + def test_migrate_to_pgvector_no_collection_name(self) -> None: """to-pgvector without collection name should exit.""" mock_migrator = Mock() with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ @@ -1550,7 +1551,7 @@ def test_migrate_to_pgvector_no_collection_name(self): '--connection-string', 'postgresql://u:p@localhost/db', '--collection-name', 'my_coll', ]) - def test_migrate_to_pgvector_success(self): + def test_migrate_to_pgvector_success(self) -> None: """Successful pgvector migration should print success message.""" mock_migrator = Mock() mock_migrator.migrate_sqlite_to_pgvector.return_value = { @@ -1566,7 +1567,7 @@ def test_migrate_to_pgvector_success(self): assert any('Migration completed successfully' in s for s in printed) @patch('sys.argv', ['migrate', 'test.swsearch', '--to-sqlite']) - def test_migrate_to_sqlite_not_implemented(self): + def test_migrate_to_sqlite_not_implemented(self) -> None: """to-sqlite should report not implemented and exit.""" mock_migrator = Mock() with patch('signalwire.search.migration.SearchIndexMigrator', return_value=mock_migrator), \ @@ -1580,7 +1581,7 @@ def test_migrate_to_sqlite_not_implemented(self): '--connection-string', 'postgresql://u:p@localhost/db', '--collection-name', 'coll', ]) - def test_migrate_exception(self): + def test_migrate_exception(self) -> None: """Exception during migration should exit with code 1.""" mock_migrator = Mock() mock_migrator.migrate_sqlite_to_pgvector.side_effect = Exception("DB error") @@ -1592,7 +1593,7 @@ def test_migrate_exception(self): assert exc_info.value.code == 1 @patch('sys.argv', ['migrate', '--info', 'test.swsearch', '--verbose']) - def test_migrate_info_verbose(self): + def test_migrate_info_verbose(self) -> None: """--info --verbose should print full config.""" mock_migrator = Mock() mock_migrator.get_index_info.return_value = { @@ -1613,7 +1614,7 @@ def test_migrate_info_verbose(self): mock_print.assert_any_call("\n Full configuration:") @patch('sys.argv', ['migrate', '--info', 'test.swsearch']) - def test_migrate_info_exception(self): + def test_migrate_info_exception(self) -> None: """Exception in info mode should exit with code 1.""" with patch('signalwire.search.migration.SearchIndexMigrator', side_effect=Exception("fail")), \ patch('builtins.print'), \ @@ -1622,7 +1623,7 @@ def test_migrate_info_exception(self): assert exc_info.value.code == 1 @patch('sys.argv', ['migrate', '--info', 'test.swsearch']) - def test_migrate_info_unknown_type(self): + def test_migrate_info_unknown_type(self) -> None: """Info with unknown index type should print 'Unable to determine'.""" mock_migrator = Mock() mock_migrator.get_index_info.return_value = { @@ -1636,14 +1637,16 @@ def test_migrate_info_unknown_type(self): mock_print.assert_any_call(" Unable to determine index type") -def _make_mock_requests_module(post_return=None, post_side_effect=None): +def _make_mock_requests_module( + post_return: object | None = None, post_side_effect: object | None = None +) -> types.ModuleType: """Create a mock requests module with real exception classes for except clauses.""" import requests as real_requests mock_mod = types.ModuleType('requests') - mock_mod.ConnectionError = real_requests.ConnectionError - mock_mod.Timeout = real_requests.Timeout - mock_mod.RequestException = real_requests.RequestException - mock_mod.post = Mock() + mock_mod.ConnectionError = real_requests.ConnectionError # type: ignore[attr-defined] + mock_mod.Timeout = real_requests.Timeout # type: ignore[attr-defined] + mock_mod.RequestException = real_requests.RequestException # type: ignore[attr-defined] + mock_mod.post = Mock() # type: ignore[attr-defined] if post_return is not None: mock_mod.post.return_value = post_return if post_side_effect is not None: @@ -1655,7 +1658,7 @@ class TestRemoteCommand: """Tests for remote_command.""" @patch('sys.argv', ['remote', 'localhost:8001', 'query', '--index-name', 'docs']) - def test_endpoint_http_prefix_added(self): + def test_endpoint_http_prefix_added(self) -> None: """Endpoint without http:// should get it prepended.""" mock_response = Mock() mock_response.status_code = 200 @@ -1674,7 +1677,7 @@ def test_endpoint_http_prefix_added(self): assert call_args[0][0].endswith('/search') @patch('sys.argv', ['remote', 'http://localhost:8001/', 'query', '--index-name', 'docs']) - def test_endpoint_trailing_slash(self): + def test_endpoint_trailing_slash(self) -> None: """Endpoint with trailing slash should append 'search' correctly.""" mock_response = Mock() mock_response.status_code = 200 @@ -1693,7 +1696,7 @@ def test_endpoint_trailing_slash(self): assert url == 'http://localhost:8001/search' @patch('sys.argv', ['remote', 'http://localhost:8001/search', 'query', '--index-name', 'docs']) - def test_endpoint_already_has_search(self): + def test_endpoint_already_has_search(self) -> None: """Endpoint already ending with /search should not double-append.""" mock_response = Mock() mock_response.status_code = 200 @@ -1713,12 +1716,12 @@ def test_endpoint_already_has_search(self): assert not url.endswith('/search/search') @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) - def test_remote_requests_import_error(self): + def test_remote_requests_import_error(self) -> None: """Missing requests library should exit with helpful message.""" import builtins original_import = builtins.__import__ - def mock_import(name, *args, **kwargs): + def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: if name == 'requests': raise ImportError("No module named 'requests'") return original_import(name, *args, **kwargs) @@ -1731,7 +1734,7 @@ def mock_import(name, *args, **kwargs): assert exc_info.value.code == 1 @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) - def test_remote_404_response(self): + def test_remote_404_response(self) -> None: """404 response should print error and exit.""" mock_response = Mock() mock_response.status_code = 404 @@ -1748,7 +1751,7 @@ def test_remote_404_response(self): mock_print.assert_any_call("Error: Index not found") @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) - def test_remote_500_response(self): + def test_remote_500_response(self) -> None: """500 response should print error and exit.""" mock_response = Mock() mock_response.status_code = 500 @@ -1764,7 +1767,7 @@ def test_remote_500_response(self): assert exc_info.value.code == 1 @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) - def test_remote_connection_error(self): + def test_remote_connection_error(self) -> None: """Connection error should print helpful message and exit.""" import requests as real_requests mock_requests = _make_mock_requests_module( @@ -1781,7 +1784,7 @@ def test_remote_connection_error(self): assert any('Could not connect' in s for s in printed) @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs', '--timeout', '5']) - def test_remote_timeout(self): + def test_remote_timeout(self) -> None: """Timeout should print timeout message and exit.""" import requests as real_requests mock_requests = _make_mock_requests_module( @@ -1801,7 +1804,7 @@ def test_remote_timeout(self): 'remote', 'http://localhost:8001', 'query', '--index-name', 'docs', '--tags', 'a,b', '--verbose', ]) - def test_remote_verbose_with_tags(self): + def test_remote_verbose_with_tags(self) -> None: """Verbose mode with tags should print payload.""" mock_response = Mock() mock_response.status_code = 200 @@ -1825,7 +1828,7 @@ def test_remote_verbose_with_tags(self): 'remote', 'http://localhost:8001', 'query', '--index-name', 'docs', '--json', ]) - def test_remote_json_output(self): + def test_remote_json_output(self) -> None: """--json flag should output raw JSON response.""" mock_response = Mock() mock_response.status_code = 200 @@ -1843,7 +1846,7 @@ def test_remote_json_output(self): assert '"results"' in printed @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) - def test_remote_success_with_results(self): + def test_remote_success_with_results(self) -> None: """Successful response with results should print them.""" mock_response = Mock() mock_response.status_code = 200 @@ -1868,7 +1871,7 @@ def test_remote_success_with_results(self): assert any('Found 1 result' in s for s in printed) @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) - def test_remote_no_results(self): + def test_remote_no_results(self) -> None: """Empty results should print 'No results found'.""" mock_response = Mock() mock_response.status_code = 200 @@ -1884,7 +1887,7 @@ def test_remote_no_results(self): mock_print.assert_any_call("No results found for 'query' in index 'docs'") @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) - def test_remote_404_json_parse_error(self): + def test_remote_404_json_parse_error(self) -> None: """404 response with unparseable JSON should fallback.""" mock_response = Mock() mock_response.status_code = 404 @@ -1901,7 +1904,7 @@ def test_remote_404_json_parse_error(self): mock_print.assert_any_call("Error: Index not found") @patch('sys.argv', ['remote', 'http://localhost:8001', 'query', '--index-name', 'docs']) - def test_remote_500_json_parse_error(self): + def test_remote_500_json_parse_error(self) -> None: """Non-404 error with unparseable JSON should fallback to status code.""" mock_response = Mock() mock_response.status_code = 500 diff --git a/tests/unit/cli/test_dokku.py b/tests/unit/cli/test_dokku.py index d559a093..4b5c6412 100644 --- a/tests/unit/cli/test_dokku.py +++ b/tests/unit/cli/test_dokku.py @@ -72,31 +72,31 @@ class TestColors: """Tests for the ANSI color constants.""" - def test_colors_has_red(self): + def test_colors_has_red(self) -> None: assert Colors.RED == '\033[0;31m' - def test_colors_has_green(self): + def test_colors_has_green(self) -> None: assert Colors.GREEN == '\033[0;32m' - def test_colors_has_yellow(self): + def test_colors_has_yellow(self) -> None: assert Colors.YELLOW == '\033[1;33m' - def test_colors_has_blue(self): + def test_colors_has_blue(self) -> None: assert Colors.BLUE == '\033[0;34m' - def test_colors_has_cyan(self): + def test_colors_has_cyan(self) -> None: assert Colors.CYAN == '\033[0;36m' - def test_colors_has_magenta(self): + def test_colors_has_magenta(self) -> None: assert Colors.MAGENTA == '\033[0;35m' - def test_colors_has_bold(self): + def test_colors_has_bold(self) -> None: assert Colors.BOLD == '\033[1m' - def test_colors_has_dim(self): + def test_colors_has_dim(self) -> None: assert Colors.DIM == '\033[2m' - def test_colors_has_nc(self): + def test_colors_has_nc(self) -> None: assert Colors.NC == '\033[0m' @@ -107,32 +107,32 @@ def test_colors_has_nc(self): class TestPrintFunctions: """Tests for colored print utility functions.""" - def test_print_step(self, capsys): + def test_print_step(self, capsys: pytest.CaptureFixture[str]) -> None: print_step("Installing packages") captured = capsys.readouterr() assert "==>" in captured.out assert "Installing packages" in captured.out assert Colors.BLUE in captured.out - def test_print_success(self, capsys): + def test_print_success(self, capsys: pytest.CaptureFixture[str]) -> None: print_success("Done!") captured = capsys.readouterr() assert "Done!" in captured.out assert Colors.GREEN in captured.out - def test_print_warning(self, capsys): + def test_print_warning(self, capsys: pytest.CaptureFixture[str]) -> None: print_warning("Watch out") captured = capsys.readouterr() assert "Watch out" in captured.out assert Colors.YELLOW in captured.out - def test_print_error(self, capsys): + def test_print_error(self, capsys: pytest.CaptureFixture[str]) -> None: print_error("Something failed") captured = capsys.readouterr() assert "Something failed" in captured.out assert Colors.RED in captured.out - def test_print_header(self, capsys): + def test_print_header(self, capsys: pytest.CaptureFixture[str]) -> None: print_header("My Header") captured = capsys.readouterr() assert "My Header" in captured.out @@ -148,29 +148,29 @@ class TestPrompt: """Tests for interactive prompt functions.""" @patch('builtins.input', return_value='myvalue') - def test_prompt_returns_user_input(self, mock_input): + def test_prompt_returns_user_input(self, mock_input: MagicMock) -> None: result = prompt("Enter name") assert result == 'myvalue' mock_input.assert_called_once_with("Enter name: ") @patch('builtins.input', return_value='') - def test_prompt_returns_default_on_empty(self, mock_input): + def test_prompt_returns_default_on_empty(self, mock_input: MagicMock) -> None: result = prompt("Enter name", "default-val") assert result == 'default-val' mock_input.assert_called_once_with("Enter name [default-val]: ") @patch('builtins.input', return_value='custom') - def test_prompt_returns_user_input_over_default(self, mock_input): + def test_prompt_returns_user_input_over_default(self, mock_input: MagicMock) -> None: result = prompt("Enter name", "default-val") assert result == 'custom' @patch('builtins.input', return_value=' spaced ') - def test_prompt_strips_whitespace(self, mock_input): + def test_prompt_strips_whitespace(self, mock_input: MagicMock) -> None: result = prompt("Enter name") assert result == 'spaced' @patch('builtins.input', return_value=' ') - def test_prompt_empty_after_strip_returns_default(self, mock_input): + def test_prompt_empty_after_strip_returns_default(self, mock_input: MagicMock) -> None: result = prompt("Question", "fallback") assert result == 'fallback' @@ -179,39 +179,39 @@ class TestPromptYesNo: """Tests for the yes/no prompt function.""" @patch('builtins.input', return_value='') - def test_default_true_on_empty(self, mock_input): + def test_default_true_on_empty(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=True) assert result is True assert "Y/n" in mock_input.call_args[0][0] @patch('builtins.input', return_value='') - def test_default_false_on_empty(self, mock_input): + def test_default_false_on_empty(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=False) assert result is False assert "y/N" in mock_input.call_args[0][0] @patch('builtins.input', return_value='y') - def test_accepts_y(self, mock_input): + def test_accepts_y(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=False) is True @patch('builtins.input', return_value='yes') - def test_accepts_yes(self, mock_input): + def test_accepts_yes(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=False) is True @patch('builtins.input', return_value='Y') - def test_accepts_uppercase_y(self, mock_input): + def test_accepts_uppercase_y(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=False) is True @patch('builtins.input', return_value='n') - def test_rejects_n(self, mock_input): + def test_rejects_n(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=True) is False @patch('builtins.input', return_value='no') - def test_rejects_no(self, mock_input): + def test_rejects_no(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=True) is False @patch('builtins.input', return_value='maybe') - def test_non_yes_returns_false(self, mock_input): + def test_non_yes_returns_false(self, mock_input: MagicMock) -> None: assert prompt_yes_no("OK?", default=True) is False @@ -222,20 +222,20 @@ def test_non_yes_returns_false(self, mock_input): class TestGeneratePassword: """Tests for the password generation function.""" - def test_default_length(self): + def test_default_length(self) -> None: pw = generate_password() assert len(pw) == 32 - def test_custom_length(self): + def test_custom_length(self) -> None: pw = generate_password(length=16) assert len(pw) == 16 - def test_uniqueness(self): + def test_uniqueness(self) -> None: pw1 = generate_password() pw2 = generate_password() assert pw1 != pw2 - def test_contains_only_url_safe_chars(self): + def test_contains_only_url_safe_chars(self) -> None: pw = generate_password(64) # token_urlsafe uses A-Z, a-z, 0-9, -, _ for ch in pw: @@ -249,32 +249,32 @@ def test_contains_only_url_safe_chars(self): class TestDokkuProjectGeneratorInit: """Tests for DokkuProjectGenerator initialization and name derivation.""" - def test_basic_name_derivation(self): + def test_basic_name_derivation(self) -> None: gen = DokkuProjectGenerator("my-agent", {}) assert gen.app_name == "my-agent" assert gen.agent_slug == "my-agent" assert gen.agent_class == "MyAgentAgent" - def test_underscore_name_derivation(self): + def test_underscore_name_derivation(self) -> None: gen = DokkuProjectGenerator("my_cool_agent", {}) assert gen.agent_slug == "my-cool-agent" assert gen.agent_class == "MyCoolAgentAgent" - def test_space_in_name(self): + def test_space_in_name(self) -> None: gen = DokkuProjectGenerator("My Agent", {}) assert gen.agent_slug == "my-agent" assert gen.agent_class == "MyAgentAgent" - def test_single_word_name(self): + def test_single_word_name(self) -> None: gen = DokkuProjectGenerator("bot", {}) assert gen.agent_slug == "bot" assert gen.agent_class == "BotAgent" - def test_default_project_dir(self): + def test_default_project_dir(self) -> None: gen = DokkuProjectGenerator("myapp", {}) assert gen.project_dir == Path("./myapp") - def test_custom_project_dir(self): + def test_custom_project_dir(self) -> None: gen = DokkuProjectGenerator("myapp", {'project_dir': '/tmp/custom'}) assert str(gen.project_dir) == "/tmp/custom" @@ -286,7 +286,7 @@ class TestDokkuProjectGeneratorGenerate: @patch.object(DokkuProjectGenerator, '_write_simple_files') @patch.object(DokkuProjectGenerator, '_write_core_files') @patch('signalwire.cli.dokku.print_success') - def test_generate_simple_mode(self, mock_ps, mock_core, mock_simple, mock_cicd, tmp_path): + def test_generate_simple_mode(self, mock_ps: MagicMock, mock_core: MagicMock, mock_simple: MagicMock, mock_cicd: MagicMock, tmp_path: Path) -> None: gen = DokkuProjectGenerator("testapp", {'project_dir': str(tmp_path / 'out')}) result = gen.generate() assert result is True @@ -298,7 +298,7 @@ def test_generate_simple_mode(self, mock_ps, mock_core, mock_simple, mock_cicd, @patch.object(DokkuProjectGenerator, '_write_simple_files') @patch.object(DokkuProjectGenerator, '_write_core_files') @patch('signalwire.cli.dokku.print_success') - def test_generate_cicd_mode(self, mock_ps, mock_core, mock_simple, mock_cicd, tmp_path): + def test_generate_cicd_mode(self, mock_ps: MagicMock, mock_core: MagicMock, mock_simple: MagicMock, mock_cicd: MagicMock, tmp_path: Path) -> None: gen = DokkuProjectGenerator("testapp", { 'project_dir': str(tmp_path / 'out'), 'cicd': True @@ -312,7 +312,7 @@ def test_generate_cicd_mode(self, mock_ps, mock_core, mock_simple, mock_cicd, tm @patch.object(DokkuProjectGenerator, '_write_core_files', side_effect=OSError("disk full")) @patch('signalwire.cli.dokku.print_error') @patch('signalwire.cli.dokku.print_success') - def test_generate_handles_exception(self, mock_ps, mock_pe, mock_core, tmp_path): + def test_generate_handles_exception(self, mock_ps: MagicMock, mock_pe: MagicMock, mock_core: MagicMock, tmp_path: Path) -> None: gen = DokkuProjectGenerator("testapp", {'project_dir': str(tmp_path / 'out')}) result = gen.generate() assert result is False @@ -323,18 +323,18 @@ def test_generate_handles_exception(self, mock_ps, mock_pe, mock_core, tmp_path) class TestDokkuProjectGeneratorWriteFile: """Tests for the _write_file helper.""" - def test_write_file_creates_file(self, tmp_path): + def test_write_file_creates_file(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("testapp", {'project_dir': str(tmp_path)}) gen._write_file('hello.txt', 'Hello World') assert (tmp_path / 'hello.txt').exists() assert (tmp_path / 'hello.txt').read_text() == 'Hello World' - def test_write_file_creates_nested_dirs(self, tmp_path): + def test_write_file_creates_nested_dirs(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("testapp", {'project_dir': str(tmp_path)}) gen._write_file('a/b/c.txt', 'nested') assert (tmp_path / 'a' / 'b' / 'c.txt').exists() - def test_write_file_executable(self, tmp_path): + def test_write_file_executable(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("testapp", {'project_dir': str(tmp_path)}) gen._write_file('script.sh', '#!/bin/bash', executable=True) mode = (tmp_path / 'script.sh').stat().st_mode @@ -344,7 +344,7 @@ def test_write_file_executable(self, tmp_path): class TestDokkuProjectGeneratorCoreFIles: """Tests that _write_core_files creates all expected files.""" - def test_core_files_without_web(self, tmp_path): + def test_core_files_without_web(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) gen._write_core_files() assert (tmp_path / 'Procfile').exists() @@ -360,7 +360,7 @@ def test_core_files_without_web(self, tmp_path): assert 'AgentBase' in content assert 'AgentServer' not in content - def test_core_files_with_web(self, tmp_path): + def test_core_files_with_web(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", { 'project_dir': str(tmp_path), 'web': True @@ -370,33 +370,33 @@ def test_core_files_with_web(self, tmp_path): assert 'AgentServer' in content assert (tmp_path / 'web' / 'index.html').exists() - def test_procfile_content(self, tmp_path): + def test_procfile_content(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) gen._write_core_files() content = (tmp_path / 'Procfile').read_text() assert 'gunicorn' in content assert 'uvicorn' in content - def test_runtime_content(self, tmp_path): + def test_runtime_content(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) gen._write_core_files() content = (tmp_path / 'runtime.txt').read_text() assert 'python-3.11' in content - def test_env_example_contains_app_name(self, tmp_path): + def test_env_example_contains_app_name(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("my-cool-app", {'project_dir': str(tmp_path)}) gen._write_core_files() content = (tmp_path / '.env.example').read_text() assert 'my-cool-app' in content - def test_app_json_contains_app_name(self, tmp_path): + def test_app_json_contains_app_name(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("testbot", {'project_dir': str(tmp_path)}) gen._write_core_files() content = (tmp_path / 'app.json').read_text() data = json.loads(content) assert data['name'] == 'testbot' - def test_app_py_uses_correct_class_name(self, tmp_path): + def test_app_py_uses_correct_class_name(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("my-agent", {'project_dir': str(tmp_path)}) gen._write_core_files() content = (tmp_path / 'app.py').read_text() @@ -407,7 +407,7 @@ def test_app_py_uses_correct_class_name(self, tmp_path): class TestDokkuProjectGeneratorSimpleFiles: """Tests for _write_simple_files.""" - def test_simple_files_created(self, tmp_path): + def test_simple_files_created(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", { 'project_dir': str(tmp_path), 'dokku_host': 'dokku.example.com', @@ -417,7 +417,7 @@ def test_simple_files_created(self, tmp_path): assert (tmp_path / 'deploy.sh').exists() assert (tmp_path / 'README.md').exists() - def test_deploy_script_is_executable(self, tmp_path): + def test_deploy_script_is_executable(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", { 'project_dir': str(tmp_path), 'dokku_host': 'dokku.example.com', @@ -427,7 +427,7 @@ def test_deploy_script_is_executable(self, tmp_path): mode = (tmp_path / 'deploy.sh').stat().st_mode assert mode & 0o755 == 0o755 - def test_deploy_script_contains_host(self, tmp_path): + def test_deploy_script_contains_host(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", { 'project_dir': str(tmp_path), 'dokku_host': 'dokku.myhost.com', @@ -437,7 +437,7 @@ def test_deploy_script_contains_host(self, tmp_path): content = (tmp_path / 'deploy.sh').read_text() assert 'dokku.myhost.com' in content - def test_readme_contains_app_name(self, tmp_path): + def test_readme_contains_app_name(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", { 'project_dir': str(tmp_path), 'dokku_host': 'dokku.example.com', @@ -447,7 +447,7 @@ def test_readme_contains_app_name(self, tmp_path): content = (tmp_path / 'README.md').read_text() assert 'myapp' in content - def test_default_dokku_host(self, tmp_path): + def test_default_dokku_host(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", { 'project_dir': str(tmp_path), }) @@ -459,7 +459,7 @@ def test_default_dokku_host(self, tmp_path): class TestDokkuProjectGeneratorCicdFiles: """Tests for _write_cicd_files.""" - def test_cicd_files_created(self, tmp_path): + def test_cicd_files_created(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) gen._write_cicd_files() assert (tmp_path / '.github' / 'workflows' / 'deploy.yml').exists() @@ -468,35 +468,35 @@ def test_cicd_files_created(self, tmp_path): assert (tmp_path / '.dokku' / 'services.yml').exists() assert (tmp_path / 'README.md').exists() - def test_deploy_workflow_content(self, tmp_path): + def test_deploy_workflow_content(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) gen._write_cicd_files() content = (tmp_path / '.github' / 'workflows' / 'deploy.yml').read_text() assert 'Deploy' in content assert 'dokku-deploy-system' in content - def test_preview_workflow_content(self, tmp_path): + def test_preview_workflow_content(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) gen._write_cicd_files() content = (tmp_path / '.github' / 'workflows' / 'preview.yml').read_text() assert 'Preview' in content assert 'pull_request' in content - def test_config_yml_content(self, tmp_path): + def test_config_yml_content(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) gen._write_cicd_files() content = (tmp_path / '.dokku' / 'config.yml').read_text() assert 'resources:' in content assert 'healthcheck:' in content - def test_services_yml_content(self, tmp_path): + def test_services_yml_content(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", {'project_dir': str(tmp_path)}) gen._write_cicd_files() content = (tmp_path / '.dokku' / 'services.yml').read_text() assert 'postgres:' in content assert 'redis:' in content - def test_cicd_readme_contains_app_name(self, tmp_path): + def test_cicd_readme_contains_app_name(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("superbot", {'project_dir': str(tmp_path)}) gen._write_cicd_files() content = (tmp_path / 'README.md').read_text() @@ -506,7 +506,7 @@ def test_cicd_readme_contains_app_name(self, tmp_path): class TestDokkuProjectGeneratorWebFiles: """Tests for _write_web_files.""" - def test_web_dir_created(self, tmp_path): + def test_web_dir_created(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("myapp", { 'project_dir': str(tmp_path), 'route': 'swaig' @@ -515,7 +515,7 @@ def test_web_dir_created(self, tmp_path): assert (tmp_path / 'web').is_dir() assert (tmp_path / 'web' / 'index.html').exists() - def test_web_index_html_contains_agent_name(self, tmp_path): + def test_web_index_html_contains_agent_name(self, tmp_path: Path) -> None: gen = DokkuProjectGenerator("Cool Bot", { 'project_dir': str(tmp_path), 'route': 'swaig' @@ -532,7 +532,7 @@ def test_web_index_html_contains_agent_name(self, tmp_path): class TestDokkuProjectGeneratorFullGenerate: """Integration-level tests for full project generation with tmp_path.""" - def test_full_simple_generate(self, tmp_path): + def test_full_simple_generate(self, tmp_path: Path) -> None: out = tmp_path / "proj" gen = DokkuProjectGenerator("test-agent", { 'project_dir': str(out), @@ -552,7 +552,7 @@ def test_full_simple_generate(self, tmp_path): assert not (out / '.github').exists() assert not (out / '.dokku').exists() - def test_full_cicd_generate(self, tmp_path): + def test_full_cicd_generate(self, tmp_path: Path) -> None: out = tmp_path / "proj" gen = DokkuProjectGenerator("test-agent", { 'project_dir': str(out), @@ -565,7 +565,7 @@ def test_full_cicd_generate(self, tmp_path): # No simple deploy.sh assert not (out / 'deploy.sh').exists() - def test_full_generate_with_web(self, tmp_path): + def test_full_generate_with_web(self, tmp_path: Path) -> None: out = tmp_path / "proj" gen = DokkuProjectGenerator("web-agent", { 'project_dir': str(out), @@ -585,8 +585,9 @@ def test_full_generate_with_web(self, tmp_path): class TestCmdInit: """Tests for the cmd_init CLI command handler.""" - def _make_args(self, name='testapp', cicd=False, web=False, host=None, - dir_val=None, force=False): + def _make_args(self, name: str = 'testapp', cicd: bool = False, web: bool = False, + host: str | None = None, dir_val: str | None = None, + force: bool = False) -> argparse.Namespace: args = argparse.Namespace() args.name = name args.cicd = cicd @@ -598,7 +599,7 @@ def _make_args(self, name='testapp', cicd=False, web=False, host=None, @patch.object(DokkuProjectGenerator, 'generate', return_value=True) @patch('signalwire.cli.dokku.Path') - def test_init_simple_with_host(self, mock_path_cls, mock_gen): + def test_init_simple_with_host(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance @@ -610,7 +611,7 @@ def test_init_simple_with_host(self, mock_path_cls, mock_gen): @patch.object(DokkuProjectGenerator, 'generate', return_value=True) @patch('signalwire.cli.dokku.Path') - def test_init_cicd_mode(self, mock_path_cls, mock_gen): + def test_init_cicd_mode(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance @@ -621,7 +622,7 @@ def test_init_cicd_mode(self, mock_path_cls, mock_gen): @patch.object(DokkuProjectGenerator, 'generate', return_value=False) @patch('signalwire.cli.dokku.Path') - def test_init_generation_failure_returns_1(self, mock_path_cls, mock_gen): + def test_init_generation_failure_returns_1(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance @@ -633,7 +634,7 @@ def test_init_generation_failure_returns_1(self, mock_path_cls, mock_gen): @patch('signalwire.cli.dokku.shutil') @patch.object(DokkuProjectGenerator, 'generate', return_value=True) @patch('signalwire.cli.dokku.Path') - def test_init_force_overwrites_existing_dir(self, mock_path_cls, mock_gen, mock_shutil): + def test_init_force_overwrites_existing_dir(self, mock_path_cls: MagicMock, mock_gen: MagicMock, mock_shutil: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_cls.return_value = mock_path_instance @@ -645,7 +646,7 @@ def test_init_force_overwrites_existing_dir(self, mock_path_cls, mock_gen, mock_ @patch('signalwire.cli.dokku.prompt_yes_no', return_value=False) @patch('signalwire.cli.dokku.Path') - def test_init_existing_dir_no_force_aborts(self, mock_path_cls, mock_prompt): + def test_init_existing_dir_no_force_aborts(self, mock_path_cls: MagicMock, mock_prompt: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_cls.return_value = mock_path_instance @@ -658,8 +659,8 @@ def test_init_existing_dir_no_force_aborts(self, mock_path_cls, mock_prompt): @patch('signalwire.cli.dokku.prompt', return_value='dokku.example.com') @patch.object(DokkuProjectGenerator, 'generate', return_value=True) @patch('signalwire.cli.dokku.Path') - def test_init_interactive_mode_simple(self, mock_path_cls, mock_gen, - mock_prompt, mock_yes_no): + def test_init_interactive_mode_simple(self, mock_path_cls: MagicMock, mock_gen: MagicMock, + mock_prompt: MagicMock, mock_yes_no: MagicMock) -> None: """When no --host and no --cicd, enters interactive mode.""" mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False @@ -674,7 +675,7 @@ def test_init_interactive_mode_simple(self, mock_path_cls, mock_gen, @patch('signalwire.cli.dokku.prompt_yes_no', side_effect=[True, True]) @patch.object(DokkuProjectGenerator, 'generate', return_value=True) @patch('signalwire.cli.dokku.Path') - def test_init_interactive_cicd_mode(self, mock_path_cls, mock_gen, mock_yes_no): + def test_init_interactive_cicd_mode(self, mock_path_cls: MagicMock, mock_gen: MagicMock, mock_yes_no: MagicMock) -> None: """When user chooses cicd in interactive mode.""" mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False @@ -686,7 +687,7 @@ def test_init_interactive_cicd_mode(self, mock_path_cls, mock_gen, mock_yes_no): @patch.object(DokkuProjectGenerator, 'generate', return_value=True) @patch('signalwire.cli.dokku.Path') - def test_init_with_web_flag(self, mock_path_cls, mock_gen): + def test_init_with_web_flag(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance @@ -697,7 +698,7 @@ def test_init_with_web_flag(self, mock_path_cls, mock_gen): @patch.object(DokkuProjectGenerator, 'generate', return_value=True) @patch('signalwire.cli.dokku.Path') - def test_init_custom_dir(self, mock_path_cls, mock_gen): + def test_init_custom_dir(self, mock_path_cls: MagicMock, mock_gen: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance @@ -714,14 +715,14 @@ def test_init_custom_dir(self, mock_path_cls, mock_gen): class TestCmdDeploy: """Tests for the cmd_deploy CLI command handler.""" - def _make_args(self, app=None, host=None): + def _make_args(self, app: str | None = None, host: str | None = None) -> argparse.Namespace: args = argparse.Namespace() args.app = app args.host = host return args @patch('signalwire.cli.dokku.Path') - def test_deploy_no_procfile_returns_error(self, mock_path_cls): + def test_deploy_no_procfile_returns_error(self, mock_path_cls: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance @@ -732,11 +733,11 @@ def test_deploy_no_procfile_returns_error(self, mock_path_cls): @patch('signalwire.cli.dokku.subprocess') @patch('signalwire.cli.dokku.Path') - def test_deploy_with_app_name_and_host(self, mock_path_cls, mock_subprocess): + def test_deploy_with_app_name_and_host(self, mock_path_cls: MagicMock, mock_subprocess: MagicMock) -> None: # Procfile exists, .git exists path_instances = {} - def path_side_effect(p): + def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m @@ -761,10 +762,10 @@ def path_side_effect(p): @patch('signalwire.cli.dokku.subprocess') @patch('signalwire.cli.dokku.Path') - def test_deploy_git_push_failure(self, mock_path_cls, mock_subprocess): + def test_deploy_git_push_failure(self, mock_path_cls: MagicMock, mock_subprocess: MagicMock) -> None: path_instances = {} - def path_side_effect(p): + def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m @@ -795,10 +796,10 @@ def path_side_effect(p): @patch('signalwire.cli.dokku.subprocess') @patch('signalwire.cli.dokku.Path') - def test_deploy_initializes_git_if_needed(self, mock_path_cls, mock_subprocess): + def test_deploy_initializes_git_if_needed(self, mock_path_cls: MagicMock, mock_subprocess: MagicMock) -> None: path_instances = {} - def path_side_effect(p): + def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m @@ -828,10 +829,10 @@ def path_side_effect(p): @patch('signalwire.cli.dokku.prompt', side_effect=['myapp', 'dokku.example.com']) @patch('signalwire.cli.dokku.subprocess') @patch('signalwire.cli.dokku.Path') - def test_deploy_prompts_for_missing_info(self, mock_path_cls, mock_subprocess, mock_prompt): + def test_deploy_prompts_for_missing_info(self, mock_path_cls: MagicMock, mock_subprocess: MagicMock, mock_prompt: MagicMock) -> None: path_instances = {} - def path_side_effect(p): + def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m @@ -856,10 +857,10 @@ def path_side_effect(p): @patch('signalwire.cli.dokku.subprocess') @patch('builtins.open', mock_open(read_data='{"name": "from-json"}')) @patch('signalwire.cli.dokku.Path') - def test_deploy_reads_app_name_from_app_json(self, mock_path_cls, mock_subprocess): + def test_deploy_reads_app_name_from_app_json(self, mock_path_cls: MagicMock, mock_subprocess: MagicMock) -> None: path_instances = {} - def path_side_effect(p): + def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m @@ -888,7 +889,8 @@ def path_side_effect(p): class TestCmdLogs: """Tests for the cmd_logs CLI command handler.""" - def _make_args(self, app=None, host=None, tail=False, num=None): + def _make_args(self, app: str | None = None, host: str | None = None, + tail: bool = False, num: int | None = None) -> argparse.Namespace: args = argparse.Namespace() args.app = app args.host = host @@ -897,7 +899,7 @@ def _make_args(self, app=None, host=None, tail=False, num=None): return args @patch('signalwire.cli.dokku.subprocess') - def test_logs_basic(self, mock_subprocess): + def test_logs_basic(self, mock_subprocess: MagicMock) -> None: args = self._make_args(app='myapp', host='dokku.example.com') result = cmd_logs(args) assert result == 0 @@ -906,14 +908,14 @@ def test_logs_basic(self, mock_subprocess): assert cmd == ['ssh', 'dokku@dokku.example.com', 'logs', 'myapp'] @patch('signalwire.cli.dokku.subprocess') - def test_logs_with_tail(self, mock_subprocess): + def test_logs_with_tail(self, mock_subprocess: MagicMock) -> None: args = self._make_args(app='myapp', host='dokku.example.com', tail=True) result = cmd_logs(args) cmd = mock_subprocess.run.call_args[0][0] assert '-t' in cmd @patch('signalwire.cli.dokku.subprocess') - def test_logs_with_num(self, mock_subprocess): + def test_logs_with_num(self, mock_subprocess: MagicMock) -> None: args = self._make_args(app='myapp', host='dokku.example.com', num=50) result = cmd_logs(args) cmd = mock_subprocess.run.call_args[0][0] @@ -921,7 +923,7 @@ def test_logs_with_num(self, mock_subprocess): assert '50' in cmd @patch('signalwire.cli.dokku.subprocess') - def test_logs_with_tail_and_num(self, mock_subprocess): + def test_logs_with_tail_and_num(self, mock_subprocess: MagicMock) -> None: args = self._make_args(app='myapp', host='dokku.example.com', tail=True, num=100) result = cmd_logs(args) cmd = mock_subprocess.run.call_args[0][0] @@ -932,7 +934,7 @@ def test_logs_with_tail_and_num(self, mock_subprocess): @patch('signalwire.cli.dokku._get_app_name', return_value='fromjson') @patch('signalwire.cli.dokku.prompt', return_value='dokku.example.com') @patch('signalwire.cli.dokku.subprocess') - def test_logs_prompts_for_missing_info(self, mock_subprocess, mock_prompt, mock_get_name): + def test_logs_prompts_for_missing_info(self, mock_subprocess: MagicMock, mock_prompt: MagicMock, mock_get_name: MagicMock) -> None: args = self._make_args() # no app, no host result = cmd_logs(args) assert result == 0 @@ -947,7 +949,8 @@ def test_logs_prompts_for_missing_info(self, mock_subprocess, mock_prompt, mock_ class TestCmdConfig: """Tests for the cmd_config CLI command handler.""" - def _make_args(self, action='show', vars_list=None, app=None, host=None): + def _make_args(self, action: str = 'show', vars_list: list[str] | None = None, + app: str | None = None, host: str | None = None) -> argparse.Namespace: args = argparse.Namespace() args.config_action = action args.vars = vars_list or [] @@ -956,7 +959,7 @@ def _make_args(self, action='show', vars_list=None, app=None, host=None): return args @patch('signalwire.cli.dokku.subprocess') - def test_config_show(self, mock_subprocess): + def test_config_show(self, mock_subprocess: MagicMock) -> None: args = self._make_args(action='show', app='myapp', host='dokku.example.com') result = cmd_config(args) assert result == 0 @@ -965,7 +968,7 @@ def test_config_show(self, mock_subprocess): assert 'myapp' in cmd @patch('signalwire.cli.dokku.subprocess') - def test_config_set(self, mock_subprocess): + def test_config_set(self, mock_subprocess: MagicMock) -> None: args = self._make_args( action='set', vars_list=['KEY=value', 'OTHER=thing'], @@ -980,7 +983,7 @@ def test_config_set(self, mock_subprocess): assert 'OTHER=thing' in cmd @patch('signalwire.cli.dokku.subprocess') - def test_config_unset(self, mock_subprocess): + def test_config_unset(self, mock_subprocess: MagicMock) -> None: args = self._make_args( action='unset', vars_list=['KEY'], @@ -993,12 +996,12 @@ def test_config_unset(self, mock_subprocess): assert 'config:unset' in cmd assert 'KEY' in cmd - def test_config_set_no_vars_returns_error(self): + def test_config_set_no_vars_returns_error(self) -> None: args = self._make_args(action='set', app='myapp', host='dokku.example.com') result = cmd_config(args) assert result == 1 - def test_config_unset_no_vars_returns_error(self): + def test_config_unset_no_vars_returns_error(self) -> None: args = self._make_args(action='unset', app='myapp', host='dokku.example.com') result = cmd_config(args) assert result == 1 @@ -1006,7 +1009,7 @@ def test_config_unset_no_vars_returns_error(self): @patch('signalwire.cli.dokku._get_app_name', return_value='fromjson') @patch('signalwire.cli.dokku.prompt', return_value='dokku.example.com') @patch('signalwire.cli.dokku.subprocess') - def test_config_prompts_for_missing_info(self, mock_subprocess, mock_prompt, mock_get_name): + def test_config_prompts_for_missing_info(self, mock_subprocess: MagicMock, mock_prompt: MagicMock, mock_get_name: MagicMock) -> None: args = self._make_args(action='show') # no app, no host result = cmd_config(args) assert result == 0 @@ -1021,7 +1024,8 @@ def test_config_prompts_for_missing_info(self, mock_subprocess, mock_prompt, moc class TestCmdScale: """Tests for the cmd_scale CLI command handler.""" - def _make_args(self, scale_args=None, app=None, host=None): + def _make_args(self, scale_args: list[str] | None = None, + app: str | None = None, host: str | None = None) -> argparse.Namespace: args = argparse.Namespace() args.scale_args = scale_args or [] args.app = app @@ -1029,7 +1033,7 @@ def _make_args(self, scale_args=None, app=None, host=None): return args @patch('signalwire.cli.dokku.subprocess') - def test_scale_show_current(self, mock_subprocess): + def test_scale_show_current(self, mock_subprocess: MagicMock) -> None: args = self._make_args(app='myapp', host='dokku.example.com') result = cmd_scale(args) assert result == 0 @@ -1040,7 +1044,7 @@ def test_scale_show_current(self, mock_subprocess): assert len(cmd) == 4 # ssh, dokku@host, ps:scale, myapp @patch('signalwire.cli.dokku.subprocess') - def test_scale_set(self, mock_subprocess): + def test_scale_set(self, mock_subprocess: MagicMock) -> None: args = self._make_args( scale_args=['web=2'], app='myapp', @@ -1053,7 +1057,7 @@ def test_scale_set(self, mock_subprocess): assert 'web=2' in cmd @patch('signalwire.cli.dokku.subprocess') - def test_scale_set_multiple(self, mock_subprocess): + def test_scale_set_multiple(self, mock_subprocess: MagicMock) -> None: args = self._make_args( scale_args=['web=2', 'worker=3'], app='myapp', @@ -1068,7 +1072,7 @@ def test_scale_set_multiple(self, mock_subprocess): @patch('signalwire.cli.dokku._get_app_name', return_value='fromjson') @patch('signalwire.cli.dokku.prompt', return_value='dokku.example.com') @patch('signalwire.cli.dokku.subprocess') - def test_scale_prompts_for_missing_info(self, mock_subprocess, mock_prompt, mock_get_name): + def test_scale_prompts_for_missing_info(self, mock_subprocess: MagicMock, mock_prompt: MagicMock, mock_get_name: MagicMock) -> None: args = self._make_args() # no app, no host result = cmd_scale(args) assert result == 0 @@ -1085,7 +1089,7 @@ class TestGetAppName: @patch('builtins.open', mock_open(read_data='{"name": "json-app"}')) @patch('signalwire.cli.dokku.Path') - def test_reads_from_app_json(self, mock_path_cls): + def test_reads_from_app_json(self, mock_path_cls: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_cls.return_value = mock_path_instance @@ -1095,7 +1099,7 @@ def test_reads_from_app_json(self, mock_path_cls): @patch('signalwire.cli.dokku.prompt', return_value='prompted-app') @patch('signalwire.cli.dokku.Path') - def test_prompts_when_no_app_json(self, mock_path_cls, mock_prompt): + def test_prompts_when_no_app_json(self, mock_path_cls: MagicMock, mock_prompt: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = False mock_path_cls.return_value = mock_path_instance @@ -1106,7 +1110,7 @@ def test_prompts_when_no_app_json(self, mock_path_cls, mock_prompt): @patch('signalwire.cli.dokku.prompt', return_value='fallback') @patch('builtins.open', side_effect=json.JSONDecodeError("err", "doc", 0)) @patch('signalwire.cli.dokku.Path') - def test_prompts_on_invalid_json(self, mock_path_cls, mock_open_fn, mock_prompt): + def test_prompts_on_invalid_json(self, mock_path_cls: MagicMock, mock_open_fn: MagicMock, mock_prompt: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_cls.return_value = mock_path_instance @@ -1116,7 +1120,7 @@ def test_prompts_on_invalid_json(self, mock_path_cls, mock_open_fn, mock_prompt) @patch('builtins.open', mock_open(read_data='{}')) @patch('signalwire.cli.dokku.Path') - def test_returns_empty_string_when_name_missing(self, mock_path_cls): + def test_returns_empty_string_when_name_missing(self, mock_path_cls: MagicMock) -> None: mock_path_instance = MagicMock() mock_path_instance.exists.return_value = True mock_path_cls.return_value = mock_path_instance @@ -1134,7 +1138,7 @@ class TestMain: @patch('signalwire.cli.dokku.cmd_init', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '--host', 'dokku.test.com']) - def test_main_init_command(self, mock_cmd_init): + def test_main_init_command(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 mock_cmd_init.assert_called_once() @@ -1145,7 +1149,7 @@ def test_main_init_command(self, mock_cmd_init): @patch('signalwire.cli.dokku.cmd_init', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '--cicd']) - def test_main_init_cicd(self, mock_cmd_init): + def test_main_init_cicd(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] @@ -1153,7 +1157,7 @@ def test_main_init_cicd(self, mock_cmd_init): @patch('signalwire.cli.dokku.cmd_init', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '--web']) - def test_main_init_web(self, mock_cmd_init): + def test_main_init_web(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] @@ -1161,7 +1165,7 @@ def test_main_init_web(self, mock_cmd_init): @patch('signalwire.cli.dokku.cmd_init', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '--force']) - def test_main_init_force(self, mock_cmd_init): + def test_main_init_force(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] @@ -1169,7 +1173,7 @@ def test_main_init_force(self, mock_cmd_init): @patch('signalwire.cli.dokku.cmd_init', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '-f']) - def test_main_init_force_short(self, mock_cmd_init): + def test_main_init_force_short(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] @@ -1177,7 +1181,7 @@ def test_main_init_force_short(self, mock_cmd_init): @patch('signalwire.cli.dokku.cmd_init', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'init', 'myapp', '--dir', '/tmp/out']) - def test_main_init_custom_dir(self, mock_cmd_init): + def test_main_init_custom_dir(self, mock_cmd_init: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_init.call_args[0][0] @@ -1185,7 +1189,7 @@ def test_main_init_custom_dir(self, mock_cmd_init): @patch('signalwire.cli.dokku.cmd_deploy', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'deploy', '--app', 'myapp', '--host', 'dokku.test.com']) - def test_main_deploy_command(self, mock_cmd_deploy): + def test_main_deploy_command(self, mock_cmd_deploy: MagicMock) -> None: result = main() assert result == 0 mock_cmd_deploy.assert_called_once() @@ -1195,7 +1199,7 @@ def test_main_deploy_command(self, mock_cmd_deploy): @patch('signalwire.cli.dokku.cmd_deploy', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'deploy', '-a', 'myapp', '-H', 'dokku.test.com']) - def test_main_deploy_short_flags(self, mock_cmd_deploy): + def test_main_deploy_short_flags(self, mock_cmd_deploy: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_deploy.call_args[0][0] @@ -1204,7 +1208,7 @@ def test_main_deploy_short_flags(self, mock_cmd_deploy): @patch('signalwire.cli.dokku.cmd_logs', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'logs', '-a', 'myapp', '-H', 'h', '-t', '-n', '20']) - def test_main_logs_command(self, mock_cmd_logs): + def test_main_logs_command(self, mock_cmd_logs: MagicMock) -> None: result = main() assert result == 0 mock_cmd_logs.assert_called_once() @@ -1215,7 +1219,7 @@ def test_main_logs_command(self, mock_cmd_logs): @patch('signalwire.cli.dokku.cmd_config', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'config', 'set', 'KEY=val', '-a', 'myapp', '-H', 'h']) - def test_main_config_set_command(self, mock_cmd_config): + def test_main_config_set_command(self, mock_cmd_config: MagicMock) -> None: result = main() assert result == 0 mock_cmd_config.assert_called_once() @@ -1225,7 +1229,7 @@ def test_main_config_set_command(self, mock_cmd_config): @patch('signalwire.cli.dokku.cmd_config', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'config', 'show', '-a', 'myapp', '-H', 'h']) - def test_main_config_show_command(self, mock_cmd_config): + def test_main_config_show_command(self, mock_cmd_config: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_config.call_args[0][0] @@ -1233,7 +1237,7 @@ def test_main_config_show_command(self, mock_cmd_config): @patch('signalwire.cli.dokku.cmd_config', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'config', 'unset', 'KEY', '-a', 'myapp', '-H', 'h']) - def test_main_config_unset_command(self, mock_cmd_config): + def test_main_config_unset_command(self, mock_cmd_config: MagicMock) -> None: result = main() assert result == 0 args = mock_cmd_config.call_args[0][0] @@ -1242,7 +1246,7 @@ def test_main_config_unset_command(self, mock_cmd_config): @patch('signalwire.cli.dokku.cmd_scale', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'scale', 'web=2', '-a', 'myapp', '-H', 'h']) - def test_main_scale_command(self, mock_cmd_scale): + def test_main_scale_command(self, mock_cmd_scale: MagicMock) -> None: result = main() assert result == 0 mock_cmd_scale.assert_called_once() @@ -1250,7 +1254,7 @@ def test_main_scale_command(self, mock_cmd_scale): assert args.scale_args == ['web=2'] @patch('sys.argv', ['sw-agent-dokku']) - def test_main_no_command_returns_1(self): + def test_main_no_command_returns_1(self) -> None: result = main() assert result == 1 @@ -1262,68 +1266,68 @@ def test_main_no_command_returns_1(self): class TestTemplates: """Tests to verify template content is correctly defined.""" - def test_procfile_template_has_gunicorn(self): + def test_procfile_template_has_gunicorn(self) -> None: assert 'gunicorn' in PROCFILE_TEMPLATE assert 'UvicornWorker' in PROCFILE_TEMPLATE - def test_runtime_template_has_python(self): + def test_runtime_template_has_python(self) -> None: assert 'python-3.11' in RUNTIME_TEMPLATE - def test_requirements_template_has_deps(self): + def test_requirements_template_has_deps(self) -> None: assert 'signalwire-agents' in REQUIREMENTS_TEMPLATE assert 'gunicorn' in REQUIREMENTS_TEMPLATE assert 'uvicorn' in REQUIREMENTS_TEMPLATE - def test_checks_template_has_health(self): + def test_checks_template_has_health(self) -> None: assert '/health' in CHECKS_TEMPLATE - def test_gitignore_template_excludes_env(self): + def test_gitignore_template_excludes_env(self) -> None: assert '.env' in GITIGNORE_TEMPLATE assert '__pycache__' in GITIGNORE_TEMPLATE - def test_env_example_template_has_placeholders(self): + def test_env_example_template_has_placeholders(self) -> None: assert '{app_name}' in ENV_EXAMPLE_TEMPLATE assert 'SIGNALWIRE_SPACE_NAME' in ENV_EXAMPLE_TEMPLATE - def test_app_template_has_class_placeholder(self): + def test_app_template_has_class_placeholder(self) -> None: assert '{agent_class}' in APP_TEMPLATE assert '{agent_name}' in APP_TEMPLATE assert '{agent_slug}' in APP_TEMPLATE - def test_app_template_with_web_has_server(self): + def test_app_template_with_web_has_server(self) -> None: assert 'AgentServer' in APP_TEMPLATE_WITH_WEB assert 'setup_swml_handler' in APP_TEMPLATE_WITH_WEB - def test_app_json_template_is_valid_json_after_format(self): + def test_app_json_template_is_valid_json_after_format(self) -> None: content = APP_JSON_TEMPLATE.format(app_name='test') data = json.loads(content) assert data['name'] == 'test' - def test_deploy_workflow_template_mentions_dokku(self): + def test_deploy_workflow_template_mentions_dokku(self) -> None: assert 'dokku-deploy-system' in DEPLOY_WORKFLOW_TEMPLATE - def test_preview_workflow_template_mentions_pull_request(self): + def test_preview_workflow_template_mentions_pull_request(self) -> None: assert 'pull_request' in PREVIEW_WORKFLOW_TEMPLATE - def test_dokku_config_template_has_resources(self): + def test_dokku_config_template_has_resources(self) -> None: assert 'resources:' in DOKKU_CONFIG_TEMPLATE assert 'memory:' in DOKKU_CONFIG_TEMPLATE - def test_services_template_has_postgres(self): + def test_services_template_has_postgres(self) -> None: assert 'postgres:' in SERVICES_TEMPLATE - def test_web_index_template_has_html(self): + def test_web_index_template_has_html(self) -> None: assert '' in WEB_INDEX_TEMPLATE assert '{agent_name}' in WEB_INDEX_TEMPLATE - def test_deploy_script_template_has_bash(self): + def test_deploy_script_template_has_bash(self) -> None: assert '#!/bin/bash' in DEPLOY_SCRIPT_TEMPLATE assert '{app_name}' in DEPLOY_SCRIPT_TEMPLATE - def test_readme_simple_template_has_deploy(self): + def test_readme_simple_template_has_deploy(self) -> None: assert 'deploy' in README_SIMPLE_TEMPLATE.lower() - def test_readme_cicd_template_has_github(self): + def test_readme_cicd_template_has_github(self) -> None: assert 'GitHub' in README_CICD_TEMPLATE @@ -1334,33 +1338,33 @@ def test_readme_cicd_template_has_github(self): class TestEdgeCases: """Tests for various edge cases and special scenarios.""" - def test_generate_password_length_zero(self): + def test_generate_password_length_zero(self) -> None: pw = generate_password(length=0) assert pw == '' - def test_generate_password_length_one(self): + def test_generate_password_length_one(self) -> None: pw = generate_password(length=1) assert len(pw) == 1 - def test_agent_class_name_mixed_delimiters(self): + def test_agent_class_name_mixed_delimiters(self) -> None: gen = DokkuProjectGenerator("my-cool_app name", {}) # "my-cool_app name" -> slug: "my-cool-app-name" # class: split on - and _ and space -> My Cool App Name + Agent assert gen.agent_slug == "my-cool-app-name" assert gen.agent_class == "MyCoolAppNameAgent" - def test_agent_class_name_all_caps(self): + def test_agent_class_name_all_caps(self) -> None: gen = DokkuProjectGenerator("ABC", {}) assert gen.agent_slug == "abc" assert gen.agent_class == "AbcAgent" @patch('signalwire.cli.dokku.subprocess') - def test_deploy_remote_url_format(self, mock_subprocess): + def test_deploy_remote_url_format(self, mock_subprocess: MagicMock) -> None: """Verify the dokku remote URL is correctly formed.""" with patch('signalwire.cli.dokku.Path') as mock_path_cls: path_instances = {} - def path_side_effect(p): + def path_side_effect(p: str) -> MagicMock: if p not in path_instances: m = MagicMock() path_instances[p] = m @@ -1388,7 +1392,7 @@ def path_side_effect(p): break @patch('signalwire.cli.dokku.subprocess') - def test_logs_command_structure(self, mock_subprocess): + def test_logs_command_structure(self, mock_subprocess: MagicMock) -> None: """Verify the SSH log command is correctly formed.""" args = argparse.Namespace( app='testapp', host='my.dokku.host', tail=True, num=200 @@ -1403,7 +1407,7 @@ def test_logs_command_structure(self, mock_subprocess): assert '--num' in cmd assert '200' in cmd - def test_config_set_empty_vars_list(self): + def test_config_set_empty_vars_list(self) -> None: """Empty vars list (not None, but []) should still fail.""" args = argparse.Namespace( config_action='set', vars=[], app='myapp', host='dokku.example.com' @@ -1411,7 +1415,7 @@ def test_config_set_empty_vars_list(self): result = cmd_config(args) assert result == 1 - def test_config_unset_empty_vars_list(self): + def test_config_unset_empty_vars_list(self) -> None: """Empty vars list (not None, but []) should still fail.""" args = argparse.Namespace( config_action='unset', vars=[], app='myapp', host='dokku.example.com' @@ -1420,7 +1424,7 @@ def test_config_unset_empty_vars_list(self): assert result == 1 @patch('signalwire.cli.dokku.subprocess') - def test_scale_show_no_extra_args(self, mock_subprocess): + def test_scale_show_no_extra_args(self, mock_subprocess: MagicMock) -> None: """When scale_args is empty, just show current scale without extra args.""" args = argparse.Namespace( scale_args=[], app='myapp', host='dokku.example.com' @@ -1430,7 +1434,7 @@ def test_scale_show_no_extra_args(self, mock_subprocess): # Should be exactly: ssh dokku@host ps:scale myapp assert cmd == ['ssh', 'dokku@dokku.example.com', 'ps:scale', 'myapp'] - def test_generate_creates_project_dir_if_missing(self, tmp_path): + def test_generate_creates_project_dir_if_missing(self, tmp_path: Path) -> None: """generate() should create the project directory with parents.""" deep_dir = tmp_path / "a" / "b" / "c" gen = DokkuProjectGenerator("myapp", { @@ -1444,7 +1448,7 @@ def test_generate_creates_project_dir_if_missing(self, tmp_path): @patch('signalwire.cli.dokku.cmd_init', return_value=0) @patch('sys.argv', ['sw-agent-dokku', 'init', 'my-app', '--cicd', '--web', '--host', 'h', '--dir', '/tmp/d', '-f']) - def test_main_all_init_flags(self, mock_cmd_init): + def test_main_all_init_flags(self, mock_cmd_init: MagicMock) -> None: """All init flags can be passed together.""" result = main() assert result == 0 diff --git a/tests/unit/cli/test_init_project.py b/tests/unit/cli/test_init_project.py index 2f3f0849..1b5e929d 100644 --- a/tests/unit/cli/test_init_project.py +++ b/tests/unit/cli/test_init_project.py @@ -13,12 +13,11 @@ """ import pytest -import sys import os import argparse +from typing import Any from pathlib import Path -from unittest.mock import Mock, patch, MagicMock, call -from io import StringIO +from unittest.mock import Mock, patch, MagicMock from signalwire.cli.init_project import ( Colors, @@ -43,8 +42,6 @@ main, CLOUD_PLATFORMS, DEFAULT_REGIONS, - TEMPLATE_GITIGNORE, - TEMPLATE_REQUIREMENTS, ) @@ -55,7 +52,7 @@ class TestColors: """Tests for the ANSI color constants.""" - def test_colors_has_expected_attributes(self): + def test_colors_has_expected_attributes(self) -> None: assert Colors.RED == '\033[0;31m' assert Colors.GREEN == '\033[0;32m' assert Colors.YELLOW == '\033[1;33m' @@ -73,25 +70,25 @@ def test_colors_has_expected_attributes(self): class TestPrintUtilities: """Tests for print_step, print_success, print_warning, print_error.""" - def test_print_step(self, capsys): + def test_print_step(self, capsys: pytest.CaptureFixture[str]) -> None: print_step("test message") captured = capsys.readouterr() assert "test message" in captured.out assert Colors.BLUE in captured.out - def test_print_success(self, capsys): + def test_print_success(self, capsys: pytest.CaptureFixture[str]) -> None: print_success("done") captured = capsys.readouterr() assert "done" in captured.out assert Colors.GREEN in captured.out - def test_print_warning(self, capsys): + def test_print_warning(self, capsys: pytest.CaptureFixture[str]) -> None: print_warning("careful") captured = capsys.readouterr() assert "careful" in captured.out assert Colors.YELLOW in captured.out - def test_print_error(self, capsys): + def test_print_error(self, capsys: pytest.CaptureFixture[str]) -> None: print_error("oops") captured = capsys.readouterr() assert "oops" in captured.out @@ -106,76 +103,76 @@ class TestPromptFunctions: """Tests for interactive prompt functions.""" @patch('builtins.input', return_value='') - def test_prompt_returns_default_when_empty(self, mock_input): + def test_prompt_returns_default_when_empty(self, mock_input: MagicMock) -> None: result = prompt("Name", "default_val") assert result == "default_val" mock_input.assert_called_once() @patch('builtins.input', return_value='custom') - def test_prompt_returns_user_input(self, mock_input): + def test_prompt_returns_user_input(self, mock_input: MagicMock) -> None: result = prompt("Name", "default_val") assert result == "custom" @patch('builtins.input', return_value=' spaced ') - def test_prompt_strips_whitespace(self, mock_input): + def test_prompt_strips_whitespace(self, mock_input: MagicMock) -> None: result = prompt("Name", "default_val") assert result == "spaced" @patch('builtins.input', return_value='hello') - def test_prompt_without_default(self, mock_input): + def test_prompt_without_default(self, mock_input: MagicMock) -> None: result = prompt("Name") assert result == "hello" # Should not include bracket notation mock_input.assert_called_once_with("Name: ") @patch('builtins.input', return_value='') - def test_prompt_yes_no_default_true(self, mock_input): + def test_prompt_yes_no_default_true(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=True) assert result is True @patch('builtins.input', return_value='') - def test_prompt_yes_no_default_false(self, mock_input): + def test_prompt_yes_no_default_false(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=False) assert result is False @patch('builtins.input', return_value='y') - def test_prompt_yes_no_explicit_yes(self, mock_input): + def test_prompt_yes_no_explicit_yes(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=False) assert result is True @patch('builtins.input', return_value='yes') - def test_prompt_yes_no_explicit_yes_full(self, mock_input): + def test_prompt_yes_no_explicit_yes_full(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=False) assert result is True @patch('builtins.input', return_value='n') - def test_prompt_yes_no_explicit_no(self, mock_input): + def test_prompt_yes_no_explicit_no(self, mock_input: MagicMock) -> None: result = prompt_yes_no("Continue?", default=True) assert result is False @patch('builtins.input', return_value='') - def test_prompt_select_returns_default(self, mock_input): + def test_prompt_select_returns_default(self, mock_input: MagicMock) -> None: result = prompt_select("Pick:", ["A", "B", "C"], default=2) assert result == 2 @patch('builtins.input', return_value='3') - def test_prompt_select_returns_chosen(self, mock_input): + def test_prompt_select_returns_chosen(self, mock_input: MagicMock) -> None: result = prompt_select("Pick:", ["A", "B", "C"], default=1) assert result == 3 @patch('builtins.input', side_effect=['bad', '0', '4', '2']) - def test_prompt_select_rejects_invalid_then_accepts(self, mock_input): + def test_prompt_select_rejects_invalid_then_accepts(self, mock_input: MagicMock) -> None: result = prompt_select("Pick:", ["A", "B", "C"], default=1) assert result == 2 assert mock_input.call_count == 4 @patch('builtins.input', side_effect=['1', '']) - def test_prompt_multiselect_toggle_and_accept(self, mock_input): + def test_prompt_multiselect_toggle_and_accept(self, mock_input: MagicMock) -> None: result = prompt_multiselect("Features:", ["A", "B"], [False, True]) assert result == [True, True] @patch('builtins.input', side_effect=['']) - def test_prompt_multiselect_accept_defaults(self, mock_input): + def test_prompt_multiselect_accept_defaults(self, mock_input: MagicMock) -> None: result = prompt_multiselect("Features:", ["A", "B"], [True, False]) assert result == [True, False] @@ -187,15 +184,15 @@ def test_prompt_multiselect_accept_defaults(self, mock_input): class TestMaskToken: """Tests for the mask_token helper.""" - def test_mask_short_token(self): + def test_mask_short_token(self) -> None: result = mask_token("abc") assert result == "***" - def test_mask_exactly_10_chars(self): + def test_mask_exactly_10_chars(self) -> None: result = mask_token("abcdefghij") assert result == "**********" - def test_mask_long_token(self): + def test_mask_long_token(self) -> None: result = mask_token("abcdefghijklmnop") assert result == "abcd...nop" @@ -212,14 +209,14 @@ class TestGetEnvCredentials: 'SIGNALWIRE_PROJECT_ID': 'proj123', 'SIGNALWIRE_TOKEN': 'tok456', }) - def test_returns_env_values(self): + def test_returns_env_values(self) -> None: creds = get_env_credentials() assert creds['space'] == 'myspace' assert creds['project'] == 'proj123' - assert creds['token'] == 'tok456' + assert creds['token'] == 'tok456' # noqa: S105 @patch.dict(os.environ, {}, clear=True) - def test_returns_empty_when_unset(self): + def test_returns_empty_when_unset(self) -> None: # Remove any env vars that might be set for key in ['SIGNALWIRE_SPACE_NAME', 'SIGNALWIRE_PROJECT_ID', 'SIGNALWIRE_TOKEN']: os.environ.pop(key, None) @@ -236,15 +233,15 @@ def test_returns_empty_when_unset(self): class TestGeneratePassword: """Tests for generate_password.""" - def test_default_length(self): + def test_default_length(self) -> None: pw = generate_password() assert len(pw) == 32 - def test_custom_length(self): + def test_custom_length(self) -> None: pw = generate_password(16) assert len(pw) == 16 - def test_passwords_are_unique(self): + def test_passwords_are_unique(self) -> None: pw1 = generate_password() pw2 = generate_password() assert pw1 != pw2 @@ -257,7 +254,7 @@ def test_passwords_are_unique(self): class TestGetAgentTemplate: """Tests for get_agent_template.""" - def test_basic_agent_template(self): + def test_basic_agent_template(self) -> None: features = {'example_tool': True, 'debug_webhooks': False, 'basic_auth': False} result = get_agent_template('basic', features) assert 'class MainAgent(AgentBase):' in result @@ -265,14 +262,14 @@ def test_basic_agent_template(self): assert 'FunctionResult' in result assert 'get_info' in result - def test_agent_template_without_tool(self): + def test_agent_template_without_tool(self) -> None: features = {'example_tool': False, 'debug_webhooks': False, 'basic_auth': False} result = get_agent_template('basic', features) assert 'class MainAgent(AgentBase):' in result assert 'get_info' not in result assert 'FunctionResult' not in result - def test_agent_template_with_all_features(self): + def test_agent_template_with_all_features(self) -> None: features = {'example_tool': True, 'debug_webhooks': True, 'basic_auth': True} result = get_agent_template('full', features) assert 'import os' in result @@ -280,7 +277,7 @@ def test_agent_template_with_all_features(self): assert 'SWML_BASIC_AUTH_USER' in result assert 'get_info' in result - def test_agent_template_with_debug_only(self): + def test_agent_template_with_debug_only(self) -> None: features = {'example_tool': False, 'debug_webhooks': True, 'basic_auth': False} result = get_agent_template('basic', features) assert 'import os' in result @@ -291,14 +288,14 @@ def test_agent_template_with_debug_only(self): class TestGetAppTemplate: """Tests for get_app_template.""" - def test_basic_app_template(self): + def test_basic_app_template(self) -> None: features = {'debug_webhooks': False, 'web_ui': False} result = get_app_template(features) assert 'from signalwire import AgentServer' in result assert 'def main():' in result assert 'server.run()' in result - def test_app_template_with_debug(self): + def test_app_template_with_debug(self) -> None: features = {'debug_webhooks': True, 'web_ui': False} result = get_app_template(features) assert 'print_debug_data' in result @@ -306,7 +303,7 @@ def test_app_template_with_debug(self): assert '/debug' in result assert '/post_prompt' in result - def test_app_template_with_web_ui(self): + def test_app_template_with_web_ui(self) -> None: features = {'debug_webhooks': False, 'web_ui': True} result = get_app_template(features) assert 'serve_static_files' in result @@ -315,13 +312,13 @@ def test_app_template_with_web_ui(self): class TestGetTestTemplate: """Tests for get_test_template.""" - def test_test_template_with_tool(self): + def test_test_template_with_tool(self) -> None: result = get_test_template(has_tool=True) assert 'test_get_info_function' in result assert 'TestDirectImport' in result assert 'test_agent_has_tools' in result - def test_test_template_without_tool(self): + def test_test_template_without_tool(self) -> None: result = get_test_template(has_tool=False) assert 'TestDirectImport' not in result assert 'test_agent_has_tools' not in result @@ -330,13 +327,13 @@ def test_test_template_without_tool(self): class TestGetReadmeTemplate: """Tests for get_readme_template.""" - def test_basic_readme(self): + def test_basic_readme(self) -> None: features = {'debug_webhooks': False, 'web_ui': False} result = get_readme_template("test-project", features) assert '# test-project' in result assert '/swml' in result - def test_readme_with_debug(self): + def test_readme_with_debug(self) -> None: features = {'debug_webhooks': True, 'web_ui': False} result = get_readme_template("test-project", features) assert '/debug' in result @@ -346,7 +343,7 @@ def test_readme_with_debug(self): class TestGetWebIndexTemplate: """Tests for get_web_index_template.""" - def test_web_template_has_html(self): + def test_web_template_has_html(self) -> None: result = get_web_index_template() assert '' in result assert 'SignalWire Agent' in result @@ -359,10 +356,10 @@ def test_web_template_has_html(self): class TestProjectGenerator: """Tests for the ProjectGenerator class.""" - def _make_config(self, platform='local', **overrides): - config = { + def _make_config(self, platform: str = 'local', **overrides: Any) -> dict[str, Any]: + config: dict[str, Any] = { 'project_name': 'test-agent', - 'project_dir': '/tmp/test-agent', + 'project_dir': '/tmp/test-agent', # noqa: S108 'platform': platform, 'agent_type': 'basic', 'features': { @@ -380,15 +377,15 @@ def _make_config(self, platform='local', **overrides): config.update(overrides) return config - def test_constructor(self): + def test_constructor(self) -> None: config = self._make_config() gen = ProjectGenerator(config) assert gen.project_name == 'test-agent' assert gen.platform == 'local' - assert gen.project_dir == Path('/tmp/test-agent') + assert gen.project_dir == Path('/tmp/test-agent') # noqa: S108 @patch.object(ProjectGenerator, '_generate_local', return_value=True) - def test_generate_dispatches_to_local(self, mock_gen): + def test_generate_dispatches_to_local(self, mock_gen: MagicMock) -> None: config = self._make_config(platform='local') gen = ProjectGenerator(config) result = gen.generate() @@ -396,7 +393,7 @@ def test_generate_dispatches_to_local(self, mock_gen): mock_gen.assert_called_once() @patch.object(ProjectGenerator, '_generate_aws', return_value=True) - def test_generate_dispatches_to_aws(self, mock_gen): + def test_generate_dispatches_to_aws(self, mock_gen: MagicMock) -> None: config = self._make_config(platform='aws') gen = ProjectGenerator(config) result = gen.generate() @@ -404,7 +401,7 @@ def test_generate_dispatches_to_aws(self, mock_gen): mock_gen.assert_called_once() @patch.object(ProjectGenerator, '_generate_gcp', return_value=True) - def test_generate_dispatches_to_gcp(self, mock_gen): + def test_generate_dispatches_to_gcp(self, mock_gen: MagicMock) -> None: config = self._make_config(platform='gcp') gen = ProjectGenerator(config) result = gen.generate() @@ -412,27 +409,27 @@ def test_generate_dispatches_to_gcp(self, mock_gen): mock_gen.assert_called_once() @patch.object(ProjectGenerator, '_generate_azure', return_value=True) - def test_generate_dispatches_to_azure(self, mock_gen): + def test_generate_dispatches_to_azure(self, mock_gen: MagicMock) -> None: config = self._make_config(platform='azure') gen = ProjectGenerator(config) result = gen.generate() assert result is True mock_gen.assert_called_once() - def test_generate_unknown_platform(self): + def test_generate_unknown_platform(self) -> None: config = self._make_config(platform='unknown_cloud') gen = ProjectGenerator(config) result = gen.generate() assert result is False - def test_generate_catches_exception(self): + def test_generate_catches_exception(self) -> None: config = self._make_config(platform='local') gen = ProjectGenerator(config) with patch.object(gen, '_generate_local', side_effect=PermissionError("denied")): result = gen.generate() assert result is False - def test_get_template_vars(self): + def test_get_template_vars(self) -> None: config = self._make_config( platform='aws', cloud_config={'region': 'us-west-2'}, @@ -447,7 +444,7 @@ def test_get_template_vars(self): assert tvars['auth_user'] == 'admin' assert len(tvars['auth_password']) == 16 - def test_get_template_vars_default_region(self): + def test_get_template_vars_default_region(self) -> None: config = self._make_config(platform='aws', cloud_config={}) gen = ProjectGenerator(config) tvars = gen._get_template_vars() @@ -455,7 +452,7 @@ def test_get_template_vars_default_region(self): @patch('pathlib.Path.mkdir') @patch('pathlib.Path.write_text') - def test_generate_local_creates_structure(self, mock_write, mock_mkdir): + def test_generate_local_creates_structure(self, mock_write: MagicMock, mock_mkdir: MagicMock) -> None: config = self._make_config() gen = ProjectGenerator(config) result = gen._generate_local() @@ -473,7 +470,7 @@ def test_generate_local_creates_structure(self, mock_write, mock_mkdir): class TestRunQuick: """Tests for run_quick configuration builder.""" - def test_basic_quick_mode(self): + def test_basic_quick_mode(self) -> None: args = argparse.Namespace( platform='local', region=None, type='basic', no_venv=False ) @@ -485,7 +482,7 @@ def test_basic_quick_mode(self): assert config['features']['debug_webhooks'] is False assert config['create_venv'] is True - def test_full_type_quick_mode(self): + def test_full_type_quick_mode(self) -> None: args = argparse.Namespace( platform='local', region=None, type='full', no_venv=True ) @@ -496,7 +493,7 @@ def test_full_type_quick_mode(self): assert config['features']['basic_auth'] is True assert config['create_venv'] is False - def test_aws_platform_quick_mode(self): + def test_aws_platform_quick_mode(self) -> None: args = argparse.Namespace( platform='aws', region='us-west-2', type='basic', no_venv=False ) @@ -509,26 +506,26 @@ def test_aws_platform_quick_mode(self): assert config['features']['tests'] is False assert config['features']['basic_auth'] is True - def test_aws_default_region(self): + def test_aws_default_region(self) -> None: args = argparse.Namespace( platform='aws', region=None, type='basic', no_venv=False ) config = run_quick('myagent', args) assert config['cloud_config']['region'] == 'us-east-1' - def test_azure_includes_resource_group(self): + def test_azure_includes_resource_group(self) -> None: args = argparse.Namespace( platform='azure', region='eastus', type='basic', no_venv=False ) config = run_quick('myagent', args) assert config['cloud_config']['resource_group'] == 'myagent-rg' - def test_project_dir_is_absolute(self): + def test_project_dir_is_absolute(self) -> None: args = argparse.Namespace( platform='local', region=None, type='basic', no_venv=False ) config = run_quick('myagent', args) - assert os.path.isabs(config['project_dir']) + assert Path(config['project_dir']).is_absolute() # ============================================================================= @@ -540,7 +537,7 @@ class TestMain: @patch('signalwire.cli.init_project.ProjectGenerator') @patch('sys.argv', ['sw-agent-init', 'testproject', '--type', 'basic', '--no-venv']) - def test_main_quick_mode(self, mock_gen_class): + def test_main_quick_mode(self, mock_gen_class: MagicMock) -> None: mock_gen = Mock() mock_gen.generate.return_value = True mock_gen_class.return_value = mock_gen @@ -555,7 +552,7 @@ def test_main_quick_mode(self, mock_gen_class): @patch('signalwire.cli.init_project.ProjectGenerator') @patch('sys.argv', ['sw-agent-init', 'testproject', '--type', 'full', '--no-venv']) - def test_main_quick_mode_full(self, mock_gen_class): + def test_main_quick_mode_full(self, mock_gen_class: MagicMock) -> None: mock_gen = Mock() mock_gen.generate.return_value = True mock_gen_class.return_value = mock_gen @@ -568,7 +565,7 @@ def test_main_quick_mode_full(self, mock_gen_class): @patch('signalwire.cli.init_project.ProjectGenerator') @patch('sys.argv', ['sw-agent-init', 'testproject', '-p', 'aws', '--no-venv']) - def test_main_aws_platform(self, mock_gen_class): + def test_main_aws_platform(self, mock_gen_class: MagicMock) -> None: mock_gen = Mock() mock_gen.generate.return_value = True mock_gen_class.return_value = mock_gen @@ -579,8 +576,8 @@ def test_main_aws_platform(self, mock_gen_class): assert config['platform'] == 'aws' @patch('signalwire.cli.init_project.ProjectGenerator') - @patch('sys.argv', ['sw-agent-init', 'testproject', '--no-venv', '--dir', '/tmp/custom']) - def test_main_custom_dir(self, mock_gen_class): + @patch('sys.argv', ['sw-agent-init', 'testproject', '--no-venv', '--dir', '/tmp/custom']) # noqa: S108 + def test_main_custom_dir(self, mock_gen_class: MagicMock) -> None: mock_gen = Mock() mock_gen.generate.return_value = True mock_gen_class.return_value = mock_gen @@ -588,11 +585,11 @@ def test_main_custom_dir(self, mock_gen_class): main() config = mock_gen_class.call_args[0][0] - assert '/tmp/custom' in config['project_dir'] + assert '/tmp/custom' in config['project_dir'] # noqa: S108 @patch('signalwire.cli.init_project.ProjectGenerator') @patch('sys.argv', ['sw-agent-init', 'testproject', '--no-venv']) - def test_main_generate_failure_exits(self, mock_gen_class): + def test_main_generate_failure_exits(self, mock_gen_class: MagicMock) -> None: mock_gen = Mock() mock_gen.generate.return_value = False mock_gen_class.return_value = mock_gen @@ -609,13 +606,13 @@ def test_main_generate_failure_exits(self, mock_gen_class): class TestConstants: """Tests for module-level constants.""" - def test_cloud_platforms_has_expected_keys(self): + def test_cloud_platforms_has_expected_keys(self) -> None: assert 'local' in CLOUD_PLATFORMS assert 'aws' in CLOUD_PLATFORMS assert 'gcp' in CLOUD_PLATFORMS assert 'azure' in CLOUD_PLATFORMS - def test_default_regions(self): + def test_default_regions(self) -> None: assert DEFAULT_REGIONS['aws'] == 'us-east-1' assert DEFAULT_REGIONS['gcp'] == 'us-central1' assert DEFAULT_REGIONS['azure'] == 'eastus' diff --git a/tests/unit/cli/test_service_loader.py b/tests/unit/cli/test_service_loader.py index a1b1665e..71f6cc0c 100644 --- a/tests/unit/cli/test_service_loader.py +++ b/tests/unit/cli/test_service_loader.py @@ -28,6 +28,7 @@ import importlib import importlib.util from pathlib import Path +from typing import Any # noqa: E402 from unittest.mock import Mock, patch, MagicMock, PropertyMock, call from io import StringIO @@ -48,7 +49,7 @@ # ============================================================================= @pytest.fixture -def capturer(): +def capturer() -> ServiceCapture: """Create a fresh ServiceCapture instance.""" return ServiceCapture() @@ -56,10 +57,11 @@ def capturer(): class _FakeSWMLService: """A plain class that is NOT a subclass of AgentBase. Used so isinstance(obj, AgentBase) returns False.""" - pass + name: str = "" + route: str = "" -def _make_mock_service(name="test_service", route="/test"): +def _make_mock_service(name: str = "test_service", route: str = "/test") -> _FakeSWMLService: """Create an object that is NOT an AgentBase instance.""" svc = _FakeSWMLService() svc.name = name @@ -67,7 +69,9 @@ def _make_mock_service(name="test_service", route="/test"): return svc -def _make_mock_agent(name="test_agent", route="/agent", class_name="TestAgent"): +def _make_mock_agent( + name: str = "test_agent", route: str = "/agent", class_name: str = "TestAgent" +) -> Mock: """Create a mock that passes isinstance checks for AgentBase.""" agent = Mock(spec=AgentBase) agent.name = name @@ -90,10 +94,10 @@ def _make_mock_agent(name="test_agent", route="/agent", class_name="TestAgent"): class TestServiceCaptureInit: """Tests for ServiceCapture initialization.""" - def test_init_creates_empty_captured_services(self, capturer): + def test_init_creates_empty_captured_services(self, capturer: ServiceCapture) -> None: assert capturer.captured_services == [] - def test_init_creates_empty_original_methods(self, capturer): + def test_init_creates_empty_original_methods(self, capturer: ServiceCapture) -> None: assert capturer.original_methods == {} @@ -104,27 +108,27 @@ def test_init_creates_empty_original_methods(self, capturer): class TestServiceCaptureErrors: """Tests for ServiceCapture.capture error conditions.""" - def test_capture_file_not_found(self, capturer, tmp_path): + def test_capture_file_not_found(self, capturer: ServiceCapture, tmp_path: Path) -> None: """FileNotFoundError when the service file doesn't exist.""" missing = str(tmp_path / "no_such_file.py") with pytest.raises(FileNotFoundError, match="Service file not found"): capturer.capture(missing) - def test_capture_non_python_file(self, capturer, tmp_path): + def test_capture_non_python_file(self, capturer: ServiceCapture, tmp_path: Path) -> None: """ValueError when the file is not a .py file.""" txt_file = tmp_path / "service.txt" txt_file.write_text("not python") with pytest.raises(ValueError, match="must be a Python file"): capturer.capture(str(txt_file)) - def test_capture_non_python_file_js(self, capturer, tmp_path): + def test_capture_non_python_file_js(self, capturer: ServiceCapture, tmp_path: Path) -> None: """ValueError for .js files.""" js_file = tmp_path / "service.js" js_file.write_text("console.log('hi')") with pytest.raises(ValueError, match="must be a Python file"): capturer.capture(str(js_file)) - def test_capture_dependencies_not_available(self, capturer, tmp_path): + def test_capture_dependencies_not_available(self, capturer: ServiceCapture, tmp_path: Path) -> None: """ImportError when DEPENDENCIES_AVAILABLE is False.""" py_file = tmp_path / "service.py" py_file.write_text("pass") @@ -132,14 +136,14 @@ def test_capture_dependencies_not_available(self, capturer, tmp_path): with pytest.raises(ImportError, match="Required dependencies not available"): capturer.capture(str(py_file)) - def test_capture_import_error_no_services_captured(self, capturer, tmp_path): + def test_capture_import_error_no_services_captured(self, capturer: ServiceCapture, tmp_path: Path) -> None: """ImportError when module exec fails and no services were captured.""" py_file = tmp_path / "bad_service.py" py_file.write_text("raise RuntimeError('import boom')") with pytest.raises(ImportError, match="Failed to load service module"): capturer.capture(str(py_file)) - def test_capture_import_error_with_services_captured(self, capturer, tmp_path): + def test_capture_import_error_with_services_captured(self, capturer: ServiceCapture, tmp_path: Path) -> None: """When module exec fails but services were already captured, no error raised.""" py_file = tmp_path / "partial_service.py" # Write a service file that will create an instance, call run(), then crash @@ -161,7 +165,7 @@ def test_capture_import_error_with_services_captured(self, capturer, tmp_path): class TestServiceCaptureSuccess: """Tests for successful service capture scenarios.""" - def test_capture_service_via_serve(self, capturer, tmp_path): + def test_capture_service_via_serve(self, capturer: ServiceCapture, tmp_path: Path) -> None: """Capture a service that calls serve().""" py_file = tmp_path / "my_service.py" py_file.write_text( @@ -173,7 +177,7 @@ def test_capture_service_via_serve(self, capturer, tmp_path): assert len(services) == 1 assert services[0].name == "serve_test" - def test_capture_service_via_run(self, capturer, tmp_path): + def test_capture_service_via_run(self, capturer: ServiceCapture, tmp_path: Path) -> None: """Capture a service that calls run() (via AgentBase).""" py_file = tmp_path / "my_agent.py" py_file.write_text( @@ -184,7 +188,7 @@ def test_capture_service_via_run(self, capturer, tmp_path): services = capturer.capture(str(py_file)) assert len(services) == 1 - def test_capture_multiple_services(self, capturer, tmp_path): + def test_capture_multiple_services(self, capturer: ServiceCapture, tmp_path: Path) -> None: """Capture multiple services from one file.""" py_file = tmp_path / "multi_service.py" py_file.write_text( @@ -199,7 +203,7 @@ def test_capture_multiple_services(self, capturer, tmp_path): names = {s.name for s in services} assert names == {"svc1", "svc2"} - def test_capture_resets_list_between_calls(self, capturer, tmp_path): + def test_capture_resets_list_between_calls(self, capturer: ServiceCapture, tmp_path: Path) -> None: """captured_services is reset before each capture call.""" py_file = tmp_path / "resettable.py" py_file.write_text( @@ -215,7 +219,7 @@ def test_capture_resets_list_between_calls(self, capturer, tmp_path): # Should be a fresh list, not appended assert len(capturer.captured_services) == 1 - def test_capture_with_suppress_output(self, capturer, tmp_path): + def test_capture_with_suppress_output(self, capturer: ServiceCapture, tmp_path: Path) -> None: """suppress_output=True suppresses stdout from the loaded module.""" py_file = tmp_path / "noisy_service.py" py_file.write_text( @@ -235,7 +239,7 @@ def test_capture_with_suppress_output(self, capturer, tmp_path): # suppress_output redirects inside capture() # (stdout was already redirected by capture, so our outer redirect sees nothing) - def test_capture_without_suppress_output(self, capturer, tmp_path, capsys): + def test_capture_without_suppress_output(self, capturer: ServiceCapture, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: """suppress_output=False (default) allows stdout from the loaded module.""" py_file = tmp_path / "chatty_service.py" py_file.write_text( @@ -246,7 +250,7 @@ def test_capture_without_suppress_output(self, capturer, tmp_path, capsys): services = capturer.capture(str(py_file), suppress_output=False) assert len(services) == 1 - def test_capture_returns_list(self, capturer, tmp_path): + def test_capture_returns_list(self, capturer: ServiceCapture, tmp_path: Path) -> None: """capture() returns a list.""" py_file = tmp_path / "list_service.py" py_file.write_text( @@ -257,7 +261,7 @@ def test_capture_returns_list(self, capturer, tmp_path): result = capturer.capture(str(py_file)) assert isinstance(result, list) - def test_capture_resolves_relative_path(self, capturer, tmp_path, monkeypatch): + def test_capture_resolves_relative_path(self, capturer: ServiceCapture, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """capture() resolves the path before checking existence.""" py_file = tmp_path / "relative_service.py" py_file.write_text( @@ -269,7 +273,7 @@ def test_capture_resolves_relative_path(self, capturer, tmp_path, monkeypatch): services = capturer.capture("relative_service.py") assert len(services) == 1 - def test_capture_empty_module_returns_empty_list(self, capturer, tmp_path): + def test_capture_empty_module_returns_empty_list(self, capturer: ServiceCapture, tmp_path: Path) -> None: """Capture of a file with no services returns empty list.""" py_file = tmp_path / "empty_service.py" py_file.write_text("x = 42\n") @@ -284,7 +288,7 @@ def test_capture_empty_module_returns_empty_list(self, capturer, tmp_path): class TestServiceCapturePatching: """Tests for the patch/restore mechanism.""" - def test_apply_patches_stores_originals(self, capturer): + def test_apply_patches_stores_originals(self, capturer: ServiceCapture) -> None: """_apply_patches stores original run/serve methods.""" original_serve = SWMLService.serve capturer._apply_patches() @@ -296,7 +300,7 @@ def test_apply_patches_stores_originals(self, capturer): finally: capturer._restore_patches() - def test_apply_patches_replaces_methods(self, capturer): + def test_apply_patches_replaces_methods(self, capturer: ServiceCapture) -> None: """_apply_patches replaces run/serve on base classes.""" original_serve = SWMLService.serve capturer._apply_patches() @@ -305,25 +309,25 @@ def test_apply_patches_replaces_methods(self, capturer): finally: capturer._restore_patches() - def test_restore_patches_restores_original_methods(self, capturer): + def test_restore_patches_restores_original_methods(self, capturer: ServiceCapture) -> None: """_restore_patches restores original methods.""" original_serve = SWMLService.serve capturer._apply_patches() capturer._restore_patches() assert SWMLService.serve is original_serve - def test_restore_patches_clears_dict(self, capturer): + def test_restore_patches_clears_dict(self, capturer: ServiceCapture) -> None: """_restore_patches clears the original_methods dict.""" capturer._apply_patches() capturer._restore_patches() assert capturer.original_methods == {} - def test_restore_patches_with_empty_dict(self, capturer): + def test_restore_patches_with_empty_dict(self, capturer: ServiceCapture) -> None: """_restore_patches handles empty original_methods gracefully.""" capturer._restore_patches() # Should not raise assert capturer.original_methods == {} - def test_patches_restored_after_error(self, capturer, tmp_path): + def test_patches_restored_after_error(self, capturer: ServiceCapture, tmp_path: Path) -> None: """Patches are restored even when capture raises an error.""" original_serve = SWMLService.serve py_file = tmp_path / "error_service.py" @@ -333,7 +337,7 @@ def test_patches_restored_after_error(self, capturer, tmp_path): # Patches should be restored assert SWMLService.serve is original_serve - def test_mock_run_captures_service(self, capturer): + def test_mock_run_captures_service(self, capturer: ServiceCapture) -> None: """The patched run() method captures the service instance.""" capturer._apply_patches() try: @@ -346,12 +350,12 @@ def test_mock_run_captures_service(self, capturer): finally: capturer._restore_patches() - def test_mock_serve_returns_service(self, capturer): + def test_mock_serve_returns_service(self, capturer: ServiceCapture) -> None: """The patched serve() method returns the service instance.""" capturer._apply_patches() try: svc = SWMLService(name="return_test", route="/rt", schema_validation=False) - result = svc.serve() + result = svc.serve() # type: ignore[func-returns-value] # test stubs serve() to return self assert result is svc finally: capturer._restore_patches() @@ -364,14 +368,14 @@ def test_mock_serve_returns_service(self, capturer): class TestLoadAndSimulateService: """Tests for the load_and_simulate_service function.""" - def test_no_services_raises_value_error(self, tmp_path): + def test_no_services_raises_value_error(self, tmp_path: Path) -> None: """ValueError when no services are found in the file.""" py_file = tmp_path / "empty.py" py_file.write_text("x = 1\n") with pytest.raises(ValueError, match="No services found"): load_and_simulate_service(str(py_file)) - def test_multiple_services_no_route_raises_value_error(self): + def test_multiple_services_no_route_raises_value_error(self) -> None: """ValueError listing available routes when multiple services found and no route specified.""" mock_svc1 = _make_mock_service(route="/a") mock_svc2 = _make_mock_service(route="/b") @@ -380,7 +384,7 @@ def test_multiple_services_no_route_raises_value_error(self): with pytest.raises(ValueError, match="Multiple services found"): load_and_simulate_service("fake.py") - def test_multiple_services_wrong_route_raises_value_error(self): + def test_multiple_services_wrong_route_raises_value_error(self) -> None: """ValueError when specified route doesn't match any service.""" mock_svc1 = _make_mock_service(route="/a") mock_svc2 = _make_mock_service(route="/b") @@ -389,7 +393,7 @@ def test_multiple_services_wrong_route_raises_value_error(self): with pytest.raises(ValueError, match="No service found for route '/c'"): load_and_simulate_service("fake.py", route="/c") - def test_multiple_services_correct_route_selects_service(self): + def test_multiple_services_correct_route_selects_service(self) -> None: """Correct service selected when route matches.""" mock_svc1 = _make_mock_service(route="/a") mock_svc2 = _make_mock_service(route="/b") @@ -404,7 +408,7 @@ def test_multiple_services_correct_route_selects_service(self): call_args = mock_asyncio.run.call_args assert call_args is not None - def test_single_service_selected_automatically(self): + def test_single_service_selected_automatically(self) -> None: """Single service is selected without needing a route.""" mock_svc = _make_mock_service(route="/only") @@ -415,7 +419,7 @@ def test_single_service_selected_automatically(self): result = load_and_simulate_service("fake.py") assert result == {"response": "data"} - def test_passes_parameters_through(self): + def test_passes_parameters_through(self) -> None: """Method, body, query_params, and headers are forwarded.""" mock_svc = _make_mock_service(route="/only") @@ -434,7 +438,7 @@ def test_passes_parameters_through(self): # Verify asyncio.run was called assert mock_asyncio.run.called - def test_multiple_services_available_routes_in_error(self): + def test_multiple_services_available_routes_in_error(self) -> None: """The error message includes the available routes.""" mock_svc1 = _make_mock_service(route="/route1") mock_svc2 = _make_mock_service(route="/route2") @@ -452,7 +456,7 @@ def test_multiple_services_available_routes_in_error(self): class TestLoadAgentFromFile: """Tests for the load_agent_from_file backward compatibility function.""" - def test_no_agents_raises_value_error(self): + def test_no_agents_raises_value_error(self) -> None: """ValueError when no agents found in the file.""" mock_svc = _make_mock_service() # Not an AgentBase @@ -460,7 +464,7 @@ def test_no_agents_raises_value_error(self): with pytest.raises(ValueError, match="No agents found"): load_agent_from_file("fake.py") - def test_single_agent_returned(self): + def test_single_agent_returned(self) -> None: """Single agent is returned directly.""" mock_agent = _make_mock_agent(name="solo") @@ -468,7 +472,7 @@ def test_single_agent_returned(self): result = load_agent_from_file("fake.py") assert result is mock_agent - def test_multiple_agents_returns_first(self): + def test_multiple_agents_returns_first(self) -> None: """When multiple agents and no class name, returns first.""" agent1 = _make_mock_agent(name="first", class_name="FirstAgent") agent2 = _make_mock_agent(name="second", class_name="SecondAgent") @@ -477,7 +481,7 @@ def test_multiple_agents_returns_first(self): result = load_agent_from_file("fake.py") assert result is agent1 - def test_multiple_agents_with_class_name(self): + def test_multiple_agents_with_class_name(self) -> None: """When multiple agents and class name is specified, matches by name.""" agent1 = _make_mock_agent(name="first", class_name="FirstAgent") agent2 = _make_mock_agent(name="second", class_name="SecondAgent") @@ -486,7 +490,7 @@ def test_multiple_agents_with_class_name(self): result = load_agent_from_file("fake.py", agent_class_name="SecondAgent") assert result is agent2 - def test_class_name_no_match_returns_first(self): + def test_class_name_no_match_returns_first(self) -> None: """When class name doesn't match any agent, returns first.""" agent1 = _make_mock_agent(name="first", class_name="FirstAgent") agent2 = _make_mock_agent(name="second", class_name="SecondAgent") @@ -495,7 +499,7 @@ def test_class_name_no_match_returns_first(self): result = load_agent_from_file("fake.py", agent_class_name="ThirdAgent") assert result is agent1 - def test_filters_non_agents(self): + def test_filters_non_agents(self) -> None: """Non-AgentBase services are filtered out.""" mock_svc = _make_mock_service() mock_agent = _make_mock_agent(name="real_agent") @@ -504,7 +508,7 @@ def test_filters_non_agents(self): result = load_agent_from_file("fake.py") assert result is mock_agent - def test_suppress_output_forwarded(self): + def test_suppress_output_forwarded(self) -> None: """suppress_output parameter is forwarded to capture().""" mock_agent = _make_mock_agent() @@ -512,7 +516,7 @@ def test_suppress_output_forwarded(self): load_agent_from_file("fake.py", suppress_output=True) mock_capture.assert_called_once_with("fake.py", suppress_output=True) - def test_empty_capture_raises(self): + def test_empty_capture_raises(self) -> None: """Empty capture list raises ValueError (no agents).""" with patch.object(ServiceCapture, 'capture', return_value=[]): with pytest.raises(ValueError, match="No agents found"): @@ -526,13 +530,13 @@ def test_empty_capture_raises(self): class TestDiscoverAgentsInFile: """Tests for the discover_agents_in_file backward compatibility function.""" - def test_empty_file_returns_empty_list(self): + def test_empty_file_returns_empty_list(self) -> None: """Empty capture returns empty list.""" with patch.object(ServiceCapture, 'capture', return_value=[]): result = discover_agents_in_file("fake.py") assert result == [] - def test_returns_proper_dict_format(self): + def test_returns_proper_dict_format(self) -> None: """Each entry has expected keys: name, class_name, type, agent_name, route, description, object.""" mock_agent = _make_mock_agent(name="discover_me", route="/d", class_name="DiscoverAgent") @@ -547,7 +551,7 @@ def test_returns_proper_dict_format(self): assert entry["route"] == "/d" assert entry["object"] is mock_agent - def test_filters_non_agent_services(self): + def test_filters_non_agent_services(self) -> None: """Non-AgentBase services are excluded.""" mock_svc = _make_mock_service() mock_agent = _make_mock_agent(name="agent_only") @@ -557,7 +561,7 @@ def test_filters_non_agent_services(self): assert len(result) == 1 assert result[0]["name"] == "agent_only" - def test_multiple_agents(self): + def test_multiple_agents(self) -> None: """Multiple agents all appear in result.""" agent1 = _make_mock_agent(name="agent1", class_name="Agent1") agent2 = _make_mock_agent(name="agent2", class_name="Agent2") @@ -568,7 +572,7 @@ def test_multiple_agents(self): names = {e["name"] for e in result} assert names == {"agent1", "agent2"} - def test_description_from_docstring(self): + def test_description_from_docstring(self) -> None: """The description field comes from the class docstring.""" mock_agent = _make_mock_agent(name="documented") # The docstring is set in _make_mock_agent via the type() call @@ -586,11 +590,11 @@ class TestSimulateRequestToService: """Tests for the async simulate_request_to_service function.""" @pytest.mark.asyncio - async def test_simulate_returns_dict_response(self): + async def test_simulate_returns_dict_response(self) -> None: """When the service handler returns a dict, it's returned as-is.""" mock_service = Mock() - async def async_handler(request, response): + async def async_handler(request: Any, response: Any) -> dict[str, Any]: return {"swml": "data"} mock_service._handle_request = async_handler @@ -603,7 +607,7 @@ async def async_handler(request, response): assert result == {"swml": "data"} @pytest.mark.asyncio - async def test_simulate_returns_body_response(self): + async def test_simulate_returns_body_response(self) -> None: """When the handler returns an object with body attr, parse as JSON.""" import json as json_mod response_obj = Mock() @@ -611,7 +615,7 @@ async def test_simulate_returns_body_response(self): mock_service = Mock() - async def async_handler(request, response): + async def async_handler(request: Any, response: Any) -> Any: return response_obj mock_service._handle_request = async_handler @@ -624,11 +628,11 @@ async def async_handler(request, response): assert result == {"parsed": True} @pytest.mark.asyncio - async def test_simulate_returns_error_for_unparseable(self): + async def test_simulate_returns_error_for_unparseable(self) -> None: """When the response is neither dict nor has body, return error dict.""" mock_service = Mock() - async def async_handler(request, response): + async def async_handler(request: Any, response: Any) -> str: return "just a string" mock_service._handle_request = async_handler diff --git a/tests/unit/core/agent/tools/test_tool_registry.py b/tests/unit/core/agent/tools/test_tool_registry.py index e715fd2c..97d0fbf7 100644 --- a/tests/unit/core/agent/tools/test_tool_registry.py +++ b/tests/unit/core/agent/tools/test_tool_registry.py @@ -8,6 +8,8 @@ Python parity baseline for: dotnet/go/typescript/java/php/rust/ruby/perl/cpp. """ +from typing import Any + import pytest from unittest.mock import MagicMock @@ -15,54 +17,55 @@ @pytest.fixture -def registry(): +def registry() -> ToolRegistry: agent = MagicMock() return ToolRegistry(agent) class TestToolRegistryDefineAndQuery: - def test_register_swaig_function_via_dict(self, registry): + def test_register_swaig_function_via_dict(self, registry: ToolRegistry) -> None: registry.register_swaig_function({"function": "lookup", "description": "Look up a value"}) assert registry.has_function("lookup") - def test_has_function_false_when_unregistered(self, registry): + def test_has_function_false_when_unregistered(self, registry: ToolRegistry) -> None: assert registry.has_function("nope") is False - def test_get_function_returns_registered(self, registry): + def test_get_function_returns_registered(self, registry: ToolRegistry) -> None: registry.register_swaig_function({"function": "lookup", "description": "x"}) fn = registry.get_function("lookup") assert fn is not None + assert isinstance(fn, dict) assert fn["function"] == "lookup" - def test_get_function_none_when_unregistered(self, registry): + def test_get_function_none_when_unregistered(self, registry: ToolRegistry) -> None: assert registry.get_function("nope") is None - def test_get_all_functions_starts_empty(self, registry): + def test_get_all_functions_starts_empty(self, registry: ToolRegistry) -> None: assert registry.get_all_functions() == {} - def test_get_all_functions_returns_registered(self, registry): + def test_get_all_functions_returns_registered(self, registry: ToolRegistry) -> None: registry.register_swaig_function({"function": "a", "description": "x"}) registry.register_swaig_function({"function": "b", "description": "y"}) all_fns = registry.get_all_functions() assert set(all_fns.keys()) == {"a", "b"} - def test_get_all_functions_returns_copy(self, registry): + def test_get_all_functions_returns_copy(self, registry: ToolRegistry) -> None: registry.register_swaig_function({"function": "a", "description": "x"}) snapshot = registry.get_all_functions() registry.register_swaig_function({"function": "b", "description": "y"}) # Original snapshot unaffected by later registration assert "b" not in snapshot - def test_remove_function_when_present(self, registry): + def test_remove_function_when_present(self, registry: ToolRegistry) -> None: registry.register_swaig_function({"function": "doomed", "description": "x"}) assert registry.remove_function("doomed") is True assert registry.has_function("doomed") is False - def test_remove_function_when_absent_returns_false(self, registry): + def test_remove_function_when_absent_returns_false(self, registry: ToolRegistry) -> None: assert registry.remove_function("never_existed") is False - def test_define_tool_registers_with_handler(self, registry): - def my_handler(args, raw_data=None): + def test_define_tool_registers_with_handler(self, registry: ToolRegistry) -> None: + def my_handler(args: dict[str, Any], raw_data: dict[str, Any] | None = None) -> dict[str, str]: return {"result": "ok"} registry.define_tool( name="echo", diff --git a/tests/unit/core/agent/tools/test_type_inference.py b/tests/unit/core/agent/tools/test_type_inference.py index 66929aad..fefcc7ad 100644 --- a/tests/unit/core/agent/tools/test_type_inference.py +++ b/tests/unit/core/agent/tools/test_type_inference.py @@ -11,9 +11,7 @@ Comprehensive tests for type-hint-based tool schema inference. """ -import pytest -from typing import Optional, List, Dict, Literal -from unittest.mock import Mock, MagicMock +from typing import Any, Optional, List, Dict, Literal from signalwire.core.agent.tools.type_inference import ( infer_schema, @@ -21,7 +19,9 @@ _resolve_type, _parse_docstring_args, ) +from signalwire.core.agent_base import AgentBase from signalwire.core.function_result import FunctionResult +from signalwire.core.swaig_function import SWAIGFunction # =========================================================================== @@ -31,72 +31,72 @@ class TestResolveType: """Tests for the _resolve_type helper.""" - def test_str(self): + def test_str(self) -> None: schema, optional = _resolve_type(str) assert schema == {"type": "string"} assert optional is False - def test_int(self): + def test_int(self) -> None: schema, optional = _resolve_type(int) assert schema == {"type": "integer"} assert optional is False - def test_float(self): + def test_float(self) -> None: schema, optional = _resolve_type(float) assert schema == {"type": "number"} assert optional is False - def test_bool(self): + def test_bool(self) -> None: schema, optional = _resolve_type(bool) assert schema == {"type": "boolean"} assert optional is False - def test_list(self): + def test_list(self) -> None: schema, optional = _resolve_type(list) assert schema == {"type": "array"} assert optional is False - def test_dict(self): + def test_dict(self) -> None: schema, optional = _resolve_type(dict) assert schema == {"type": "object"} assert optional is False - def test_optional_str(self): + def test_optional_str(self) -> None: schema, optional = _resolve_type(Optional[str]) assert schema == {"type": "string"} assert optional is True - def test_optional_int(self): + def test_optional_int(self) -> None: schema, optional = _resolve_type(Optional[int]) assert schema == {"type": "integer"} assert optional is True - def test_literal_strings(self): + def test_literal_strings(self) -> None: schema, optional = _resolve_type(Literal["a", "b", "c"]) assert schema == {"type": "string", "enum": ["a", "b", "c"]} assert optional is False - def test_literal_ints(self): + def test_literal_ints(self) -> None: schema, optional = _resolve_type(Literal[1, 2, 3]) assert schema == {"type": "integer", "enum": [1, 2, 3]} assert optional is False - def test_list_of_str(self): + def test_list_of_str(self) -> None: schema, optional = _resolve_type(List[str]) assert schema == {"type": "array", "items": {"type": "string"}} assert optional is False - def test_list_of_int(self): + def test_list_of_int(self) -> None: schema, optional = _resolve_type(List[int]) assert schema == {"type": "array", "items": {"type": "integer"}} assert optional is False - def test_dict_str_any(self): + def test_dict_str_any(self) -> None: schema, optional = _resolve_type(Dict[str, int]) assert schema == {"type": "object"} assert optional is False - def test_unknown_type_falls_back_to_string(self): + def test_unknown_type_falls_back_to_string(self) -> None: class CustomType: pass schema, optional = _resolve_type(CustomType) @@ -111,22 +111,22 @@ class CustomType: class TestParseDocstringArgs: """Tests for docstring parsing.""" - def test_empty_docstring(self): + def test_empty_docstring(self) -> None: summary, params = _parse_docstring_args("") assert summary == "" assert params == {} - def test_none_docstring(self): - summary, params = _parse_docstring_args(None) + def test_none_docstring(self) -> None: + summary, params = _parse_docstring_args(None) # type: ignore[arg-type] # intentional invalid input assert summary == "" assert params == {} - def test_summary_only(self): + def test_summary_only(self) -> None: summary, params = _parse_docstring_args("Get the weather forecast.") assert summary == "Get the weather forecast." assert params == {} - def test_args_block(self): + def test_args_block(self) -> None: doc = """Get the weather forecast. Args: @@ -138,7 +138,7 @@ def test_args_block(self): assert params["city"] == "Name of the city" assert params["units"] == "Temperature units (celsius or fahrenheit)" - def test_args_block_with_type_annotations(self): + def test_args_block_with_type_annotations(self) -> None: doc = """Look up a user. Args: @@ -150,7 +150,7 @@ def test_args_block_with_type_annotations(self): assert params["name"] == "The user's name" assert params["age"] == "The user's age" - def test_args_block_with_returns_section(self): + def test_args_block_with_returns_section(self) -> None: doc = """Do something. Args: @@ -164,7 +164,7 @@ def test_args_block_with_returns_section(self): assert params["x"] == "First param" assert params["y"] == "Second param" - def test_multiline_param_description(self): + def test_multiline_param_description(self) -> None: doc = """Search function. Args: @@ -185,44 +185,44 @@ def test_multiline_param_description(self): class TestInferSchemaDetection: """Tests for when infer_schema should and should not activate.""" - def test_old_style_args_raw_data(self): + def test_old_style_args_raw_data(self) -> None: """Old-style (args, raw_data) should not be treated as typed.""" - def handler(args, raw_data): + def handler(args: dict[str, Any], raw_data: dict[str, Any]) -> None: pass params, required, desc, is_typed, has_raw_data = infer_schema(handler) assert is_typed is False - def test_old_style_args_only(self): + def test_old_style_args_only(self) -> None: """Old-style (args,) should not be treated as typed.""" - def handler(args): + def handler(args: dict[str, Any]) -> None: pass params, required, desc, is_typed, has_raw_data = infer_schema(handler) assert is_typed is False - def test_varargs_fallback(self): + def test_varargs_fallback(self) -> None: """Functions with *args should fall back to old style.""" - def handler(*args): + def handler(*args: Any) -> None: pass params, required, desc, is_typed, has_raw_data = infer_schema(handler) assert is_typed is False - def test_kwargs_fallback(self): + def test_kwargs_fallback(self) -> None: """Functions with **kwargs should fall back to old style.""" - def handler(**kwargs): + def handler(**kwargs: Any) -> None: pass params, required, desc, is_typed, has_raw_data = infer_schema(handler) assert is_typed is False - def test_typed_params_detected(self): + def test_typed_params_detected(self) -> None: """Typed parameters should be detected as new style.""" - def handler(city: str, units: str = "celsius"): + def handler(city: str, units: str = "celsius") -> None: pass params, required, desc, is_typed, has_raw_data = infer_schema(handler) assert is_typed is True - def test_zero_param_tool(self): + def test_zero_param_tool(self) -> None: """Function with no params (after self filtering) is a valid zero-param typed tool.""" - def handler(): + def handler() -> None: """Get the current time.""" pass params, required, desc, is_typed, has_raw_data = infer_schema(handler) @@ -231,18 +231,18 @@ def handler(): assert required == [] assert desc == "Get the current time." - def test_self_filtered_out(self): + def test_self_filtered_out(self) -> None: """self parameter should be filtered out.""" - def handler(self, city: str): + def handler(self: Any, city: str) -> None: pass params, required, desc, is_typed, has_raw_data = infer_schema(handler) assert is_typed is True assert "self" not in params assert "city" in params - def test_no_annotations_fallback(self): + def test_no_annotations_fallback(self) -> None: """No type hints on non-standard param names should fall back.""" - def handler(city, units): + def handler(city, units) -> None: # type: ignore[no-untyped-def] # intentional: exercises the no-annotations fallback path pass params, required, desc, is_typed, has_raw_data = infer_schema(handler) assert is_typed is False @@ -255,44 +255,44 @@ def handler(city, units): class TestInferSchemaTypes: """Tests for correct type mapping in inferred schemas.""" - def test_string_param(self): - def handler(name: str): + def test_string_param(self) -> None: + def handler(name: str) -> None: pass params, *_ = infer_schema(handler) assert params["name"]["type"] == "string" - def test_int_param(self): - def handler(count: int): + def test_int_param(self) -> None: + def handler(count: int) -> None: pass params, *_ = infer_schema(handler) assert params["count"]["type"] == "integer" - def test_float_param(self): - def handler(price: float): + def test_float_param(self) -> None: + def handler(price: float) -> None: pass params, *_ = infer_schema(handler) assert params["price"]["type"] == "number" - def test_bool_param(self): - def handler(enabled: bool): + def test_bool_param(self) -> None: + def handler(enabled: bool) -> None: pass params, *_ = infer_schema(handler) assert params["enabled"]["type"] == "boolean" - def test_list_param(self): - def handler(items: list): + def test_list_param(self) -> None: + def handler(items: list[Any]) -> None: pass params, *_ = infer_schema(handler) assert params["items"]["type"] == "array" - def test_dict_param(self): - def handler(data: dict): + def test_dict_param(self) -> None: + def handler(data: dict[str, Any]) -> None: pass params, *_ = infer_schema(handler) assert params["data"]["type"] == "object" - def test_optional_param(self): - def handler(name: str, nickname: Optional[str] = None): + def test_optional_param(self) -> None: + def handler(name: str, nickname: Optional[str] = None) -> None: pass params, required, *_ = infer_schema(handler) assert params["name"]["type"] == "string" @@ -300,15 +300,15 @@ def handler(name: str, nickname: Optional[str] = None): assert "name" in required assert "nickname" not in required - def test_literal_param(self): - def handler(color: Literal["red", "green", "blue"]): + def test_literal_param(self) -> None: + def handler(color: Literal["red", "green", "blue"]) -> None: pass params, *_ = infer_schema(handler) assert params["color"]["type"] == "string" assert params["color"]["enum"] == ["red", "green", "blue"] - def test_list_of_str_param(self): - def handler(tags: List[str]): + def test_list_of_str_param(self) -> None: + def handler(tags: List[str]) -> None: pass params, *_ = infer_schema(handler) assert params["tags"]["type"] == "array" @@ -322,26 +322,26 @@ def handler(tags: List[str]): class TestInferSchemaRequired: """Tests for required vs optional parameter detection.""" - def test_no_default_is_required(self): - def handler(city: str): + def test_no_default_is_required(self) -> None: + def handler(city: str) -> None: pass _, required, *_ = infer_schema(handler) assert "city" in required - def test_with_default_is_optional(self): - def handler(city: str = "London"): + def test_with_default_is_optional(self) -> None: + def handler(city: str = "London") -> None: pass _, required, *_ = infer_schema(handler) assert "city" not in required - def test_optional_type_is_not_required(self): - def handler(city: Optional[str]): + def test_optional_type_is_not_required(self) -> None: + def handler(city: Optional[str]) -> None: pass _, required, *_ = infer_schema(handler) assert "city" not in required - def test_mixed_required_optional(self): - def handler(city: str, units: str = "celsius", country: Optional[str] = None): + def test_mixed_required_optional(self) -> None: + def handler(city: str, units: str = "celsius", country: Optional[str] = None) -> None: pass _, required, *_ = infer_schema(handler) assert "city" in required @@ -356,21 +356,21 @@ def handler(city: str, units: str = "celsius", country: Optional[str] = None): class TestInferSchemaDocstring: """Tests for docstring-driven description and parameter docs.""" - def test_description_from_docstring(self): - def handler(city: str): + def test_description_from_docstring(self) -> None: + def handler(city: str) -> None: """Get the weather forecast.""" pass _, _, desc, *_ = infer_schema(handler) assert desc == "Get the weather forecast." - def test_no_docstring(self): - def handler(city: str): + def test_no_docstring(self) -> None: + def handler(city: str) -> None: pass _, _, desc, *_ = infer_schema(handler) assert desc is None - def test_param_descriptions_from_docstring(self): - def handler(city: str, units: str = "celsius"): + def test_param_descriptions_from_docstring(self) -> None: + def handler(city: str, units: str = "celsius") -> None: """Get the weather. Args: @@ -390,30 +390,30 @@ def handler(city: str, units: str = "celsius"): class TestInferSchemaRawData: """Tests for raw_data parameter detection and exclusion.""" - def test_raw_data_detected(self): - def handler(city: str, raw_data: dict = None): + def test_raw_data_detected(self) -> None: + def handler(city: str, raw_data: Optional[dict[str, Any]] = None) -> None: pass _, _, _, is_typed, has_raw_data = infer_schema(handler) assert is_typed is True assert has_raw_data is True - def test_raw_data_excluded_from_schema(self): - def handler(city: str, raw_data: dict = None): + def test_raw_data_excluded_from_schema(self) -> None: + def handler(city: str, raw_data: Optional[dict[str, Any]] = None) -> None: pass params, *_ = infer_schema(handler) assert "raw_data" not in params assert "city" in params - def test_no_raw_data(self): - def handler(city: str): + def test_no_raw_data(self) -> None: + def handler(city: str) -> None: pass _, _, _, is_typed, has_raw_data = infer_schema(handler) assert is_typed is True assert has_raw_data is False - def test_only_raw_data(self): + def test_only_raw_data(self) -> None: """Function with only raw_data param is a zero-param typed tool with raw_data.""" - def handler(raw_data: dict): + def handler(raw_data: dict[str, Any]) -> None: pass params, required, _, is_typed, has_raw_data = infer_schema(handler) assert is_typed is True @@ -429,8 +429,8 @@ def handler(raw_data: dict): class TestCreateTypedHandlerWrapper: """Tests for the handler wrapper function.""" - def test_wrapper_unpacks_args(self): - def handler(city: str, units: str = "celsius"): + def test_wrapper_unpacks_args(self) -> None: + def handler(city: str, units: str = "celsius") -> FunctionResult: return FunctionResult(f"Weather in {city} ({units})") wrapper = create_typed_handler_wrapper(handler, has_raw_data=False) @@ -439,9 +439,10 @@ def handler(city: str, units: str = "celsius"): assert "London" in result.response assert "fahrenheit" in result.response - def test_wrapper_passes_raw_data(self): - def handler(city: str, raw_data: dict = None): - return FunctionResult(f"Weather in {city}, call={raw_data.get('call_id', 'none')}") + def test_wrapper_passes_raw_data(self) -> None: + def handler(city: str, raw_data: Optional[dict[str, Any]] = None) -> FunctionResult: + call_id = raw_data.get("call_id", "none") if raw_data else "none" + return FunctionResult(f"Weather in {city}, call={call_id}") wrapper = create_typed_handler_wrapper(handler, has_raw_data=True) result = wrapper({"city": "Paris"}, {"call_id": "abc123"}) @@ -449,8 +450,8 @@ def handler(city: str, raw_data: dict = None): assert "Paris" in result.response assert "abc123" in result.response - def test_wrapper_without_raw_data(self): - def handler(name: str): + def test_wrapper_without_raw_data(self) -> None: + def handler(name: str) -> FunctionResult: return FunctionResult(f"Hello, {name}") wrapper = create_typed_handler_wrapper(handler, has_raw_data=False) @@ -458,30 +459,30 @@ def handler(name: str): assert isinstance(result, FunctionResult) assert "Alice" in result.response - def test_wrapper_preserves_name(self): - def my_tool(x: str): + def test_wrapper_preserves_name(self) -> None: + def my_tool(x: str) -> None: pass wrapper = create_typed_handler_wrapper(my_tool, has_raw_data=False) assert wrapper.__name__ == "my_tool" - def test_wrapper_preserves_doc(self): - def my_tool(x: str): + def test_wrapper_preserves_doc(self) -> None: + def my_tool(x: str) -> None: """My tool docstring.""" pass wrapper = create_typed_handler_wrapper(my_tool, has_raw_data=False) assert wrapper.__doc__ == "My tool docstring." - def test_wrapper_has_wrapped_attr(self): - def my_tool(x: str): + def test_wrapper_has_wrapped_attr(self) -> None: + def my_tool(x: str) -> None: pass wrapper = create_typed_handler_wrapper(my_tool, has_raw_data=False) - assert wrapper.__wrapped__ is my_tool + assert wrapper.__wrapped__ is my_tool # type: ignore[attr-defined] # __wrapped__ set by functools.wraps - def test_wrapper_with_empty_args(self): - def handler(): + def test_wrapper_with_empty_args(self) -> None: + def handler() -> FunctionResult: return FunctionResult("done") wrapper = create_typed_handler_wrapper(handler, has_raw_data=False) @@ -489,8 +490,8 @@ def handler(): assert isinstance(result, FunctionResult) assert result.response == "done" - def test_wrapper_with_default_values(self): - def handler(city: str, units: str = "celsius"): + def test_wrapper_with_default_values(self) -> None: + def handler(city: str, units: str = "celsius") -> FunctionResult: return FunctionResult(f"{city}:{units}") wrapper = create_typed_handler_wrapper(handler, has_raw_data=False) @@ -507,16 +508,15 @@ def handler(city: str, units: str = "celsius"): class TestEndToEndIntegration: """Tests that type inference works end-to-end through the decorator and registry.""" - def test_class_decorator_with_typed_handler(self): + def test_class_decorator_with_typed_handler(self) -> None: """Test that @AgentBase.tool() with typed params infers schema correctly.""" - from signalwire import AgentBase class TestAgent(AgentBase): - def __init__(self): + def __init__(self) -> None: super().__init__("Test Agent", route="/test") @AgentBase.tool(name="get_weather") - def get_weather(self, city: str, units: str = "celsius"): + def get_weather(self, city: str, units: str = "celsius") -> FunctionResult: """Get the weather forecast. Args: @@ -527,7 +527,7 @@ def get_weather(self, city: str, units: str = "celsius"): agent = TestAgent() func = agent._tool_registry.get_function("get_weather") - assert func is not None + assert isinstance(func, SWAIGFunction) assert func.is_typed_handler is True assert "city" in func.parameters assert func.parameters["city"]["type"] == "string" @@ -538,37 +538,35 @@ def get_weather(self, city: str, units: str = "celsius"): assert "units" not in func.required assert func.description == "Get the weather forecast." - def test_class_decorator_with_explicit_params_not_overridden(self): + def test_class_decorator_with_explicit_params_not_overridden(self) -> None: """Explicit parameters= should always win over inference.""" - from signalwire import AgentBase explicit_params = { "location": {"type": "string", "description": "Location name"} } class TestAgent(AgentBase): - def __init__(self): + def __init__(self) -> None: super().__init__("Test Agent", route="/test2") @AgentBase.tool(name="get_weather2", parameters=explicit_params, description="Explicit desc") - def get_weather(self, city: str, units: str = "celsius"): + def get_weather(self, city: str, units: str = "celsius") -> FunctionResult: """Get the weather forecast.""" return FunctionResult(f"Weather in {city}") agent = TestAgent() func = agent._tool_registry.get_function("get_weather2") - assert func is not None + assert isinstance(func, SWAIGFunction) assert func.is_typed_handler is False assert "location" in func.parameters assert "city" not in func.parameters assert func.description == "Explicit desc" - def test_class_decorator_old_style_still_works(self): + def test_class_decorator_old_style_still_works(self) -> None: """Old-style (self, args, raw_data) should work as before.""" - from signalwire import AgentBase class TestAgent(AgentBase): - def __init__(self): + def __init__(self) -> None: super().__init__("Test Agent", route="/test3") @AgentBase.tool( @@ -576,25 +574,24 @@ def __init__(self): description="Old style tool", parameters={"x": {"type": "string", "description": "Input"}}, ) - def old_tool(self, args, raw_data): + def old_tool(self, args, raw_data) -> FunctionResult: # type: ignore[no-untyped-def] # intentional: exercises the old-style (untyped args, raw_data) path return FunctionResult("done") agent = TestAgent() func = agent._tool_registry.get_function("old_tool") - assert func is not None + assert isinstance(func, SWAIGFunction) assert func.is_typed_handler is False assert func.parameters == {"x": {"type": "string", "description": "Input"}} - def test_typed_handler_execution_through_on_function_call(self): + def test_typed_handler_execution_through_on_function_call(self) -> None: """Typed handler should execute correctly through the standard dispatch.""" - from signalwire import AgentBase class TestAgent(AgentBase): - def __init__(self): + def __init__(self) -> None: super().__init__("Test Agent", route="/test4") @AgentBase.tool(name="greet") - def greet(self, name: str, greeting: str = "Hello"): + def greet(self, name: str, greeting: str = "Hello") -> FunctionResult: """Greet someone. Args: @@ -609,16 +606,15 @@ def greet(self, name: str, greeting: str = "Hello"): assert "Hi" in result.response assert "Alice" in result.response - def test_typed_handler_with_defaults(self): + def test_typed_handler_with_defaults(self) -> None: """Typed handler should use defaults for missing args.""" - from signalwire import AgentBase class TestAgent(AgentBase): - def __init__(self): + def __init__(self) -> None: super().__init__("Test Agent", route="/test5") @AgentBase.tool(name="greet2") - def greet(self, name: str, greeting: str = "Hello"): + def greet(self, name: str, greeting: str = "Hello") -> FunctionResult: """Greet someone.""" return FunctionResult(f"{greeting}, {name}!") @@ -628,16 +624,15 @@ def greet(self, name: str, greeting: str = "Hello"): assert "Hello" in result.response assert "Bob" in result.response - def test_typed_handler_with_raw_data(self): + def test_typed_handler_with_raw_data(self) -> None: """Typed handler with raw_data should receive it.""" - from signalwire import AgentBase class TestAgent(AgentBase): - def __init__(self): + def __init__(self) -> None: super().__init__("Test Agent", route="/test6") @AgentBase.tool(name="check_call") - def check_call(self, query: str, raw_data: dict = None): + def check_call(self, query: str, raw_data: Optional[dict[str, Any]] = None) -> FunctionResult: """Check the call. Args: @@ -652,16 +647,15 @@ def check_call(self, query: str, raw_data: dict = None): assert "test" in result.response assert "c42" in result.response - def test_literal_enum_in_swml(self): + def test_literal_enum_in_swml(self) -> None: """Literal types should produce enum in SWML output.""" - from signalwire import AgentBase class TestAgent(AgentBase): - def __init__(self): + def __init__(self) -> None: super().__init__("Test Agent", route="/test7") @AgentBase.tool(name="set_mode") - def set_mode(self, mode: Literal["auto", "manual", "off"]): + def set_mode(self, mode: Literal["auto", "manual", "off"]) -> FunctionResult: """Set the operating mode. Args: @@ -671,17 +665,17 @@ def set_mode(self, mode: Literal["auto", "manual", "off"]): agent = TestAgent() func = agent._tool_registry.get_function("set_mode") + assert isinstance(func, SWAIGFunction) assert func.parameters["mode"]["type"] == "string" assert func.parameters["mode"]["enum"] == ["auto", "manual", "off"] - def test_instance_decorator_with_typed_handler(self): + def test_instance_decorator_with_typed_handler(self) -> None: """Test the instance-level @agent.tool() decorator with typed params.""" - from signalwire import AgentBase agent = AgentBase("Test Agent", route="/test8") @agent._tool_decorator(name="lookup") - def lookup(user_id: int, include_details: bool = False): + def lookup(user_id: int, include_details: bool = False) -> FunctionResult: """Look up a user. Args: @@ -691,7 +685,7 @@ def lookup(user_id: int, include_details: bool = False): return FunctionResult(f"User {user_id}") func = agent._tool_registry.get_function("lookup") - assert func is not None + assert isinstance(func, SWAIGFunction) assert func.is_typed_handler is True assert func.parameters["user_id"]["type"] == "integer" assert func.parameters["include_details"]["type"] == "boolean" @@ -699,16 +693,15 @@ def lookup(user_id: int, include_details: bool = False): assert "include_details" not in func.required assert func.description == "Look up a user." - def test_swml_output_includes_inferred_schema(self): + def test_swml_output_includes_inferred_schema(self) -> None: """The generated SWML should include the inferred parameter schema.""" - from signalwire import AgentBase class TestAgent(AgentBase): - def __init__(self): + def __init__(self) -> None: super().__init__("Test Agent", route="/test9") @AgentBase.tool(name="search") - def search(self, query: str, limit: int = 10): + def search(self, query: str, limit: int = 10) -> FunctionResult: """Search for items. Args: @@ -719,6 +712,7 @@ def search(self, query: str, limit: int = 10): agent = TestAgent() func = agent._tool_registry.get_function("search") + assert isinstance(func, SWAIGFunction) swaig = func.to_swaig("https://example.com") assert swaig["function"] == "search" diff --git a/tests/unit/core/mixins/test_ai_config_mixin.py b/tests/unit/core/mixins/test_ai_config_mixin.py index a28b1c55..3dce2234 100644 --- a/tests/unit/core/mixins/test_ai_config_mixin.py +++ b/tests/unit/core/mixins/test_ai_config_mixin.py @@ -11,6 +11,8 @@ Unit tests for AIConfigMixin """ +from typing import Any + import pytest from unittest.mock import Mock @@ -23,22 +25,22 @@ class MockAIConfigHost(AIConfigMixin): all the attributes the mixin expects to find on self. """ - def __init__(self): - self._hints = [] - self._languages = [] - self._pronounce = [] - self._params = {} - self._global_data = {} - self.native_functions = [] - self._internal_fillers = {} - self._function_includes = [] - self._prompt_llm_params = {} - self._post_prompt_llm_params = {} + def __init__(self) -> None: + self._hints: list[Any] = [] + self._languages: list[dict[str, Any]] = [] + self._pronounce: list[dict[str, Any]] = [] + self._params: dict[str, Any] = {} + self._global_data: dict[str, Any] = {} + self.native_functions: list[Any] = [] + self._internal_fillers: dict[str, Any] = {} + self._function_includes: list[dict[str, Any]] = [] + self._prompt_llm_params: dict[str, Any] = {} + self._post_prompt_llm_params: dict[str, Any] = {} self.log = Mock() @pytest.fixture -def host(): +def host() -> MockAIConfigHost: """Return a fresh MockAIConfigHost.""" return MockAIConfigHost() @@ -50,7 +52,7 @@ def host(): class TestAddPatternHint: """Tests for AIConfigMixin.add_pattern_hint""" - def test_adds_pattern_hint_with_all_fields(self, host): + def test_adds_pattern_hint_with_all_fields(self, host: MockAIConfigHost) -> None: result = host.add_pattern_hint("SignalWire", r"signal\s*wire", "SignalWire") assert len(host._hints) == 1 assert host._hints[0] == { @@ -60,31 +62,31 @@ def test_adds_pattern_hint_with_all_fields(self, host): "ignore_case": False, } - def test_adds_pattern_hint_with_ignore_case(self, host): + def test_adds_pattern_hint_with_ignore_case(self, host: MockAIConfigHost) -> None: host.add_pattern_hint("Test", r"t.st", "Test!", ignore_case=True) assert host._hints[0]["ignore_case"] is True - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.add_pattern_hint("h", "p", "r") assert result is host - def test_empty_hint_does_not_add(self, host): + def test_empty_hint_does_not_add(self, host: MockAIConfigHost) -> None: host.add_pattern_hint("", "pattern", "replace") assert len(host._hints) == 0 - def test_empty_pattern_does_not_add(self, host): + def test_empty_pattern_does_not_add(self, host: MockAIConfigHost) -> None: host.add_pattern_hint("hint", "", "replace") assert len(host._hints) == 0 - def test_empty_replace_does_not_add(self, host): + def test_empty_replace_does_not_add(self, host: MockAIConfigHost) -> None: host.add_pattern_hint("hint", "pattern", "") assert len(host._hints) == 0 - def test_none_hint_does_not_add(self, host): - host.add_pattern_hint(None, "pattern", "replace") + def test_none_hint_does_not_add(self, host: MockAIConfigHost) -> None: + host.add_pattern_hint(None, "pattern", "replace") # type: ignore[arg-type] # intentional invalid input assert len(host._hints) == 0 - def test_multiple_pattern_hints(self, host): + def test_multiple_pattern_hints(self, host: MockAIConfigHost) -> None: host.add_pattern_hint("A", "a", "A!") host.add_pattern_hint("B", "b", "B!") assert len(host._hints) == 2 @@ -97,7 +99,7 @@ def test_multiple_pattern_hints(self, host): class TestAddLanguage: """Tests for AIConfigMixin.add_language""" - def test_simple_voice_string(self, host): + def test_simple_voice_string(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "en-US-Neural2-F") assert len(host._languages) == 1 lang = host._languages[0] @@ -107,49 +109,49 @@ def test_simple_voice_string(self, host): assert "engine" not in lang assert "model" not in lang - def test_explicit_engine_param(self, host): + def test_explicit_engine_param(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "josh", engine="elevenlabs") lang = host._languages[0] assert lang["voice"] == "josh" assert lang["engine"] == "elevenlabs" assert "model" not in lang - def test_explicit_model_param(self, host): + def test_explicit_model_param(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "josh", model="eleven_turbo_v2_5") lang = host._languages[0] assert lang["voice"] == "josh" assert lang["model"] == "eleven_turbo_v2_5" assert "engine" not in lang - def test_explicit_engine_and_model_params(self, host): + def test_explicit_engine_and_model_params(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5") lang = host._languages[0] assert lang["voice"] == "josh" assert lang["engine"] == "elevenlabs" assert lang["model"] == "eleven_turbo_v2_5" - def test_combined_format_engine_voice_model(self, host): + def test_combined_format_engine_voice_model(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "elevenlabs.josh:eleven_turbo_v2_5") lang = host._languages[0] assert lang["voice"] == "josh" assert lang["engine"] == "elevenlabs" assert lang["model"] == "eleven_turbo_v2_5" - def test_combined_format_parse_failure_fallback(self, host): + def test_combined_format_parse_failure_fallback(self, host: MockAIConfigHost) -> None: """Malformed combined format (dot but no colon) uses voice as-is.""" host.add_language("English", "en-US", "some-voice-no-colon") lang = host._languages[0] assert lang["voice"] == "some-voice-no-colon" assert "engine" not in lang - def test_combined_format_with_dot_but_missing_colon(self, host): + def test_combined_format_with_dot_but_missing_colon(self, host: MockAIConfigHost) -> None: """Voice with a dot but no colon is treated as a simple voice string.""" host.add_language("English", "en-US", "engine.voice") lang = host._languages[0] # No colon means no combined format, simple voice assert lang["voice"] == "engine.voice" - def test_combined_format_value_error_fallback(self, host): + def test_combined_format_value_error_fallback(self, host: MockAIConfigHost) -> None: """When split produces wrong number of parts, fall back to voice as-is.""" # This has colon and dot but the split on ":" gives engine_voice with no "." # Actually "a:b.c" -> split(":", 1) -> ("a", "b.c"), then "a".split(".", 1) -> ValueError @@ -160,7 +162,7 @@ def test_combined_format_value_error_fallback(self, host): # "nodot:model" -> split(":", 1) -> ("nodot", "model"), then "nodot".split(".", 1) -> ValueError assert lang["voice"] == "nodot:model" - def test_both_speech_fillers_and_function_fillers(self, host): + def test_both_speech_fillers_and_function_fillers(self, host: MockAIConfigHost) -> None: speech = ["um", "uh"] func = ["let me check", "one moment"] host.add_language("English", "en-US", "voice1", @@ -170,7 +172,7 @@ def test_both_speech_fillers_and_function_fillers(self, host): assert lang["function_fillers"] == func assert "fillers" not in lang - def test_only_speech_fillers_uses_deprecated_field(self, host): + def test_only_speech_fillers_uses_deprecated_field(self, host: MockAIConfigHost) -> None: speech = ["um", "uh"] host.add_language("English", "en-US", "voice1", speech_fillers=speech) lang = host._languages[0] @@ -178,7 +180,7 @@ def test_only_speech_fillers_uses_deprecated_field(self, host): assert "speech_fillers" not in lang assert "function_fillers" not in lang - def test_only_function_fillers_uses_deprecated_field(self, host): + def test_only_function_fillers_uses_deprecated_field(self, host: MockAIConfigHost) -> None: func = ["let me check"] host.add_language("English", "en-US", "voice1", function_fillers=func) lang = host._languages[0] @@ -186,18 +188,18 @@ def test_only_function_fillers_uses_deprecated_field(self, host): assert "speech_fillers" not in lang assert "function_fillers" not in lang - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.add_language("English", "en-US", "voice1") assert result is host - def test_no_fillers_produces_no_filler_keys(self, host): + def test_no_fillers_produces_no_filler_keys(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "voice1") lang = host._languages[0] assert "fillers" not in lang assert "speech_fillers" not in lang assert "function_fillers" not in lang - def test_combined_format_colon_and_dot_both_present_but_no_dot_in_engine_part(self, host): + def test_combined_format_colon_and_dot_both_present_but_no_dot_in_engine_part(self, host: MockAIConfigHost) -> None: """Voice like '.name:model' where engine part is empty string after split.""" host.add_language("English", "en-US", ".voice:model") lang = host._languages[0] @@ -216,53 +218,53 @@ def test_combined_format_colon_and_dot_both_present_but_no_dot_in_engine_part(se class TestPerLanguageParams: """Tests for the per-language ``params`` dict support.""" - def test_add_language_with_params_attaches_params(self, host): + def test_add_language_with_params_attaches_params(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "josh", engine="elevenlabs", params={"stability": 0.5, "similarity_boost": 0.75}) assert host._languages[0]["params"] == {"stability": 0.5, "similarity_boost": 0.75} - def test_add_language_without_params_omits_key(self, host): + def test_add_language_without_params_omits_key(self, host: MockAIConfigHost) -> None: host.add_language("French", "fr-FR", "fr-FR-Neural2-A") assert "params" not in host._languages[0] - def test_add_language_with_empty_params_omits_key(self, host): + def test_add_language_with_empty_params_omits_key(self, host: MockAIConfigHost) -> None: host.add_language("French", "fr-FR", "v", params={}) assert "params" not in host._languages[0] - def test_get_language_params_returns_set_dict(self, host): + def test_get_language_params_returns_set_dict(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "v", params={"a": 1}) assert host.get_language_params("en-US") == {"a": 1} - def test_get_language_params_returns_none_when_unset(self, host): + def test_get_language_params_returns_none_when_unset(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "v") assert host.get_language_params("en-US") is None - def test_get_language_params_returns_none_for_unknown_code(self, host): + def test_get_language_params_returns_none_for_unknown_code(self, host: MockAIConfigHost) -> None: assert host.get_language_params("zh-CN") is None - def test_set_language_params_replaces_existing(self, host): + def test_set_language_params_replaces_existing(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "v", params={"a": 1}) host.set_language_params("en-US", {"b": 2}) assert host.get_language_params("en-US") == {"b": 2} - def test_set_language_params_adds_when_unset(self, host): + def test_set_language_params_adds_when_unset(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "v") host.set_language_params("en-US", {"c": 3}) assert host.get_language_params("en-US") == {"c": 3} - def test_set_language_params_empty_dict_removes_key(self, host): + def test_set_language_params_empty_dict_removes_key(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "v", params={"a": 1}) host.set_language_params("en-US", {}) assert host.get_language_params("en-US") is None assert "params" not in host._languages[0] - def test_set_language_params_unknown_code_is_noop(self, host): + def test_set_language_params_unknown_code_is_noop(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "v") host.set_language_params("zh-CN", {"a": 1}) # The known language remains untouched. assert host._languages[0].get("params") is None - def test_set_language_params_returns_self_for_chaining(self, host): + def test_set_language_params_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: host.add_language("English", "en-US", "v") assert host.set_language_params("en-US", {"a": 1}) is host @@ -274,28 +276,28 @@ def test_set_language_params_returns_self_for_chaining(self, host): class TestSetLanguages: """Tests for AIConfigMixin.set_languages""" - def test_sets_languages_with_valid_list(self, host): + def test_sets_languages_with_valid_list(self, host: MockAIConfigHost) -> None: langs = [{"name": "English", "code": "en-US", "voice": "voice1"}] result = host.set_languages(langs) assert host._languages is langs - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.set_languages([{"name": "French", "code": "fr-FR", "voice": "v"}]) assert result is host - def test_empty_list_does_not_set(self, host): + def test_empty_list_does_not_set(self, host: MockAIConfigHost) -> None: host._languages = [{"name": "existing"}] host.set_languages([]) assert host._languages == [{"name": "existing"}] - def test_none_does_not_set(self, host): + def test_none_does_not_set(self, host: MockAIConfigHost) -> None: host._languages = [{"name": "existing"}] - host.set_languages(None) + host.set_languages(None) # type: ignore[arg-type] # intentional invalid input assert host._languages == [{"name": "existing"}] - def test_non_list_does_not_set(self, host): + def test_non_list_does_not_set(self, host: MockAIConfigHost) -> None: host._languages = [{"name": "existing"}] - host.set_languages("not a list") + host.set_languages("not a list") # type: ignore[arg-type] # intentional invalid input assert host._languages == [{"name": "existing"}] @@ -306,7 +308,7 @@ def test_non_list_does_not_set(self, host): class TestAddPronunciation: """Tests for AIConfigMixin.add_pronunciation""" - def test_ignore_case_true_adds_key(self, host): + def test_ignore_case_true_adds_key(self, host: MockAIConfigHost) -> None: host.add_pronunciation("SQL", "sequel", ignore_case=True) assert len(host._pronounce) == 1 rule = host._pronounce[0] @@ -314,12 +316,12 @@ def test_ignore_case_true_adds_key(self, host): assert rule["with"] == "sequel" assert rule["ignore_case"] is True - def test_ignore_case_false_omits_key(self, host): + def test_ignore_case_false_omits_key(self, host: MockAIConfigHost) -> None: host.add_pronunciation("API", "A.P.I.") rule = host._pronounce[0] assert "ignore_case" not in rule - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.add_pronunciation("A", "B") assert result is host @@ -331,28 +333,28 @@ def test_returns_self_for_chaining(self, host): class TestSetPronunciations: """Tests for AIConfigMixin.set_pronunciations""" - def test_sets_pronunciations_with_valid_list(self, host): + def test_sets_pronunciations_with_valid_list(self, host: MockAIConfigHost) -> None: rules = [{"replace": "SQL", "with": "sequel"}] result = host.set_pronunciations(rules) assert host._pronounce is rules - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.set_pronunciations([{"replace": "A", "with": "B"}]) assert result is host - def test_empty_list_does_not_set(self, host): + def test_empty_list_does_not_set(self, host: MockAIConfigHost) -> None: host._pronounce = [{"replace": "old"}] host.set_pronunciations([]) assert host._pronounce == [{"replace": "old"}] - def test_none_does_not_set(self, host): + def test_none_does_not_set(self, host: MockAIConfigHost) -> None: host._pronounce = [{"replace": "old"}] - host.set_pronunciations(None) + host.set_pronunciations(None) # type: ignore[arg-type] # intentional invalid input assert host._pronounce == [{"replace": "old"}] - def test_non_list_does_not_set(self, host): + def test_non_list_does_not_set(self, host: MockAIConfigHost) -> None: host._pronounce = [{"replace": "old"}] - host.set_pronunciations({"not": "a list"}) + host.set_pronunciations({"not": "a list"}) # type: ignore[arg-type] # intentional invalid input assert host._pronounce == [{"replace": "old"}] @@ -363,23 +365,23 @@ def test_non_list_does_not_set(self, host): class TestSetGlobalData: """Tests for AIConfigMixin.set_global_data""" - def test_sets_global_data_with_valid_dict(self, host): + def test_sets_global_data_with_valid_dict(self, host: MockAIConfigHost) -> None: data = {"key": "value", "num": 42} result = host.set_global_data(data) assert host._global_data == data - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.set_global_data({"k": "v"}) assert result is host - def test_empty_dict_does_not_set(self, host): + def test_empty_dict_does_not_set(self, host: MockAIConfigHost) -> None: host._global_data = {"old": "data"} host.set_global_data({}) assert host._global_data == {"old": "data"} - def test_none_does_not_set(self, host): + def test_none_does_not_set(self, host: MockAIConfigHost) -> None: host._global_data = {"old": "data"} - host.set_global_data(None) + host.set_global_data(None) # type: ignore[arg-type] # intentional invalid input assert host._global_data == {"old": "data"} @@ -390,23 +392,23 @@ def test_none_does_not_set(self, host): class TestUpdateGlobalData: """Tests for AIConfigMixin.update_global_data""" - def test_updates_global_data(self, host): + def test_updates_global_data(self, host: MockAIConfigHost) -> None: host._global_data = {"existing": "value"} result = host.update_global_data({"new": "data"}) assert host._global_data == {"existing": "value", "new": "data"} - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.update_global_data({"k": "v"}) assert result is host - def test_empty_dict_does_not_update(self, host): + def test_empty_dict_does_not_update(self, host: MockAIConfigHost) -> None: host._global_data = {"old": "data"} host.update_global_data({}) assert host._global_data == {"old": "data"} - def test_none_does_not_update(self, host): + def test_none_does_not_update(self, host: MockAIConfigHost) -> None: host._global_data = {"old": "data"} - host.update_global_data(None) + host.update_global_data(None) # type: ignore[arg-type] # intentional invalid input assert host._global_data == {"old": "data"} @@ -417,26 +419,26 @@ def test_none_does_not_update(self, host): class TestSetNativeFunctions: """Tests for AIConfigMixin.set_native_functions""" - def test_sets_native_functions(self, host): + def test_sets_native_functions(self, host: MockAIConfigHost) -> None: result = host.set_native_functions(["check_time", "wait_for_user"]) assert host.native_functions == ["check_time", "wait_for_user"] - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.set_native_functions(["fn"]) assert result is host - def test_filters_non_string_entries(self, host): - host.set_native_functions(["valid", 123, None, "also_valid"]) + def test_filters_non_string_entries(self, host: MockAIConfigHost) -> None: + host.set_native_functions(["valid", 123, None, "also_valid"]) # type: ignore[list-item] # intentional invalid input assert host.native_functions == ["valid", "also_valid"] - def test_empty_list_does_not_set(self, host): + def test_empty_list_does_not_set(self, host: MockAIConfigHost) -> None: host.native_functions = ["old"] host.set_native_functions([]) assert host.native_functions == ["old"] - def test_none_does_not_set(self, host): + def test_none_does_not_set(self, host: MockAIConfigHost) -> None: host.native_functions = ["old"] - host.set_native_functions(None) + host.set_native_functions(None) # type: ignore[arg-type] # intentional invalid input assert host.native_functions == ["old"] @@ -447,41 +449,41 @@ def test_none_does_not_set(self, host): class TestSetInternalFillers: """Tests for AIConfigMixin.set_internal_fillers""" - def test_sets_internal_fillers_with_valid_dict(self, host): + def test_sets_internal_fillers_with_valid_dict(self, host: MockAIConfigHost) -> None: fillers = { "next_step": {"en-US": ["Moving on...", "Let's continue..."]} } result = host.set_internal_fillers(fillers) assert host._internal_fillers == fillers - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.set_internal_fillers({"fn": {"en": ["filler"]}}) assert result is host - def test_creates_internal_fillers_attr_if_missing(self, host): + def test_creates_internal_fillers_attr_if_missing(self, host: MockAIConfigHost) -> None: del host._internal_fillers host.set_internal_fillers({"fn": {"en": ["filler"]}}) assert host._internal_fillers == {"fn": {"en": ["filler"]}} - def test_updates_existing_fillers(self, host): + def test_updates_existing_fillers(self, host: MockAIConfigHost) -> None: host._internal_fillers = {"fn1": {"en": ["old"]}} host.set_internal_fillers({"fn2": {"en": ["new"]}}) assert "fn1" in host._internal_fillers assert "fn2" in host._internal_fillers - def test_empty_dict_does_not_set(self, host): + def test_empty_dict_does_not_set(self, host: MockAIConfigHost) -> None: host._internal_fillers = {"existing": {"en": ["x"]}} host.set_internal_fillers({}) assert host._internal_fillers == {"existing": {"en": ["x"]}} - def test_none_does_not_set(self, host): + def test_none_does_not_set(self, host: MockAIConfigHost) -> None: host._internal_fillers = {"existing": {"en": ["x"]}} - host.set_internal_fillers(None) + host.set_internal_fillers(None) # type: ignore[arg-type] # intentional invalid input assert host._internal_fillers == {"existing": {"en": ["x"]}} - def test_non_dict_does_not_set(self, host): + def test_non_dict_does_not_set(self, host: MockAIConfigHost) -> None: host._internal_fillers = {"existing": {"en": ["x"]}} - host.set_internal_fillers(["not", "a", "dict"]) + host.set_internal_fillers(["not", "a", "dict"]) # type: ignore[arg-type] # intentional invalid input assert host._internal_fillers == {"existing": {"en": ["x"]}} @@ -492,39 +494,39 @@ def test_non_dict_does_not_set(self, host): class TestAddInternalFiller: """Tests for AIConfigMixin.add_internal_filler""" - def test_adds_filler_for_new_function(self, host): + def test_adds_filler_for_new_function(self, host: MockAIConfigHost) -> None: result = host.add_internal_filler("next_step", "en-US", ["Moving on..."]) assert host._internal_fillers["next_step"]["en-US"] == ["Moving on..."] - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.add_internal_filler("fn", "en", ["filler"]) assert result is host - def test_creates_internal_fillers_attr_if_missing(self, host): + def test_creates_internal_fillers_attr_if_missing(self, host: MockAIConfigHost) -> None: del host._internal_fillers host.add_internal_filler("fn", "en", ["filler"]) assert host._internal_fillers == {"fn": {"en": ["filler"]}} - def test_adds_language_to_existing_function(self, host): + def test_adds_language_to_existing_function(self, host: MockAIConfigHost) -> None: host._internal_fillers = {"fn": {"en": ["english filler"]}} host.add_internal_filler("fn", "es", ["filler espanol"]) assert host._internal_fillers["fn"]["en"] == ["english filler"] assert host._internal_fillers["fn"]["es"] == ["filler espanol"] - def test_empty_function_name_does_not_add(self, host): + def test_empty_function_name_does_not_add(self, host: MockAIConfigHost) -> None: host.add_internal_filler("", "en", ["filler"]) assert host._internal_fillers == {} - def test_empty_language_code_does_not_add(self, host): + def test_empty_language_code_does_not_add(self, host: MockAIConfigHost) -> None: host.add_internal_filler("fn", "", ["filler"]) assert host._internal_fillers == {} - def test_empty_fillers_list_does_not_add(self, host): + def test_empty_fillers_list_does_not_add(self, host: MockAIConfigHost) -> None: host.add_internal_filler("fn", "en", []) assert host._internal_fillers == {} - def test_none_fillers_does_not_add(self, host): - host.add_internal_filler("fn", "en", None) + def test_none_fillers_does_not_add(self, host: MockAIConfigHost) -> None: + host.add_internal_filler("fn", "en", None) # type: ignore[arg-type] # intentional invalid input assert host._internal_fillers == {} @@ -535,7 +537,7 @@ def test_none_fillers_does_not_add(self, host): class TestAddFunctionInclude: """Tests for AIConfigMixin.add_function_include""" - def test_adds_include_with_meta_data(self, host): + def test_adds_include_with_meta_data(self, host: MockAIConfigHost) -> None: host.add_function_include( "https://example.com/swaig", ["func1", "func2"], @@ -547,17 +549,17 @@ def test_adds_include_with_meta_data(self, host): assert inc["functions"] == ["func1", "func2"] assert inc["meta_data"] == {"auth_token": "abc123"} - def test_adds_include_without_meta_data(self, host): + def test_adds_include_without_meta_data(self, host: MockAIConfigHost) -> None: host.add_function_include("https://example.com", ["fn1"]) inc = host._function_includes[0] assert "meta_data" not in inc - def test_meta_data_non_dict_not_added(self, host): - host.add_function_include("https://example.com", ["fn1"], meta_data="not_dict") + def test_meta_data_non_dict_not_added(self, host: MockAIConfigHost) -> None: + host.add_function_include("https://example.com", ["fn1"], meta_data="not_dict") # type: ignore[arg-type] # intentional invalid input inc = host._function_includes[0] assert "meta_data" not in inc - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.add_function_include("https://example.com", ["fn"]) assert result is host @@ -569,7 +571,7 @@ def test_returns_self_for_chaining(self, host): class TestSetFunctionIncludes: """Tests for AIConfigMixin.set_function_includes""" - def test_sets_valid_includes(self, host): + def test_sets_valid_includes(self, host: MockAIConfigHost) -> None: includes = [ {"url": "https://example.com", "functions": ["fn1", "fn2"]}, {"url": "https://other.com", "functions": ["fn3"]}, @@ -578,49 +580,49 @@ def test_sets_valid_includes(self, host): assert len(host._function_includes) == 2 assert host._function_includes[0]["url"] == "https://example.com" - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.set_function_includes([{"url": "u", "functions": ["f"]}]) assert result is host - def test_filters_out_invalid_includes_missing_url(self, host): + def test_filters_out_invalid_includes_missing_url(self, host: MockAIConfigHost) -> None: includes = [ {"functions": ["fn1"]}, # missing url {"url": "https://valid.com", "functions": ["fn2"]}, ] - host.set_function_includes(includes) + host.set_function_includes(includes) # type: ignore[arg-type] # intentional invalid input assert len(host._function_includes) == 1 assert host._function_includes[0]["url"] == "https://valid.com" - def test_filters_out_invalid_includes_missing_functions(self, host): + def test_filters_out_invalid_includes_missing_functions(self, host: MockAIConfigHost) -> None: includes = [ {"url": "https://example.com"}, # missing functions ] host.set_function_includes(includes) assert len(host._function_includes) == 0 - def test_filters_out_non_dict_includes(self, host): + def test_filters_out_non_dict_includes(self, host: MockAIConfigHost) -> None: includes = [ "not a dict", {"url": "https://valid.com", "functions": ["fn"]}, ] - host.set_function_includes(includes) + host.set_function_includes(includes) # type: ignore[arg-type] # intentional invalid input assert len(host._function_includes) == 1 - def test_filters_out_includes_with_non_list_functions(self, host): + def test_filters_out_includes_with_non_list_functions(self, host: MockAIConfigHost) -> None: includes = [ {"url": "https://example.com", "functions": "not_a_list"}, ] host.set_function_includes(includes) assert len(host._function_includes) == 0 - def test_empty_list_does_not_set(self, host): + def test_empty_list_does_not_set(self, host: MockAIConfigHost) -> None: host._function_includes = [{"url": "old", "functions": ["f"]}] host.set_function_includes([]) assert host._function_includes == [{"url": "old", "functions": ["f"]}] - def test_none_does_not_set(self, host): + def test_none_does_not_set(self, host: MockAIConfigHost) -> None: host._function_includes = [{"url": "old", "functions": ["f"]}] - host.set_function_includes(None) + host.set_function_includes(None) # type: ignore[arg-type] # intentional invalid input assert host._function_includes == [{"url": "old", "functions": ["f"]}] @@ -631,27 +633,27 @@ def test_none_does_not_set(self, host): class TestSetPromptLlmParams: """Tests for AIConfigMixin.set_prompt_llm_params""" - def test_sets_params(self, host): + def test_sets_params(self, host: MockAIConfigHost) -> None: result = host.set_prompt_llm_params(model="gpt-4o-mini", temperature=0.7) assert host._prompt_llm_params["model"] == "gpt-4o-mini" assert host._prompt_llm_params["temperature"] == 0.7 - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.set_prompt_llm_params(model="gpt-4o") assert result is host - def test_no_params_does_not_modify(self, host): + def test_no_params_does_not_modify(self, host: MockAIConfigHost) -> None: host._prompt_llm_params = {"existing": "value"} host.set_prompt_llm_params() assert host._prompt_llm_params == {"existing": "value"} - def test_updates_existing_params(self, host): + def test_updates_existing_params(self, host: MockAIConfigHost) -> None: host.set_prompt_llm_params(model="old") host.set_prompt_llm_params(model="new", temperature=0.5) assert host._prompt_llm_params["model"] == "new" assert host._prompt_llm_params["temperature"] == 0.5 - def test_arbitrary_params_accepted(self, host): + def test_arbitrary_params_accepted(self, host: MockAIConfigHost) -> None: host.set_prompt_llm_params( barge_confidence=0.6, presence_penalty=0.3, @@ -669,27 +671,27 @@ def test_arbitrary_params_accepted(self, host): class TestSetPostPromptLlmParams: """Tests for AIConfigMixin.set_post_prompt_llm_params""" - def test_sets_params(self, host): + def test_sets_params(self, host: MockAIConfigHost) -> None: result = host.set_post_prompt_llm_params(model="gpt-4o-mini", temperature=0.5) assert host._post_prompt_llm_params["model"] == "gpt-4o-mini" assert host._post_prompt_llm_params["temperature"] == 0.5 - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockAIConfigHost) -> None: result = host.set_post_prompt_llm_params(model="gpt-4o") assert result is host - def test_no_params_does_not_modify(self, host): + def test_no_params_does_not_modify(self, host: MockAIConfigHost) -> None: host._post_prompt_llm_params = {"existing": "value"} host.set_post_prompt_llm_params() assert host._post_prompt_llm_params == {"existing": "value"} - def test_updates_existing_params(self, host): + def test_updates_existing_params(self, host: MockAIConfigHost) -> None: host.set_post_prompt_llm_params(model="old") host.set_post_prompt_llm_params(model="new", top_p=0.8) assert host._post_prompt_llm_params["model"] == "new" assert host._post_prompt_llm_params["top_p"] == 0.8 - def test_arbitrary_params_accepted(self, host): + def test_arbitrary_params_accepted(self, host: MockAIConfigHost) -> None: host.set_post_prompt_llm_params( presence_penalty=0.3, frequency_penalty=0.1, @@ -705,7 +707,7 @@ def test_arbitrary_params_accepted(self, host): class TestMethodChaining: """Verify that mixin methods support fluent chaining.""" - def test_chain_multiple_ai_config_methods(self, host): + def test_chain_multiple_ai_config_methods(self, host: MockAIConfigHost) -> None: result = ( host .add_hint("test") diff --git a/tests/unit/core/mixins/test_auth_mixin.py b/tests/unit/core/mixins/test_auth_mixin.py index ba580571..e4138896 100644 --- a/tests/unit/core/mixins/test_auth_mixin.py +++ b/tests/unit/core/mixins/test_auth_mixin.py @@ -23,11 +23,11 @@ class ConcreteAuthMixin(AuthMixin): """Concrete implementation of AuthMixin for testing purposes.""" - def __init__(self, basic_auth=None): + def __init__(self, basic_auth: tuple[str, str] | None = None) -> None: self._basic_auth = basic_auth or ("testuser", "testpass") -def _make_basic_auth_header(username, password): +def _make_basic_auth_header(username: str, password: str) -> str: """Helper to create a Base64-encoded Basic auth header value.""" credentials = f"{username}:{password}" encoded = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") @@ -41,53 +41,54 @@ def _make_basic_auth_header(username, password): class TestValidateBasicAuth: """Tests for validate_basic_auth method.""" - def test_valid_credentials(self): + def test_valid_credentials(self) -> None: """Valid username and password returns True.""" mixin = ConcreteAuthMixin(("admin", "secret")) assert mixin.validate_basic_auth("admin", "secret") is True - def test_invalid_username(self): + def test_invalid_username(self) -> None: """Wrong username returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) assert mixin.validate_basic_auth("wrong", "secret") is False - def test_invalid_password(self): + def test_invalid_password(self) -> None: """Wrong password returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) assert mixin.validate_basic_auth("admin", "wrong") is False - def test_both_invalid(self): + def test_both_invalid(self) -> None: """Both wrong username and password returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) assert mixin.validate_basic_auth("wrong", "alsowrong") is False - def test_empty_credentials(self): + def test_empty_credentials(self) -> None: """Empty strings match if _basic_auth is also empty strings.""" mixin = ConcreteAuthMixin(("", "")) assert mixin.validate_basic_auth("", "") is True - def test_empty_credentials_mismatch(self): + def test_empty_credentials_mismatch(self) -> None: """Empty strings do not match non-empty credentials.""" mixin = ConcreteAuthMixin(("admin", "secret")) assert mixin.validate_basic_auth("", "") is False - def test_credentials_with_special_characters(self): + def test_credentials_with_special_characters(self) -> None: """Credentials containing colons and special characters work.""" mixin = ConcreteAuthMixin(("user@domain.com", "p@ss:w0rd!")) assert mixin.validate_basic_auth("user@domain.com", "p@ss:w0rd!") is True - def test_delegation_can_be_overridden(self): + def test_delegation_can_be_overridden(self) -> None: """Subclass can override validate_basic_auth to change behavior.""" class CustomAuth(AuthMixin): - def __init__(self): + def __init__(self) -> None: self._basic_auth = ("unused", "unused") - def validate_basic_auth(self, username, password): + def validate_basic_auth(self, username: str, password: str) -> bool: # Always accept a specific master key - if password == "master-key": + if password == "master-key": # noqa: S105 # test literal, not a real secret return True - return super().validate_basic_auth(username, password) + result: bool = super().validate_basic_auth(username, password) + return result custom = CustomAuth() assert custom.validate_basic_auth("anyone", "master-key") is True @@ -102,20 +103,20 @@ def validate_basic_auth(self, username, password): class TestGetBasicAuthCredentials: """Tests for get_basic_auth_credentials method.""" - def test_returns_tuple_without_source(self): + def test_returns_tuple_without_source(self) -> None: """Without include_source, returns a 2-tuple.""" mixin = ConcreteAuthMixin(("myuser", "mypass")) result = mixin.get_basic_auth_credentials() assert result == ("myuser", "mypass") - def test_returns_tuple_with_source_provided(self): + def test_returns_tuple_with_source_provided(self) -> None: """With include_source and non-env, non-generated credentials, source is 'provided'.""" mixin = ConcreteAuthMixin(("myuser", "mypass")) with patch.dict(os.environ, {}, clear=True): result = mixin.get_basic_auth_credentials(include_source=True) assert result == ("myuser", "mypass", "provided") - def test_source_environment_when_matching_env_vars(self): + def test_source_environment_when_matching_env_vars(self) -> None: """When credentials match environment variables, source is 'environment'.""" mixin = ConcreteAuthMixin(("envuser", "envpass")) env = {"SWML_BASIC_AUTH_USER": "envuser", "SWML_BASIC_AUTH_PASSWORD": "envpass"} @@ -123,15 +124,16 @@ def test_source_environment_when_matching_env_vars(self): result = mixin.get_basic_auth_credentials(include_source=True) assert result == ("envuser", "envpass", "environment") - def test_source_not_environment_when_env_partially_matches(self): + def test_source_not_environment_when_env_partially_matches(self) -> None: """If only one env var matches, source is not 'environment'.""" mixin = ConcreteAuthMixin(("envuser", "differentpass")) env = {"SWML_BASIC_AUTH_USER": "envuser", "SWML_BASIC_AUTH_PASSWORD": "envpass"} with patch.dict(os.environ, env, clear=True): result = mixin.get_basic_auth_credentials(include_source=True) + assert len(result) == 3 assert result[2] != "environment" - def test_source_generated_when_looks_generated(self): + def test_source_generated_when_looks_generated(self) -> None: """Credentials that look generated (user_ prefix, long password) get source 'generated'.""" long_password = "a" * 25 # Longer than 20 characters mixin = ConcreteAuthMixin(("user_abc123", long_password)) @@ -139,21 +141,23 @@ def test_source_generated_when_looks_generated(self): result = mixin.get_basic_auth_credentials(include_source=True) assert result == ("user_abc123", long_password, "generated") - def test_source_not_generated_short_password(self): + def test_source_not_generated_short_password(self) -> None: """user_ prefix but short password does not count as 'generated'.""" mixin = ConcreteAuthMixin(("user_abc", "short")) with patch.dict(os.environ, {}, clear=True): result = mixin.get_basic_auth_credentials(include_source=True) + assert len(result) == 3 assert result[2] == "provided" - def test_source_not_generated_no_prefix(self): + def test_source_not_generated_no_prefix(self) -> None: """Long password but no user_ prefix does not count as 'generated'.""" mixin = ConcreteAuthMixin(("admin", "a" * 25)) with patch.dict(os.environ, {}, clear=True): result = mixin.get_basic_auth_credentials(include_source=True) + assert len(result) == 3 assert result[2] == "provided" - def test_environment_takes_priority_over_generated(self): + def test_environment_takes_priority_over_generated(self) -> None: """Even if credentials look generated, environment match takes priority.""" long_password = "a" * 25 mixin = ConcreteAuthMixin(("user_abc123", long_password)) @@ -163,9 +167,10 @@ def test_environment_takes_priority_over_generated(self): } with patch.dict(os.environ, env, clear=True): result = mixin.get_basic_auth_credentials(include_source=True) + assert len(result) == 3 assert result[2] == "environment" - def test_include_source_false_explicit(self): + def test_include_source_false_explicit(self) -> None: """Explicitly passing include_source=False returns 2-tuple.""" mixin = ConcreteAuthMixin(("u", "p")) result = mixin.get_basic_auth_credentials(include_source=False) @@ -180,66 +185,66 @@ def test_include_source_false_explicit(self): class TestCheckBasicAuth: """Tests for _check_basic_auth with FastAPI request objects.""" - def _make_request(self, auth_header=None): + def _make_request(self, auth_header: str | None = None) -> Mock: """Create a mock FastAPI request with optional Authorization header.""" request = Mock() - headers = {} + headers: dict[str, str] = {} if auth_header is not None: headers["Authorization"] = auth_header request.headers = Mock() request.headers.get = Mock(side_effect=lambda key, default=None: headers.get(key, default)) return request - def test_valid_credentials(self): + def test_valid_credentials(self) -> None: """Valid Basic auth header returns True.""" mixin = ConcreteAuthMixin(("admin", "secret")) header = _make_basic_auth_header("admin", "secret") request = self._make_request(header) assert mixin._check_basic_auth(request) is True - def test_invalid_credentials(self): + def test_invalid_credentials(self) -> None: """Invalid Basic auth header returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) header = _make_basic_auth_header("admin", "wrong") request = self._make_request(header) assert mixin._check_basic_auth(request) is False - def test_missing_auth_header(self): + def test_missing_auth_header(self) -> None: """Missing Authorization header returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) request = self._make_request(None) assert mixin._check_basic_auth(request) is False - def test_non_basic_scheme(self): + def test_non_basic_scheme(self) -> None: """Authorization header with non-Basic scheme returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) request = self._make_request("Bearer sometoken123") assert mixin._check_basic_auth(request) is False - def test_malformed_base64(self): + def test_malformed_base64(self) -> None: """Malformed base64 returns False instead of raising.""" mixin = ConcreteAuthMixin(("admin", "secret")) request = self._make_request("Basic !!!not-base64!!!") assert mixin._check_basic_auth(request) is False - def test_base64_without_colon(self): + def test_base64_without_colon(self) -> None: """Base64 content without colon separator returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) no_colon = base64.b64encode(b"nocolonhere").decode("utf-8") request = self._make_request(f"Basic {no_colon}") assert mixin._check_basic_auth(request) is False - def test_password_with_colon(self): + def test_password_with_colon(self) -> None: """Password containing colons is handled correctly (split on first colon only).""" mixin = ConcreteAuthMixin(("user", "pass:with:colons")) header = _make_basic_auth_header("user", "pass:with:colons") request = self._make_request(header) assert mixin._check_basic_auth(request) is True - def test_delegates_to_validate_basic_auth(self): + def test_delegates_to_validate_basic_auth(self) -> None: """_check_basic_auth delegates validation to validate_basic_auth.""" mixin = ConcreteAuthMixin(("admin", "secret")) - mixin.validate_basic_auth = Mock(return_value=True) + mixin.validate_basic_auth = Mock(return_value=True) # type: ignore[method-assign] # mock header = _make_basic_auth_header("admin", "secret") request = self._make_request(header) @@ -248,7 +253,7 @@ def test_delegates_to_validate_basic_auth(self): assert result is True mixin.validate_basic_auth.assert_called_once_with("admin", "secret") - def test_empty_auth_header(self): + def test_empty_auth_header(self) -> None: """Empty Authorization header string returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) request = self._make_request("") @@ -262,7 +267,7 @@ def test_empty_auth_header(self): class TestCheckCgiAuth: """Tests for _check_cgi_auth method.""" - def test_valid_http_authorization(self): + def test_valid_http_authorization(self) -> None: """Valid HTTP_AUTHORIZATION env var returns True.""" mixin = ConcreteAuthMixin(("admin", "secret")) auth_header = _make_basic_auth_header("admin", "secret") @@ -270,7 +275,7 @@ def test_valid_http_authorization(self): with patch.dict(os.environ, env, clear=True): assert mixin._check_cgi_auth() is True - def test_invalid_http_authorization(self): + def test_invalid_http_authorization(self) -> None: """Invalid HTTP_AUTHORIZATION credentials return False.""" mixin = ConcreteAuthMixin(("admin", "secret")) auth_header = _make_basic_auth_header("admin", "wrong") @@ -278,7 +283,7 @@ def test_invalid_http_authorization(self): with patch.dict(os.environ, env, clear=True): assert mixin._check_cgi_auth() is False - def test_remote_user_trusted(self): + def test_remote_user_trusted(self) -> None: """REMOTE_USER without HTTP_AUTHORIZATION returns True only with SWML_TRUST_REMOTE_USER.""" mixin = ConcreteAuthMixin(("admin", "secret")) # Without SWML_TRUST_REMOTE_USER, REMOTE_USER is not trusted @@ -290,30 +295,30 @@ def test_remote_user_trusted(self): with patch.dict(os.environ, env, clear=True): assert mixin._check_cgi_auth() is True - def test_no_auth_env_vars(self): + def test_no_auth_env_vars(self) -> None: """No auth env vars returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) with patch.dict(os.environ, {}, clear=True): assert mixin._check_cgi_auth() is False - def test_non_basic_scheme_in_env(self): + def test_non_basic_scheme_in_env(self) -> None: """Non-Basic scheme in HTTP_AUTHORIZATION returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) env = {"HTTP_AUTHORIZATION": "Bearer token123"} with patch.dict(os.environ, env, clear=True): assert mixin._check_cgi_auth() is False - def test_malformed_base64_in_env(self): + def test_malformed_base64_in_env(self) -> None: """Malformed base64 in HTTP_AUTHORIZATION returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) env = {"HTTP_AUTHORIZATION": "Basic !!!invalid!!!"} with patch.dict(os.environ, env, clear=True): assert mixin._check_cgi_auth() is False - def test_delegates_to_validate_basic_auth(self): + def test_delegates_to_validate_basic_auth(self) -> None: """_check_cgi_auth delegates to validate_basic_auth.""" mixin = ConcreteAuthMixin(("admin", "secret")) - mixin.validate_basic_auth = Mock(return_value=True) + mixin.validate_basic_auth = Mock(return_value=True) # type: ignore[method-assign] # mock auth_header = _make_basic_auth_header("admin", "secret") env = {"HTTP_AUTHORIZATION": auth_header} with patch.dict(os.environ, env, clear=True): @@ -321,7 +326,7 @@ def test_delegates_to_validate_basic_auth(self): assert result is True mixin.validate_basic_auth.assert_called_once_with("admin", "secret") - def test_http_authorization_takes_precedence_over_remote_user(self): + def test_http_authorization_takes_precedence_over_remote_user(self) -> None: """When both HTTP_AUTHORIZATION and REMOTE_USER are set, auth header is used.""" mixin = ConcreteAuthMixin(("admin", "secret")) auth_header = _make_basic_auth_header("admin", "wrong") @@ -338,31 +343,31 @@ def test_http_authorization_takes_precedence_over_remote_user(self): class TestSendCgiAuthChallenge: """Tests for _send_cgi_auth_challenge method.""" - def test_returns_string_response(self): + def test_returns_string_response(self) -> None: """Challenge response is a string.""" mixin = ConcreteAuthMixin() result = mixin._send_cgi_auth_challenge() assert isinstance(result, str) - def test_contains_401_status(self): + def test_contains_401_status(self) -> None: """Response contains 401 Unauthorized status line.""" mixin = ConcreteAuthMixin() result = mixin._send_cgi_auth_challenge() assert "401 Unauthorized" in result - def test_contains_www_authenticate_header(self): + def test_contains_www_authenticate_header(self) -> None: """Response contains WWW-Authenticate header.""" mixin = ConcreteAuthMixin() result = mixin._send_cgi_auth_challenge() assert "WWW-Authenticate: Basic" in result - def test_contains_json_content_type(self): + def test_contains_json_content_type(self) -> None: """Response contains JSON content type.""" mixin = ConcreteAuthMixin() result = mixin._send_cgi_auth_challenge() assert "Content-Type: application/json" in result - def test_contains_error_body(self): + def test_contains_error_body(self) -> None: """Response body contains error JSON.""" mixin = ConcreteAuthMixin() result = mixin._send_cgi_auth_challenge() @@ -372,7 +377,7 @@ def test_contains_error_body(self): parsed = json.loads(body) assert parsed == {"error": "Unauthorized"} - def test_uses_crlf_line_endings(self): + def test_uses_crlf_line_endings(self) -> None: """Response uses CRLF line endings for HTTP compliance.""" mixin = ConcreteAuthMixin() result = mixin._send_cgi_auth_challenge() @@ -386,71 +391,71 @@ def test_uses_crlf_line_endings(self): class TestCheckLambdaAuth: """Tests for _check_lambda_auth method.""" - def test_valid_credentials(self): + def test_valid_credentials(self) -> None: """Valid auth header in event returns True.""" mixin = ConcreteAuthMixin(("admin", "secret")) header = _make_basic_auth_header("admin", "secret") event = {"headers": {"Authorization": header}} assert mixin._check_lambda_auth(event) is True - def test_invalid_credentials(self): + def test_invalid_credentials(self) -> None: """Invalid credentials in event returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) header = _make_basic_auth_header("admin", "wrong") event = {"headers": {"Authorization": header}} assert mixin._check_lambda_auth(event) is False - def test_none_event(self): + def test_none_event(self) -> None: """None event returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) assert mixin._check_lambda_auth(None) is False - def test_empty_event(self): + def test_empty_event(self) -> None: """Empty event dict returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) assert mixin._check_lambda_auth({}) is False - def test_no_headers_key(self): + def test_no_headers_key(self) -> None: """Event without 'headers' key returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) assert mixin._check_lambda_auth({"body": "data"}) is False - def test_case_insensitive_header(self): + def test_case_insensitive_header(self) -> None: """Authorization header lookup is case-insensitive.""" mixin = ConcreteAuthMixin(("admin", "secret")) header = _make_basic_auth_header("admin", "secret") event = {"headers": {"authorization": header}} assert mixin._check_lambda_auth(event) is True - def test_mixed_case_header(self): + def test_mixed_case_header(self) -> None: """Mixed case 'AUTHORIZATION' header works.""" mixin = ConcreteAuthMixin(("admin", "secret")) header = _make_basic_auth_header("admin", "secret") event = {"headers": {"AUTHORIZATION": header}} assert mixin._check_lambda_auth(event) is True - def test_missing_auth_in_headers(self): + def test_missing_auth_in_headers(self) -> None: """Headers dict without authorization key returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) event = {"headers": {"Content-Type": "application/json"}} assert mixin._check_lambda_auth(event) is False - def test_non_basic_scheme(self): + def test_non_basic_scheme(self) -> None: """Non-Basic scheme returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) event = {"headers": {"Authorization": "Bearer token123"}} assert mixin._check_lambda_auth(event) is False - def test_malformed_base64(self): + def test_malformed_base64(self) -> None: """Malformed base64 returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) event = {"headers": {"Authorization": "Basic !!!bad!!!"}} assert mixin._check_lambda_auth(event) is False - def test_delegates_to_validate_basic_auth(self): + def test_delegates_to_validate_basic_auth(self) -> None: """_check_lambda_auth delegates to validate_basic_auth.""" mixin = ConcreteAuthMixin(("admin", "secret")) - mixin.validate_basic_auth = Mock(return_value=True) + mixin.validate_basic_auth = Mock(return_value=True) # type: ignore[method-assign] # mock header = _make_basic_auth_header("admin", "secret") event = {"headers": {"Authorization": header}} @@ -467,32 +472,32 @@ def test_delegates_to_validate_basic_auth(self): class TestSendLambdaAuthChallenge: """Tests for _send_lambda_auth_challenge method.""" - def test_returns_dict(self): + def test_returns_dict(self) -> None: """Challenge returns a dictionary.""" mixin = ConcreteAuthMixin() result = mixin._send_lambda_auth_challenge() assert isinstance(result, dict) - def test_status_code_401(self): + def test_status_code_401(self) -> None: """Response has statusCode 401.""" mixin = ConcreteAuthMixin() result = mixin._send_lambda_auth_challenge() assert result["statusCode"] == 401 - def test_www_authenticate_header(self): + def test_www_authenticate_header(self) -> None: """Response includes WWW-Authenticate header.""" mixin = ConcreteAuthMixin() result = mixin._send_lambda_auth_challenge() assert "WWW-Authenticate" in result["headers"] assert "Basic" in result["headers"]["WWW-Authenticate"] - def test_content_type_json(self): + def test_content_type_json(self) -> None: """Response includes JSON content type header.""" mixin = ConcreteAuthMixin() result = mixin._send_lambda_auth_challenge() assert result["headers"]["Content-Type"] == "application/json" - def test_body_is_json_error(self): + def test_body_is_json_error(self) -> None: """Response body is JSON-encoded error.""" mixin = ConcreteAuthMixin() result = mixin._send_lambda_auth_challenge() @@ -507,7 +512,7 @@ def test_body_is_json_error(self): class TestCheckGoogleCloudFunctionAuth: """Tests for _check_google_cloud_function_auth method.""" - def _make_flask_request(self, auth_header=None): + def _make_flask_request(self, auth_header: str | None = None) -> Mock: """Create a mock Flask-like request.""" request = Mock() headers = Mock() @@ -518,48 +523,48 @@ def _make_flask_request(self, auth_header=None): request.headers = headers return request - def test_valid_credentials(self): + def test_valid_credentials(self) -> None: """Valid credentials return True.""" mixin = ConcreteAuthMixin(("admin", "secret")) header = _make_basic_auth_header("admin", "secret") request = self._make_flask_request(header) assert mixin._check_google_cloud_function_auth(request) is True - def test_invalid_credentials(self): + def test_invalid_credentials(self) -> None: """Invalid credentials return False.""" mixin = ConcreteAuthMixin(("admin", "secret")) header = _make_basic_auth_header("admin", "wrong") request = self._make_flask_request(header) assert mixin._check_google_cloud_function_auth(request) is False - def test_no_headers_attribute(self): + def test_no_headers_attribute(self) -> None: """Object without headers attribute returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) request = object() # No headers attribute assert mixin._check_google_cloud_function_auth(request) is False - def test_missing_auth_header(self): + def test_missing_auth_header(self) -> None: """Missing Authorization header returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) request = self._make_flask_request(None) assert mixin._check_google_cloud_function_auth(request) is False - def test_non_basic_scheme(self): + def test_non_basic_scheme(self) -> None: """Non-Basic scheme returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) request = self._make_flask_request("Bearer token123") assert mixin._check_google_cloud_function_auth(request) is False - def test_malformed_base64(self): + def test_malformed_base64(self) -> None: """Malformed base64 returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) request = self._make_flask_request("Basic !!!bad!!!") assert mixin._check_google_cloud_function_auth(request) is False - def test_delegates_to_validate_basic_auth(self): + def test_delegates_to_validate_basic_auth(self) -> None: """Delegates validation to validate_basic_auth.""" mixin = ConcreteAuthMixin(("admin", "secret")) - mixin.validate_basic_auth = Mock(return_value=True) + mixin.validate_basic_auth = Mock(return_value=True) # type: ignore[method-assign] # mock header = _make_basic_auth_header("admin", "secret") request = self._make_flask_request(header) @@ -568,7 +573,7 @@ def test_delegates_to_validate_basic_auth(self): assert result is True mixin.validate_basic_auth.assert_called_once_with("admin", "secret") - def test_password_with_colon(self): + def test_password_with_colon(self) -> None: """Password containing colons is handled correctly.""" mixin = ConcreteAuthMixin(("user", "pass:with:colons")) header = _make_basic_auth_header("user", "pass:with:colons") @@ -584,7 +589,7 @@ class TestSendGoogleCloudFunctionAuthChallenge: """Tests for _send_google_cloud_function_auth_challenge method.""" @patch("signalwire.core.mixins.auth_mixin.AuthMixin._send_google_cloud_function_auth_challenge") - def test_returns_response_object(self, mock_challenge): + def test_returns_response_object(self, mock_challenge: Mock) -> None: """Challenge returns a Flask Response-like object.""" mock_response = Mock() mock_response.status_code = 401 @@ -599,7 +604,7 @@ def test_returns_response_object(self, mock_challenge): assert result.status_code == 401 assert "WWW-Authenticate" in result.headers - def test_challenge_calls_flask_response(self): + def test_challenge_calls_flask_response(self) -> None: """The method constructs a Flask Response with correct parameters.""" mock_response_cls = Mock() mock_response_instance = Mock() @@ -624,7 +629,7 @@ def test_challenge_calls_flask_response(self): class TestCheckAzureFunctionAuth: """Tests for _check_azure_function_auth method.""" - def _make_azure_request(self, auth_header=None): + def _make_azure_request(self, auth_header: str | None = None) -> Mock: """Create a mock Azure Functions request.""" req = Mock() headers = Mock() @@ -635,48 +640,48 @@ def _make_azure_request(self, auth_header=None): req.headers = headers return req - def test_valid_credentials(self): + def test_valid_credentials(self) -> None: """Valid credentials return True.""" mixin = ConcreteAuthMixin(("admin", "secret")) header = _make_basic_auth_header("admin", "secret") req = self._make_azure_request(header) assert mixin._check_azure_function_auth(req) is True - def test_invalid_credentials(self): + def test_invalid_credentials(self) -> None: """Invalid credentials return False.""" mixin = ConcreteAuthMixin(("admin", "secret")) header = _make_basic_auth_header("admin", "wrong") req = self._make_azure_request(header) assert mixin._check_azure_function_auth(req) is False - def test_no_headers_attribute(self): + def test_no_headers_attribute(self) -> None: """Object without headers attribute returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) req = object() # No headers attribute assert mixin._check_azure_function_auth(req) is False - def test_missing_auth_header(self): + def test_missing_auth_header(self) -> None: """Missing Authorization header returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) req = self._make_azure_request(None) assert mixin._check_azure_function_auth(req) is False - def test_non_basic_scheme(self): + def test_non_basic_scheme(self) -> None: """Non-Basic scheme returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) req = self._make_azure_request("Bearer token123") assert mixin._check_azure_function_auth(req) is False - def test_malformed_base64(self): + def test_malformed_base64(self) -> None: """Malformed base64 returns False.""" mixin = ConcreteAuthMixin(("admin", "secret")) req = self._make_azure_request("Basic !!!bad!!!") assert mixin._check_azure_function_auth(req) is False - def test_delegates_to_validate_basic_auth(self): + def test_delegates_to_validate_basic_auth(self) -> None: """Delegates validation to validate_basic_auth.""" mixin = ConcreteAuthMixin(("admin", "secret")) - mixin.validate_basic_auth = Mock(return_value=True) + mixin.validate_basic_auth = Mock(return_value=True) # type: ignore[method-assign] # mock header = _make_basic_auth_header("admin", "secret") req = self._make_azure_request(header) @@ -685,7 +690,7 @@ def test_delegates_to_validate_basic_auth(self): assert result is True mixin.validate_basic_auth.assert_called_once_with("admin", "secret") - def test_password_with_colon(self): + def test_password_with_colon(self) -> None: """Password containing colons is handled correctly.""" mixin = ConcreteAuthMixin(("user", "pass:with:colons")) header = _make_basic_auth_header("user", "pass:with:colons") @@ -700,7 +705,7 @@ def test_password_with_colon(self): class TestSendAzureFunctionAuthChallenge: """Tests for _send_azure_function_auth_challenge method.""" - def test_challenge_calls_azure_http_response(self): + def test_challenge_calls_azure_http_response(self) -> None: """The method constructs an Azure HttpResponse with correct parameters.""" mock_http_response_cls = Mock() mock_http_response_instance = Mock() @@ -748,10 +753,10 @@ def test_challenge_calls_azure_http_response(self): class TestValidateBasicAuthDelegationIntegration: """Verify that all auth check methods ultimately delegate to validate_basic_auth.""" - def test_check_basic_auth_uses_validate(self): + def test_check_basic_auth_uses_validate(self) -> None: """_check_basic_auth calls validate_basic_auth with decoded creds.""" mixin = ConcreteAuthMixin(("u", "p")) - mixin.validate_basic_auth = Mock(return_value=False) + mixin.validate_basic_auth = Mock(return_value=False) # type: ignore[method-assign] # mock header = _make_basic_auth_header("u", "p") request = Mock() request.headers = Mock() @@ -760,29 +765,29 @@ def test_check_basic_auth_uses_validate(self): mixin._check_basic_auth(request) mixin.validate_basic_auth.assert_called_once_with("u", "p") - def test_check_cgi_auth_uses_validate(self): + def test_check_cgi_auth_uses_validate(self) -> None: """_check_cgi_auth calls validate_basic_auth with decoded creds.""" mixin = ConcreteAuthMixin(("u", "p")) - mixin.validate_basic_auth = Mock(return_value=False) + mixin.validate_basic_auth = Mock(return_value=False) # type: ignore[method-assign] # mock header = _make_basic_auth_header("u", "p") with patch.dict(os.environ, {"HTTP_AUTHORIZATION": header}, clear=True): mixin._check_cgi_auth() mixin.validate_basic_auth.assert_called_once_with("u", "p") - def test_check_lambda_auth_uses_validate(self): + def test_check_lambda_auth_uses_validate(self) -> None: """_check_lambda_auth calls validate_basic_auth with decoded creds.""" mixin = ConcreteAuthMixin(("u", "p")) - mixin.validate_basic_auth = Mock(return_value=False) + mixin.validate_basic_auth = Mock(return_value=False) # type: ignore[method-assign] # mock header = _make_basic_auth_header("u", "p") event = {"headers": {"Authorization": header}} mixin._check_lambda_auth(event) mixin.validate_basic_auth.assert_called_once_with("u", "p") - def test_check_google_cloud_function_auth_uses_validate(self): + def test_check_google_cloud_function_auth_uses_validate(self) -> None: """_check_google_cloud_function_auth calls validate_basic_auth.""" mixin = ConcreteAuthMixin(("u", "p")) - mixin.validate_basic_auth = Mock(return_value=False) + mixin.validate_basic_auth = Mock(return_value=False) # type: ignore[method-assign] # mock header = _make_basic_auth_header("u", "p") request = Mock() request.headers = Mock() @@ -791,10 +796,10 @@ def test_check_google_cloud_function_auth_uses_validate(self): mixin._check_google_cloud_function_auth(request) mixin.validate_basic_auth.assert_called_once_with("u", "p") - def test_check_azure_function_auth_uses_validate(self): + def test_check_azure_function_auth_uses_validate(self) -> None: """_check_azure_function_auth calls validate_basic_auth.""" mixin = ConcreteAuthMixin(("u", "p")) - mixin.validate_basic_auth = Mock(return_value=False) + mixin.validate_basic_auth = Mock(return_value=False) # type: ignore[method-assign] # mock header = _make_basic_auth_header("u", "p") req = Mock() req.headers = Mock() @@ -803,14 +808,14 @@ def test_check_azure_function_auth_uses_validate(self): mixin._check_azure_function_auth(req) mixin.validate_basic_auth.assert_called_once_with("u", "p") - def test_overridden_validate_affects_all_checks(self): + def test_overridden_validate_affects_all_checks(self) -> None: """Overriding validate_basic_auth changes behavior of all check methods.""" class AlwaysAccept(AuthMixin): - def __init__(self): + def __init__(self) -> None: self._basic_auth = ("admin", "secret") - def validate_basic_auth(self, username, password): + def validate_basic_auth(self, username: str, password: str) -> bool: return True # Accept anything mixin = AlwaysAccept() @@ -850,30 +855,34 @@ def validate_basic_auth(self, username, password): class TestSecurityConfigIntegration: """Test AuthMixin behavior when _basic_auth is set from SecurityConfig.get_basic_auth.""" - def test_credentials_from_security_config_provided(self): + def test_credentials_from_security_config_provided(self) -> None: """When SecurityConfig provides explicit user/pass, validate_basic_auth works.""" mixin = ConcreteAuthMixin(("configuser", "configpass")) assert mixin.validate_basic_auth("configuser", "configpass") is True assert mixin.validate_basic_auth("other", "other") is False - def test_credentials_from_security_config_generated(self): + def test_credentials_from_security_config_generated(self) -> None: """When SecurityConfig generates a long password, get_basic_auth_credentials detects it.""" import secrets generated_pass = secrets.token_urlsafe(32) mixin = ConcreteAuthMixin(("user_abc123", generated_pass)) with patch.dict(os.environ, {}, clear=True): - _, _, source = mixin.get_basic_auth_credentials(include_source=True) + creds = mixin.get_basic_auth_credentials(include_source=True) + assert len(creds) == 3 + _, _, source = creds assert source == "generated" - def test_credentials_from_env_via_security_config(self): + def test_credentials_from_env_via_security_config(self) -> None: """When SecurityConfig loads from env, get_basic_auth_credentials detects environment source.""" mixin = ConcreteAuthMixin(("envuser", "envpass")) env = {"SWML_BASIC_AUTH_USER": "envuser", "SWML_BASIC_AUTH_PASSWORD": "envpass"} with patch.dict(os.environ, env, clear=True): - _, _, source = mixin.get_basic_auth_credentials(include_source=True) + creds = mixin.get_basic_auth_credentials(include_source=True) + assert len(creds) == 3 + _, _, source = creds assert source == "environment" - def test_end_to_end_auth_check_with_config_credentials(self): + def test_end_to_end_auth_check_with_config_credentials(self) -> None: """Full flow: credentials set, request made, auth succeeds.""" mixin = ConcreteAuthMixin(("myagent", "s3cret!")) header = _make_basic_auth_header("myagent", "s3cret!") @@ -882,7 +891,7 @@ def test_end_to_end_auth_check_with_config_credentials(self): request.headers.get = Mock(return_value=header) assert mixin._check_basic_auth(request) is True - def test_end_to_end_auth_check_rejects_wrong_creds(self): + def test_end_to_end_auth_check_rejects_wrong_creds(self) -> None: """Full flow: credentials set, wrong request made, auth fails.""" mixin = ConcreteAuthMixin(("myagent", "s3cret!")) header = _make_basic_auth_header("myagent", "wrongpass") diff --git a/tests/unit/core/mixins/test_prompt_mixin.py b/tests/unit/core/mixins/test_prompt_mixin.py index 00def574..44318a65 100644 --- a/tests/unit/core/mixins/test_prompt_mixin.py +++ b/tests/unit/core/mixins/test_prompt_mixin.py @@ -13,7 +13,7 @@ import pytest from unittest.mock import Mock, patch, MagicMock, PropertyMock -from typing import Dict, List, Any, Optional +from typing import Any, Iterator, Optional from signalwire.core.mixins.prompt_mixin import PromptMixin @@ -26,13 +26,13 @@ class MockPromptHost(PromptMixin): def __init__( self, - use_pom=True, - pom=None, - name="TestAgent", - prompt_manager=None, - contexts_builder=None, - contexts_defined=False, - ): + use_pom: bool = True, + pom: Any = None, + name: str = "TestAgent", + prompt_manager: Any = None, + contexts_builder: Any = None, + contexts_defined: bool = False, + ) -> None: self._use_pom = use_pom self.pom = pom self.name = name @@ -47,7 +47,7 @@ def __init__( # --------------------------------------------------------------------------- @pytest.fixture -def mock_prompt_manager(): +def mock_prompt_manager() -> Mock: """Return a fresh Mock standing in for PromptManager.""" pm = Mock() pm.get_prompt.return_value = None @@ -56,7 +56,7 @@ def mock_prompt_manager(): @pytest.fixture -def host(mock_prompt_manager): +def host(mock_prompt_manager: Mock) -> MockPromptHost: """Return a MockPromptHost wired with a mock prompt manager.""" return MockPromptHost(prompt_manager=mock_prompt_manager) @@ -68,20 +68,20 @@ def host(mock_prompt_manager): class TestSetPromptText: """Tests for PromptMixin.set_prompt_text""" - def test_delegates_to_prompt_manager(self, host): + def test_delegates_to_prompt_manager(self, host: MockPromptHost) -> None: result = host.set_prompt_text("Hello world") host._prompt_manager.set_prompt_text.assert_called_once_with("Hello world") - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: result = host.set_prompt_text("prompt") assert result is host - def test_empty_string_is_accepted(self, host): + def test_empty_string_is_accepted(self, host: MockPromptHost) -> None: result = host.set_prompt_text("") host._prompt_manager.set_prompt_text.assert_called_once_with("") assert result is host - def test_long_prompt_text(self, host): + def test_long_prompt_text(self, host: MockPromptHost) -> None: long_text = "x" * 10000 result = host.set_prompt_text(long_text) host._prompt_manager.set_prompt_text.assert_called_once_with(long_text) @@ -95,15 +95,15 @@ def test_long_prompt_text(self, host): class TestSetPostPrompt: """Tests for PromptMixin.set_post_prompt""" - def test_delegates_to_prompt_manager(self, host): + def test_delegates_to_prompt_manager(self, host: MockPromptHost) -> None: result = host.set_post_prompt("Summarize the conversation") host._prompt_manager.set_post_prompt.assert_called_once_with("Summarize the conversation") - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: result = host.set_post_prompt("summary") assert result is host - def test_empty_string(self, host): + def test_empty_string(self, host: MockPromptHost) -> None: result = host.set_post_prompt("") host._prompt_manager.set_post_prompt.assert_called_once_with("") assert result is host @@ -116,22 +116,22 @@ def test_empty_string(self, host): class TestSetPromptPom: """Tests for PromptMixin.set_prompt_pom""" - def test_delegates_to_prompt_manager(self, host): + def test_delegates_to_prompt_manager(self, host: MockPromptHost) -> None: pom_data = [{"title": "Section A", "body": "Body A"}] result = host.set_prompt_pom(pom_data) host._prompt_manager.set_prompt_pom.assert_called_once_with(pom_data) - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: result = host.set_prompt_pom([]) assert result is host - def test_empty_list(self, host): + def test_empty_list(self, host: MockPromptHost) -> None: result = host.set_prompt_pom([]) host._prompt_manager.set_prompt_pom.assert_called_once_with([]) assert result is host - def test_complex_pom_structure(self, host): - pom_data = [ + def test_complex_pom_structure(self, host: MockPromptHost) -> None: + pom_data: list[dict[str, Any]] = [ {"title": "Section A", "body": "Body A", "bullets": ["b1", "b2"]}, {"title": "Section B", "body": "Body B", "subsections": [ {"title": "Sub B1", "body": "Sub body"} @@ -149,7 +149,7 @@ def test_complex_pom_structure(self, host): class TestPromptAddSection: """Tests for PromptMixin.prompt_add_section""" - def test_delegates_basic_section(self, host): + def test_delegates_basic_section(self, host: MockPromptHost) -> None: result = host.prompt_add_section("Intro", body="Welcome") host._prompt_manager.prompt_add_section.assert_called_once_with( title="Intro", @@ -160,11 +160,11 @@ def test_delegates_basic_section(self, host): subsections=None, ) - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: result = host.prompt_add_section("Title") assert result is host - def test_with_bullets(self, host): + def test_with_bullets(self, host: MockPromptHost) -> None: bullets = ["Point 1", "Point 2"] host.prompt_add_section("Rules", bullets=bullets) host._prompt_manager.prompt_add_section.assert_called_once_with( @@ -176,7 +176,7 @@ def test_with_bullets(self, host): subsections=None, ) - def test_with_numbered_flags(self, host): + def test_with_numbered_flags(self, host: MockPromptHost) -> None: host.prompt_add_section("Steps", numbered=True, numbered_bullets=True) host._prompt_manager.prompt_add_section.assert_called_once_with( title="Steps", @@ -187,7 +187,7 @@ def test_with_numbered_flags(self, host): subsections=None, ) - def test_with_subsections(self, host): + def test_with_subsections(self, host: MockPromptHost) -> None: subs = [{"title": "Sub1", "body": "sub body"}] host.prompt_add_section("Main", subsections=subs) host._prompt_manager.prompt_add_section.assert_called_once_with( @@ -199,7 +199,7 @@ def test_with_subsections(self, host): subsections=subs, ) - def test_all_parameters(self, host): + def test_all_parameters(self, host: MockPromptHost) -> None: bullets = ["a", "b"] subs = [{"title": "Sub", "body": "sb"}] host.prompt_add_section( @@ -227,7 +227,7 @@ def test_all_parameters(self, host): class TestPromptAddToSection: """Tests for PromptMixin.prompt_add_to_section""" - def test_add_body(self, host): + def test_add_body(self, host: MockPromptHost) -> None: result = host.prompt_add_to_section("Intro", body="More text") host._prompt_manager.prompt_add_to_section.assert_called_once_with( title="Intro", @@ -236,7 +236,7 @@ def test_add_body(self, host): bullets=None, ) - def test_add_single_bullet(self, host): + def test_add_single_bullet(self, host: MockPromptHost) -> None: host.prompt_add_to_section("Rules", bullet="New rule") host._prompt_manager.prompt_add_to_section.assert_called_once_with( title="Rules", @@ -245,7 +245,7 @@ def test_add_single_bullet(self, host): bullets=None, ) - def test_add_multiple_bullets(self, host): + def test_add_multiple_bullets(self, host: MockPromptHost) -> None: bullets = ["rule1", "rule2"] host.prompt_add_to_section("Rules", bullets=bullets) host._prompt_manager.prompt_add_to_section.assert_called_once_with( @@ -255,7 +255,7 @@ def test_add_multiple_bullets(self, host): bullets=bullets, ) - def test_add_body_and_bullets(self, host): + def test_add_body_and_bullets(self, host: MockPromptHost) -> None: host.prompt_add_to_section("Mixed", body="intro", bullet="b1", bullets=["b2"]) host._prompt_manager.prompt_add_to_section.assert_called_once_with( title="Mixed", @@ -264,7 +264,7 @@ def test_add_body_and_bullets(self, host): bullets=["b2"], ) - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: result = host.prompt_add_to_section("S") assert result is host @@ -276,7 +276,7 @@ def test_returns_self_for_chaining(self, host): class TestPromptAddSubsection: """Tests for PromptMixin.prompt_add_subsection""" - def test_basic_subsection(self, host): + def test_basic_subsection(self, host: MockPromptHost) -> None: result = host.prompt_add_subsection("Parent", "Child", body="child body") host._prompt_manager.prompt_add_subsection.assert_called_once_with( parent_title="Parent", @@ -285,7 +285,7 @@ def test_basic_subsection(self, host): bullets=None, ) - def test_subsection_with_bullets(self, host): + def test_subsection_with_bullets(self, host: MockPromptHost) -> None: bullets = ["x", "y"] host.prompt_add_subsection("P", "C", bullets=bullets) host._prompt_manager.prompt_add_subsection.assert_called_once_with( @@ -295,7 +295,7 @@ def test_subsection_with_bullets(self, host): bullets=bullets, ) - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockPromptHost) -> None: result = host.prompt_add_subsection("P", "C") assert result is host @@ -307,16 +307,16 @@ def test_returns_self_for_chaining(self, host): class TestPromptHasSection: """Tests for PromptMixin.prompt_has_section""" - def test_section_exists(self, host): + def test_section_exists(self, host: MockPromptHost) -> None: host._prompt_manager.prompt_has_section.return_value = True assert host.prompt_has_section("Intro") is True host._prompt_manager.prompt_has_section.assert_called_once_with("Intro") - def test_section_does_not_exist(self, host): + def test_section_does_not_exist(self, host: MockPromptHost) -> None: host._prompt_manager.prompt_has_section.return_value = False assert host.prompt_has_section("NonExistent") is False - def test_empty_title(self, host): + def test_empty_title(self, host: MockPromptHost) -> None: host._prompt_manager.prompt_has_section.return_value = False assert host.prompt_has_section("") is False @@ -328,11 +328,11 @@ def test_empty_title(self, host): class TestGetPrompt: """Tests for PromptMixin.get_prompt""" - def test_returns_prompt_manager_result_when_available(self, host): + def test_returns_prompt_manager_result_when_available(self, host: MockPromptHost) -> None: host._prompt_manager.get_prompt.return_value = "Manager prompt" assert host.get_prompt() == "Manager prompt" - def test_returns_pom_render_dict_when_available(self, host): + def test_returns_pom_render_dict_when_available(self, host: MockPromptHost) -> None: host._prompt_manager.get_prompt.return_value = None mock_pom = Mock() mock_pom.render_dict.return_value = [{"title": "S", "body": "B"}] @@ -343,7 +343,7 @@ def test_returns_pom_render_dict_when_available(self, host): assert result == [{"title": "S", "body": "B"}] mock_pom.render_dict.assert_called_once() - def test_falls_back_to_to_dict(self, host): + def test_falls_back_to_to_dict(self, host: MockPromptHost) -> None: host._prompt_manager.get_prompt.return_value = None mock_pom = Mock(spec=[]) # no render_dict mock_pom.to_dict = Mock(return_value=[{"title": "T"}]) @@ -353,7 +353,7 @@ def test_falls_back_to_to_dict(self, host): result = host.get_prompt() assert result == [{"title": "T"}] - def test_falls_back_to_to_list(self, host): + def test_falls_back_to_to_list(self, host: MockPromptHost) -> None: host._prompt_manager.get_prompt.return_value = None mock_pom = Mock(spec=[]) mock_pom.to_list = Mock(return_value=[{"title": "L"}]) @@ -363,7 +363,7 @@ def test_falls_back_to_to_list(self, host): result = host.get_prompt() assert result == [{"title": "L"}] - def test_falls_back_to_render_returning_json_string(self, host): + def test_falls_back_to_render_returning_json_string(self, host: MockPromptHost) -> None: host._prompt_manager.get_prompt.return_value = None mock_pom = Mock(spec=[]) mock_pom.render = Mock(return_value='[{"title": "R"}]') @@ -373,7 +373,7 @@ def test_falls_back_to_render_returning_json_string(self, host): result = host.get_prompt() assert result == [{"title": "R"}] - def test_render_returning_non_json_string_returns_raw(self, host): + def test_render_returning_non_json_string_returns_raw(self, host: MockPromptHost) -> None: """When render() returns a non-JSON string, the raw string is still returned. The inner try/except catches the JSON decode error and passes, but @@ -389,7 +389,7 @@ def test_render_returning_non_json_string_returns_raw(self, host): result = host.get_prompt() assert result == "not json at all {{{" - def test_render_returning_list_directly(self, host): + def test_render_returning_list_directly(self, host: MockPromptHost) -> None: host._prompt_manager.get_prompt.return_value = None mock_pom = Mock(spec=[]) mock_pom.render = Mock(return_value=[{"title": "Direct"}]) @@ -399,13 +399,13 @@ def test_render_returning_list_directly(self, host): result = host.get_prompt() assert result == [{"title": "Direct"}] - def test_falls_back_to_pom_sections_attribute(self, host): + def test_falls_back_to_pom_sections_attribute(self, host: MockPromptHost) -> None: """When no standard method exists, the code inspects pom.__dict__['_sections'].""" host._prompt_manager.get_prompt.return_value = None class BarePom: - def __init__(self): - self._sections = [{"title": "bare"}] + def __init__(self) -> None: + self._sections: list[dict[str, str]] = [{"title": "bare"}] host.pom = BarePom() host._use_pom = True @@ -413,20 +413,20 @@ def __init__(self): result = host.get_prompt() assert result == [{"title": "bare"}] - def test_default_prompt_when_pom_not_in_use(self, host): + def test_default_prompt_when_pom_not_in_use(self, host: MockPromptHost) -> None: host._prompt_manager.get_prompt.return_value = None host._use_pom = False host.name = "Acme Bot" assert host.get_prompt() == "You are Acme Bot, a helpful AI assistant." - def test_default_prompt_when_pom_is_none(self, host): + def test_default_prompt_when_pom_is_none(self, host: MockPromptHost) -> None: host._prompt_manager.get_prompt.return_value = None host._use_pom = True host.pom = None host.name = "Helper" assert host.get_prompt() == "You are Helper, a helpful AI assistant." - def test_pom_exception_falls_back_to_default(self, host): + def test_pom_exception_falls_back_to_default(self, host: MockPromptHost) -> None: """When the POM raises an exception, the mixin logs and returns default.""" host._prompt_manager.get_prompt.return_value = None mock_pom = Mock() @@ -439,12 +439,12 @@ def test_pom_exception_falls_back_to_default(self, host): assert result == "You are CrashBot, a helpful AI assistant." host.log.error.assert_called_once() - def test_pom_with_empty_sections_dict_falls_to_default(self, host): + def test_pom_with_empty_sections_dict_falls_to_default(self, host: MockPromptHost) -> None: """When __dict__['_sections'] is not a list, fall to default.""" host._prompt_manager.get_prompt.return_value = None class WeirdPom: - def __init__(self): + def __init__(self) -> None: self._sections = "not a list" host.pom = WeirdPom() @@ -454,7 +454,7 @@ def __init__(self): result = host.get_prompt() assert result == "You are Bot, a helpful AI assistant." - def test_prompt_manager_returns_list(self, host): + def test_prompt_manager_returns_list(self, host: MockPromptHost) -> None: """When prompt_manager.get_prompt returns a list, it is returned directly.""" host._prompt_manager.get_prompt.return_value = [{"title": "FromManager"}] assert host.get_prompt() == [{"title": "FromManager"}] @@ -467,11 +467,11 @@ def test_prompt_manager_returns_list(self, host): class TestGetPostPrompt: """Tests for PromptMixin.get_post_prompt""" - def test_returns_prompt_manager_result(self, host): + def test_returns_prompt_manager_result(self, host: MockPromptHost) -> None: host._prompt_manager.get_post_prompt.return_value = "Post text" assert host.get_post_prompt() == "Post text" - def test_returns_none_when_not_set(self, host): + def test_returns_none_when_not_set(self, host: MockPromptHost) -> None: host._prompt_manager.get_post_prompt.return_value = None assert host.get_post_prompt() is None @@ -483,11 +483,11 @@ def test_returns_none_when_not_set(self, host): class TestValidatePromptModeExclusivity: """Tests for PromptMixin._validate_prompt_mode_exclusivity""" - def test_delegates_to_prompt_manager(self, host): + def test_delegates_to_prompt_manager(self, host: MockPromptHost) -> None: host._validate_prompt_mode_exclusivity() host._prompt_manager._validate_prompt_mode_exclusivity.assert_called_once() - def test_propagates_value_error(self, host): + def test_propagates_value_error(self, host: MockPromptHost) -> None: host._prompt_manager._validate_prompt_mode_exclusivity.side_effect = ValueError("conflict") with pytest.raises(ValueError, match="conflict"): host._validate_prompt_mode_exclusivity() @@ -500,13 +500,13 @@ def test_propagates_value_error(self, host): class TestDefineContextsWithArg: """Tests for PromptMixin.define_contexts when called with contexts arg""" - def test_sets_contexts_and_returns_self(self, host): - ctx = {"main": {"steps": []}} + def test_sets_contexts_and_returns_self(self, host: MockPromptHost) -> None: + ctx: dict[str, Any] = {"main": {"steps": []}} result = host.define_contexts(contexts=ctx) host._prompt_manager.define_contexts.assert_called_once_with(ctx) assert result is host - def test_with_dict_contexts(self, host): + def test_with_dict_contexts(self, host: MockPromptHost) -> None: ctx = {"ctx1": {"steps": [{"name": "s1"}]}} result = host.define_contexts(contexts=ctx) assert result is host @@ -521,7 +521,7 @@ class TestDefineContextsWithoutArg: """Tests for PromptMixin.define_contexts when called without contexts arg""" @patch("signalwire.core.mixins.prompt_mixin.ContextBuilder") - def test_creates_context_builder_on_first_call(self, MockCB, host): + def test_creates_context_builder_on_first_call(self, MockCB: Mock, host: MockPromptHost) -> None: host._contexts_builder = None mock_cb_instance = MockCB.return_value @@ -532,7 +532,7 @@ def test_creates_context_builder_on_first_call(self, MockCB, host): assert host._contexts_defined is True @patch("signalwire.core.mixins.prompt_mixin.ContextBuilder") - def test_returns_existing_builder_on_subsequent_calls(self, MockCB, host): + def test_returns_existing_builder_on_subsequent_calls(self, MockCB: Mock, host: MockPromptHost) -> None: existing_builder = Mock() host._contexts_builder = existing_builder @@ -550,14 +550,14 @@ class TestContextsProperty: """Tests for PromptMixin.contexts property""" @patch("signalwire.core.mixins.prompt_mixin.ContextBuilder") - def test_returns_context_builder(self, MockCB, host): + def test_returns_context_builder(self, MockCB: Mock, host: MockPromptHost) -> None: host._contexts_builder = None mock_cb = MockCB.return_value result = host.contexts assert result is mock_cb - def test_returns_existing_builder(self, host): + def test_returns_existing_builder(self, host: MockPromptHost) -> None: existing = Mock() host._contexts_builder = existing @@ -573,13 +573,13 @@ class TestProcessPromptSections: # ----- Skipping conditions ----- - def test_skipped_when_no_prompt_sections_attr(self, host): + def test_skipped_when_no_prompt_sections_attr(self, host: MockPromptHost) -> None: """No PROMPT_SECTIONS attribute => nothing happens.""" assert not hasattr(host.__class__, "PROMPT_SECTIONS") host._process_prompt_sections() host._prompt_manager.prompt_add_section.assert_not_called() - def test_skipped_when_prompt_sections_is_none(self, host): + def test_skipped_when_prompt_sections_is_none(self, host: MockPromptHost) -> None: host.__class__.PROMPT_SECTIONS = None try: host._process_prompt_sections() @@ -587,7 +587,7 @@ def test_skipped_when_prompt_sections_is_none(self, host): finally: del host.__class__.PROMPT_SECTIONS - def test_skipped_when_use_pom_is_false(self, host): + def test_skipped_when_use_pom_is_false(self, host: MockPromptHost) -> None: host.__class__.PROMPT_SECTIONS = {"Section": "content"} host._use_pom = False try: @@ -598,7 +598,7 @@ def test_skipped_when_use_pom_is_false(self, host): # ----- Dict-based PROMPT_SECTIONS ----- - def test_dict_with_string_content(self): + def test_dict_with_string_content(self) -> None: """Dict mapping title -> plain string adds a body section.""" class StrHost(MockPromptHost): @@ -606,7 +606,7 @@ class StrHost(MockPromptHost): h = StrHost() h._process_prompt_sections() - h.prompt_add_section = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock # Re-run after patching to verify the call was made by the real method # Better approach: spy on the instance pm = Mock() @@ -614,33 +614,33 @@ class StrHost(MockPromptHost): # Need to call again fresh h2 = StrHost(prompt_manager=pm) # Spy on the instance method - h2.prompt_add_section = Mock() + h2.prompt_add_section = Mock() # type: ignore[method-assign] # mock h2._process_prompt_sections() h2.prompt_add_section.assert_called_once_with("Greeting", body="Hello there") - def test_dict_with_list_content(self): + def test_dict_with_list_content(self) -> None: """Dict mapping title -> list of strings adds bullets.""" class ListHost(MockPromptHost): - PROMPT_SECTIONS = {"Rules": ["Rule 1", "Rule 2"]} + PROMPT_SECTIONS: dict[str, Any] = {"Rules": ["Rule 1", "Rule 2"]} h = ListHost() - h.prompt_add_section = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_called_once_with("Rules", bullets=["Rule 1", "Rule 2"]) - def test_dict_with_empty_list_skipped(self): + def test_dict_with_empty_list_skipped(self) -> None: """Dict mapping title -> empty list does NOT create a section.""" class EmptyListHost(MockPromptHost): - PROMPT_SECTIONS = {"Empty": []} + PROMPT_SECTIONS: dict[str, Any] = {"Empty": []} h = EmptyListHost() - h.prompt_add_section = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_not_called() - def test_dict_with_dict_content_body_only(self): + def test_dict_with_dict_content_body_only(self) -> None: """Dict mapping title -> dict with body key.""" class DictBodyHost(MockPromptHost): @@ -649,8 +649,8 @@ class DictBodyHost(MockPromptHost): } h = DictBodyHost() - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_called_once_with( "Info", @@ -660,7 +660,7 @@ class DictBodyHost(MockPromptHost): numbered_bullets=False, ) - def test_dict_with_dict_content_bullets(self): + def test_dict_with_dict_content_bullets(self) -> None: """Dict mapping title -> dict with bullets key.""" class DictBulletsHost(MockPromptHost): @@ -669,8 +669,8 @@ class DictBulletsHost(MockPromptHost): } h = DictBulletsHost() - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_called_once_with( "Tips", @@ -680,7 +680,7 @@ class DictBulletsHost(MockPromptHost): numbered_bullets=False, ) - def test_dict_with_dict_content_numbered(self): + def test_dict_with_dict_content_numbered(self) -> None: """Dict mapping title -> dict with numbered flags.""" class NumHost(MockPromptHost): @@ -694,8 +694,8 @@ class NumHost(MockPromptHost): } h = NumHost() - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_called_once_with( "Steps", @@ -705,20 +705,20 @@ class NumHost(MockPromptHost): numbered_bullets=True, ) - def test_dict_with_dict_content_empty_skipped(self): + def test_dict_with_dict_content_empty_skipped(self) -> None: """Dict -> dict with no body, no bullets, no subsections => section is skipped.""" class EmptyDictHost(MockPromptHost): - PROMPT_SECTIONS = { + PROMPT_SECTIONS: dict[str, Any] = { "Nothing": {} } h = EmptyDictHost() - h.prompt_add_section = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_not_called() - def test_dict_with_subsections(self): + def test_dict_with_subsections(self) -> None: """Dict -> dict containing subsections list.""" class SubHost(MockPromptHost): @@ -733,8 +733,8 @@ class SubHost(MockPromptHost): } h = SubHost() - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_called_once_with( @@ -752,7 +752,7 @@ class SubHost(MockPromptHost): "Parent", "Child2", body="", bullets=["b1"], ) - def test_dict_subsection_without_title_skipped(self): + def test_dict_subsection_without_title_skipped(self) -> None: """Subsections without a 'title' key are skipped.""" class NoTitleSubHost(MockPromptHost): @@ -766,12 +766,12 @@ class NoTitleSubHost(MockPromptHost): } h = NoTitleSubHost() - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_subsection.assert_not_called() - def test_dict_subsection_empty_body_and_bullets_skipped(self): + def test_dict_subsection_empty_body_and_bullets_skipped(self) -> None: """Subsections with empty body and empty bullets are skipped.""" class EmptySubHost(MockPromptHost): @@ -785,14 +785,14 @@ class EmptySubHost(MockPromptHost): } h = EmptySubHost() - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_subsection.assert_not_called() # ----- List-based PROMPT_SECTIONS ----- - def test_list_sections_with_pom(self): + def test_list_sections_with_pom(self) -> None: """List-based PROMPT_SECTIONS processed when POM is available.""" mock_pom = Mock() @@ -803,8 +803,8 @@ class ListSectionHost(MockPromptHost): ] h = ListSectionHost(pom=mock_pom) - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() assert h.prompt_add_section.call_count == 2 @@ -815,7 +815,7 @@ class ListSectionHost(MockPromptHost): "Section B", body="", bullets=["b1", "b2"], numbered=False, numbered_bullets=False, ) - def test_list_sections_without_pom_does_nothing(self): + def test_list_sections_without_pom_does_nothing(self) -> None: """List-based PROMPT_SECTIONS skipped when pom is None.""" class ListNoPomHost(MockPromptHost): @@ -824,11 +824,11 @@ class ListNoPomHost(MockPromptHost): ] h = ListNoPomHost(pom=None) - h.prompt_add_section = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_not_called() - def test_list_section_without_title_skipped(self): + def test_list_section_without_title_skipped(self) -> None: """List entries without 'title' are silently skipped.""" mock_pom = Mock() @@ -838,11 +838,11 @@ class NoTitleListHost(MockPromptHost): ] h = NoTitleListHost(pom=mock_pom) - h.prompt_add_section = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_not_called() - def test_list_section_empty_body_and_no_bullets_skipped(self): + def test_list_section_empty_body_and_no_bullets_skipped(self) -> None: """List section with empty body and no bullets (and no subsections) is skipped.""" mock_pom = Mock() @@ -852,11 +852,11 @@ class EmptyListSectionHost(MockPromptHost): ] h = EmptyListSectionHost(pom=mock_pom) - h.prompt_add_section = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_not_called() - def test_list_section_with_subsections(self): + def test_list_section_with_subsections(self) -> None: """List-based section with subsections.""" mock_pom = Mock() @@ -873,14 +873,14 @@ class ListSubHost(MockPromptHost): ] h = ListSubHost(pom=mock_pom) - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_called_once() assert h.prompt_add_subsection.call_count == 2 - def test_list_section_subsection_without_title_skipped(self): + def test_list_section_subsection_without_title_skipped(self) -> None: """Subsections in list mode without title are skipped.""" mock_pom = Mock() @@ -896,12 +896,12 @@ class ListSubNoTitleHost(MockPromptHost): ] h = ListSubNoTitleHost(pom=mock_pom) - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_subsection.assert_not_called() - def test_list_section_subsection_empty_content_skipped(self): + def test_list_section_subsection_empty_content_skipped(self) -> None: """Subsections in list mode with empty body and bullets are skipped.""" mock_pom = Mock() @@ -917,12 +917,12 @@ class ListSubEmptyHost(MockPromptHost): ] h = ListSubEmptyHost(pom=mock_pom) - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_subsection.assert_not_called() - def test_list_section_with_numbered_flags(self): + def test_list_section_with_numbered_flags(self) -> None: """List-based sections pass numbered flags through.""" mock_pom = Mock() @@ -937,8 +937,8 @@ class NumListHost(MockPromptHost): ] h = NumListHost(pom=mock_pom) - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_called_once_with( "Steps", @@ -950,7 +950,7 @@ class NumListHost(MockPromptHost): # ----- Multiple sections in dict ----- - def test_dict_multiple_sections(self): + def test_dict_multiple_sections(self) -> None: """Multiple sections in a dict are all processed.""" class MultiHost(MockPromptHost): @@ -961,8 +961,8 @@ class MultiHost(MockPromptHost): } h = MultiHost() - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() assert h.prompt_add_section.call_count == 3 @@ -974,11 +974,11 @@ class MultiHost(MockPromptHost): class TestMethodChaining: """Verify that mixin methods support fluent chaining.""" - def test_chain_set_prompt_text_and_post_prompt(self, host): + def test_chain_set_prompt_text_and_post_prompt(self, host: MockPromptHost) -> None: result = host.set_prompt_text("main").set_post_prompt("post") assert result is host - def test_chain_add_sections(self, host): + def test_chain_add_sections(self, host: MockPromptHost) -> None: result = ( host .prompt_add_section("A", body="a") @@ -988,12 +988,12 @@ def test_chain_add_sections(self, host): ) assert result is host - def test_chain_set_prompt_pom(self, host): + def test_chain_set_prompt_pom(self, host: MockPromptHost) -> None: result = host.set_prompt_pom([]).set_post_prompt("post") assert result is host - def test_chain_define_contexts_with_arg(self, host): - result = host.define_contexts(contexts={"c": {}}).set_post_prompt("post") + def test_chain_define_contexts_with_arg(self, host: MockPromptHost) -> None: + result = host.define_contexts(contexts={"c": {}}).set_post_prompt("post") # type: ignore[union-attr] # define_contexts(contexts=...) returns self, not ContextBuilder assert result is host @@ -1004,14 +1004,14 @@ def test_chain_define_contexts_with_arg(self, host): class TestEdgeCases: """Miscellaneous edge-case tests for PromptMixin.""" - def test_get_prompt_default_with_special_chars_in_name(self, host): + def test_get_prompt_default_with_special_chars_in_name(self, host: MockPromptHost) -> None: host._prompt_manager.get_prompt.return_value = None host._use_pom = False host.name = "Agent & Friends" expected = "You are Agent & Friends, a helpful AI assistant." assert host.get_prompt() == expected - def test_prompt_add_section_title_only(self, host): + def test_prompt_add_section_title_only(self, host: MockPromptHost) -> None: """Adding a section with only a title is valid.""" result = host.prompt_add_section("Title Only") host._prompt_manager.prompt_add_section.assert_called_once_with( @@ -1024,7 +1024,7 @@ def test_prompt_add_section_title_only(self, host): ) assert result is host - def test_prompt_add_to_section_no_content(self, host): + def test_prompt_add_to_section_no_content(self, host: MockPromptHost) -> None: """Calling prompt_add_to_section with no content args still delegates.""" result = host.prompt_add_to_section("Title") host._prompt_manager.prompt_add_to_section.assert_called_once_with( @@ -1035,7 +1035,7 @@ def test_prompt_add_to_section_no_content(self, host): ) assert result is host - def test_prompt_add_subsection_empty_body_and_bullets(self, host): + def test_prompt_add_subsection_empty_body_and_bullets(self, host: MockPromptHost) -> None: """Subsection with default empty body and no bullets.""" result = host.prompt_add_subsection("P", "C") host._prompt_manager.prompt_add_subsection.assert_called_once_with( @@ -1046,18 +1046,18 @@ def test_prompt_add_subsection_empty_body_and_bullets(self, host): ) assert result is host - def test_prompt_manager_raises_on_set_prompt_text(self, host): + def test_prompt_manager_raises_on_set_prompt_text(self, host: MockPromptHost) -> None: """If the prompt manager raises, the exception propagates.""" host._prompt_manager.set_prompt_text.side_effect = ValueError("conflict") with pytest.raises(ValueError, match="conflict"): host.set_prompt_text("oops") - def test_prompt_manager_raises_on_set_prompt_pom(self, host): + def test_prompt_manager_raises_on_set_prompt_pom(self, host: MockPromptHost) -> None: host._prompt_manager.set_prompt_pom.side_effect = ValueError("use_pom must be True") with pytest.raises(ValueError, match="use_pom must be True"): host.set_prompt_pom([{"title": "T"}]) - def test_get_prompt_pom_no_usable_method_no_sections_attr(self, host): + def test_get_prompt_pom_no_usable_method_no_sections_attr(self, host: MockPromptHost) -> None: """POM object with no known method and no _sections in __dict__ returns default.""" host._prompt_manager.get_prompt.return_value = None @@ -1072,14 +1072,14 @@ class MinimalPom: assert result == "You are MinBot, a helpful AI assistant." @patch("signalwire.core.mixins.prompt_mixin.ContextBuilder") - def test_define_contexts_without_arg_sets_contexts_defined(self, MockCB, host): + def test_define_contexts_without_arg_sets_contexts_defined(self, MockCB: Mock, host: MockPromptHost) -> None: host._contexts_builder = None host._contexts_defined = False host.define_contexts() assert host._contexts_defined is True @patch("signalwire.core.mixins.prompt_mixin.ContextBuilder") - def test_define_contexts_without_arg_does_not_reset_flag(self, MockCB, host): + def test_define_contexts_without_arg_does_not_reset_flag(self, MockCB: Mock, host: MockPromptHost) -> None: """Calling define_contexts() twice does not reset _contexts_defined.""" host._contexts_builder = None host._contexts_defined = False @@ -1089,7 +1089,7 @@ def test_define_contexts_without_arg_does_not_reset_flag(self, MockCB, host): host.define_contexts() assert host._contexts_defined is True - def test_process_prompt_sections_dict_subsection_with_body_and_bullets(self): + def test_process_prompt_sections_dict_subsection_with_body_and_bullets(self) -> None: """Subsection that has both body and bullets is added.""" class BothSubHost(MockPromptHost): @@ -1103,14 +1103,14 @@ class BothSubHost(MockPromptHost): } h = BothSubHost() - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_subsection.assert_called_once_with( "Parent", "Sub", body="sub body", bullets=["sb1"], ) - def test_process_prompt_sections_list_subsection_with_body_and_bullets(self): + def test_process_prompt_sections_list_subsection_with_body_and_bullets(self) -> None: """List-mode subsection that has both body and bullets is added.""" mock_pom = Mock() @@ -1126,14 +1126,14 @@ class ListBothSubHost(MockPromptHost): ] h = ListBothSubHost(pom=mock_pom) - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_subsection.assert_called_once_with( "P", "S", body="sb", bullets=["x"], ) - def test_process_prompt_sections_dict_with_subsections_key_but_empty_body(self): + def test_process_prompt_sections_dict_with_subsections_key_but_empty_body(self) -> None: """Section dict has 'subsections' key so it is created even without body.""" class SubOnlyHost(MockPromptHost): @@ -1146,14 +1146,14 @@ class SubOnlyHost(MockPromptHost): } h = SubOnlyHost() - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() # Section should be created because 'subsections' key is present h.prompt_add_section.assert_called_once() h.prompt_add_subsection.assert_called_once() - def test_process_prompt_sections_list_with_subsections_key_but_empty_body(self): + def test_process_prompt_sections_list_with_subsections_key_but_empty_body(self) -> None: """List mode: section has 'subsections' key so it is created even without body.""" mock_pom = Mock() @@ -1168,8 +1168,8 @@ class ListSubOnlyHost(MockPromptHost): ] h = ListSubOnlyHost(pom=mock_pom) - h.prompt_add_section = Mock() - h.prompt_add_subsection = Mock() + h.prompt_add_section = Mock() # type: ignore[method-assign] # mock + h.prompt_add_subsection = Mock() # type: ignore[method-assign] # mock h._process_prompt_sections() h.prompt_add_section.assert_called_once() h.prompt_add_subsection.assert_called_once() diff --git a/tests/unit/core/mixins/test_serverless_mixin.py b/tests/unit/core/mixins/test_serverless_mixin.py index beba7fac..5c549da4 100644 --- a/tests/unit/core/mixins/test_serverless_mixin.py +++ b/tests/unit/core/mixins/test_serverless_mixin.py @@ -16,6 +16,7 @@ import base64 import os import sys +from typing import Any from unittest.mock import Mock, MagicMock, patch, PropertyMock from signalwire.core.mixins.serverless_mixin import ServerlessMixin @@ -29,75 +30,76 @@ class _MockLogger: """Minimal structured logger mock that supports .bind() chaining.""" - def bind(self, **kwargs): + def bind(self, **kwargs: Any) -> "_MockLogger": return self - def debug(self, *args, **kwargs): + def debug(self, *args: Any, **kwargs: Any) -> None: pass - def info(self, *args, **kwargs): + def info(self, *args: Any, **kwargs: Any) -> None: pass - def warning(self, *args, **kwargs): + def warning(self, *args: Any, **kwargs: Any) -> None: pass - def error(self, *args, **kwargs): + def error(self, *args: Any, **kwargs: Any) -> None: pass class _MockToolRegistry: """Minimal tool registry mock.""" - def __init__(self, functions=None): + def __init__(self, functions: dict[str, Any] | None = None) -> None: self._swaig_functions = functions or {} class ConcreteServerlessMixin(ServerlessMixin): """Concrete implementation of ServerlessMixin for testing.""" - def __init__(self, swaig_functions=None): + def __init__(self, swaig_functions: dict[str, Any] | None = None) -> None: self.log = _MockLogger() self._tool_registry = _MockToolRegistry(swaig_functions or {}) - self._proxy_url_base = None + self._proxy_url_base: str | None = None # type: ignore[assignment] # base infers str from guarded assignment; runtime value is optional self._proxy_url_base_from_env = False self._swml_response = '{"sections": {}}' # Stubs for methods the mixin calls but that live in other mixins / base class - def _check_cgi_auth(self): + def _check_cgi_auth(self) -> bool: return True - def _send_cgi_auth_challenge(self): + def _send_cgi_auth_challenge(self) -> str: return "Status: 401\r\n\r\n" - def _check_lambda_auth(self, event): + def _check_lambda_auth(self, event: Any) -> bool: return True - def _send_lambda_auth_challenge(self): + def _send_lambda_auth_challenge(self) -> dict[str, Any]: return {"statusCode": 401, "body": "Unauthorized"} - def _check_google_cloud_function_auth(self, request): + def _check_google_cloud_function_auth(self, request: Any) -> bool: return True - def _send_google_cloud_function_auth_challenge(self): + def _send_google_cloud_function_auth_challenge(self) -> Any: return Mock(status_code=401) - def _check_azure_function_auth(self, req): + def _check_azure_function_auth(self, req: Any) -> bool: return True - def _send_azure_function_auth_challenge(self): + def _send_azure_function_auth_challenge(self) -> Any: return Mock(status_code=401) - def _render_swml(self, **kwargs): + def _render_swml(self, **kwargs: Any) -> str: return self._swml_response - def on_function_call(self, function_name, args, raw_data): + def on_function_call(self, function_name: str, args: Any, raw_data: Any) -> dict[str, Any]: fn = self._tool_registry._swaig_functions.get(function_name) if fn: - return fn(args, raw_data) + result: dict[str, Any] = fn(args, raw_data) + return result return {"error": f"Function '{function_name}' not found"} -def _make_flask_request(path="/", method="GET", json_data=None, url=None): +def _make_flask_request(path: str = "/", method: str = "GET", json_data: Any = None, url: str | None = None) -> Mock: """Create a mock Flask request for GCF tests.""" request = Mock() request.path = path @@ -116,7 +118,7 @@ def _make_flask_request(path="/", method="GET", json_data=None, url=None): return request -def _make_azure_request(url=None, method="GET", body=None): +def _make_azure_request(url: str | None = None, method: str = "GET", body: Any = None) -> Mock: """Create a mock Azure Functions HttpRequest for Azure tests.""" req = Mock() req.url = url or "https://myapp.azurewebsites.net/api/myagent" @@ -128,7 +130,7 @@ def _make_azure_request(url=None, method="GET", body=None): return req -def _swaig_body(function_name, args=None, call_id=None): +def _swaig_body(function_name: str, args: Any = None, call_id: str | None = None) -> dict[str, Any]: """Build a typical SWAIG request body dict.""" body = { "function": function_name, @@ -149,7 +151,7 @@ def _swaig_body(function_name, args=None, call_id=None): class TestLambdaHandlerRootPath: """Lambda handler returns SWML for root path requests.""" - def test_root_path_returns_swml(self): + def test_root_path_returns_swml(self) -> None: """Root path returns SWML document with 200.""" mixin = ConcreteServerlessMixin() event = {"rawPath": "/", "headers": {}} @@ -158,7 +160,7 @@ def test_root_path_returns_swml(self): assert result["headers"]["Content-Type"] == "application/json" assert result["body"] == mixin._swml_response - def test_empty_raw_path_returns_swml(self): + def test_empty_raw_path_returns_swml(self) -> None: """Empty rawPath returns SWML.""" mixin = ConcreteServerlessMixin() event = {"rawPath": "", "headers": {}} @@ -166,7 +168,7 @@ def test_empty_raw_path_returns_swml(self): assert result["statusCode"] == 200 assert result["body"] == mixin._swml_response - def test_none_event_returns_swml(self): + def test_none_event_returns_swml(self) -> None: """None event returns SWML.""" mixin = ConcreteServerlessMixin() result = mixin.handle_serverless_request(event=None, mode="lambda") @@ -177,7 +179,7 @@ def test_none_event_returns_swml(self): class TestLambdaHandlerPathRouting: """Lambda handler routes path-based function calls.""" - def test_path_based_function_call(self): + def test_path_based_function_call(self) -> None: """Path like /say_hello invokes the named function.""" mixin = ConcreteServerlessMixin( swaig_functions={"say_hello": lambda args, raw: {"response": "hi"}} @@ -193,7 +195,7 @@ def test_path_based_function_call(self): result_body = json.loads(result["body"]) assert result_body["response"] == "hi" - def test_swaig_endpoint_with_function_in_body(self): + def test_swaig_endpoint_with_function_in_body(self) -> None: """/swaig endpoint dispatches using function name from body.""" mixin = ConcreteServerlessMixin( swaig_functions={"greet": lambda args, raw: {"response": "hello"}} @@ -213,7 +215,7 @@ def test_swaig_endpoint_with_function_in_body(self): class TestLambdaHandlerV1Payload: """Lambda handler supports API Gateway v1 (REST API) payload format.""" - def test_v1_path_parameters_proxy(self): + def test_v1_path_parameters_proxy(self) -> None: """REST API v1 uses pathParameters.proxy for routing.""" mixin = ConcreteServerlessMixin( swaig_functions={"lookup": lambda args, raw: {"status": "found"}} @@ -234,7 +236,7 @@ def test_v1_path_parameters_proxy(self): class TestLambdaHandlerBase64Body: """Lambda handler decodes base64-encoded request bodies.""" - def test_base64_encoded_body(self): + def test_base64_encoded_body(self) -> None: """isBase64Encoded flag triggers base64 decoding.""" mixin = ConcreteServerlessMixin( swaig_functions={"echo": lambda args, raw: {"args": args}} @@ -256,7 +258,7 @@ def test_base64_encoded_body(self): class TestLambdaHandlerArgumentExtraction: """Lambda handler extracts arguments from parsed and raw formats.""" - def test_parsed_arguments(self): + def test_parsed_arguments(self) -> None: """Arguments from argument.parsed[0] are used.""" mixin = ConcreteServerlessMixin( swaig_functions={"fn": lambda args, raw: {"got": args}} @@ -270,7 +272,7 @@ def test_parsed_arguments(self): result_body = json.loads(result["body"]) assert result_body["got"] == {"key": "value"} - def test_raw_arguments_fallback(self): + def test_raw_arguments_fallback(self) -> None: """When parsed is empty, argument.raw is used as fallback.""" mixin = ConcreteServerlessMixin( swaig_functions={"fn": lambda args, raw: {"got": args}} @@ -284,7 +286,7 @@ def test_raw_arguments_fallback(self): result_body = json.loads(result["body"]) assert result_body["got"] == {"from_raw": True} - def test_invalid_body_json_continues_with_empty_args(self): + def test_invalid_body_json_continues_with_empty_args(self) -> None: """If body is not valid JSON, parsing continues with empty args.""" mixin = ConcreteServerlessMixin( swaig_functions={"fn": lambda args, raw: {"got": args}} @@ -295,7 +297,7 @@ def test_invalid_body_json_continues_with_empty_args(self): result_body = json.loads(result["body"]) assert result_body["got"] == {} - def test_body_as_dict(self): + def test_body_as_dict(self) -> None: """Body that is already a dict (not a string) is handled.""" mixin = ConcreteServerlessMixin( swaig_functions={"fn": lambda args, raw: {"got": args}} @@ -309,12 +311,12 @@ def test_body_as_dict(self): class TestLambdaHandlerAuth: """Lambda handler authentication checks.""" - def test_auth_failure_returns_challenge(self): + def test_auth_failure_returns_challenge(self) -> None: """When auth fails, the challenge response is returned.""" mixin = ConcreteServerlessMixin() - mixin._check_lambda_auth = Mock(return_value=False) + mixin._check_lambda_auth = Mock(return_value=False) # type: ignore[method-assign] # test monkeypatch challenge = {"statusCode": 401, "body": "Unauthorized"} - mixin._send_lambda_auth_challenge = Mock(return_value=challenge) + mixin._send_lambda_auth_challenge = Mock(return_value=challenge) # type: ignore[method-assign] # test monkeypatch event = {"rawPath": "/", "headers": {}} result = mixin.handle_serverless_request(event=event, mode="lambda") assert result["statusCode"] == 401 @@ -324,20 +326,20 @@ def test_auth_failure_returns_challenge(self): class TestLambdaHandlerErrors: """Lambda handler error handling.""" - def test_exception_returns_500(self): + def test_exception_returns_500(self) -> None: """Exceptions in lambda mode return 500 with error body.""" mixin = ConcreteServerlessMixin() - mixin._check_lambda_auth = Mock(side_effect=RuntimeError("boom")) + mixin._check_lambda_auth = Mock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] # test monkeypatch event = {"rawPath": "/", "headers": {}} result = mixin.handle_serverless_request(event=event, mode="lambda") assert result["statusCode"] == 500 body = json.loads(result["body"]) assert "boom" in body["error"] - def test_render_swml_error_returns_500(self): + def test_render_swml_error_returns_500(self) -> None: """Exception in _render_swml returns 500.""" mixin = ConcreteServerlessMixin() - mixin._render_swml = Mock(side_effect=ValueError("swml error")) + mixin._render_swml = Mock(side_effect=ValueError("swml error")) # type: ignore[method-assign] # test monkeypatch event = {"rawPath": "/", "headers": {}} result = mixin.handle_serverless_request(event=event, mode="lambda") assert result["statusCode"] == 500 @@ -350,7 +352,7 @@ def test_render_swml_error_returns_500(self): class TestGCFHandlerRootPath: """GCF handler returns SWML for root path requests.""" - def test_root_path_get_returns_swml(self): + def test_root_path_get_returns_swml(self) -> None: """GET to root path returns SWML.""" mock_response_cls = Mock() mock_response_instance = Mock() @@ -367,7 +369,7 @@ def test_root_path_get_returns_swml(self): assert call_kwargs["status"] == 200 assert call_kwargs["response"] == mixin._swml_response - def test_root_path_post_no_body_returns_swml(self): + def test_root_path_post_no_body_returns_swml(self) -> None: """POST to root path with no useful body returns SWML.""" mock_response_cls = Mock() mock_response_cls.return_value = Mock() @@ -385,7 +387,7 @@ def test_root_path_post_no_body_returns_swml(self): class TestGCFHandlerFunctionRouting: """GCF handler function routing.""" - def test_path_based_function_call(self): + def test_path_based_function_call(self) -> None: """POST to /say_hello routes to the named function.""" mock_response_cls = Mock() mock_response_cls.return_value = Mock() @@ -404,7 +406,7 @@ def test_path_based_function_call(self): response_body = json.loads(call_kwargs["response"]) assert response_body["response"] == "hi" - def test_swaig_endpoint_with_function_in_body(self): + def test_swaig_endpoint_with_function_in_body(self) -> None: """POST to /swaig with function in body dispatches correctly.""" mock_response_cls = Mock() mock_response_cls.return_value = Mock() @@ -426,7 +428,7 @@ def test_swaig_endpoint_with_function_in_body(self): class TestGCFHandlerBodyParsing: """GCF handler request body parsing.""" - def test_non_json_body_fallback(self): + def test_non_json_body_fallback(self) -> None: """When is_json is False, get_data(as_text=True) is used.""" mock_response_cls = Mock() mock_response_cls.return_value = Mock() @@ -448,7 +450,7 @@ def test_non_json_body_fallback(self): call_kwargs = mock_response_cls.call_args[1] assert call_kwargs["status"] == 200 - def test_malformed_body_continues(self): + def test_malformed_body_continues(self) -> None: """Malformed POST body does not crash; continues with empty args.""" mock_response_cls = Mock() mock_response_cls.return_value = Mock() @@ -472,7 +474,7 @@ def test_malformed_body_continues(self): class TestGCFHandlerURLBaseDetection: """GCF handler detects base URL from request.""" - def test_base_url_set_from_request(self): + def test_base_url_set_from_request(self) -> None: """Proxy URL base is derived from the request URL.""" mock_response_cls = Mock() mock_response_cls.return_value = Mock() @@ -489,7 +491,7 @@ def test_base_url_set_from_request(self): assert mixin._proxy_url_base == "https://us-central1-myproject.cloudfunctions.net" - def test_base_url_not_overridden_when_env_set(self): + def test_base_url_not_overridden_when_env_set(self) -> None: """When _proxy_url_base_from_env is True, URL is not overridden.""" mock_response_cls = Mock() mock_response_cls.return_value = Mock() @@ -511,12 +513,12 @@ def test_base_url_not_overridden_when_env_set(self): class TestGCFHandlerAuth: """GCF handler authentication via handle_serverless_request dispatch.""" - def test_auth_failure_returns_challenge(self): + def test_auth_failure_returns_challenge(self) -> None: """When auth fails, the challenge response is returned.""" mixin = ConcreteServerlessMixin() - mixin._check_google_cloud_function_auth = Mock(return_value=False) + mixin._check_google_cloud_function_auth = Mock(return_value=False) # type: ignore[method-assign] # test monkeypatch challenge = Mock(status_code=401) - mixin._send_google_cloud_function_auth_challenge = Mock(return_value=challenge) + mixin._send_google_cloud_function_auth_challenge = Mock(return_value=challenge) # type: ignore[method-assign] # test monkeypatch request = _make_flask_request(path="/") result = mixin.handle_serverless_request(event=request, mode="google_cloud_function") assert result.status_code == 401 @@ -525,13 +527,13 @@ def test_auth_failure_returns_challenge(self): class TestGCFHandlerErrors: """GCF handler error handling.""" - def test_exception_returns_500(self): + def test_exception_returns_500(self) -> None: """Exceptions in GCF handler return 500 Flask response.""" mock_response_cls = Mock() mock_response_cls.return_value = Mock() mixin = ConcreteServerlessMixin() - mixin._render_swml = Mock(side_effect=RuntimeError("boom")) + mixin._render_swml = Mock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] # test monkeypatch request = _make_flask_request(path="/") with patch.dict("sys.modules", {"flask": Mock(Response=mock_response_cls)}): @@ -551,7 +553,7 @@ def test_exception_returns_500(self): class TestAzureHandlerRootPath: """Azure handler returns SWML for root path.""" - def test_root_path_returns_swml(self): + def test_root_path_returns_swml(self) -> None: """GET to root azure path returns SWML.""" mock_http_response = Mock() mock_func = Mock() @@ -588,7 +590,7 @@ def test_root_path_returns_swml(self): class TestAzureHandlerFunctionRouting: """Azure handler function routing.""" - def test_swaig_endpoint_with_function(self): + def test_swaig_endpoint_with_function(self) -> None: """POST to /api/myagent/swaig with function name dispatches correctly.""" mock_http_response = Mock() mock_func = Mock() @@ -625,7 +627,7 @@ def test_swaig_endpoint_with_function(self): response_body = json.loads(call_kwargs["body"]) assert response_body["response"] == "hi" - def test_path_based_function_routing(self): + def test_path_based_function_routing(self) -> None: """POST to /api/myagent/say_hello routes by path.""" mock_http_response = Mock() mock_func = Mock() @@ -665,7 +667,7 @@ def test_path_based_function_routing(self): class TestAzureHandlerURLParsing: """Azure handler URL parsing and base URL detection.""" - def test_base_url_set_from_request(self): + def test_base_url_set_from_request(self) -> None: """Proxy URL base includes /api/function_app_name.""" mock_http_response = Mock() mock_func = Mock() @@ -695,7 +697,7 @@ def test_base_url_set_from_request(self): assert mixin._proxy_url_base == "https://myapp.azurewebsites.net/api/myagent" - def test_url_without_api_prefix(self): + def test_url_without_api_prefix(self) -> None: """URL without /api/ sets base URL to scheme://netloc/api.""" mock_http_response = Mock() mock_func = Mock() @@ -725,7 +727,7 @@ def test_url_without_api_prefix(self): assert mixin._proxy_url_base == "https://myapp.azurewebsites.net/api" - def test_base_url_not_overridden_when_env_set(self): + def test_base_url_not_overridden_when_env_set(self) -> None: """When _proxy_url_base_from_env is True, URL is not overridden.""" mock_http_response = Mock() mock_func = Mock() @@ -761,12 +763,12 @@ def test_base_url_not_overridden_when_env_set(self): class TestAzureHandlerAuth: """Azure handler authentication via handle_serverless_request dispatch.""" - def test_auth_failure_returns_challenge(self): + def test_auth_failure_returns_challenge(self) -> None: """When auth fails, the challenge response is returned.""" mixin = ConcreteServerlessMixin() - mixin._check_azure_function_auth = Mock(return_value=False) + mixin._check_azure_function_auth = Mock(return_value=False) # type: ignore[method-assign] # test monkeypatch challenge = Mock(status_code=401) - mixin._send_azure_function_auth_challenge = Mock(return_value=challenge) + mixin._send_azure_function_auth_challenge = Mock(return_value=challenge) # type: ignore[method-assign] # test monkeypatch req = _make_azure_request() result = mixin.handle_serverless_request(event=req, mode="azure_function") assert result.status_code == 401 @@ -775,14 +777,14 @@ def test_auth_failure_returns_challenge(self): class TestAzureHandlerErrors: """Azure handler error handling.""" - def test_exception_returns_500(self): + def test_exception_returns_500(self) -> None: """Exceptions in Azure handler return 500 HttpResponse.""" mock_http_response = Mock() mock_func = Mock() mock_func.HttpResponse = mock_http_response mixin = ConcreteServerlessMixin() - mixin._render_swml = Mock(side_effect=RuntimeError("azure boom")) + mixin._render_swml = Mock(side_effect=RuntimeError("azure boom")) # type: ignore[method-assign] # test monkeypatch req = _make_azure_request( url="https://myapp.azurewebsites.net/api/myagent", method="GET", @@ -809,7 +811,7 @@ def test_exception_returns_500(self): error_body = json.loads(call_kwargs["body"]) assert "azure boom" in error_body["error"] - def test_malformed_body_continues(self): + def test_malformed_body_continues(self) -> None: """Malformed POST body does not crash; continues with empty args.""" mock_http_response = Mock() mock_func = Mock() @@ -849,14 +851,14 @@ def test_malformed_body_continues(self): class TestExecuteSwaigFunction: """Tests for _execute_swaig_function.""" - def test_function_not_found(self): + def test_function_not_found(self) -> None: """Unknown function name returns error dict.""" mixin = ConcreteServerlessMixin() result = mixin._execute_swaig_function("nonexistent") assert "error" in result assert "nonexistent" in result["error"] - def test_successful_dict_result(self): + def test_successful_dict_result(self) -> None: """Function returning a dict passes it through.""" mixin = ConcreteServerlessMixin( swaig_functions={"fn": lambda args, raw: {"key": "val"}} @@ -864,9 +866,9 @@ def test_successful_dict_result(self): result = mixin._execute_swaig_function("fn", {"x": 1}) assert result == {"key": "val"} - def test_successful_swaig_function_result(self): + def test_successful_swaig_function_result(self) -> None: """Function returning FunctionResult is converted to dict.""" - def handler(args, raw): + def handler(args: Any, raw: Any) -> FunctionResult: return FunctionResult("Done") mixin = ConcreteServerlessMixin(swaig_functions={"fn": handler}) @@ -874,7 +876,7 @@ def handler(args, raw): assert "response" in result assert result["response"] == "Done" - def test_successful_string_result(self): + def test_successful_string_result(self) -> None: """Function returning a string is wrapped in response dict.""" mixin = ConcreteServerlessMixin( swaig_functions={"fn": lambda args, raw: "just a string"} @@ -882,11 +884,11 @@ def test_successful_string_result(self): result = mixin._execute_swaig_function("fn") assert result == {"response": "just a string"} - def test_none_args_default_to_empty_dict(self): + def test_none_args_default_to_empty_dict(self) -> None: """None args are replaced with empty dict.""" - received = {} + received: dict[str, Any] = {} - def handler(args, raw): + def handler(args: Any, raw: Any) -> dict[str, Any]: received["args"] = args return {"ok": True} @@ -894,11 +896,11 @@ def handler(args, raw): mixin._execute_swaig_function("fn", None) assert received["args"] == {} - def test_none_raw_data_builds_default(self): + def test_none_raw_data_builds_default(self) -> None: """None raw_data is replaced with structured default.""" - received = {} + received: dict[str, Any] = {} - def handler(args, raw): + def handler(args: Any, raw: Any) -> dict[str, Any]: received["raw"] = raw return {"ok": True} @@ -909,9 +911,9 @@ def handler(args, raw): assert raw["call_id"] == "c123" assert raw["argument"]["parsed"] == [{"key": "val"}] - def test_exception_during_execution(self): + def test_exception_during_execution(self) -> None: """Exception in function returns error dict.""" - def handler(args, raw): + def handler(args: Any, raw: Any) -> dict[str, Any]: raise ValueError("function error") mixin = ConcreteServerlessMixin(swaig_functions={"fn": handler}) @@ -928,13 +930,13 @@ def handler(args, raw): class TestModeDetection: """handle_serverless_request dispatches based on mode.""" - def test_lambda_mode_dispatch(self): + def test_lambda_mode_dispatch(self) -> None: """mode='lambda' dispatches to lambda handler.""" mixin = ConcreteServerlessMixin() result = mixin.handle_serverless_request(event=None, mode="lambda") assert result["statusCode"] == 200 - def test_gcf_mode_dispatch(self): + def test_gcf_mode_dispatch(self) -> None: """mode='google_cloud_function' dispatches to GCF handler.""" mock_response_cls = Mock() mock_response_cls.return_value = Mock() @@ -947,7 +949,7 @@ def test_gcf_mode_dispatch(self): mock_response_cls.assert_called_once() - def test_azure_mode_dispatch(self): + def test_azure_mode_dispatch(self) -> None: """mode='azure_function' dispatches to Azure handler.""" mock_http_response = Mock() mock_func = Mock() @@ -974,32 +976,32 @@ def test_azure_mode_dispatch(self): mock_http_response.assert_called_once() - def test_cgi_mode_dispatch_root(self): + def test_cgi_mode_dispatch_root(self) -> None: """mode='cgi' with empty PATH_INFO renders SWML.""" mixin = ConcreteServerlessMixin() with patch.dict(os.environ, {"PATH_INFO": ""}, clear=False): result = mixin.handle_serverless_request(mode="cgi") assert result == mixin._swml_response - def test_cgi_mode_auth_failure(self): + def test_cgi_mode_auth_failure(self) -> None: """mode='cgi' with auth failure returns challenge.""" mixin = ConcreteServerlessMixin() - mixin._check_cgi_auth = Mock(return_value=False) - mixin._send_cgi_auth_challenge = Mock(return_value="Status: 401\r\n\r\n") + mixin._check_cgi_auth = Mock(return_value=False) # type: ignore[method-assign] # test monkeypatch + mixin._send_cgi_auth_challenge = Mock(return_value="Status: 401\r\n\r\n") # type: ignore[method-assign] # test monkeypatch result = mixin.handle_serverless_request(mode="cgi") assert result == "Status: 401\r\n\r\n" - def test_mode_auto_detection_lambda(self): + def test_mode_auto_detection_lambda(self) -> None: """When mode is None, get_execution_mode() is called.""" mixin = ConcreteServerlessMixin() with patch("signalwire.core.mixins.serverless_mixin.get_execution_mode", return_value="lambda"): result = mixin.handle_serverless_request(event=None) assert result["statusCode"] == 200 - def test_non_lambda_exception_reraises(self): + def test_non_lambda_exception_reraises(self) -> None: """Exceptions in non-lambda modes are re-raised (not wrapped in 500).""" mixin = ConcreteServerlessMixin() - mixin._check_cgi_auth = Mock(side_effect=RuntimeError("cgi boom")) + mixin._check_cgi_auth = Mock(side_effect=RuntimeError("cgi boom")) # type: ignore[method-assign] # test monkeypatch with pytest.raises(RuntimeError, match="cgi boom"): mixin.handle_serverless_request(mode="cgi") @@ -1011,7 +1013,7 @@ def test_non_lambda_exception_reraises(self): class TestCGIModeBodyParsing: """CGI mode parses POST data from stdin.""" - def test_cgi_function_call_with_post_body(self): + def test_cgi_function_call_with_post_body(self) -> None: """CGI mode parses function call from stdin POST data.""" mixin = ConcreteServerlessMixin( swaig_functions={"hello": lambda args, raw: {"response": "world"}} @@ -1032,7 +1034,7 @@ def test_cgi_function_call_with_post_body(self): assert result["response"] == "world" - def test_cgi_function_call_with_raw_args(self): + def test_cgi_function_call_with_raw_args(self) -> None: """CGI mode falls back to argument.raw when parsed is empty.""" mixin = ConcreteServerlessMixin( swaig_functions={"hello": lambda args, raw: {"got": args}} @@ -1056,7 +1058,7 @@ def test_cgi_function_call_with_raw_args(self): assert result["got"] == {"from_raw": True} - def test_cgi_missing_content_length(self): + def test_cgi_missing_content_length(self) -> None: """CGI mode with no CONTENT_LENGTH still works (no body parsed).""" mixin = ConcreteServerlessMixin( swaig_functions={"hello": lambda args, raw: {"response": "ok"}} diff --git a/tests/unit/core/mixins/test_state_mixin.py b/tests/unit/core/mixins/test_state_mixin.py index 68c85d5e..f338c938 100644 --- a/tests/unit/core/mixins/test_state_mixin.py +++ b/tests/unit/core/mixins/test_state_mixin.py @@ -24,7 +24,11 @@ class MockStateHost(StateMixin): all the attributes the mixin expects to find on self. """ - def __init__(self, session_manager=None, tool_registry=None): + def __init__( + self, + session_manager: Mock | None = None, + tool_registry: Mock | None = None, + ) -> None: self.log = Mock() if session_manager is not None: self._session_manager = session_manager @@ -37,7 +41,7 @@ def __init__(self, session_manager=None, tool_registry=None): # --------------------------------------------------------------------------- @pytest.fixture -def mock_session_manager(): +def mock_session_manager() -> Mock: """Return a fresh Mock standing in for SessionManager.""" sm = Mock() sm.create_tool_token.return_value = "test-token-abc123" @@ -58,7 +62,7 @@ def mock_session_manager(): @pytest.fixture -def mock_tool_registry(): +def mock_tool_registry() -> Mock: """Return a mock tool registry with some registered functions.""" registry = Mock() # Create a SWAIGFunction-like mock (non-dict with secure attribute) @@ -79,7 +83,7 @@ def mock_tool_registry(): @pytest.fixture -def host(mock_session_manager, mock_tool_registry): +def host(mock_session_manager: Mock, mock_tool_registry: Mock) -> MockStateHost: """Return a MockStateHost wired with mock dependencies.""" return MockStateHost( session_manager=mock_session_manager, @@ -94,30 +98,30 @@ def host(mock_session_manager, mock_tool_registry): class TestCreateToolToken: """Tests for StateMixin._create_tool_token""" - def test_creates_token_successfully(self, host, mock_session_manager): + def test_creates_token_successfully(self, host: MockStateHost, mock_session_manager: Mock) -> None: token = host._create_tool_token("my_tool", "call-123") mock_session_manager.create_tool_token.assert_called_once_with("my_tool", "call-123") assert token == "test-token-abc123" - def test_returns_empty_string_when_no_session_manager(self): + def test_returns_empty_string_when_no_session_manager(self) -> None: """When _session_manager attribute is missing, return empty string.""" h = MockStateHost() # No session_manager passed result = h._create_tool_token("tool", "call-1") assert result == "" h.log.error.assert_called_once() - def test_returns_empty_string_on_exception(self, host, mock_session_manager): + def test_returns_empty_string_on_exception(self, host: MockStateHost, mock_session_manager: Mock) -> None: """When session_manager.create_tool_token raises, return empty string.""" mock_session_manager.create_tool_token.side_effect = RuntimeError("boom") result = host._create_tool_token("tool", "call-1") assert result == "" host.log.error.assert_called_once() - def test_passes_correct_args_to_session_manager(self, host, mock_session_manager): + def test_passes_correct_args_to_session_manager(self, host: MockStateHost, mock_session_manager: Mock) -> None: host._create_tool_token("func_name", "call-xyz") mock_session_manager.create_tool_token.assert_called_once_with("func_name", "call-xyz") - def test_returns_whatever_session_manager_returns(self, host, mock_session_manager): + def test_returns_whatever_session_manager_returns(self, host: MockStateHost, mock_session_manager: Mock) -> None: mock_session_manager.create_tool_token.return_value = "custom-token-value" result = host._create_tool_token("t", "c") assert result == "custom-token-value" @@ -130,22 +134,22 @@ def test_returns_whatever_session_manager_returns(self, host, mock_session_manag class TestValidateToolTokenBasic: """Tests for StateMixin.validate_tool_token basic paths""" - def test_returns_false_for_unknown_function(self, host): + def test_returns_false_for_unknown_function(self, host: MockStateHost) -> None: result = host.validate_tool_token("unknown_func", "token", "call-123") assert result is False host.log.warning.assert_called() - def test_returns_true_for_non_secure_function(self, host): + def test_returns_true_for_non_secure_function(self, host: MockStateHost) -> None: """Non-secure functions should always be allowed.""" result = host.validate_tool_token("non_secure_tool", "any-token", "call-123") assert result is True - def test_validates_secure_function_with_valid_token(self, host, mock_session_manager): + def test_validates_secure_function_with_valid_token(self, host: MockStateHost, mock_session_manager: Mock) -> None: mock_session_manager.validate_tool_token.return_value = True result = host.validate_tool_token("secure_tool", "valid-token", "call-123") assert result is True - def test_rejects_secure_function_with_invalid_token(self, host, mock_session_manager): + def test_rejects_secure_function_with_invalid_token(self, host: MockStateHost, mock_session_manager: Mock) -> None: mock_session_manager.validate_tool_token.return_value = False result = host.validate_tool_token("secure_tool", "bad-token", "call-123") assert result is False @@ -158,21 +162,21 @@ def test_rejects_secure_function_with_invalid_token(self, host, mock_session_man class TestValidateToolTokenDataMap: """Tests for data_map function handling in validate_tool_token""" - def test_data_map_functions_are_always_secure(self, host, mock_session_manager): + def test_data_map_functions_are_always_secure(self, host: MockStateHost, mock_session_manager: Mock) -> None: """Data map functions (raw dicts) are treated as secure by default.""" mock_session_manager.validate_tool_token.return_value = True result = host.validate_tool_token("data_map_tool", "valid-token", "call-123") assert result is True mock_session_manager.validate_tool_token.assert_called() - def test_data_map_missing_token_returns_false(self, host): + def test_data_map_missing_token_returns_false(self, host: MockStateHost) -> None: """Data map functions with missing token should fail validation.""" result = host.validate_tool_token("data_map_tool", "", "call-123") assert result is False - def test_data_map_none_token_returns_false(self, host): + def test_data_map_none_token_returns_false(self, host: MockStateHost) -> None: """Data map functions with None token should fail validation.""" - result = host.validate_tool_token("data_map_tool", None, "call-123") + result = host.validate_tool_token("data_map_tool", None, "call-123") # type: ignore[arg-type] # intentional invalid input for validation test assert result is False @@ -183,12 +187,12 @@ def test_data_map_none_token_returns_false(self, host): class TestValidateToolTokenNoSessionManager: """Tests for validate_tool_token when session_manager is absent""" - def test_returns_false_when_no_session_manager(self, mock_tool_registry): + def test_returns_false_when_no_session_manager(self, mock_tool_registry: Mock) -> None: h = MockStateHost(tool_registry=mock_tool_registry) result = h.validate_tool_token("secure_tool", "token", "call-1") assert result is False - def test_non_secure_still_allowed_without_session_manager(self, mock_tool_registry): + def test_non_secure_still_allowed_without_session_manager(self, mock_tool_registry: Mock) -> None: """Non-secure functions should still be allowed even without session manager.""" h = MockStateHost(tool_registry=mock_tool_registry) result = h.validate_tool_token("non_secure_tool", "token", "call-1") @@ -202,11 +206,11 @@ def test_non_secure_still_allowed_without_session_manager(self, mock_tool_regist class TestValidateToolTokenDebug: """Tests for the debug_token branch in validate_tool_token""" - def test_debug_token_called_when_available(self, host, mock_session_manager): + def test_debug_token_called_when_available(self, host: MockStateHost, mock_session_manager: Mock) -> None: host.validate_tool_token("secure_tool", "some-token", "call-123") mock_session_manager.debug_token.assert_called() - def test_function_mismatch_logged(self, host, mock_session_manager): + def test_function_mismatch_logged(self, host: MockStateHost, mock_session_manager: Mock) -> None: """When the token's function name doesn't match, a warning is logged.""" mock_session_manager.debug_token.return_value = { "valid_format": True, @@ -224,7 +228,7 @@ def test_function_mismatch_logged(self, host, mock_session_manager): for call in host.log.warning.call_args_list ) - def test_call_id_mismatch_logged(self, host, mock_session_manager): + def test_call_id_mismatch_logged(self, host: MockStateHost, mock_session_manager: Mock) -> None: """When the token's call_id doesn't match, a warning is logged.""" mock_session_manager.debug_token.return_value = { "valid_format": True, @@ -241,7 +245,7 @@ def test_call_id_mismatch_logged(self, host, mock_session_manager): for call in host.log.warning.call_args_list ) - def test_expired_token_logged(self, host, mock_session_manager): + def test_expired_token_logged(self, host: MockStateHost, mock_session_manager: Mock) -> None: """When the token is expired, a warning is logged.""" mock_session_manager.debug_token.return_value = { "valid_format": True, @@ -261,7 +265,7 @@ def test_expired_token_logged(self, host, mock_session_manager): for call in host.log.warning.call_args_list ) - def test_debug_token_exception_handled(self, host, mock_session_manager): + def test_debug_token_exception_handled(self, host: MockStateHost, mock_session_manager: Mock) -> None: """If debug_token raises, it should be caught and logged.""" mock_session_manager.debug_token.side_effect = RuntimeError("debug failed") # Should not raise, should still return validation result @@ -279,7 +283,7 @@ def test_debug_token_exception_handled(self, host, mock_session_manager): class TestValidateToolTokenCallIdExtraction: """Tests for extracting call_id from token when provided call_id is empty""" - def test_uses_call_id_from_token_when_empty(self, host, mock_session_manager): + def test_uses_call_id_from_token_when_empty(self, host: MockStateHost, mock_session_manager: Mock) -> None: """When call_id is empty, tries to extract from the token.""" mock_session_manager.debug_token.return_value = { "valid_format": True, @@ -293,9 +297,9 @@ def test_uses_call_id_from_token_when_empty(self, host, mock_session_manager): result = host.validate_tool_token("secure_tool", "some-token", "") assert result is True - def test_extracted_call_id_validation_fails_falls_through(self, host, mock_session_manager): + def test_extracted_call_id_validation_fails_falls_through(self, host: MockStateHost, mock_session_manager: Mock) -> None: """When extracted call_id validation fails, falls through to normal validation.""" - def validate_side_effect(fn, token, cid): + def validate_side_effect(fn: str, token: str, cid: str) -> bool: if cid == "extracted-call-id": return False return False @@ -320,7 +324,7 @@ def validate_side_effect(fn, token, cid): class TestValidateToolTokenExceptions: """Tests for exception handling in validate_tool_token""" - def test_returns_false_on_unexpected_exception(self, host, mock_session_manager): + def test_returns_false_on_unexpected_exception(self, host: MockStateHost, mock_session_manager: Mock) -> None: """Any unexpected exception should result in False.""" mock_session_manager.validate_tool_token.side_effect = RuntimeError("unexpected") # Also need debug_token to not cause issue diff --git a/tests/unit/core/mixins/test_tool_mixin.py b/tests/unit/core/mixins/test_tool_mixin.py index 674af4aa..3929a0bc 100644 --- a/tests/unit/core/mixins/test_tool_mixin.py +++ b/tests/unit/core/mixins/test_tool_mixin.py @@ -13,6 +13,7 @@ import json import pytest +from typing import Any, Callable from unittest.mock import Mock, MagicMock, patch from signalwire.core.mixins.tool_mixin import ToolMixin @@ -26,7 +27,7 @@ class MockToolHost(ToolMixin): all the attributes the mixin expects to find on self. """ - def __init__(self, tool_registry=None): + def __init__(self, tool_registry: Mock | None = None) -> None: self._tool_registry = tool_registry or Mock() self.log = Mock() # Bind returns another mock logger @@ -38,7 +39,7 @@ def __init__(self, tool_registry=None): # --------------------------------------------------------------------------- @pytest.fixture -def mock_registry(): +def mock_registry() -> Mock: """Return a fresh Mock for ToolRegistry.""" reg = Mock() reg._swaig_functions = {} @@ -46,12 +47,17 @@ def mock_registry(): @pytest.fixture -def host(mock_registry): +def host(mock_registry: Mock) -> MockToolHost: """Return a MockToolHost wired with a mock tool registry.""" return MockToolHost(tool_registry=mock_registry) -def _make_swaig_function(name="test_tool", handler=None, secure=True, webhook_url=None): +def _make_swaig_function( + name: str = "test_tool", + handler: Callable[..., Any] | None = None, + secure: bool = True, + webhook_url: str | None = None, +) -> SWAIGFunction: """Helper to create a SWAIGFunction instance.""" if handler is None: handler = Mock(return_value=FunctionResult("ok")) @@ -72,7 +78,7 @@ def _make_swaig_function(name="test_tool", handler=None, secure=True, webhook_ur class TestDefineTool: """Tests for ToolMixin.define_tool""" - def test_delegates_to_registry(self, host, mock_registry): + def test_delegates_to_registry(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock() host.define_tool( name="my_func", @@ -92,20 +98,20 @@ def test_delegates_to_registry(self, host, mock_registry): is_typed_handler=False, ) - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockToolHost) -> None: result = host.define_tool( name="f", description="d", parameters={}, handler=Mock() ) assert result is host - def test_passes_secure_false(self, host, mock_registry): + def test_passes_secure_false(self, host: MockToolHost, mock_registry: Mock) -> None: host.define_tool( name="f", description="d", parameters={}, handler=Mock(), secure=False ) call_kwargs = mock_registry.define_tool.call_args[1] assert call_kwargs["secure"] is False - def test_passes_fillers(self, host, mock_registry): + def test_passes_fillers(self, host: MockToolHost, mock_registry: Mock) -> None: fillers = {"en": ["one moment", "please wait"]} host.define_tool( name="f", description="d", parameters={}, handler=Mock(), fillers=fillers @@ -113,7 +119,7 @@ def test_passes_fillers(self, host, mock_registry): call_kwargs = mock_registry.define_tool.call_args[1] assert call_kwargs["fillers"] == fillers - def test_passes_webhook_url(self, host, mock_registry): + def test_passes_webhook_url(self, host: MockToolHost, mock_registry: Mock) -> None: host.define_tool( name="f", description="d", parameters={}, handler=Mock(), webhook_url="https://example.com/hook" @@ -121,7 +127,7 @@ def test_passes_webhook_url(self, host, mock_registry): call_kwargs = mock_registry.define_tool.call_args[1] assert call_kwargs["webhook_url"] == "https://example.com/hook" - def test_passes_required(self, host, mock_registry): + def test_passes_required(self, host: MockToolHost, mock_registry: Mock) -> None: host.define_tool( name="f", description="d", parameters={}, handler=Mock(), required=["x", "y"] @@ -129,7 +135,7 @@ def test_passes_required(self, host, mock_registry): call_kwargs = mock_registry.define_tool.call_args[1] assert call_kwargs["required"] == ["x", "y"] - def test_chain_multiple_define_tool_calls(self, host): + def test_chain_multiple_define_tool_calls(self, host: MockToolHost) -> None: result = ( host .define_tool(name="a", description="A", parameters={}, handler=Mock()) @@ -137,7 +143,7 @@ def test_chain_multiple_define_tool_calls(self, host): ) assert result is host - def test_passes_is_typed_handler(self, host, mock_registry): + def test_passes_is_typed_handler(self, host: MockToolHost, mock_registry: Mock) -> None: host.define_tool( name="f", description="d", parameters={}, handler=Mock(), is_typed_handler=True @@ -145,7 +151,7 @@ def test_passes_is_typed_handler(self, host, mock_registry): call_kwargs = mock_registry.define_tool.call_args[1] assert call_kwargs["is_typed_handler"] is True - def test_is_typed_handler_defaults_false(self, host, mock_registry): + def test_is_typed_handler_defaults_false(self, host: MockToolHost, mock_registry: Mock) -> None: host.define_tool( name="f", description="d", parameters={}, handler=Mock() ) @@ -160,12 +166,12 @@ def test_is_typed_handler_defaults_false(self, host, mock_registry): class TestRegisterSwaigFunction: """Tests for ToolMixin.register_swaig_function""" - def test_delegates_to_registry(self, host, mock_registry): + def test_delegates_to_registry(self, host: MockToolHost, mock_registry: Mock) -> None: func_dict = {"function": "data_map_func", "data_map": {"url": "https://example.com"}} host.register_swaig_function(func_dict) mock_registry.register_swaig_function.assert_called_once_with(func_dict) - def test_returns_self_for_chaining(self, host): + def test_returns_self_for_chaining(self, host: MockToolHost) -> None: result = host.register_swaig_function({"function": "f", "data_map": {}}) assert result is host @@ -177,26 +183,26 @@ def test_returns_self_for_chaining(self, host): class TestDefineTools: """Tests for ToolMixin.define_tools""" - def test_returns_empty_list_when_no_functions(self, host, mock_registry): + def test_returns_empty_list_when_no_functions(self, host: MockToolHost, mock_registry: Mock) -> None: mock_registry._swaig_functions = {} result = host.define_tools() assert result == [] - def test_returns_swaig_function_objects(self, host, mock_registry): + def test_returns_swaig_function_objects(self, host: MockToolHost, mock_registry: Mock) -> None: func = _make_swaig_function("tool1") mock_registry._swaig_functions = {"tool1": func} result = host.define_tools() assert len(result) == 1 assert result[0] is func - def test_returns_raw_dicts_for_data_map(self, host, mock_registry): + def test_returns_raw_dicts_for_data_map(self, host: MockToolHost, mock_registry: Mock) -> None: data_map = {"function": "dm_func", "data_map": {"url": "https://example.com"}} mock_registry._swaig_functions = {"dm_func": data_map} result = host.define_tools() assert len(result) == 1 assert result[0] is data_map - def test_returns_mixed_types(self, host, mock_registry): + def test_returns_mixed_types(self, host: MockToolHost, mock_registry: Mock) -> None: func = _make_swaig_function("regular") data_map = {"function": "dm", "data_map": {}} mock_registry._swaig_functions = {"regular": func, "dm": data_map} @@ -211,25 +217,25 @@ def test_returns_mixed_types(self, host, mock_registry): class TestOnFunctionCall: """Tests for ToolMixin.on_function_call""" - def test_unknown_function_returns_error(self, host, mock_registry): + def test_unknown_function_returns_error(self, host: MockToolHost, mock_registry: Mock) -> None: mock_registry._swaig_functions = {} result = host.on_function_call("nonexistent", {}) assert "not found" in result["response"] - def test_data_map_function_returns_error(self, host, mock_registry): + def test_data_map_function_returns_error(self, host: MockToolHost, mock_registry: Mock) -> None: mock_registry._swaig_functions = { "dm": {"function": "dm", "data_map": {}} } result = host.on_function_call("dm", {}) assert "Data map" in result["response"] - def test_webhook_function_returns_error(self, host, mock_registry): + def test_webhook_function_returns_error(self, host: MockToolHost, mock_registry: Mock) -> None: func = _make_swaig_function("webhook_func", webhook_url="https://example.com") mock_registry._swaig_functions = {"webhook_func": func} result = host.on_function_call("webhook_func", {}) assert "webhook" in result["response"].lower() or "External" in result["response"] - def test_calls_handler_successfully(self, host, mock_registry): + def test_calls_handler_successfully(self, host: MockToolHost, mock_registry: Mock) -> None: expected_result = FunctionResult("success") handler = Mock(return_value=expected_result) func = _make_swaig_function("my_tool", handler=handler) @@ -239,7 +245,7 @@ def test_calls_handler_successfully(self, host, mock_registry): handler.assert_called_once_with({"key": "val"}, {"raw": "data"}) assert result is expected_result - def test_handler_returning_none_creates_default(self, host, mock_registry): + def test_handler_returning_none_creates_default(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock(return_value=None) func = _make_swaig_function("my_tool", handler=handler) mock_registry._swaig_functions = {"my_tool": func} @@ -247,7 +253,7 @@ def test_handler_returning_none_creates_default(self, host, mock_registry): result = host.on_function_call("my_tool", {}) assert isinstance(result, FunctionResult) - def test_handler_exception_returns_error(self, host, mock_registry): + def test_handler_exception_returns_error(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock(side_effect=RuntimeError("handler crash")) func = _make_swaig_function("my_tool", handler=handler) mock_registry._swaig_functions = {"my_tool": func} @@ -263,12 +269,12 @@ def test_handler_exception_returns_error(self, host, mock_registry): class TestExecuteSwaigFunction: """Tests for ToolMixin._execute_swaig_function""" - def test_function_not_found(self, host, mock_registry): + def test_function_not_found(self, host: MockToolHost, mock_registry: Mock) -> None: mock_registry._swaig_functions = {} result = host._execute_swaig_function("nonexistent") assert "error" in result - def test_default_args_when_none(self, host, mock_registry): + def test_default_args_when_none(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock(return_value=FunctionResult("done")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -277,7 +283,7 @@ def test_default_args_when_none(self, host, mock_registry): assert "response" in result assert result["response"] == "done" - def test_passes_args_and_raw_data(self, host, mock_registry): + def test_passes_args_and_raw_data(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock(return_value=FunctionResult("ok")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -287,7 +293,7 @@ def test_passes_args_and_raw_data(self, host, mock_registry): handler.assert_called_once() assert result["response"] == "ok" - def test_with_call_id(self, host, mock_registry): + def test_with_call_id(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock(return_value=FunctionResult("ok")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -295,7 +301,7 @@ def test_with_call_id(self, host, mock_registry): result = host._execute_swaig_function("tool", args={}, call_id="call-42") assert result["response"] == "ok" - def test_constructs_raw_data_with_args(self, host, mock_registry): + def test_constructs_raw_data_with_args(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock(return_value=FunctionResult("fine")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -307,7 +313,7 @@ def test_constructs_raw_data_with_args(self, host, mock_registry): assert raw_data["function"] == "tool" assert raw_data["call_id"] == "c1" - def test_handler_returning_dict(self, host, mock_registry): + def test_handler_returning_dict(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock(return_value={"response": "dict result"}) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -315,7 +321,7 @@ def test_handler_returning_dict(self, host, mock_registry): result = host._execute_swaig_function("tool") assert result["response"] == "dict result" - def test_handler_returning_string(self, host, mock_registry): + def test_handler_returning_string(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock(return_value="just a string") func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -323,7 +329,7 @@ def test_handler_returning_string(self, host, mock_registry): result = host._execute_swaig_function("tool") assert "response" in result - def test_handler_exception_returns_error_response(self, host, mock_registry): + def test_handler_exception_returns_error_response(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock(side_effect=RuntimeError("boom")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -334,7 +340,7 @@ def test_handler_exception_returns_error_response(self, host, mock_registry): assert "response" in result assert "Error" in result["response"] - def test_empty_args_creates_empty_raw_data_argument(self, host, mock_registry): + def test_empty_args_creates_empty_raw_data_argument(self, host: MockToolHost, mock_registry: Mock) -> None: handler = Mock(return_value=FunctionResult("ok")) func = _make_swaig_function("tool", handler=handler) mock_registry._swaig_functions = {"tool": func} @@ -352,15 +358,15 @@ def test_empty_args_creates_empty_raw_data_argument(self, host, mock_registry): class TestToolDecorator: """Tests for ToolMixin._tool_decorator""" - def test_decorator_returns_callable(self, host): + def test_decorator_returns_callable(self, host: MockToolHost) -> None: decorator = host._tool_decorator(name="test_func") assert callable(decorator) - def test_decorated_function_is_registered(self, host, mock_registry): + def test_decorated_function_is_registered(self, host: MockToolHost, mock_registry: Mock) -> None: mock_registry.define_tool = Mock() @host._tool_decorator(name="greet", description="Greet user", parameters={"name": {"type": "string"}}) - def greet(args, raw_data): + def greet(args: dict[str, Any], raw_data: dict[str, Any]) -> FunctionResult: return FunctionResult("Hello") mock_registry.define_tool.assert_called_once() @@ -376,29 +382,29 @@ def greet(args, raw_data): class TestToolClassDecorator: """Tests for ToolMixin.tool class method decorator""" - def test_class_decorator_marks_function(self): + def test_class_decorator_marks_function(self) -> None: decorator = ToolMixin.tool(name="class_func", parameters={}) - def my_func(self, args, raw_data): + def my_func(self: Any, args: dict[str, Any], raw_data: dict[str, Any]) -> FunctionResult: return FunctionResult("hi") decorated = decorator(my_func) - assert decorated._is_tool is True - assert decorated._tool_name == "class_func" + assert decorated._is_tool is True # type: ignore[attr-defined] # dynamic attr set by decorator + assert decorated._tool_name == "class_func" # type: ignore[attr-defined] # dynamic attr set by decorator - def test_class_decorator_uses_function_name_when_no_name(self): + def test_class_decorator_uses_function_name_when_no_name(self) -> None: decorator = ToolMixin.tool(parameters={}) - def my_func(self, args, raw_data): + def my_func(self: Any, args: dict[str, Any], raw_data: dict[str, Any]) -> None: pass decorated = decorator(my_func) - assert decorated._tool_name == "my_func" + assert decorated._tool_name == "my_func" # type: ignore[attr-defined] # dynamic attr set by decorator - def test_class_decorator_preserves_function(self): + def test_class_decorator_preserves_function(self) -> None: decorator = ToolMixin.tool(name="func") - def my_func(self, args, raw_data): + def my_func(self: Any, args: dict[str, Any], raw_data: dict[str, Any]) -> str: return "original" decorated = decorator(my_func) diff --git a/tests/unit/core/mixins/test_web_mixin.py b/tests/unit/core/mixins/test_web_mixin.py index eaab9eb8..2d6d94ec 100644 --- a/tests/unit/core/mixins/test_web_mixin.py +++ b/tests/unit/core/mixins/test_web_mixin.py @@ -17,8 +17,11 @@ import base64 import asyncio import types +from typing import Any, Awaitable, TypeVar from unittest.mock import Mock, patch, MagicMock, AsyncMock +from fastapi import FastAPI + from signalwire.core.mixins.web_mixin import WebMixin from signalwire.core.function_result import FunctionResult # SWAIG handler was lifted from WebMixin into SWMLService, with extension @@ -33,13 +36,13 @@ # Helpers # --------------------------------------------------------------------------- -def _make_auth_header(username, password): +def _make_auth_header(username: str, password: str) -> str: """Create a Basic Auth header value.""" encoded = base64.b64encode(f"{username}:{password}".encode()).decode() return f"Basic {encoded}" -def _router_mounted_under(app, prefix): +def _router_mounted_under(app: FastAPI, prefix: str) -> bool: """Whether the agent's router is mounted under ``prefix`` on ``app``. Robust across Starlette versions. Starlette 0.x flattened @@ -59,7 +62,13 @@ def _router_mounted_under(app, prefix): ) -def _make_request(method="GET", headers=None, body=None, query_params=None, url_path="/"): +def _make_request( + method: str = "GET", + headers: dict[str, str] | None = None, + body: dict[str, Any] | bytes | None = None, + query_params: dict[str, str] | None = None, + url_path: str = "/", +) -> AsyncMock: """Create a mock FastAPI Request object.""" request = AsyncMock() request.method = method @@ -79,7 +88,7 @@ def _make_request(method="GET", headers=None, body=None, query_params=None, url_ return request -def _build_mixin(**overrides): +def _build_mixin(**overrides: Any) -> Any: """ Build a minimal object that inherits from WebMixin and provides all the attributes / methods that WebMixin expects from the host class @@ -91,7 +100,7 @@ def _build_mixin(**overrides): tool_registry = MagicMock() tool_registry._swaig_functions = {} - defaults = dict( + defaults: dict[str, Any] = dict( _app=None, _basic_auth=("user", "pass"), _proxy_url_base=None, @@ -114,6 +123,8 @@ def _build_mixin(**overrides): ) defaults.update(overrides) + # WebMixin is typed as Any to mypy (its module isn't fully resolvable under + # --strict), so subclassing it trips [misc]; the subclass is intentional. class FakeAgent(WebMixin): pass @@ -166,7 +177,10 @@ class FakeAgent(WebMixin): return agent -def _run(coro): +_T = TypeVar("_T") + + +def _run(coro: Awaitable[_T]) -> _T: """Run a coroutine synchronously for testing.""" loop = asyncio.new_event_loop() try: @@ -182,20 +196,20 @@ def _run(coro): class TestGetApp: """Tests for WebMixin.get_app()""" - def test_get_app_creates_fastapi_app(self): + def test_get_app_creates_fastapi_app(self) -> None: agent = _build_mixin() app = agent.get_app() assert app is not None # Should be cached assert agent.get_app() is app - def test_get_app_returns_cached_app(self): + def test_get_app_returns_cached_app(self) -> None: agent = _build_mixin() fake_app = MagicMock() agent._app = fake_app assert agent.get_app() is fake_app - def test_get_app_root_route_no_prefix(self): + def test_get_app_root_route_no_prefix(self) -> None: """When the agent's route is "/", the FastAPI app must include the agent router WITHOUT a prefix. Health/ready must be reachable at the bare paths "/health" and "/ready".""" @@ -212,7 +226,7 @@ def test_get_app_root_route_no_prefix(self): if p: assert not p.startswith("//") - def test_get_app_non_root_route_uses_prefix(self): + def test_get_app_non_root_route_uses_prefix(self) -> None: """When the agent's route is "/myagent", the agent router's paths must be MOUNTED under that prefix. Health/ready remain at the bare paths because they're attached to the root app.""" @@ -237,13 +251,13 @@ def test_get_app_non_root_route_uses_prefix(self): class TestAsRouter: """Tests for WebMixin.as_router()""" - def test_as_router_returns_api_router(self): + def test_as_router_returns_api_router(self) -> None: from fastapi import APIRouter agent = _build_mixin() router = agent.as_router() assert isinstance(router, APIRouter) - def test_as_router_registers_routes(self): + def test_as_router_registers_routes(self) -> None: agent = _build_mixin() router = agent.as_router() paths = [r.path for r in router.routes if hasattr(r, "path")] @@ -254,7 +268,7 @@ def test_as_router_registers_routes(self): assert "/post_prompt" in paths assert "/check_for_input" in paths - def test_as_router_registers_callback_routes(self): + def test_as_router_registers_callback_routes(self) -> None: cb = MagicMock() agent = _build_mixin(_routing_callbacks={"/sip": cb}) router = agent.as_router() @@ -269,7 +283,7 @@ def test_as_router_registers_callback_routes(self): class TestRegisterRoutes: """Tests for WebMixin._register_routes()""" - def test_register_routes_creates_slash_variants(self): + def test_register_routes_creates_slash_variants(self) -> None: agent = _build_mixin() from fastapi import APIRouter router = APIRouter() @@ -281,7 +295,7 @@ def test_register_routes_creates_slash_variants(self): assert "/post_prompt/" in paths assert "/check_for_input/" in paths - def test_register_routes_skips_root_callback(self): + def test_register_routes_skips_root_callback(self) -> None: """Routing callbacks for '/' should be skipped (handled by root handler).""" cb = MagicMock() agent = _build_mixin(_routing_callbacks={"/": cb, "/custom": MagicMock()}) @@ -300,14 +314,14 @@ def test_register_routes_skips_root_callback(self): class TestTokenEnforcement: """Tests for basic auth enforcement across endpoints.""" - def test_root_request_rejects_unauthorized(self): + def test_root_request_rejects_unauthorized(self) -> None: agent = _build_mixin() agent._check_basic_auth = MagicMock(return_value=False) request = _make_request("POST", body={}) response = _run(agent._handle_root_request(request)) assert response.status_code == 401 - def test_root_request_allows_authorized(self): + def test_root_request_allows_authorized(self) -> None: agent = _build_mixin() agent._check_basic_auth = MagicMock(return_value=True) request = _make_request("POST", body={}) @@ -315,14 +329,14 @@ def test_root_request_allows_authorized(self): # Should get a 200 JSON response (SWML) assert response.status_code == 200 - def test_debug_request_rejects_unauthorized(self): + def test_debug_request_rejects_unauthorized(self) -> None: agent = _build_mixin() agent._check_basic_auth = MagicMock(return_value=False) request = _make_request("GET", url_path="/agent/debug") response = _run(agent._handle_debug_request(request)) assert response.status_code == 401 - def test_debug_request_allows_authorized(self): + def test_debug_request_allows_authorized(self) -> None: agent = _build_mixin() agent._check_basic_auth = MagicMock(return_value=True) request = _make_request("GET", url_path="/agent/debug") @@ -330,7 +344,7 @@ def test_debug_request_allows_authorized(self): assert response.status_code == 200 # Debug endpoint returns SWML successfully - def test_swaig_request_rejects_unauthorized(self): + def test_swaig_request_rejects_unauthorized(self) -> None: agent = _build_mixin() agent._check_basic_auth = MagicMock(return_value=False) response_obj = MagicMock() @@ -339,14 +353,14 @@ def test_swaig_request_rejects_unauthorized(self): response = _run(agent._handle_swaig_request(request, response_obj)) assert response.status_code == 401 - def test_post_prompt_request_rejects_unauthorized(self): + def test_post_prompt_request_rejects_unauthorized(self) -> None: agent = _build_mixin() agent._check_basic_auth = MagicMock(return_value=False) request = _make_request("POST", body={"summary": "done"}) response = _run(agent._handle_post_prompt_request(request)) assert response.status_code == 401 - def test_check_for_input_rejects_unauthorized(self): + def test_check_for_input_rejects_unauthorized(self) -> None: agent = _build_mixin() agent._check_basic_auth = MagicMock(return_value=False) request = _make_request("POST", body={"conversation_id": "abc"}) @@ -361,28 +375,28 @@ def test_check_for_input_rejects_unauthorized(self): class TestHandleRootRequest: """Tests for _handle_root_request.""" - def test_get_request_returns_swml(self): + def test_get_request_returns_swml(self) -> None: agent = _build_mixin() request = _make_request("GET") response = _run(agent._handle_root_request(request)) assert response.status_code == 200 assert response.media_type == "application/json" - def test_post_request_with_body(self): + def test_post_request_with_body(self) -> None: agent = _build_mixin() request = _make_request("POST", body={"call_id": "call-123"}) response = _run(agent._handle_root_request(request)) assert response.status_code == 200 agent._render_swml.assert_called() - def test_post_request_with_empty_body(self): + def test_post_request_with_empty_body(self) -> None: agent = _build_mixin() request = _make_request("POST") request.body = AsyncMock(return_value=b"") response = _run(agent._handle_root_request(request)) assert response.status_code == 200 - def test_post_request_with_malformed_json(self): + def test_post_request_with_malformed_json(self) -> None: agent = _build_mixin() request = _make_request("POST") request.body = AsyncMock(return_value=b"not-json") @@ -391,7 +405,7 @@ def test_post_request_with_malformed_json(self): # Should still succeed, just with empty body assert response.status_code == 200 - def test_call_id_extracted_from_post_body(self): + def test_call_id_extracted_from_post_body(self) -> None: agent = _build_mixin() request = _make_request("POST", body={"call_id": "cid-xyz"}) _run(agent._handle_root_request(request)) @@ -399,7 +413,7 @@ def test_call_id_extracted_from_post_body(self): args, kwargs = agent._render_swml.call_args assert args[0] == "cid-xyz" - def test_call_id_extracted_from_nested_call(self): + def test_call_id_extracted_from_nested_call(self) -> None: agent = _build_mixin() body = {"call": {"call_id": "nested-id"}} request = _make_request("POST", body=body) @@ -407,14 +421,14 @@ def test_call_id_extracted_from_nested_call(self): args, kwargs = agent._render_swml.call_args assert args[0] == "nested-id" - def test_call_id_from_query_params_on_get(self): + def test_call_id_from_query_params_on_get(self) -> None: agent = _build_mixin() request = _make_request("GET", query_params={"call_id": "q-id"}) _run(agent._handle_root_request(request)) args, kwargs = agent._render_swml.call_args assert args[0] == "q-id" - def test_proxy_detection_from_forwarded_headers(self): + def test_proxy_detection_from_forwarded_headers(self) -> None: agent = _build_mixin() headers = { "X-Forwarded-Host": "proxy.example.com", @@ -425,7 +439,7 @@ def test_proxy_detection_from_forwarded_headers(self): _run(agent._handle_root_request(request)) assert agent._proxy_url_base == "https://proxy.example.com" - def test_proxy_from_env_not_overridden_by_headers(self): + def test_proxy_from_env_not_overridden_by_headers(self) -> None: agent = _build_mixin( _proxy_url_base="https://env-proxy.example.com", _proxy_url_base_from_env=True, @@ -439,13 +453,13 @@ def test_proxy_from_env_not_overridden_by_headers(self): # Should keep the env proxy URL, not the header one assert agent._proxy_url_base == "https://env-proxy.example.com" - def test_no_proxy_headers_clears_proxy(self): + def test_no_proxy_headers_clears_proxy(self) -> None: agent = _build_mixin(_proxy_url_base="https://old-proxy.example.com") request = _make_request("GET") _run(agent._handle_root_request(request)) assert agent._proxy_url_base is None - def test_no_proxy_headers_keeps_env_proxy(self): + def test_no_proxy_headers_keeps_env_proxy(self) -> None: agent = _build_mixin( _proxy_url_base="https://env-proxy.example.com", _proxy_url_base_from_env=True, @@ -454,7 +468,7 @@ def test_no_proxy_headers_keeps_env_proxy(self): _run(agent._handle_root_request(request)) assert agent._proxy_url_base == "https://env-proxy.example.com" - def test_callback_path_routing(self): + def test_callback_path_routing(self) -> None: cb_fn = MagicMock(return_value="/redirect-here") agent = _build_mixin(_routing_callbacks={"/sip": cb_fn}) request = _make_request("POST", body={"some": "data"}, url_path="/agent/sip") @@ -463,7 +477,7 @@ def test_callback_path_routing(self): # A redirect should be returned assert response.status_code == 307 - def test_callback_returns_none_continues_normally(self): + def test_callback_returns_none_continues_normally(self) -> None: cb_fn = MagicMock(return_value=None) agent = _build_mixin(_routing_callbacks={"/sip": cb_fn}) request = _make_request("POST", body={"some": "data"}, url_path="/agent/sip") @@ -472,7 +486,7 @@ def test_callback_returns_none_continues_normally(self): # No redirect; falls through to normal SWML rendering assert response.status_code == 200 - def test_callback_exception_handled_gracefully(self): + def test_callback_exception_handled_gracefully(self) -> None: cb_fn = MagicMock(side_effect=RuntimeError("callback boom")) agent = _build_mixin(_routing_callbacks={"/sip": cb_fn}) request = _make_request("POST", body={"some": "data"}, url_path="/agent/sip") @@ -481,14 +495,14 @@ def test_callback_exception_handled_gracefully(self): # Should still succeed with SWML assert response.status_code == 200 - def test_on_swml_request_called(self): + def test_on_swml_request_called(self) -> None: agent = _build_mixin() agent.on_swml_request = MagicMock(return_value=None) request = _make_request("POST", body={}) _run(agent._handle_root_request(request)) agent.on_swml_request.assert_called_once() - def test_render_swml_exception_returns_500(self): + def test_render_swml_exception_returns_500(self) -> None: agent = _build_mixin() agent._render_swml = MagicMock(side_effect=RuntimeError("render fail")) request = _make_request("GET") @@ -505,42 +519,42 @@ def test_render_swml_exception_returns_500(self): class TestHandleDebugRequest: """Tests for _handle_debug_request.""" - def test_get_returns_swml_with_debug_header(self): + def test_get_returns_swml_with_debug_header(self) -> None: agent = _build_mixin() request = _make_request("GET", url_path="/agent/debug") response = _run(agent._handle_debug_request(request)) assert response.status_code == 200 # Debug endpoint returns SWML successfully - def test_post_extracts_call_id_from_body(self): + def test_post_extracts_call_id_from_body(self) -> None: agent = _build_mixin() request = _make_request("POST", body={"call_id": "debug-call-1"}, url_path="/agent/debug") _run(agent._handle_debug_request(request)) args, kwargs = agent._render_swml.call_args assert args[0] == "debug-call-1" - def test_get_extracts_call_id_from_query(self): + def test_get_extracts_call_id_from_query(self) -> None: agent = _build_mixin() request = _make_request("GET", query_params={"call_id": "q-debug"}, url_path="/agent/debug") _run(agent._handle_debug_request(request)) args, kwargs = agent._render_swml.call_args assert args[0] == "q-debug" - def test_post_malformed_body_still_renders(self): + def test_post_malformed_body_still_renders(self) -> None: agent = _build_mixin() request = _make_request("POST", url_path="/agent/debug") request.json = AsyncMock(side_effect=json.JSONDecodeError("err", "doc", 0)) _run(agent._handle_debug_request(request)) agent._render_swml.assert_called() - def test_render_exception_returns_500(self): + def test_render_exception_returns_500(self) -> None: agent = _build_mixin() agent._render_swml = MagicMock(side_effect=ValueError("bad")) request = _make_request("GET", url_path="/agent/debug") response = _run(agent._handle_debug_request(request)) assert response.status_code == 500 - def test_on_swml_request_called_with_none_callback(self): + def test_on_swml_request_called_with_none_callback(self) -> None: agent = _build_mixin() agent.on_swml_request = MagicMock(return_value=None) request = _make_request("POST", body={"call_id": "x"}, url_path="/agent/debug") @@ -555,7 +569,7 @@ def test_on_swml_request_called_with_none_callback(self): class TestHandleSwaigRequest: """Tests for _handle_swaig_request.""" - def test_get_returns_swml(self): + def test_get_returns_swml(self) -> None: agent = _build_mixin() resp = MagicMock() resp.headers = {} @@ -563,7 +577,7 @@ def test_get_returns_swml(self): response = _run(agent._handle_swaig_request(request, resp)) assert response.status_code == 200 - def test_post_missing_function_name_returns_400(self): + def test_post_missing_function_name_returns_400(self) -> None: agent = _build_mixin() resp = MagicMock() resp.headers = {} @@ -571,7 +585,7 @@ def test_post_missing_function_name_returns_400(self): response = _run(agent._handle_swaig_request(request, resp)) assert response.status_code == 400 - def test_post_calls_function(self): + def test_post_calls_function(self) -> None: agent = _build_mixin() agent.on_function_call = MagicMock(return_value=FunctionResult("done")) resp = MagicMock() @@ -587,7 +601,7 @@ def test_post_calls_function(self): assert "response" in result assert result["response"] == "done" - def test_post_parses_raw_arguments(self): + def test_post_parses_raw_arguments(self) -> None: agent = _build_mixin() agent.on_function_call = MagicMock(return_value={"response": "ok"}) resp = MagicMock() @@ -600,7 +614,7 @@ def test_post_parses_raw_arguments(self): _run(agent._handle_swaig_request(request, resp)) agent.on_function_call.assert_called_once_with("raw_func", {"a": 1}, body) - def test_post_invalid_raw_arguments_uses_empty(self): + def test_post_invalid_raw_arguments_uses_empty(self) -> None: agent = _build_mixin() agent.on_function_call = MagicMock(return_value={"response": "ok"}) resp = MagicMock() @@ -613,7 +627,7 @@ def test_post_invalid_raw_arguments_uses_empty(self): _run(agent._handle_swaig_request(request, resp)) agent.on_function_call.assert_called_once_with("bad_raw", {}, body) - def test_token_validation_valid(self): + def test_token_validation_valid(self) -> None: agent = _build_mixin() agent._session_manager.validate_tool_token = MagicMock(return_value=True) agent._tool_registry._swaig_functions = {"my_func": {"secure": True}} @@ -631,7 +645,7 @@ def test_token_validation_valid(self): # Function should still be called agent.on_function_call.assert_called() - def test_token_validation_invalid_secure_function_returns_swaig_error(self): + def test_token_validation_invalid_secure_function_returns_swaig_error(self) -> None: """When a secure function has an invalid token, the handler returns a SWAIG-format dict with a 'response' key describing the error, so the AI can relay the message to the user.""" @@ -655,7 +669,7 @@ def test_token_validation_invalid_secure_function_returns_swaig_error(self): # Function should NOT have been called agent.on_function_call.assert_not_called() - def test_token_validation_invalid_nonsecure_function_continues(self): + def test_token_validation_invalid_nonsecure_function_continues(self) -> None: agent = _build_mixin() agent._session_manager.validate_tool_token = MagicMock(return_value=False) agent._session_manager.debug_token = MagicMock(return_value={}) @@ -673,7 +687,7 @@ def test_token_validation_invalid_nonsecure_function_continues(self): # Should proceed since function is not secure agent.on_function_call.assert_called() - def test_dynamic_config_callback_creates_ephemeral(self): + def test_dynamic_config_callback_creates_ephemeral(self) -> None: ephemeral = MagicMock() ephemeral.on_function_call = MagicMock(return_value={"response": "ephemeral"}) config_cb = MagicMock() @@ -688,7 +702,7 @@ def test_dynamic_config_callback_creates_ephemeral(self): config_cb.assert_called_once() ephemeral.on_function_call.assert_called_once() - def test_function_execution_error_returns_error_dict(self): + def test_function_execution_error_returns_error_dict(self) -> None: agent = _build_mixin() agent.on_function_call = MagicMock(side_effect=RuntimeError("boom")) resp = MagicMock() @@ -699,7 +713,7 @@ def test_function_execution_error_returns_error_dict(self): assert "error" in result assert "boom" in result["error"] - def test_swaig_function_result_dict_passthrough(self): + def test_swaig_function_result_dict_passthrough(self) -> None: agent = _build_mixin() agent.on_function_call = MagicMock(return_value={"response": "direct dict"}) resp = MagicMock() @@ -709,7 +723,7 @@ def test_swaig_function_result_dict_passthrough(self): result = _run(agent._handle_swaig_request(request, resp)) assert result == {"response": "direct dict"} - def test_swaig_function_result_string_wrapped(self): + def test_swaig_function_result_string_wrapped(self) -> None: agent = _build_mixin() agent.on_function_call = MagicMock(return_value="plain string") resp = MagicMock() @@ -727,14 +741,14 @@ def test_swaig_function_result_string_wrapped(self): class TestHandlePostPromptRequest: """Tests for _handle_post_prompt_request.""" - def test_get_returns_swml(self): + def test_get_returns_swml(self) -> None: agent = _build_mixin() agent.on_swml_request = MagicMock(return_value=None) request = _make_request("GET", url_path="/agent/post_prompt") response = _run(agent._handle_post_prompt_request(request)) assert response.status_code == 200 - def test_post_calls_on_summary(self): + def test_post_calls_on_summary(self) -> None: agent = _build_mixin() agent._find_summary_in_post_data = MagicMock(return_value={"summary": "the call ended"}) agent.on_summary = MagicMock(return_value=None) @@ -744,7 +758,7 @@ def test_post_calls_on_summary(self): agent.on_summary.assert_called_once_with({"summary": "the call ended"}, body) assert result == {"success": True} - def test_post_with_no_summary(self): + def test_post_with_no_summary(self) -> None: agent = _build_mixin() agent._find_summary_in_post_data = MagicMock(return_value=None) agent.on_summary = MagicMock(return_value=None) @@ -754,7 +768,7 @@ def test_post_with_no_summary(self): agent.on_summary.assert_called_once_with(None, body) assert result == {"success": True} - def test_post_fetch_conversation_returns_result(self): + def test_post_fetch_conversation_returns_result(self) -> None: agent = _build_mixin() agent._find_summary_in_post_data = MagicMock(return_value="some summary") fetch_result = {"conversation": [{"role": "user", "content": "hi"}]} @@ -764,7 +778,7 @@ def test_post_fetch_conversation_returns_result(self): result = _run(agent._handle_post_prompt_request(request)) assert result == fetch_result - def test_post_token_validation(self): + def test_post_token_validation(self) -> None: agent = _build_mixin() agent._session_manager.validate_tool_token = MagicMock(return_value=True) body = {"call_id": "c1"} @@ -776,7 +790,7 @@ def test_post_token_validation(self): _run(agent._handle_post_prompt_request(request)) agent._session_manager.validate_tool_token.assert_called_once_with("post_prompt", "good", "c1") - def test_post_token_fallback_to_token_param(self): + def test_post_token_fallback_to_token_param(self) -> None: agent = _build_mixin() agent._session_manager.validate_tool_token = MagicMock(return_value=True) body = {"call_id": "c1"} @@ -788,7 +802,7 @@ def test_post_token_fallback_to_token_param(self): _run(agent._handle_post_prompt_request(request)) agent._session_manager.validate_tool_token.assert_called_once_with("post_prompt", "fallback-tok", "c1") - def test_post_dynamic_config_creates_ephemeral(self): + def test_post_dynamic_config_creates_ephemeral(self) -> None: ephemeral = MagicMock() ephemeral._find_summary_in_post_data = MagicMock(return_value=None) ephemeral.on_summary = MagicMock(return_value=None) @@ -802,7 +816,7 @@ def test_post_dynamic_config_creates_ephemeral(self): config_cb.assert_called_once() ephemeral.on_summary.assert_called_once() - def test_post_exception_returns_500(self): + def test_post_exception_returns_500(self) -> None: agent = _build_mixin() agent._find_summary_in_post_data = MagicMock(side_effect=RuntimeError("oops")) body = {"call_id": "c1"} @@ -810,7 +824,7 @@ def test_post_exception_returns_500(self): response = _run(agent._handle_post_prompt_request(request)) assert response.status_code == 500 - def test_suppress_logs_flag(self): + def test_suppress_logs_flag(self) -> None: """When _suppress_logs=True the handler must STILL produce a valid response — log suppression must not break the response path. We assert the flag was honoured by the agent and that the @@ -836,7 +850,7 @@ def test_suppress_logs_flag(self): class TestHandleCheckForInputRequest: """Tests for _handle_check_for_input_request.""" - def test_post_with_conversation_id(self): + def test_post_with_conversation_id(self) -> None: agent = _build_mixin() body = {"conversation_id": "conv-123"} request = _make_request("POST", body=body, url_path="/agent/check_for_input") @@ -845,16 +859,16 @@ def test_post_with_conversation_id(self): assert result["conversation_id"] == "conv-123" assert result["new_input"] is False - def test_get_with_conversation_id(self): + def test_get_with_conversation_id(self) -> None: agent = _build_mixin() request = _make_request("GET", query_params={"conversation_id": "conv-456"}, url_path="/agent/check_for_input") result = _run(agent._handle_check_for_input_request(request)) assert result["status"] == "success" assert result["conversation_id"] == "conv-456" - def test_missing_conversation_id_returns_400(self): + def test_missing_conversation_id_returns_400(self) -> None: agent = _build_mixin() - body = {} + body: dict[str, Any] = {} request = _make_request("POST", body=body, url_path="/agent/check_for_input") response = _run(agent._handle_check_for_input_request(request)) assert response.status_code == 400 @@ -867,39 +881,39 @@ def test_missing_conversation_id_returns_400(self): class TestOnRequestAndOnSwmlRequest: """Tests for on_request and on_swml_request methods.""" - def test_on_request_delegates_to_on_swml_request(self): + def test_on_request_delegates_to_on_swml_request(self) -> None: agent = _build_mixin() agent.on_swml_request = MagicMock(return_value={"custom": True}) result = agent.on_request({"data": "val"}, "/cb") agent.on_swml_request.assert_called_once_with({"data": "val"}, "/cb", None) assert result == {"custom": True} - def test_on_request_returns_none_when_on_swml_request_not_callable(self): + def test_on_request_returns_none_when_on_swml_request_not_callable(self) -> None: agent = _build_mixin() # Override on_swml_request with a non-callable so the callable() check fails agent.on_swml_request = "not_callable" result = agent.on_request(None, None) assert result is None - def test_on_swml_request_returns_ephemeral_marker_with_dynamic_callback(self): + def test_on_swml_request_returns_ephemeral_marker_with_dynamic_callback(self) -> None: cb = MagicMock() agent = _build_mixin(_dynamic_config_callback=cb) result = agent.on_swml_request({"data": True}, None, None) assert result is not None assert result["__use_ephemeral_agent"] is True - def test_on_swml_request_returns_none_without_dynamic_callback(self): + def test_on_swml_request_returns_none_without_dynamic_callback(self) -> None: agent = _build_mixin() result = agent.on_swml_request(None, None, None) assert result is None - def test_on_swml_request_skips_ephemeral_agents(self): + def test_on_swml_request_skips_ephemeral_agents(self) -> None: cb = MagicMock() agent = _build_mixin(_dynamic_config_callback=cb, _is_ephemeral=True) result = agent.on_swml_request(None, None, None) assert result is None - def test_on_swml_request_includes_request_in_marker(self): + def test_on_swml_request_includes_request_in_marker(self) -> None: cb = MagicMock() agent = _build_mixin(_dynamic_config_callback=cb) mock_req = MagicMock() @@ -915,26 +929,26 @@ def test_on_swml_request_includes_request_in_marker(self): class TestRegisterRoutingCallback: """Tests for register_routing_callback.""" - def test_registers_callback_with_normalized_path(self): + def test_registers_callback_with_normalized_path(self) -> None: agent = _build_mixin() fn = MagicMock() agent.register_routing_callback(fn, "/sip/") assert "/sip" in agent._routing_callbacks assert agent._routing_callbacks["/sip"] is fn - def test_adds_leading_slash(self): + def test_adds_leading_slash(self) -> None: agent = _build_mixin() fn = MagicMock() agent.register_routing_callback(fn, "custom") assert "/custom" in agent._routing_callbacks - def test_default_path_is_sip(self): + def test_default_path_is_sip(self) -> None: agent = _build_mixin() fn = MagicMock() agent.register_routing_callback(fn) assert "/sip" in agent._routing_callbacks - def test_initializes_routing_callbacks_dict(self): + def test_initializes_routing_callbacks_dict(self) -> None: agent = _build_mixin() # Remove the dict to test initialization if hasattr(agent, "_routing_callbacks"): @@ -952,7 +966,7 @@ def test_initializes_routing_callbacks_dict(self): class TestSetDynamicConfigCallback: """Tests for set_dynamic_config_callback.""" - def test_sets_callback(self): + def test_sets_callback(self) -> None: agent = _build_mixin() fn = MagicMock() result = agent.set_dynamic_config_callback(fn) @@ -967,25 +981,25 @@ def test_sets_callback(self): class TestManualSetProxyUrl: """Tests for manual_set_proxy_url.""" - def test_sets_proxy_url(self): + def test_sets_proxy_url(self) -> None: agent = _build_mixin() result = agent.manual_set_proxy_url("https://example.ngrok.io/") assert agent._proxy_url_base == "https://example.ngrok.io" assert agent._proxy_detection_done is True assert result is agent - def test_strips_trailing_slash(self): + def test_strips_trailing_slash(self) -> None: agent = _build_mixin() agent.manual_set_proxy_url("https://proxy.com///") assert agent._proxy_url_base == "https://proxy.com" - def test_empty_string_does_not_set(self): + def test_empty_string_does_not_set(self) -> None: agent = _build_mixin(_proxy_url_base="old") agent.manual_set_proxy_url("") # Should not have changed assert agent._proxy_url_base == "old" - def test_none_does_not_set(self): + def test_none_does_not_set(self) -> None: agent = _build_mixin(_proxy_url_base="old") agent.manual_set_proxy_url(None) assert agent._proxy_url_base == "old" @@ -998,7 +1012,7 @@ def test_none_does_not_set(self): class TestSetupGracefulShutdown: """Tests for setup_graceful_shutdown.""" - def test_registers_signal_handlers(self): + def test_registers_signal_handlers(self) -> None: agent = _build_mixin() import signal as sig_module with patch.object(sig_module, "signal") as mock_signal: @@ -1016,7 +1030,7 @@ def test_registers_signal_handlers(self): class TestEnableDebugRoutes: """Tests for enable_debug_routes.""" - def test_returns_self_for_chaining(self): + def test_returns_self_for_chaining(self) -> None: agent = _build_mixin() result = agent.enable_debug_routes() assert result is agent @@ -1029,13 +1043,13 @@ def test_returns_self_for_chaining(self): class TestRoutePrefixHandling: """Tests verifying route prefix behaviour with different route configurations.""" - def test_root_route_no_prefix(self): + def test_root_route_no_prefix(self) -> None: agent = _build_mixin(route="/") app = agent.get_app() # Router should be included without prefix assert app is not None - def test_custom_route_uses_prefix(self): + def test_custom_route_uses_prefix(self) -> None: """A multi-segment route like "/v1/mybot" must mount the agent router under that exact prefix — at least one route on the app must begin with "/v1/mybot".""" @@ -1046,7 +1060,7 @@ def test_custom_route_uses_prefix(self): f"{[(type(r).__name__, getattr(r, 'path', None)) for r in app.routes]}" ) - def test_serve_root_route(self): + def test_serve_root_route(self) -> None: """When the agent is mounted at the root ("/"), the agent's own routes must NOT carry an unintended prefix. Bare /health must be a registered route on the assembled app.""" @@ -1057,7 +1071,7 @@ def test_serve_root_route(self): assert "/health" in paths assert "/ready" in paths - def test_serve_with_prefix(self): + def test_serve_with_prefix(self) -> None: agent = _build_mixin(route="/bot") app = agent.get_app() # Verify the router was created @@ -1071,7 +1085,7 @@ def test_serve_with_prefix(self): class TestAzureModeBehavior: """Tests for Azure Function mode in the run() method.""" - def test_run_azure_function_mode(self): + def test_run_azure_function_mode(self) -> None: agent = _build_mixin() mock_event = MagicMock() agent.handle_serverless_request = MagicMock(return_value="azure-response") @@ -1079,7 +1093,7 @@ def test_run_azure_function_mode(self): agent.handle_serverless_request.assert_called_once_with(mock_event, None, "azure_function") assert result == "azure-response" - def test_run_lambda_mode(self): + def test_run_lambda_mode(self) -> None: agent = _build_mixin() mock_event = {"headers": {}, "body": "{}"} agent.handle_serverless_request = MagicMock(return_value={"statusCode": 200}) @@ -1087,7 +1101,7 @@ def test_run_lambda_mode(self): agent.handle_serverless_request.assert_called_once_with(mock_event, None, "lambda") assert result == {"statusCode": 200} - def test_run_cgi_mode(self): + def test_run_cgi_mode(self) -> None: agent = _build_mixin() agent.handle_serverless_request = MagicMock(return_value="CGI output") with patch("builtins.print") as mock_print: @@ -1096,20 +1110,20 @@ def test_run_cgi_mode(self): mock_print.assert_called_once_with("CGI output") assert result == "CGI output" - def test_run_google_cloud_function_mode(self): + def test_run_google_cloud_function_mode(self) -> None: agent = _build_mixin() mock_event = MagicMock() agent.handle_serverless_request = MagicMock(return_value="gcf-response") result = agent.run(event=mock_event, force_mode="google_cloud_function") assert result == "gcf-response" - def test_run_server_mode_calls_serve(self): + def test_run_server_mode_calls_serve(self) -> None: agent = _build_mixin() agent.serve = MagicMock() agent.run(force_mode="server", host="127.0.0.1", port=9000) agent.serve.assert_called_once_with("127.0.0.1", 9000) - def test_run_lambda_error_returns_500(self): + def test_run_lambda_error_returns_500(self) -> None: agent = _build_mixin() agent.handle_serverless_request = MagicMock(side_effect=RuntimeError("lambda fail")) result = agent.run(force_mode="lambda") @@ -1117,14 +1131,14 @@ def test_run_lambda_error_returns_500(self): body = json.loads(result["body"]) assert "lambda fail" in body["error"] - def test_run_non_lambda_error_raises(self): + def test_run_non_lambda_error_raises(self) -> None: agent = _build_mixin() agent.handle_serverless_request = MagicMock(side_effect=RuntimeError("cgi fail")) with pytest.raises(RuntimeError, match="cgi fail"): with patch("builtins.print"): agent.run(force_mode="cgi") - def test_run_auto_detection_defaults_to_server(self): + def test_run_auto_detection_defaults_to_server(self) -> None: agent = _build_mixin() agent.serve = MagicMock() with patch("signalwire.core.mixins.web_mixin.get_execution_mode", return_value="server"): @@ -1139,11 +1153,11 @@ def test_run_auto_detection_defaults_to_server(self): class TestServe: """Tests for serve() method.""" - def _patch_uvicorn(self): + def _patch_uvicorn(self) -> Any: """Patch uvicorn inside serve() since it uses a local import.""" return patch.dict("sys.modules", {"uvicorn": MagicMock()}) - def test_serve_uses_default_host_and_port(self): + def test_serve_uses_default_host_and_port(self) -> None: import sys mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): @@ -1154,7 +1168,7 @@ def test_serve_uses_default_host_and_port(self): assert kwargs["host"] == "0.0.0.0" assert kwargs["port"] == 3000 - def test_serve_uses_override_host_and_port(self): + def test_serve_uses_override_host_and_port(self) -> None: import sys mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): @@ -1164,7 +1178,7 @@ def test_serve_uses_override_host_and_port(self): assert kwargs["host"] == "127.0.0.1" assert kwargs["port"] == 9999 - def test_serve_with_ssl(self): + def test_serve_with_ssl(self) -> None: import sys mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): @@ -1178,7 +1192,7 @@ def test_serve_with_ssl(self): assert kwargs["ssl_certfile"] == "/path/to/cert.pem" assert kwargs["ssl_keyfile"] == "/path/to/key.pem" - def test_serve_without_ssl(self): + def test_serve_without_ssl(self) -> None: import sys mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): @@ -1188,7 +1202,7 @@ def test_serve_without_ssl(self): assert "ssl_certfile" not in kwargs assert "ssl_keyfile" not in kwargs - def test_serve_caches_app(self): + def test_serve_caches_app(self) -> None: import sys mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): @@ -1196,7 +1210,7 @@ def test_serve_caches_app(self): agent.serve() assert agent._app is not None - def test_serve_reuses_cached_app(self): + def test_serve_reuses_cached_app(self) -> None: import sys mock_uvicorn = MagicMock() fake_app = MagicMock() @@ -1209,7 +1223,7 @@ def test_serve_reuses_cached_app(self): call_args = mock_uvicorn.run.call_args assert call_args[0][0] is fake_app - def test_serve_root_route_includes_router_without_prefix(self): + def test_serve_root_route_includes_router_without_prefix(self) -> None: import sys mock_uvicorn = MagicMock() with patch.dict(sys.modules, {"uvicorn": mock_uvicorn}): @@ -1225,7 +1239,7 @@ def test_serve_root_route_includes_router_without_prefix(self): class TestHandleRootRequestModifications: """Tests for on_swml_request modification paths in _handle_root_request.""" - def test_on_swml_request_returns_truthy_modifications(self): + def test_on_swml_request_returns_truthy_modifications(self) -> None: """Line 540: when on_swml_request returns truthy, modifications are passed to _render_swml.""" agent = _build_mixin() mods = {"some_mod": True} @@ -1235,7 +1249,7 @@ def test_on_swml_request_returns_truthy_modifications(self): args, kwargs = agent._render_swml.call_args assert args[1] == mods - def test_on_swml_request_exception_handled(self): + def test_on_swml_request_exception_handled(self) -> None: """Line 541-542: when on_swml_request raises, error is logged and rendering continues.""" agent = _build_mixin() agent.on_swml_request = MagicMock(side_effect=RuntimeError("swml request boom")) @@ -1250,7 +1264,7 @@ def test_on_swml_request_exception_handled(self): class TestHandleDebugRequestModifications: """Tests for on_swml_request modification paths in _handle_debug_request.""" - def test_on_swml_request_returns_truthy_modifications(self): + def test_on_swml_request_returns_truthy_modifications(self) -> None: """Line 607: when on_swml_request returns truthy, modifications are passed to _render_swml.""" agent = _build_mixin() mods = {"debug_mod": True} @@ -1260,7 +1274,7 @@ def test_on_swml_request_returns_truthy_modifications(self): args, _ = agent._render_swml.call_args assert args[1] == mods - def test_on_swml_request_exception_handled(self): + def test_on_swml_request_exception_handled(self) -> None: """Lines 608-609: when on_swml_request raises, error is logged and rendering continues.""" agent = _build_mixin() agent.on_swml_request = MagicMock(side_effect=ValueError("debug swml err")) @@ -1273,7 +1287,7 @@ def test_on_swml_request_exception_handled(self): class TestHandleSwaigRequestMalformedBody: """Tests for malformed body and dynamic config errors in _handle_swaig_request.""" - def test_malformed_json_body_returns_400_missing_function(self): + def test_malformed_json_body_returns_400_missing_function(self) -> None: """Lines 669-671: when request.json() fails, body={} and function is missing -> 400.""" agent = _build_mixin() resp = MagicMock() @@ -1283,7 +1297,7 @@ def test_malformed_json_body_returns_400_missing_function(self): response = _run(agent._handle_swaig_request(request, resp)) assert response.status_code == 400 - def test_dynamic_config_callback_error_still_calls_function(self): + def test_dynamic_config_callback_error_still_calls_function(self) -> None: """Lines 746-747: when dynamic config callback raises, error is logged but function still called.""" config_cb = MagicMock(side_effect=RuntimeError("config boom")) ephemeral = MagicMock() @@ -1302,7 +1316,7 @@ def test_dynamic_config_callback_error_still_calls_function(self): class TestHandlePostPromptRequestExtraPaths: """Tests for additional branches in _handle_post_prompt_request.""" - def test_body_parsing_error_extracting_call_id(self): + def test_body_parsing_error_extracting_call_id(self) -> None: """Lines 812-813: error when extracting call_id from body.""" agent = _build_mixin() agent._find_summary_in_post_data = MagicMock(return_value=None) @@ -1323,7 +1337,7 @@ def test_body_parsing_error_extracting_call_id(self): # Should still succeed with empty body parsed later assert result == {"success": True} - def test_invalid_token_with_debug_token(self): + def test_invalid_token_with_debug_token(self) -> None: """Lines 834-840: invalid token triggers debug_token call.""" agent = _build_mixin() agent._session_manager.validate_tool_token = MagicMock(return_value=False) @@ -1340,7 +1354,7 @@ def test_invalid_token_with_debug_token(self): agent._session_manager.debug_token.assert_called_once_with("bad-tok") assert result == {"success": True} - def test_token_validation_error(self): + def test_token_validation_error(self) -> None: """Line 839-840: exception during token validation is caught.""" agent = _build_mixin() agent._session_manager.validate_tool_token = MagicMock(side_effect=RuntimeError("token err")) @@ -1355,7 +1369,7 @@ def test_token_validation_error(self): result = _run(agent._handle_post_prompt_request(request)) assert result == {"success": True} - def test_body_not_pre_parsed_falls_through_to_request_json(self): + def test_body_not_pre_parsed_falls_through_to_request_json(self) -> None: """Line 859: when _post_prompt_body is not set, falls through to request.json().""" agent = _build_mixin() agent._find_summary_in_post_data = MagicMock(return_value=None) @@ -1377,7 +1391,7 @@ def test_body_not_pre_parsed_falls_through_to_request_json(self): result = _run(agent._handle_post_prompt_request(request)) assert result == {"success": True} - def test_post_body_parsing_error_results_in_empty_body(self): + def test_post_body_parsing_error_results_in_empty_body(self) -> None: """Lines 866-868: when body parsing fails completely, body defaults to {}.""" agent = _build_mixin() agent._find_summary_in_post_data = MagicMock(return_value=None) @@ -1401,7 +1415,7 @@ def test_post_body_parsing_error_results_in_empty_body(self): agent.on_summary.assert_called_once_with(None, {}) assert result == {"success": True} - def test_dynamic_config_callback_error_in_post_prompt(self): + def test_dynamic_config_callback_error_in_post_prompt(self) -> None: """Lines 884-885: dynamic config callback error is caught.""" config_cb = MagicMock(side_effect=RuntimeError("dyn cfg err")) ephemeral = MagicMock() @@ -1416,7 +1430,7 @@ def test_dynamic_config_callback_error_in_post_prompt(self): ephemeral.on_summary.assert_called_once() assert result == {"success": True} - def test_on_summary_exception_handled(self): + def test_on_summary_exception_handled(self) -> None: """Lines 900-901: exception from on_summary is caught.""" agent = _build_mixin() agent._find_summary_in_post_data = MagicMock(return_value="some summary") @@ -1431,7 +1445,7 @@ def test_on_summary_exception_handled(self): class TestHandleCheckForInputExtraPaths: """Tests for additional branches in _handle_check_for_input_request.""" - def test_malformed_post_body_returns_400(self): + def test_malformed_post_body_returns_400(self) -> None: """Lines 950-951: when request.json() fails, conversation_id is None -> 400.""" agent = _build_mixin() request = _make_request("POST", url_path="/agent/check_for_input") @@ -1439,7 +1453,7 @@ def test_malformed_post_body_returns_400(self): response = _run(agent._handle_check_for_input_request(request)) assert response.status_code == 400 - def test_general_exception_returns_500(self): + def test_general_exception_returns_500(self) -> None: """Lines 971-973: unexpected exception returns 500.""" agent = _build_mixin() # Make _check_basic_auth raise to trigger the outer exception handler @@ -1454,14 +1468,14 @@ def test_general_exception_returns_500(self): class TestGracefulShutdownHandler: """Tests for the signal handler function registered by setup_graceful_shutdown.""" - def test_signal_handler_calls_sys_exit(self): + def test_signal_handler_calls_sys_exit(self) -> None: """Lines 1123-1136: signal handler performs cleanup and calls sys.exit(0).""" agent = _build_mixin() # Capture the registered handler handlers = {} import signal as sig_module - def fake_signal(signum, handler): + def fake_signal(signum: int, handler: Any) -> None: handlers[signum] = handler with patch.object(sig_module, "signal", side_effect=fake_signal): @@ -1472,13 +1486,13 @@ def fake_signal(signum, handler): handlers[sig_module.SIGTERM](sig_module.SIGTERM, None) assert exc_info.value.code == 0 - def test_signal_handler_cleanup_error(self): + def test_signal_handler_cleanup_error(self) -> None: """Lines 1133-1134: when cleanup raises, error is logged but sys.exit still called.""" agent = _build_mixin() # Make the log.info raise during "cleanup_completed" to trigger the except branch call_count = [0] original_info = agent.log.info - def info_side_effect(*args, **kwargs): + def info_side_effect(*args: Any, **kwargs: Any) -> Any: call_count[0] += 1 if call_count[0] == 2: # second log.info call is "cleanup_completed" raise RuntimeError("cleanup log error") @@ -1489,7 +1503,7 @@ def info_side_effect(*args, **kwargs): handlers = {} import signal as sig_module - def fake_signal(signum, handler): + def fake_signal(signum: int, handler: Any) -> None: handlers[signum] = handler with patch.object(sig_module, "signal", side_effect=fake_signal): @@ -1503,7 +1517,7 @@ def fake_signal(signum, handler): class TestGetAppEndpointsViaTestClient: """Tests for the inline health/ready endpoints in get_app() using TestClient.""" - def test_health_endpoint(self): + def test_health_endpoint(self) -> None: """Lines 54: health endpoint returns healthy status.""" from starlette.testclient import TestClient agent = _build_mixin() @@ -1515,7 +1529,7 @@ def test_health_endpoint(self): assert data["status"] == "healthy" assert data["agent"] == "test_agent" - def test_ready_endpoint(self): + def test_ready_endpoint(self) -> None: """Line 65: ready endpoint returns ready status.""" from starlette.testclient import TestClient agent = _build_mixin() @@ -1530,18 +1544,19 @@ def test_ready_endpoint(self): class TestRootRequestProxyParentDetection: """Tests for proxy detection fallback to parent class.""" - def test_no_proxy_headers_calls_parent_detect_proxy(self): + def test_no_proxy_headers_calls_parent_detect_proxy(self) -> None: """Lines 452-457: when parent has _detect_proxy_from_request, it is called.""" class FakeParent: - def __init__(self): - self._proxy_url_base = None + def __init__(self) -> None: + self._proxy_url_base: str | None = None - def _detect_proxy_from_request(self, request): + def _detect_proxy_from_request(self, request: Any) -> None: # Simulates parent detecting proxy and setting its own attribute self._proxy_url_base = "https://parent-detected.example.com" + # WebMixin is Any to mypy; intentional subclass (see _build_mixin). class FakeAgentWithParent(WebMixin, FakeParent): - def __init__(self): + def __init__(self) -> None: FakeParent.__init__(self) log = MagicMock() @@ -1575,7 +1590,7 @@ def __init__(self): agent.on_function_call = MagicMock(return_value=FunctionResult("ok")) agent.on_summary = MagicMock(return_value=None) agent._find_summary_in_post_data = MagicMock(return_value=None) - agent.on_swml_request = MagicMock(return_value=None) + agent.on_swml_request = MagicMock(return_value=None) # type: ignore[method-assign] # mock request = _make_request("GET") _run(agent._handle_root_request(request)) @@ -1590,7 +1605,7 @@ def __init__(self): class TestSecurityBodySizeLimit: """Test request body size limit enforcement (413).""" - def test_oversized_body_returns_413_root(self): + def test_oversized_body_returns_413_root(self) -> None: agent = _build_mixin() # Simulate a request with Content-Length > 10MB headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} @@ -1598,7 +1613,7 @@ def test_oversized_body_returns_413_root(self): response = _run(agent._handle_root_request(request)) assert response.status_code == 413 - def test_oversized_body_returns_413_swaig(self): + def test_oversized_body_returns_413_swaig(self) -> None: agent = _build_mixin() headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} request = _make_request("POST", headers=headers, body={"function": "test"}) @@ -1607,28 +1622,28 @@ def test_oversized_body_returns_413_swaig(self): response = _run(agent._handle_swaig_request(request, response_obj)) assert response.status_code == 413 - def test_oversized_body_returns_413_debug(self): + def test_oversized_body_returns_413_debug(self) -> None: agent = _build_mixin() headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} request = _make_request("POST", headers=headers, body={}) response = _run(agent._handle_debug_request(request)) assert response.status_code == 413 - def test_oversized_body_returns_413_post_prompt(self): + def test_oversized_body_returns_413_post_prompt(self) -> None: agent = _build_mixin() headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} request = _make_request("POST", headers=headers, body={"summary": "x"}) response = _run(agent._handle_post_prompt_request(request)) assert response.status_code == 413 - def test_oversized_body_returns_413_check_for_input(self): + def test_oversized_body_returns_413_check_for_input(self) -> None: agent = _build_mixin() headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} request = _make_request("POST", headers=headers, body={"conversation_id": "abc"}) response = _run(agent._handle_check_for_input_request(request)) assert response.status_code == 413 - def test_oversized_body_returns_413_debug_events(self): + def test_oversized_body_returns_413_debug_events(self) -> None: agent = _build_mixin() headers = {"content-length": str(11 * 1024 * 1024), "content-type": "application/json"} request = _make_request("POST", headers=headers, body={"label": "test"}) @@ -1639,14 +1654,14 @@ def test_oversized_body_returns_413_debug_events(self): class TestSecurityContentType: """Test content-type validation (415).""" - def test_wrong_content_type_returns_415_root(self): + def test_wrong_content_type_returns_415_root(self) -> None: agent = _build_mixin() headers = {"content-type": "text/plain"} request = _make_request("POST", headers=headers, body={"key": "value"}) response = _run(agent._handle_root_request(request)) assert response.status_code == 415 - def test_wrong_content_type_returns_415_swaig(self): + def test_wrong_content_type_returns_415_swaig(self) -> None: agent = _build_mixin() headers = {"content-type": "text/xml"} request = _make_request("POST", headers=headers, body={"function": "test"}) @@ -1655,21 +1670,21 @@ def test_wrong_content_type_returns_415_swaig(self): response = _run(agent._handle_swaig_request(request, response_obj)) assert response.status_code == 415 - def test_wrong_content_type_returns_415_debug(self): + def test_wrong_content_type_returns_415_debug(self) -> None: agent = _build_mixin() headers = {"content-type": "text/html"} request = _make_request("POST", headers=headers, body={}) response = _run(agent._handle_debug_request(request)) assert response.status_code == 415 - def test_wrong_content_type_returns_415_post_prompt(self): + def test_wrong_content_type_returns_415_post_prompt(self) -> None: agent = _build_mixin() headers = {"content-type": "multipart/form-data"} request = _make_request("POST", headers=headers, body={"summary": "x"}) response = _run(agent._handle_post_prompt_request(request)) assert response.status_code == 415 - def test_correct_content_type_passes(self): + def test_correct_content_type_passes(self) -> None: agent = _build_mixin() headers = {"content-type": "application/json; charset=utf-8"} request = _make_request("POST", headers=headers, body={}) @@ -1680,7 +1695,7 @@ def test_correct_content_type_passes(self): class TestSecurityFunctionNameValidation: """Test function name format validation (400).""" - def test_invalid_function_name_returns_400(self): + def test_invalid_function_name_returns_400(self) -> None: agent = _build_mixin() headers = {"content-type": "application/json"} request = _make_request("POST", headers=headers, @@ -1690,7 +1705,7 @@ def test_invalid_function_name_returns_400(self): response = _run(agent._handle_swaig_request(request, response_obj)) assert response.status_code == 400 - def test_function_name_with_spaces_returns_400(self): + def test_function_name_with_spaces_returns_400(self) -> None: agent = _build_mixin() headers = {"content-type": "application/json"} request = _make_request("POST", headers=headers, @@ -1700,7 +1715,7 @@ def test_function_name_with_spaces_returns_400(self): response = _run(agent._handle_swaig_request(request, response_obj)) assert response.status_code == 400 - def test_valid_function_name_passes(self): + def test_valid_function_name_passes(self) -> None: agent = _build_mixin() agent._tool_registry._swaig_functions = {"get_balance": {"handler": MagicMock()}} headers = {"content-type": "application/json"} @@ -1717,7 +1732,7 @@ def test_valid_function_name_passes(self): class TestSecurityCORS: """Test CORS configuration.""" - def test_cors_credentials_false(self): + def test_cors_credentials_false(self) -> None: agent = _build_mixin() app = agent.get_app() # Check that CORS middleware was added with allow_credentials=False @@ -1734,7 +1749,7 @@ def test_cors_credentials_false(self): class TestSecurityHeaders: """Test security headers in responses.""" - def test_security_headers_present_via_get_app(self): + def test_security_headers_present_via_get_app(self) -> None: from starlette.testclient import TestClient agent = _build_mixin(route="/") app = agent.get_app() @@ -1748,14 +1763,14 @@ def test_security_headers_present_via_get_app(self): class TestSecurityDebugGuard: """Test debug endpoint guard.""" - def test_debug_endpoint_disabled_returns_404(self): + def test_debug_endpoint_disabled_returns_404(self) -> None: agent = _build_mixin() agent._debug_endpoint_enabled = False request = _make_request("GET", url_path="/agent/debug") response = _run(agent._handle_debug_request(request)) assert response.status_code == 404 - def test_debug_endpoint_enabled_by_default(self): + def test_debug_endpoint_enabled_by_default(self) -> None: agent = _build_mixin() request = _make_request("GET", url_path="/agent/debug") response = _run(agent._handle_debug_request(request)) @@ -1766,7 +1781,7 @@ def test_debug_endpoint_enabled_by_default(self): class TestSecurityProxyValidation: """Test proxy header validation.""" - def test_malformed_host_rejected(self): + def test_malformed_host_rejected(self) -> None: agent = _build_mixin() agent._proxy_url_base_from_env = False headers = { @@ -1780,7 +1795,7 @@ def test_malformed_host_rejected(self): # proxy should NOT have been set assert agent._proxy_url_base is None or "DROP TABLE" not in str(agent._proxy_url_base) - def test_invalid_proto_rejected(self): + def test_invalid_proto_rejected(self) -> None: agent = _build_mixin() agent._proxy_url_base_from_env = False headers = { @@ -1792,7 +1807,7 @@ def test_invalid_proto_rejected(self): _run(agent._handle_root_request(request)) assert agent._proxy_url_base is None or "ftp" not in str(agent._proxy_url_base) - def test_valid_proxy_accepted(self): + def test_valid_proxy_accepted(self) -> None: agent = _build_mixin() agent._proxy_url_base_from_env = False headers = { @@ -1808,14 +1823,14 @@ def test_valid_proxy_accepted(self): class TestSessionManagerDebugGuard: """Test that debug_token requires _debug_mode.""" - def test_debug_token_disabled_by_default(self): + def test_debug_token_disabled_by_default(self) -> None: from signalwire.core.security.session_manager import SessionManager manager = SessionManager() token = manager.generate_token("func", "call_123") result = manager.debug_token(token) assert result == {"error": "debug mode not enabled"} - def test_debug_token_enabled(self): + def test_debug_token_enabled(self) -> None: from signalwire.core.security.session_manager import SessionManager manager = SessionManager() manager._debug_mode = True diff --git a/tests/unit/core/test_agent_base.py b/tests/unit/core/test_agent_base.py index 59541d8c..6c125514 100644 --- a/tests/unit/core/test_agent_base.py +++ b/tests/unit/core/test_agent_base.py @@ -19,20 +19,21 @@ from typing import Dict, Any, List, Optional from signalwire.core.agent_base import AgentBase +from signalwire.core.swaig_function import SWAIGFunction class TestAgentBaseInitialization: """Test AgentBase initialization""" - def _create_mock_agent(self, **kwargs): + def _create_mock_agent(self, **kwargs: Any) -> AgentBase: """Helper to create a properly mocked agent""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) agent = AgentBase(schema_validation=False, **kwargs) return agent - def test_basic_initialization(self): + def test_basic_initialization(self) -> None: """Test basic AgentBase initialization""" agent = self._create_mock_agent(name="test_agent") @@ -43,7 +44,7 @@ def test_basic_initialization(self): assert agent._use_pom is True assert agent.native_functions == [] - def test_initialization_with_custom_params(self): + def test_initialization_with_custom_params(self) -> None: """Test AgentBase initialization with custom parameters""" agent = self._create_mock_agent( name="custom_agent", @@ -69,27 +70,27 @@ def test_initialization_with_custom_params(self): class TestAgentBasePromptMethods: """Test AgentBase prompt-related methods""" - def setup_method(self): + def setup_method(self) -> None: """Set up test fixtures""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) self.agent = AgentBase("test_agent", use_pom=False, schema_validation=False) - def test_set_prompt_text(self): + def test_set_prompt_text(self) -> None: """Test setting prompt text""" result = self.agent.set_prompt_text("You are a helpful assistant") assert result is self.agent # Should return self for chaining assert self.agent._prompt_manager._prompt_text == "You are a helpful assistant" - def test_set_post_prompt(self): + def test_set_post_prompt(self) -> None: """Test setting post-prompt text""" result = self.agent.set_post_prompt("End of conversation") assert result is self.agent assert self.agent._prompt_manager._post_prompt_text == "End of conversation" - def test_get_prompt_with_raw_text(self): + def test_get_prompt_with_raw_text(self) -> None: """Test get_prompt with raw text""" self.agent.set_prompt_text("Raw prompt text") @@ -97,13 +98,13 @@ def test_get_prompt_with_raw_text(self): assert result == "Raw prompt text" - def test_get_prompt_without_text(self): + def test_get_prompt_without_text(self) -> None: """Test get_prompt without any text set""" result = self.agent.get_prompt() assert result == "You are test_agent, a helpful AI assistant." - def test_get_post_prompt(self): + def test_get_post_prompt(self) -> None: """Test get_post_prompt""" self.agent.set_post_prompt("Post prompt text") @@ -111,58 +112,58 @@ def test_get_post_prompt(self): assert result == "Post prompt text" - def test_get_post_prompt_none(self): + def test_get_post_prompt_none(self) -> None: """Test get_post_prompt when none set""" result = self.agent.get_post_prompt() assert result is None - def test_get_raw_prompt_when_set(self): + def test_get_raw_prompt_when_set(self) -> None: """get_raw_prompt returns the raw prompt text once set.""" self.agent.set_prompt_text("Raw prompt text") assert self.agent._prompt_manager.get_raw_prompt() == "Raw prompt text" - def test_get_raw_prompt_none_when_unset(self): + def test_get_raw_prompt_none_when_unset(self) -> None: """get_raw_prompt returns None when no prompt text is set.""" assert self.agent._prompt_manager.get_raw_prompt() is None - def test_get_contexts_none_when_unset(self): + def test_get_contexts_none_when_unset(self) -> None: """get_contexts returns None before any contexts are defined.""" assert self.agent._prompt_manager.get_contexts() is None - def test_set_prompt_pom_raises_when_use_pom_false(self): + def test_set_prompt_pom_raises_when_use_pom_false(self) -> None: """set_prompt_pom raises ValueError when use_pom is False.""" # ``self.agent`` is constructed with ``use_pom=False`` with pytest.raises(ValueError, match="use_pom must be True"): self.agent._prompt_manager.set_prompt_pom([{"title": "X", "body": "y"}]) - def test_set_prompt_pom_succeeds_when_use_pom_true(self): + def test_set_prompt_pom_succeeds_when_use_pom_true(self) -> None: """set_prompt_pom assigns the POM list when use_pom is True.""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) agent = AgentBase("pom_agent", use_pom=True, schema_validation=False) sections = [{"title": "Greeting", "body": "Hello"}] agent._prompt_manager.set_prompt_pom(sections) - assert agent.pom == sections + assert agent.pom == sections # type: ignore[comparison-overlap] # pom holds the raw list at runtime class TestAgentBaseConfigurationMethods: """Test AgentBase configuration methods""" - def setup_method(self): + def setup_method(self) -> None: """Set up test fixtures""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) self.agent = AgentBase("test_agent", schema_validation=False) - def test_add_hint(self): + def test_add_hint(self) -> None: """Test adding hints""" result = self.agent.add_hint("Test hint") assert result is self.agent assert "Test hint" in self.agent._hints - def test_add_hints(self): + def test_add_hints(self) -> None: """Test adding multiple hints""" hints = ["Hint 1", "Hint 2"] result = self.agent.add_hints(hints) @@ -170,7 +171,7 @@ def test_add_hints(self): assert result is self.agent assert all(hint in self.agent._hints for hint in hints) - def test_add_language(self): + def test_add_language(self) -> None: """Test adding language configuration""" result = self.agent.add_language("English", "en", "alice") @@ -182,7 +183,7 @@ def test_add_language(self): assert language["code"] == "en" assert language["voice"] == "alice" - def test_add_pronunciation(self): + def test_add_pronunciation(self) -> None: """Test adding pronunciation rules""" result = self.agent.add_pronunciation("AI", "Artificial Intelligence") @@ -193,14 +194,14 @@ def test_add_pronunciation(self): assert rule["replace"] == "AI" assert rule["with"] == "Artificial Intelligence" - def test_set_param(self): + def test_set_param(self) -> None: """Test setting parameters""" result = self.agent.set_param("temperature", 0.7) assert result is self.agent assert self.agent._params["temperature"] == 0.7 - def test_set_params(self): + def test_set_params(self) -> None: """Test setting multiple parameters""" params = {"temperature": 0.7, "max_tokens": 100} result = self.agent.set_params(params) @@ -208,7 +209,7 @@ def test_set_params(self): assert result is self.agent assert self.agent._params == params - def test_set_global_data(self): + def test_set_global_data(self) -> None: """Test setting global data""" data = {"user_id": "123"} result = self.agent.set_global_data(data) @@ -216,7 +217,7 @@ def test_set_global_data(self): assert result is self.agent assert self.agent._global_data == data - def test_update_global_data(self): + def test_update_global_data(self) -> None: """Test updating global data""" self.agent._global_data = {"existing": "value"} new_data = {"new_key": "new_value"} @@ -226,7 +227,7 @@ def test_update_global_data(self): assert result is self.agent assert self.agent._global_data == {"existing": "value", "new_key": "new_value"} - def test_set_native_functions(self): + def test_set_native_functions(self) -> None: """Test setting native functions""" functions = ["func1", "func2"] result = self.agent.set_native_functions(functions) @@ -234,7 +235,7 @@ def test_set_native_functions(self): assert result is self.agent assert self.agent.native_functions == functions - def test_add_function_include(self): + def test_add_function_include(self) -> None: """Test adding function includes""" result = self.agent.add_function_include("http://example.com", ["func1"]) @@ -249,13 +250,13 @@ def test_add_function_include(self): class TestAgentBaseToolMethods: """Test AgentBase tool-related methods""" - def setup_method(self): + def setup_method(self) -> None: """Set up test fixtures""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) self.agent = AgentBase("test_agent", schema_validation=False) - def test_define_tool(self): + def test_define_tool(self) -> None: """Test defining a tool""" def test_handler(arg1: str, arg2: int) -> str: return f"{arg1}_{arg2}" @@ -273,7 +274,7 @@ def test_handler(arg1: str, arg2: int) -> str: assert result is self.agent assert "test_tool" in self.agent._tool_registry._swaig_functions - def test_register_swaig_function(self): + def test_register_swaig_function(self) -> None: """Test registering SWAIG function from dictionary""" function_dict = { "function": "test_func", @@ -286,7 +287,7 @@ def test_register_swaig_function(self): assert result is self.agent assert "test_func" in self.agent._tool_registry._swaig_functions - def test_define_tools(self): + def test_define_tools(self) -> None: """Test define_tools method""" # Add a tool first mock_tool = Mock() @@ -297,63 +298,63 @@ def test_define_tools(self): assert isinstance(tools, list) assert len(tools) == 1 - assert tools[0].name == "test_tool" + assert tools[0].name == "test_tool" # type: ignore[union-attr] # mock tool - def test_on_function_call(self): + def test_on_function_call(self) -> None: """Test on_function_call method""" result = self.agent.on_function_call("test_func", {"arg": "value"}) assert result == {"response": "Function 'test_func' not found"} - def test_on_summary(self): + def test_on_summary(self) -> None: """Test on_summary method""" # This is a hook method that should be overridden by subclasses # Should not raise any exceptions - self.agent.on_summary({"summary": "test"}) + self.agent.on_summary({"summary": "test"}) # type: ignore[arg-type] # hook accepts arbitrary summary dict at runtime class TestAgentBaseAuthMethods: """Test AgentBase authentication methods""" - def setup_method(self): + def setup_method(self) -> None: """Set up test fixtures""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) self.agent = AgentBase("test_agent", basic_auth=("user", "pass"), schema_validation=False) - def test_validate_basic_auth_success(self): + def test_validate_basic_auth_success(self) -> None: """Test successful basic auth validation""" result = self.agent.validate_basic_auth("user", "pass") assert result is True - def test_validate_basic_auth_failure(self): + def test_validate_basic_auth_failure(self) -> None: """Test failed basic auth validation""" result = self.agent.validate_basic_auth("wrong", "creds") assert result is False - def test_validate_basic_auth_no_auth_configured(self): + def test_validate_basic_auth_no_auth_configured(self) -> None: """Test basic auth validation when no auth is configured""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) agent = AgentBase("test_agent", schema_validation=False) # No basic_auth - agent._basic_auth = (None, None) # Explicitly set no auth + agent._basic_auth = (None, None) # type: ignore[assignment] # intentional: test no-auth path result = agent.validate_basic_auth("any", "creds") assert result is False - def test_get_basic_auth_credentials(self): + def test_get_basic_auth_credentials(self) -> None: """Test getting basic auth credentials""" - username, password = self.agent.get_basic_auth_credentials() + username, password = self.agent.get_basic_auth_credentials() # type: ignore[misc] # include_source defaults False -> 2-tuple assert username == "user" assert password == "pass" - def test_get_basic_auth_credentials_with_source(self): + def test_get_basic_auth_credentials_with_source(self) -> None: """Test getting basic auth credentials with source""" - username, password, source = self.agent.get_basic_auth_credentials(include_source=True) + username, password, source = self.agent.get_basic_auth_credentials(include_source=True) # type: ignore[misc] # include_source=True returns 3-tuple assert username == "user" assert password == "pass" @@ -363,19 +364,19 @@ def test_get_basic_auth_credentials_with_source(self): class TestAgentBaseURLMethods: """Test AgentBase URL-related methods""" - def setup_method(self): + def setup_method(self) -> None: """Set up test fixtures""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) self.agent = AgentBase("test_agent", host="localhost", port=3000, route="/test", schema_validation=False) - def test_get_full_url_basic(self): + def test_get_full_url_basic(self) -> None: """Test getting full URL without auth""" url = self.agent.get_full_url() assert url == "http://localhost:3000/test" - def test_get_full_url_with_auth(self): + def test_get_full_url_with_auth(self) -> None: """Test getting full URL with auth""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) @@ -386,21 +387,21 @@ def test_get_full_url_with_auth(self): assert url == "http://user:pass@localhost:3000/test" - def test_set_web_hook_url(self): + def test_set_web_hook_url(self) -> None: """Test setting webhook URL""" result = self.agent.set_web_hook_url("http://example.com/webhook") assert result is self.agent assert self.agent._web_hook_url_override == "http://example.com/webhook" - def test_set_post_prompt_url(self): + def test_set_post_prompt_url(self) -> None: """Test setting post-prompt URL""" result = self.agent.set_post_prompt_url("http://example.com/post") assert result is self.agent assert self.agent._post_prompt_url_override == "http://example.com/post" - def test_manual_set_proxy_url(self): + def test_manual_set_proxy_url(self) -> None: """Test manually setting proxy URL""" result = self.agent.manual_set_proxy_url("http://proxy.example.com") @@ -411,7 +412,7 @@ def test_manual_set_proxy_url(self): class TestAgentBaseSkillMethods: """Test AgentBase skill-related methods""" - def setup_method(self): + def setup_method(self) -> None: """Set up test fixtures""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) @@ -421,7 +422,7 @@ def setup_method(self): self.mock_skill_manager_instance = Mock() self.agent.skill_manager = self.mock_skill_manager_instance - def test_add_skill(self): + def test_add_skill(self) -> None: """Test adding a skill""" self.mock_skill_manager_instance.load_skill.return_value = (True, None) @@ -430,14 +431,14 @@ def test_add_skill(self): assert result is self.agent self.mock_skill_manager_instance.load_skill.assert_called_once_with("test_skill", params={"param": "value"}) - def test_remove_skill(self): + def test_remove_skill(self) -> None: """Test removing a skill""" result = self.agent.remove_skill("test_skill") assert result is self.agent self.mock_skill_manager_instance.unload_skill.assert_called_once_with("test_skill") - def test_list_skills(self): + def test_list_skills(self) -> None: """Test listing skills""" self.mock_skill_manager_instance.list_loaded_skills.return_value = ["skill1", "skill2"] @@ -446,7 +447,7 @@ def test_list_skills(self): assert result == ["skill1", "skill2"] self.mock_skill_manager_instance.list_loaded_skills.assert_called_once() - def test_has_skill(self): + def test_has_skill(self) -> None: """Test checking if skill exists""" self.mock_skill_manager_instance.has_skill.return_value = True @@ -459,7 +460,7 @@ def test_has_skill(self): class TestAgentBaseTokenMethods: """Test AgentBase token-related methods""" - def setup_method(self): + def setup_method(self) -> None: """Set up test fixtures""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) @@ -469,7 +470,7 @@ def setup_method(self): self.mock_session_manager_instance = Mock() self.agent._session_manager = self.mock_session_manager_instance - def test_create_tool_token(self): + def test_create_tool_token(self) -> None: """Test creating tool token""" self.mock_session_manager_instance.create_tool_token.return_value = "test_token" @@ -478,7 +479,7 @@ def test_create_tool_token(self): assert token == "test_token" self.mock_session_manager_instance.create_tool_token.assert_called_once_with("test_tool", "call_123") - def test_validate_tool_token(self): + def test_validate_tool_token(self) -> None: """Test validating tool token""" # Add a mock function to the agent's tool registry mock_func = Mock() @@ -496,21 +497,21 @@ def test_validate_tool_token(self): class TestAgentBaseMiscMethods: """Test miscellaneous AgentBase methods""" - def setup_method(self): + def setup_method(self) -> None: """Set up test fixtures""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) self.agent = AgentBase("test_agent", schema_validation=False) - def test_get_name(self): + def test_get_name(self) -> None: """Test getting agent name""" name = self.agent.get_name() assert name == "test_agent" - def test_set_dynamic_config_callback(self): + def test_set_dynamic_config_callback(self) -> None: """Test setting dynamic config callback""" - def callback(request_data, call_data, meta_data, config): + def callback(request_data: Any, call_data: Any, meta_data: Any, config: Any) -> None: pass result = self.agent.set_dynamic_config_callback(callback) @@ -518,7 +519,7 @@ def callback(request_data, call_data, meta_data, config): assert result is self.agent assert self.agent._dynamic_config_callback == callback - def test_on_request(self): + def test_on_request(self) -> None: """Test on_request method""" # on_request calls on_swml_request which returns None when # no dynamic config callback is set @@ -527,7 +528,7 @@ def test_on_request(self): # Default implementation should return None assert result is None - def test_on_swml_request(self): + def test_on_swml_request(self) -> None: """Test on_swml_request method""" # When no dynamic config callback is set, on_swml_request returns None result = self.agent.on_swml_request({"test": "data"}) @@ -539,10 +540,10 @@ def test_on_swml_request(self): class TestAgentBaseDeclarativePrompts: """Test AgentBase declarative prompt sections""" - def test_process_prompt_sections_dict(self): + def test_process_prompt_sections_dict(self) -> None: """Test processing declarative prompt sections from dict""" class TestAgent(AgentBase): - PROMPT_SECTIONS = { + PROMPT_SECTIONS = { # type: ignore[assignment] # base declares None; subclass overrides with dict "Instructions": "Follow these rules", "Rules": ["Rule 1", "Rule 2"], "Complex": { @@ -560,10 +561,10 @@ class TestAgent(AgentBase): # Should have called prompt_add_section for each section assert mock_add_section.call_count == 3 - def test_process_prompt_sections_no_pom(self): + def test_process_prompt_sections_no_pom(self) -> None: """Test processing prompt sections when POM is disabled""" class TestAgent(AgentBase): - PROMPT_SECTIONS = {"Test": "Content"} + PROMPT_SECTIONS = {"Test": "Content"} # type: ignore[assignment] # base declares None; subclass overrides with dict with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) @@ -579,7 +580,7 @@ class TestAgent(AgentBase): # ======================================================================== -def _make_agent(**kwargs): +def _make_agent(**kwargs: Any) -> AgentBase: """Module-level helper to create a properly mocked agent.""" with pytest.MonkeyPatch().context() as m: m.setattr("signalwire.core.agent_base.uvicorn", Mock()) @@ -590,57 +591,57 @@ def _make_agent(**kwargs): class TestRenderSwml: """Test _render_swml() output structure.""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="render_test", use_pom=False, **kw) # -- basic document structure -- - def test_render_swml_returns_valid_json(self): + def test_render_swml_returns_valid_json(self) -> None: agent = self._make() doc = json.loads(agent._render_swml()) assert "version" in doc assert "sections" in doc assert "main" in doc["sections"] - def test_render_swml_version_is_1_0_0(self): + def test_render_swml_version_is_1_0_0(self) -> None: agent = self._make() doc = json.loads(agent._render_swml()) assert doc["version"] == "1.0.0" - def test_render_swml_main_section_is_list(self): + def test_render_swml_main_section_is_list(self) -> None: agent = self._make() doc = json.loads(agent._render_swml()) assert isinstance(doc["sections"]["main"], list) - def test_render_swml_contains_answer_verb(self): + def test_render_swml_contains_answer_verb(self) -> None: agent = self._make() doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] assert "answer" in verb_names - def test_render_swml_contains_ai_verb(self): + def test_render_swml_contains_ai_verb(self) -> None: agent = self._make() doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] assert "ai" in verb_names - def test_render_swml_answer_before_ai(self): + def test_render_swml_answer_before_ai(self) -> None: agent = self._make() doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] assert verb_names.index("answer") < verb_names.index("ai") - def test_render_swml_no_answer_when_auto_answer_false(self): + def test_render_swml_no_answer_when_auto_answer_false(self) -> None: agent = self._make(auto_answer=False) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] assert "answer" not in verb_names - def test_render_swml_ai_section_has_prompt(self): + def test_render_swml_ai_section_has_prompt(self) -> None: agent = self._make() agent.set_prompt_text("Hello world") doc = json.loads(agent._render_swml()) @@ -649,7 +650,7 @@ def test_render_swml_ai_section_has_prompt(self): ai_config = ai_verb["ai"] assert "prompt" in ai_config - def test_render_swml_ai_prompt_text(self): + def test_render_swml_ai_prompt_text(self) -> None: agent = self._make() agent.set_prompt_text("Be a helpful agent") doc = json.loads(agent._render_swml()) @@ -660,7 +661,7 @@ def test_render_swml_ai_prompt_text(self): else: assert "Be a helpful agent" in prompt - def test_render_swml_with_post_prompt(self): + def test_render_swml_with_post_prompt(self) -> None: agent = self._make() agent.set_post_prompt("Summarize the conversation") doc = json.loads(agent._render_swml()) @@ -668,7 +669,7 @@ def test_render_swml_with_post_prompt(self): ai_config = ai_verb["ai"] assert "post_prompt" in ai_config - def test_render_swml_with_hints(self): + def test_render_swml_with_hints(self) -> None: agent = self._make() agent.add_hints(["hint1", "hint2"]) doc = json.loads(agent._render_swml()) @@ -677,7 +678,7 @@ def test_render_swml_with_hints(self): assert "hint1" in ai_verb["ai"]["hints"] assert "hint2" in ai_verb["ai"]["hints"] - def test_render_swml_with_languages(self): + def test_render_swml_with_languages(self) -> None: agent = self._make() agent.add_language("English", "en", "alice") doc = json.loads(agent._render_swml()) @@ -685,7 +686,7 @@ def test_render_swml_with_languages(self): assert "languages" in ai_verb["ai"] assert ai_verb["ai"]["languages"][0]["name"] == "English" - def test_render_swml_with_params(self): + def test_render_swml_with_params(self) -> None: agent = self._make() agent.set_params({"temperature": 0.5}) doc = json.loads(agent._render_swml()) @@ -693,7 +694,7 @@ def test_render_swml_with_params(self): assert "params" in ai_verb["ai"] assert ai_verb["ai"]["params"]["temperature"] == 0.5 - def test_render_swml_with_global_data(self): + def test_render_swml_with_global_data(self) -> None: agent = self._make() agent.set_global_data({"key": "value"}) doc = json.loads(agent._render_swml()) @@ -701,7 +702,7 @@ def test_render_swml_with_global_data(self): assert "global_data" in ai_verb["ai"] assert ai_verb["ai"]["global_data"]["key"] == "value" - def test_render_swml_with_pronunciation(self): + def test_render_swml_with_pronunciation(self) -> None: agent = self._make() agent.add_pronunciation("SQL", "sequel") doc = json.loads(agent._render_swml()) @@ -709,21 +710,21 @@ def test_render_swml_with_pronunciation(self): assert "pronounce" in ai_verb["ai"] assert ai_verb["ai"]["pronounce"][0]["replace"] == "SQL" - def test_render_swml_with_record_call(self): + def test_render_swml_with_record_call(self) -> None: agent = self._make(record_call=True) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] assert "record_call" in verb_names - def test_render_swml_record_call_before_ai(self): + def test_render_swml_record_call_before_ai(self) -> None: agent = self._make(record_call=True) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] assert verb_names.index("record_call") < verb_names.index("ai") - def test_render_swml_record_call_format(self): + def test_render_swml_record_call_format(self) -> None: agent = self._make(record_call=True, record_format="wav", record_stereo=False) doc = json.loads(agent._render_swml()) verbs = doc["sections"]["main"] @@ -731,7 +732,7 @@ def test_render_swml_record_call_format(self): assert record_verb["record_call"]["format"] == "wav" assert record_verb["record_call"]["stereo"] is False - def test_render_swml_with_native_functions(self): + def test_render_swml_with_native_functions(self) -> None: agent = self._make(native_functions=["transfer", "check_time"]) doc = json.loads(agent._render_swml()) ai_verb = [v for v in doc["sections"]["main"] if isinstance(v, dict) and "ai" in v][0] @@ -739,7 +740,7 @@ def test_render_swml_with_native_functions(self): assert "native_functions" in swaig assert "transfer" in swaig["native_functions"] - def test_render_swml_with_function_includes(self): + def test_render_swml_with_function_includes(self) -> None: agent = self._make() agent.add_function_include("http://example.com/funcs", ["fn1"]) doc = json.loads(agent._render_swml()) @@ -752,124 +753,124 @@ def test_render_swml_with_function_includes(self): class TestEphemeralCopy: """Test _create_ephemeral_copy().""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="ephemeral_test", use_pom=False, **kw) - def test_ephemeral_copy_returns_agent(self): + def test_ephemeral_copy_returns_agent(self) -> None: agent = self._make() copy = agent._create_ephemeral_copy() assert isinstance(copy, AgentBase) - def test_ephemeral_copy_is_marked_ephemeral(self): + def test_ephemeral_copy_is_marked_ephemeral(self) -> None: agent = self._make() copy = agent._create_ephemeral_copy() assert getattr(copy, '_is_ephemeral', False) is True - def test_ephemeral_copy_has_same_name(self): + def test_ephemeral_copy_has_same_name(self) -> None: agent = self._make() copy = agent._create_ephemeral_copy() assert copy.name == agent.name - def test_ephemeral_params_independent(self): + def test_ephemeral_params_independent(self) -> None: agent = self._make() agent.set_params({"temperature": 0.5}) copy = agent._create_ephemeral_copy() copy._params["temperature"] = 0.9 assert agent._params["temperature"] == 0.5 - def test_ephemeral_hints_independent(self): + def test_ephemeral_hints_independent(self) -> None: agent = self._make() agent.add_hint("original hint") copy = agent._create_ephemeral_copy() copy._hints.append("new hint") assert "new hint" not in agent._hints - def test_ephemeral_global_data_independent(self): + def test_ephemeral_global_data_independent(self) -> None: agent = self._make() agent.set_global_data({"key": "original"}) copy = agent._create_ephemeral_copy() copy._global_data["key"] = "modified" assert agent._global_data["key"] == "original" - def test_ephemeral_languages_independent(self): + def test_ephemeral_languages_independent(self) -> None: agent = self._make() agent.add_language("English", "en", "alice") copy = agent._create_ephemeral_copy() copy._languages.append({"name": "French", "code": "fr", "voice": "bob"}) assert len(agent._languages) == 1 - def test_ephemeral_pronounce_independent(self): + def test_ephemeral_pronounce_independent(self) -> None: agent = self._make() agent.add_pronunciation("AI", "A I") copy = agent._create_ephemeral_copy() copy._pronounce.append({"replace": "SQL", "with": "sequel"}) assert len(agent._pronounce) == 1 - def test_ephemeral_function_includes_independent(self): + def test_ephemeral_function_includes_independent(self) -> None: agent = self._make() agent.add_function_include("http://example.com", ["fn1"]) copy = agent._create_ephemeral_copy() copy._function_includes.append({"url": "http://other.com", "functions": ["fn2"]}) assert len(agent._function_includes) == 1 - def test_ephemeral_pre_answer_verbs_independent(self): + def test_ephemeral_pre_answer_verbs_independent(self) -> None: agent = self._make() agent.add_pre_answer_verb("sleep", {"time": 1000}) copy = agent._create_ephemeral_copy() copy._pre_answer_verbs.append(("set", {"var": "x"})) assert len(agent._pre_answer_verbs) == 1 - def test_ephemeral_post_answer_verbs_independent(self): + def test_ephemeral_post_answer_verbs_independent(self) -> None: agent = self._make() agent.add_post_answer_verb("play", {"url": "say:hello"}) copy = agent._create_ephemeral_copy() copy._post_answer_verbs.append(("sleep", {"time": 500})) assert len(agent._post_answer_verbs) == 1 - def test_ephemeral_post_ai_verbs_independent(self): + def test_ephemeral_post_ai_verbs_independent(self) -> None: agent = self._make() agent.add_post_ai_verb("hangup", {}) copy = agent._create_ephemeral_copy() copy._post_ai_verbs.append(("request", {"url": "http://x.com"})) assert len(agent._post_ai_verbs) == 1 - def test_ephemeral_prompt_text_independent(self): + def test_ephemeral_prompt_text_independent(self) -> None: agent = self._make() agent.set_prompt_text("original prompt") copy = agent._create_ephemeral_copy() copy._prompt_manager._prompt_text = "modified prompt" assert agent._prompt_manager._prompt_text == "original prompt" - def test_ephemeral_post_prompt_text_independent(self): + def test_ephemeral_post_prompt_text_independent(self) -> None: agent = self._make() agent.set_post_prompt("original post") copy = agent._create_ephemeral_copy() copy._prompt_manager._post_prompt_text = "modified post" assert agent._prompt_manager._post_prompt_text == "original post" - def test_ephemeral_swaig_query_params_independent(self): + def test_ephemeral_swaig_query_params_independent(self) -> None: agent = self._make() agent.add_swaig_query_params({"tier": "premium"}) copy = agent._create_ephemeral_copy() copy._swaig_query_params["tier"] = "basic" assert agent._swaig_query_params["tier"] == "premium" - def test_ephemeral_tool_registry_independent(self): + def test_ephemeral_tool_registry_independent(self) -> None: agent = self._make() copy = agent._create_ephemeral_copy() assert id(copy._tool_registry) != id(agent._tool_registry) - def test_ephemeral_prompt_manager_independent(self): + def test_ephemeral_prompt_manager_independent(self) -> None: agent = self._make() copy = agent._create_ephemeral_copy() assert id(copy._prompt_manager) != id(agent._prompt_manager) - def test_ephemeral_skill_manager_independent(self): + def test_ephemeral_skill_manager_independent(self) -> None: agent = self._make() copy = agent._create_ephemeral_copy() assert id(copy.skill_manager) != id(agent.skill_manager) - def test_ephemeral_answer_config_independent(self): + def test_ephemeral_answer_config_independent(self) -> None: agent = self._make() agent.add_answer_verb({"max_duration": 3600}) copy = agent._create_ephemeral_copy() @@ -880,60 +881,60 @@ def test_ephemeral_answer_config_independent(self): class TestVerbInsertion: """Test add_pre_answer_verb(), add_post_answer_verb(), add_post_ai_verb().""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="verb_test", use_pom=False, **kw) # -- pre-answer verbs -- - def test_add_pre_answer_verb_safe_verb(self): + def test_add_pre_answer_verb_safe_verb(self) -> None: agent = self._make() result = agent.add_pre_answer_verb("sleep", {"time": 1000}) assert result is agent assert len(agent._pre_answer_verbs) == 1 assert agent._pre_answer_verbs[0] == ("sleep", {"time": 1000}) - def test_add_pre_answer_verb_multiple(self): + def test_add_pre_answer_verb_multiple(self) -> None: agent = self._make() agent.add_pre_answer_verb("sleep", {"time": 500}) agent.add_pre_answer_verb("set", {"var": "x", "val": "1"}) assert len(agent._pre_answer_verbs) == 2 - def test_add_pre_answer_verb_unsafe_raises(self): + def test_add_pre_answer_verb_unsafe_raises(self) -> None: agent = self._make() with pytest.raises(ValueError, match="not safe for pre-answer"): agent.add_pre_answer_verb("record_call", {"format": "mp4"}) - def test_add_pre_answer_verb_auto_answer_verb_with_flag(self): + def test_add_pre_answer_verb_auto_answer_verb_with_flag(self) -> None: """play verb with auto_answer=False should not raise""" agent = self._make() agent.add_pre_answer_verb("play", {"urls": ["ring:us"], "auto_answer": False}) assert len(agent._pre_answer_verbs) == 1 - def test_add_pre_answer_verb_auto_answer_verb_warns_without_flag(self): + def test_add_pre_answer_verb_auto_answer_verb_warns_without_flag(self) -> None: """play verb without auto_answer=False should still add (warns only)""" agent = self._make() agent.add_pre_answer_verb("play", {"urls": ["ring:us"]}) assert len(agent._pre_answer_verbs) == 1 - def test_add_pre_answer_verb_transfer(self): + def test_add_pre_answer_verb_transfer(self) -> None: agent = self._make() agent.add_pre_answer_verb("transfer", {"dest": "sip:foo@bar.com"}) assert agent._pre_answer_verbs[0][0] == "transfer" - def test_add_pre_answer_verb_connect_auto_answer_false(self): + def test_add_pre_answer_verb_connect_auto_answer_false(self) -> None: agent = self._make() agent.add_pre_answer_verb("connect", {"from": "+15551234567", "auto_answer": False}) assert agent._pre_answer_verbs[0][0] == "connect" # -- post-answer verbs -- - def test_add_post_answer_verb_basic(self): + def test_add_post_answer_verb_basic(self) -> None: agent = self._make() result = agent.add_post_answer_verb("play", {"url": "say:Welcome"}) assert result is agent assert len(agent._post_answer_verbs) == 1 - def test_add_post_answer_verb_multiple(self): + def test_add_post_answer_verb_multiple(self) -> None: agent = self._make() agent.add_post_answer_verb("play", {"url": "say:Hello"}) agent.add_post_answer_verb("sleep", {"time": 500}) @@ -943,13 +944,13 @@ def test_add_post_answer_verb_multiple(self): # -- post-ai verbs -- - def test_add_post_ai_verb_basic(self): + def test_add_post_ai_verb_basic(self) -> None: agent = self._make() result = agent.add_post_ai_verb("hangup", {}) assert result is agent assert len(agent._post_ai_verbs) == 1 - def test_add_post_ai_verb_multiple(self): + def test_add_post_ai_verb_multiple(self) -> None: agent = self._make() agent.add_post_ai_verb("request", {"url": "http://api.com/log", "method": "POST"}) agent.add_post_ai_verb("hangup", {}) @@ -957,21 +958,21 @@ def test_add_post_ai_verb_multiple(self): # -- clearing verbs -- - def test_clear_pre_answer_verbs(self): + def test_clear_pre_answer_verbs(self) -> None: agent = self._make() agent.add_pre_answer_verb("sleep", {"time": 500}) result = agent.clear_pre_answer_verbs() assert result is agent assert len(agent._pre_answer_verbs) == 0 - def test_clear_post_answer_verbs(self): + def test_clear_post_answer_verbs(self) -> None: agent = self._make() agent.add_post_answer_verb("play", {"url": "say:test"}) result = agent.clear_post_answer_verbs() assert result is agent assert len(agent._post_answer_verbs) == 0 - def test_clear_post_ai_verbs(self): + def test_clear_post_ai_verbs(self) -> None: agent = self._make() agent.add_post_ai_verb("hangup", {}) result = agent.clear_post_ai_verbs() @@ -980,7 +981,7 @@ def test_clear_post_ai_verbs(self): # -- verb ordering in rendered SWML -- - def test_pre_answer_verbs_before_answer_in_swml(self): + def test_pre_answer_verbs_before_answer_in_swml(self) -> None: agent = self._make() agent.add_pre_answer_verb("sleep", {"time": 500}) doc = json.loads(agent._render_swml()) @@ -988,7 +989,7 @@ def test_pre_answer_verbs_before_answer_in_swml(self): verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] assert verb_names.index("sleep") < verb_names.index("answer") - def test_post_answer_verbs_between_answer_and_ai(self): + def test_post_answer_verbs_between_answer_and_ai(self) -> None: agent = self._make() agent.add_post_answer_verb("play", {"url": "say:Welcome"}) doc = json.loads(agent._render_swml()) @@ -999,7 +1000,7 @@ def test_post_answer_verbs_between_answer_and_ai(self): ai_idx = verb_names.index("ai") assert answer_idx < play_idx < ai_idx - def test_post_ai_verbs_after_ai(self): + def test_post_ai_verbs_after_ai(self) -> None: agent = self._make() agent.add_post_ai_verb("hangup", {}) doc = json.loads(agent._render_swml()) @@ -1007,7 +1008,7 @@ def test_post_ai_verbs_after_ai(self): verb_names = [list(v.keys())[0] for v in verbs if isinstance(v, dict)] assert verb_names.index("ai") < verb_names.index("hangup") - def test_full_verb_ordering(self): + def test_full_verb_ordering(self) -> None: """Test all five phases appear in correct order.""" agent = self._make(record_call=True) agent.add_pre_answer_verb("sleep", {"time": 200}) @@ -1023,18 +1024,18 @@ def test_full_verb_ordering(self): assert verb_names.index("play") < verb_names.index("ai") assert verb_names.index("ai") < verb_names.index("hangup") - def test_add_answer_verb_config(self): + def test_add_answer_verb_config(self) -> None: agent = self._make() result = agent.add_answer_verb({"max_duration": 3600}) assert result is agent assert agent._answer_config == {"max_duration": 3600} - def test_add_answer_verb_none_config(self): + def test_add_answer_verb_none_config(self) -> None: agent = self._make() agent.add_answer_verb(None) assert agent._answer_config == {} - def test_answer_verb_config_in_swml(self): + def test_answer_verb_config_in_swml(self) -> None: agent = self._make() agent.add_answer_verb({"max_duration": 3600}) doc = json.loads(agent._render_swml()) @@ -1046,47 +1047,47 @@ def test_answer_verb_config_in_swml(self): class TestSipRouting: """Test SIP routing methods.""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="sip_test", use_pom=False, **kw) - def test_register_sip_username(self): + def test_register_sip_username(self) -> None: agent = self._make() result = agent.register_sip_username("alice") assert result is agent assert "alice" in agent._sip_usernames - def test_register_sip_username_lowercased(self): + def test_register_sip_username_lowercased(self) -> None: agent = self._make() agent.register_sip_username("Alice") assert "alice" in agent._sip_usernames - def test_register_multiple_sip_usernames(self): + def test_register_multiple_sip_usernames(self) -> None: agent = self._make() agent.register_sip_username("alice") agent.register_sip_username("bob") assert "alice" in agent._sip_usernames assert "bob" in agent._sip_usernames - def test_register_sip_username_deduplication(self): + def test_register_sip_username_deduplication(self) -> None: agent = self._make() agent.register_sip_username("alice") agent.register_sip_username("alice") assert len(agent._sip_usernames) == 1 - def test_auto_map_sip_usernames(self): + def test_auto_map_sip_usernames(self) -> None: agent = self._make() result = agent.auto_map_sip_usernames() assert result is agent # Agent name is "sip_test", so "sip_test" should be registered assert "sip_test" in agent._sip_usernames - def test_auto_map_registers_route_variant(self): + def test_auto_map_registers_route_variant(self) -> None: agent = _make_agent(name="myagent", route="/myroute", use_pom=False) agent.auto_map_sip_usernames() assert "myagent" in agent._sip_usernames assert "myroute" in agent._sip_usernames - def test_auto_map_no_vowels_variant(self): + def test_auto_map_no_vowels_variant(self) -> None: """For names longer than 3 chars, a no-vowels variant should be registered.""" agent = _make_agent(name="testing", use_pom=False) agent.auto_map_sip_usernames() @@ -1094,7 +1095,7 @@ def test_auto_map_no_vowels_variant(self): # "tstng" is "testing" without vowels assert "tstng" in agent._sip_usernames - def test_auto_map_short_name_no_vowel_variant(self): + def test_auto_map_short_name_no_vowel_variant(self) -> None: """Names <= 3 chars should NOT get a no-vowels variant.""" agent = _make_agent(name="abc", use_pom=False) agent.auto_map_sip_usernames() @@ -1102,23 +1103,23 @@ def test_auto_map_short_name_no_vowel_variant(self): # Should not have a no-vowels variant for short names assert len([u for u in agent._sip_usernames if u != "abc"]) <= 1 # only route variant - def test_enable_sip_routing_returns_self(self): + def test_enable_sip_routing_returns_self(self) -> None: agent = self._make() result = agent.enable_sip_routing() assert result is agent - def test_enable_sip_routing_auto_map_true(self): + def test_enable_sip_routing_auto_map_true(self) -> None: agent = self._make() agent.enable_sip_routing(auto_map=True) # Should have registered at least the agent name assert hasattr(agent, '_sip_usernames') assert "sip_test" in agent._sip_usernames - def test_enable_sip_routing_auto_map_false(self): + def test_enable_sip_routing_auto_map_false(self) -> None: agent = self._make() agent.enable_sip_routing(auto_map=False) # With auto_map=False, _sip_usernames might not be populated - usernames = getattr(agent, '_sip_usernames', set()) + usernames: set[str] = getattr(agent, '_sip_usernames', set()) # Should NOT have auto-mapped the agent name assert "sip_test" not in usernames @@ -1126,19 +1127,19 @@ def test_enable_sip_routing_auto_map_false(self): class TestUrlBuilding: """Test get_full_url() across different execution modes.""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="url_test", use_pom=False, **kw) # -- Server mode (default) -- - def test_server_mode_basic_url(self): + def test_server_mode_basic_url(self) -> None: agent = self._make(host="localhost", port=3000, route="/test") url = agent.get_full_url() assert "localhost" in url assert "3000" in url assert "/test" in url - def test_server_mode_with_auth(self): + def test_server_mode_with_auth(self) -> None: agent = _make_agent( name="url_test", host="localhost", port=3000, route="/test", basic_auth=("user", "pass"), use_pom=False @@ -1146,7 +1147,7 @@ def test_server_mode_with_auth(self): url = agent.get_full_url(include_auth=True) assert "user:pass@" in url - def test_server_mode_without_auth(self): + def test_server_mode_without_auth(self) -> None: agent = _make_agent( name="url_test", host="localhost", port=3000, route="/test", basic_auth=("user", "pass"), use_pom=False @@ -1156,13 +1157,13 @@ def test_server_mode_without_auth(self): # -- Proxy URL -- - def test_proxy_url_takes_precedence(self): + def test_proxy_url_takes_precedence(self) -> None: agent = self._make(host="localhost", port=3000) agent._proxy_url_base = "https://proxy.example.com/agent" url = agent.get_full_url() assert url == "https://proxy.example.com/agent" - def test_proxy_url_with_auth(self): + def test_proxy_url_with_auth(self) -> None: agent = _make_agent( name="url_test", host="localhost", port=3000, basic_auth=("u", "p"), use_pom=False @@ -1171,7 +1172,7 @@ def test_proxy_url_with_auth(self): url = agent.get_full_url(include_auth=True) assert "u:p@" in url - def test_proxy_url_trailing_slash_stripped(self): + def test_proxy_url_trailing_slash_stripped(self) -> None: agent = self._make() agent._proxy_url_base = "https://proxy.example.com/" url = agent.get_full_url() @@ -1179,7 +1180,7 @@ def test_proxy_url_trailing_slash_stripped(self): # -- CGI mode -- - def test_cgi_mode_url(self): + def test_cgi_mode_url(self) -> None: agent = self._make(route="/cgitest") env = { "GATEWAY_INTERFACE": "CGI/1.1", @@ -1193,7 +1194,7 @@ def test_cgi_mode_url(self): assert "example.com" in url assert "/cgi-bin/agent.py" in url - def test_cgi_mode_https(self): + def test_cgi_mode_https(self) -> None: agent = self._make(route="/") env = { "GATEWAY_INTERFACE": "CGI/1.1", @@ -1208,7 +1209,7 @@ def test_cgi_mode_https(self): # -- Lambda mode -- - def test_lambda_mode_with_function_url(self): + def test_lambda_mode_with_function_url(self) -> None: agent = self._make(route="/") env = { "AWS_LAMBDA_FUNCTION_NAME": "my-func", @@ -1219,7 +1220,7 @@ def test_lambda_mode_with_function_url(self): url = agent.get_full_url() assert "abc123.lambda-url.us-east-1.on.aws" in url - def test_lambda_mode_fallback_construction(self): + def test_lambda_mode_fallback_construction(self) -> None: agent = self._make(route="/") env = { "AWS_LAMBDA_FUNCTION_NAME": "my-func", @@ -1236,7 +1237,7 @@ def test_lambda_mode_fallback_construction(self): # -- Google Cloud Function mode -- - def test_gcf_mode_url(self): + def test_gcf_mode_url(self) -> None: agent = self._make(route="/") env = { "K_SERVICE": "my-service", @@ -1253,7 +1254,7 @@ def test_gcf_mode_url(self): assert "my-project" in url assert "my-service" in url - def test_gcf_mode_no_project(self): + def test_gcf_mode_no_project(self) -> None: agent = self._make(route="/") env = { "K_SERVICE": "my-service", @@ -1271,7 +1272,7 @@ def test_gcf_mode_no_project(self): # -- Azure Function mode -- - def test_azure_mode_url(self): + def test_azure_mode_url(self) -> None: agent = self._make(route="/") env = { "FUNCTIONS_WORKER_RUNTIME": "python", @@ -1290,7 +1291,7 @@ def test_azure_mode_url(self): assert "my-app.azurewebsites.net" in url assert "my-func" in url - def test_azure_mode_no_app_name(self): + def test_azure_mode_no_app_name(self) -> None: agent = self._make(route="/") env = { "FUNCTIONS_WORKER_RUNTIME": "python", @@ -1312,7 +1313,7 @@ def test_azure_mode_no_app_name(self): # -- Manual proxy URL -- - def test_manual_set_proxy_url(self): + def test_manual_set_proxy_url(self) -> None: agent = self._make() result = agent.manual_set_proxy_url("https://custom.example.com") assert result is agent @@ -1320,13 +1321,13 @@ def test_manual_set_proxy_url(self): # -- Webhook URL overrides -- - def test_set_web_hook_url(self): + def test_set_web_hook_url(self) -> None: agent = self._make() result = agent.set_web_hook_url("https://webhook.example.com/swaig") assert result is agent assert agent._web_hook_url_override == "https://webhook.example.com/swaig" - def test_set_post_prompt_url(self): + def test_set_post_prompt_url(self) -> None: agent = self._make() result = agent.set_post_prompt_url("https://webhook.example.com/pp") assert result is agent @@ -1336,12 +1337,12 @@ def test_set_post_prompt_url(self): class TestToolRegistration: """Test define_tool() with parameter schemas.""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="tool_test", use_pom=False, **kw) - def test_define_tool_basic(self): + def test_define_tool_basic(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} result = agent.define_tool( @@ -1353,9 +1354,9 @@ def handler(args, raw): assert result is agent assert "my_tool" in agent._tool_registry._swaig_functions - def test_define_tool_with_required(self): + def test_define_tool_with_required(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool( @@ -1366,38 +1367,41 @@ def handler(args, raw): required=["name"] ) func = agent._tool_registry._swaig_functions["req_tool"] + assert isinstance(func, SWAIGFunction) assert "name" in func.required - def test_define_tool_secure_default(self): + def test_define_tool_secure_default(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool("sec_tool", "Secure tool", {}, handler) func = agent._tool_registry._swaig_functions["sec_tool"] + assert isinstance(func, SWAIGFunction) assert func.secure is True - def test_define_tool_not_secure(self): + def test_define_tool_not_secure(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool("unsec_tool", "Unsecured tool", {}, handler, secure=False) func = agent._tool_registry._swaig_functions["unsec_tool"] + assert isinstance(func, SWAIGFunction) assert func.secure is False - def test_define_tool_duplicate_raises(self): + def test_define_tool_duplicate_raises(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool("dup_tool", "First", {}, handler) with pytest.raises(ValueError, match="already exists"): agent.define_tool("dup_tool", "Second", {}, handler) - def test_define_tool_with_webhook_url(self): + def test_define_tool_with_webhook_url(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool( @@ -1405,21 +1409,23 @@ def handler(args, raw): webhook_url="https://external.com/api" ) func = agent._tool_registry._swaig_functions["webhook_tool"] + assert isinstance(func, SWAIGFunction) assert func.webhook_url == "https://external.com/api" assert func.is_external is True - def test_define_tool_description(self): + def test_define_tool_description(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool("desc_tool", "My description", {}, handler) func = agent._tool_registry._swaig_functions["desc_tool"] + assert isinstance(func, SWAIGFunction) assert func.description == "My description" - def test_define_tool_complex_parameters(self): + def test_define_tool_complex_parameters(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} params = { @@ -1433,11 +1439,12 @@ def handler(args, raw): } agent.define_tool("complex_tool", "Complex params", params, handler) func = agent._tool_registry._swaig_functions["complex_tool"] + assert isinstance(func, SWAIGFunction) assert func.parameters == params - def test_define_tools_returns_list(self): + def test_define_tools_returns_list(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool("tool_a", "Tool A", {}, handler) @@ -1445,7 +1452,7 @@ def handler(args, raw): tools = agent.define_tools() assert len(tools) == 2 - def test_register_swaig_function_raw_dict(self): + def test_register_swaig_function_raw_dict(self) -> None: agent = self._make() func_dict = { "function": "data_map_fn", @@ -1457,7 +1464,7 @@ def test_register_swaig_function_raw_dict(self): assert result is agent assert "data_map_fn" in agent._tool_registry._swaig_functions - def test_register_swaig_function_in_define_tools(self): + def test_register_swaig_function_in_define_tools(self) -> None: agent = self._make() func_dict = { "function": "dm_fn", @@ -1468,12 +1475,12 @@ def test_register_swaig_function_in_define_tools(self): tools = agent.define_tools() assert len(tools) == 1 - def test_on_function_call_missing_function(self): + def test_on_function_call_missing_function(self) -> None: agent = self._make() result = agent.on_function_call("nonexistent", {"arg": "val"}) assert "not found" in result["response"] - def test_on_function_call_data_map_function(self): + def test_on_function_call_data_map_function(self) -> None: agent = self._make() agent.register_swaig_function({ "function": "dm_fn", @@ -1484,27 +1491,27 @@ def test_on_function_call_data_map_function(self): result = agent.on_function_call("dm_fn", {}) assert "Data map" in result["response"] or "data_map" in result["response"].lower() - def test_on_function_call_success(self): + def test_on_function_call_success(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": f"got {args.get('x')}"} agent.define_tool("fn", "test", {"type": "object", "properties": {"x": {"type": "string"}}}, handler) result = agent.on_function_call("fn", {"x": "hello"}) assert "got hello" in str(result) - def test_on_function_call_handler_exception(self): + def test_on_function_call_handler_exception(self) -> None: agent = self._make() - def bad_handler(args, raw): + def bad_handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: raise RuntimeError("boom") agent.define_tool("bad_fn", "bad", {}, bad_handler) result = agent.on_function_call("bad_fn", {}) assert "Error" in result["response"] or "boom" in result["response"] - def test_tool_appears_in_rendered_swml(self): + def test_tool_appears_in_rendered_swml(self) -> None: agent = self._make() - def handler(args, raw): + def handler(args: dict[str, Any], raw: Any) -> dict[str, Any]: return {"response": "ok"} agent.define_tool( @@ -1523,10 +1530,10 @@ def handler(args, raw): class TestSkillIntegration: """Test add_skill() lifecycle.""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="skill_test", use_pom=False, **kw) - def test_add_skill_success(self): + def test_add_skill_success(self) -> None: agent = self._make() agent.skill_manager = Mock() agent.skill_manager.load_skill.return_value = (True, "") @@ -1534,53 +1541,53 @@ def test_add_skill_success(self): assert result is agent agent.skill_manager.load_skill.assert_called_once_with("test_skill", params={"param": "value"}) - def test_add_skill_failure_raises(self): + def test_add_skill_failure_raises(self) -> None: agent = self._make() agent.skill_manager = Mock() agent.skill_manager.load_skill.return_value = (False, "Skill not found") with pytest.raises(ValueError, match="Failed to load skill"): agent.add_skill("bad_skill") - def test_add_skill_no_params(self): + def test_add_skill_no_params(self) -> None: agent = self._make() agent.skill_manager = Mock() agent.skill_manager.load_skill.return_value = (True, "") agent.add_skill("simple_skill") agent.skill_manager.load_skill.assert_called_once_with("simple_skill", params=None) - def test_remove_skill(self): + def test_remove_skill(self) -> None: agent = self._make() agent.skill_manager = Mock() result = agent.remove_skill("some_skill") assert result is agent agent.skill_manager.unload_skill.assert_called_once_with("some_skill") - def test_list_skills(self): + def test_list_skills(self) -> None: agent = self._make() agent.skill_manager = Mock() agent.skill_manager.list_loaded_skills.return_value = ["skill_a", "skill_b"] result = agent.list_skills() assert result == ["skill_a", "skill_b"] - def test_has_skill_true(self): + def test_has_skill_true(self) -> None: agent = self._make() agent.skill_manager = Mock() agent.skill_manager.has_skill.return_value = True assert agent.has_skill("existing_skill") is True - def test_has_skill_false(self): + def test_has_skill_false(self) -> None: agent = self._make() agent.skill_manager = Mock() agent.skill_manager.has_skill.return_value = False assert agent.has_skill("missing_skill") is False - def test_skill_manager_initialized(self): + def test_skill_manager_initialized(self) -> None: """Verify skill_manager is a SkillManager by default.""" agent = self._make() from signalwire.core.skill_manager import SkillManager assert isinstance(agent.skill_manager, SkillManager) - def test_skill_manager_agent_reference(self): + def test_skill_manager_agent_reference(self) -> None: agent = self._make() assert agent.skill_manager.agent is agent @@ -1588,40 +1595,40 @@ def test_skill_manager_agent_reference(self): class TestSwaigQueryParams: """Test SWAIG query parameter methods.""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="qp_test", use_pom=False, **kw) - def test_add_swaig_query_params(self): + def test_add_swaig_query_params(self) -> None: agent = self._make() result = agent.add_swaig_query_params({"tier": "premium"}) assert result is agent assert agent._swaig_query_params["tier"] == "premium" - def test_add_swaig_query_params_merge(self): + def test_add_swaig_query_params_merge(self) -> None: agent = self._make() agent.add_swaig_query_params({"a": "1"}) agent.add_swaig_query_params({"b": "2"}) assert agent._swaig_query_params == {"a": "1", "b": "2"} - def test_add_swaig_query_params_overwrite(self): + def test_add_swaig_query_params_overwrite(self) -> None: agent = self._make() agent.add_swaig_query_params({"a": "1"}) agent.add_swaig_query_params({"a": "2"}) assert agent._swaig_query_params["a"] == "2" - def test_clear_swaig_query_params(self): + def test_clear_swaig_query_params(self) -> None: agent = self._make() agent.add_swaig_query_params({"x": "y"}) result = agent.clear_swaig_query_params() assert result is agent assert agent._swaig_query_params == {} - def test_add_swaig_query_params_none(self): + def test_add_swaig_query_params_none(self) -> None: agent = self._make() - agent.add_swaig_query_params(None) + agent.add_swaig_query_params(None) # type: ignore[arg-type] # intentional invalid input assert agent._swaig_query_params == {} - def test_add_swaig_query_params_empty(self): + def test_add_swaig_query_params_empty(self) -> None: agent = self._make() agent.add_swaig_query_params({}) assert agent._swaig_query_params == {} @@ -1630,22 +1637,22 @@ def test_add_swaig_query_params_empty(self): class TestDynamicConfig: """Test dynamic configuration callback.""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="dynconfig_test", use_pom=False, **kw) - def test_set_dynamic_config_callback(self): + def test_set_dynamic_config_callback(self) -> None: agent = self._make() - def callback(qp, bp, h, a): + def callback(qp: Any, bp: Any, h: Any, a: Any) -> None: pass result = agent.set_dynamic_config_callback(callback) assert result is agent assert agent._dynamic_config_callback is callback - def test_dynamic_config_callback_initially_none(self): + def test_dynamic_config_callback_initially_none(self) -> None: agent = self._make() assert agent._dynamic_config_callback is None - def test_on_request_returns_none_without_callback(self): + def test_on_request_returns_none_without_callback(self) -> None: agent = self._make() result = agent.on_request({"test": "data"}) assert result is None @@ -1654,92 +1661,92 @@ def test_on_request_returns_none_without_callback(self): class TestFindSummary: """Test _find_summary_in_post_data.""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="summary_test", use_pom=False, **kw) - def test_find_summary_none_body(self): + def test_find_summary_none_body(self) -> None: agent = self._make() - result = agent._find_summary_in_post_data(None, agent.log) + result = agent._find_summary_in_post_data(None, agent.log) # type: ignore[arg-type] # intentional invalid input assert result is None - def test_find_summary_empty_body(self): + def test_find_summary_empty_body(self) -> None: agent = self._make() result = agent._find_summary_in_post_data({}, agent.log) assert result is None - def test_find_summary_direct_key(self): + def test_find_summary_direct_key(self) -> None: agent = self._make() - result = agent._find_summary_in_post_data({"summary": {"text": "hello"}}, agent.log) + result = agent._find_summary_in_post_data({"summary": {"text": "hello"}}, agent.log) # type: ignore[typeddict-unknown-key] # exercises arbitrary post-data shape assert result == {"text": "hello"} - def test_find_summary_from_post_prompt_data_parsed(self): + def test_find_summary_from_post_prompt_data_parsed(self) -> None: agent = self._make() body = {"post_prompt_data": {"parsed": [{"outcome": "success"}]}} - result = agent._find_summary_in_post_data(body, agent.log) + result = agent._find_summary_in_post_data(body, agent.log) # type: ignore[arg-type] # exercises arbitrary post-data shape assert result == {"outcome": "success"} - def test_find_summary_from_post_prompt_data_raw_json(self): + def test_find_summary_from_post_prompt_data_raw_json(self) -> None: agent = self._make() body = {"post_prompt_data": {"raw": '{"outcome": "done"}'}} - result = agent._find_summary_in_post_data(body, agent.log) + result = agent._find_summary_in_post_data(body, agent.log) # type: ignore[arg-type] # exercises arbitrary post-data shape assert result == {"outcome": "done"} - def test_find_summary_from_post_prompt_data_raw_not_json(self): + def test_find_summary_from_post_prompt_data_raw_not_json(self) -> None: agent = self._make() body = {"post_prompt_data": {"raw": "just a string"}} - result = agent._find_summary_in_post_data(body, agent.log) + result = agent._find_summary_in_post_data(body, agent.log) # type: ignore[arg-type] # exercises arbitrary post-data shape assert result == "just a string" - def test_find_summary_no_matching_key(self): + def test_find_summary_no_matching_key(self) -> None: agent = self._make() - result = agent._find_summary_in_post_data({"other": "data"}, agent.log) + result = agent._find_summary_in_post_data({"other": "data"}, agent.log) # type: ignore[typeddict-unknown-key] # exercises arbitrary post-data shape assert result is None class TestOnSummary: """Test on_summary hook.""" - def _make(self, **kw): + def _make(self, **kw: Any) -> AgentBase: return _make_agent(name="summary_hook_test", use_pom=False, **kw) - def test_on_summary_does_not_raise(self): + def test_on_summary_does_not_raise(self) -> None: """The default on_summary is a no-op: returns None and leaves the agent's name unchanged.""" agent = self._make() name_before = agent.name - result = agent.on_summary({"summary": "test"}) + result = agent.on_summary({"summary": "test"}) # type: ignore[func-returns-value,arg-type] # hook returns None; arbitrary summary dict assert result is None assert agent.name == name_before - def test_on_summary_with_raw_data(self): + def test_on_summary_with_raw_data(self) -> None: """on_summary accepts an optional raw_data kwarg without echoing it back into agent state.""" agent = self._make() name_before = agent.name - result = agent.on_summary( - {"summary": "test"}, + result = agent.on_summary( # type: ignore[func-returns-value] # hook returns None + {"summary": "test"}, # type: ignore[arg-type] # arbitrary summary dict raw_data={"call_id": "marker-call-id-zzz"}, ) assert result is None assert agent.name == name_before - def test_on_summary_none(self): + def test_on_summary_none(self) -> None: """on_summary must accept summary=None (e.g. when no summary was found in the post data) and still no-op.""" agent = self._make() - result = agent.on_summary(None) + result = agent.on_summary(None) # type: ignore[func-returns-value] # hook returns None assert result is None class TestAgentId: """Test agent_id generation.""" - def test_auto_generated_agent_id(self): + def test_auto_generated_agent_id(self) -> None: agent = _make_agent(name="id_test", use_pom=False) assert agent.agent_id is not None # Should be a valid UUID uuid.UUID(agent.agent_id) - def test_custom_agent_id(self): + def test_custom_agent_id(self) -> None: agent = _make_agent(name="id_test", agent_id="custom-123", use_pom=False) assert agent.agent_id == "custom-123" diff --git a/tests/unit/core/test_agent_server.py b/tests/unit/core/test_agent_server.py index 2bce2f6a..b6d33dd7 100644 --- a/tests/unit/core/test_agent_server.py +++ b/tests/unit/core/test_agent_server.py @@ -30,17 +30,19 @@ import os import json from pathlib import Path +from typing import Any from unittest.mock import Mock, patch, MagicMock, call from io import StringIO from fastapi.testclient import TestClient -from signalwire import AgentBase, AgentServer +from signalwire.agent_server import AgentServer +from signalwire.core.agent_base import AgentBase class SimpleTestAgent(AgentBase): """Simple agent for testing""" - def __init__(self, name="test_agent", route="/test"): + def __init__(self, name: str = "test_agent", route: str = "/test") -> None: super().__init__( name=name, route=route, @@ -53,7 +55,7 @@ def __init__(self, name="test_agent", route="/test"): class TestAgentServerInitialization: """Test AgentServer initialization and default state""" - def test_default_initialization(self): + def test_default_initialization(self) -> None: """Test default host, port, and log_level""" server = AgentServer() assert server.host == "0.0.0.0" @@ -64,20 +66,20 @@ def test_default_initialization(self): assert server._sip_route is None assert server._sip_username_mapping == {} - def test_custom_initialization(self): + def test_custom_initialization(self) -> None: """Test custom host, port, and log_level""" server = AgentServer(host="127.0.0.1", port=8080, log_level="DEBUG") assert server.host == "127.0.0.1" assert server.port == 8080 assert server.log_level == "debug" # Should be lowered - def test_app_is_fastapi_instance(self): + def test_app_is_fastapi_instance(self) -> None: """Test that self.app is a FastAPI instance""" from fastapi import FastAPI server = AgentServer() assert isinstance(server.app, FastAPI) - def test_health_endpoint_registered_on_init(self): + def test_health_endpoint_registered_on_init(self) -> None: """Test that health endpoints are available immediately after init""" server = AgentServer() client = TestClient(server.app) @@ -88,7 +90,7 @@ def test_health_endpoint_registered_on_init(self): assert data["agents"] == 0 assert data["routes"] == [] - def test_ready_endpoint_registered_on_init(self): + def test_ready_endpoint_registered_on_init(self) -> None: """Test that ready endpoint is available immediately after init""" server = AgentServer() client = TestClient(server.app) @@ -102,7 +104,7 @@ def test_ready_endpoint_registered_on_init(self): class TestAgentRegistration: """Test agent registration, retrieval, and unregistration""" - def test_register_agent_with_explicit_route(self): + def test_register_agent_with_explicit_route(self) -> None: """Test registering an agent with an explicit route""" server = AgentServer() agent = SimpleTestAgent() @@ -110,28 +112,28 @@ def test_register_agent_with_explicit_route(self): assert "/support" in server.agents assert server.agents["/support"] is agent - def test_register_agent_uses_agent_default_route(self): + def test_register_agent_uses_agent_default_route(self) -> None: """Test registering an agent without specifying a route uses agent's route""" server = AgentServer() agent = SimpleTestAgent(name="myagent", route="/myroute") server.register(agent) assert "/myroute" in server.agents - def test_register_normalizes_route_adds_slash(self): + def test_register_normalizes_route_adds_slash(self) -> None: """Test that routes without leading slash get normalized""" server = AgentServer() agent = SimpleTestAgent() server.register(agent, "noslash") assert "/noslash" in server.agents - def test_register_normalizes_route_strips_trailing_slash(self): + def test_register_normalizes_route_strips_trailing_slash(self) -> None: """Test that trailing slashes are stripped""" server = AgentServer() agent = SimpleTestAgent() server.register(agent, "/trailing/") assert "/trailing" in server.agents - def test_register_duplicate_route_raises(self): + def test_register_duplicate_route_raises(self) -> None: """Test that registering two agents on same route raises ValueError""" server = AgentServer() agent1 = SimpleTestAgent(name="agent1") @@ -140,7 +142,7 @@ def test_register_duplicate_route_raises(self): with pytest.raises(ValueError, match="Route '/shared' is already in use"): server.register(agent2, "/shared") - def test_register_multiple_agents_different_routes(self): + def test_register_multiple_agents_different_routes(self) -> None: """Test registering multiple agents on different routes""" server = AgentServer() agent1 = SimpleTestAgent(name="support_agent") @@ -151,7 +153,7 @@ def test_register_multiple_agents_different_routes(self): assert "/support" in server.agents assert "/sales" in server.agents - def test_health_endpoint_reflects_registered_agents(self): + def test_health_endpoint_reflects_registered_agents(self) -> None: """Test that health endpoint shows registered agent count and routes""" server = AgentServer() server.register(SimpleTestAgent(name="a1"), "/r1") @@ -166,12 +168,12 @@ def test_health_endpoint_reflects_registered_agents(self): class TestAgentRetrieval: """Test get_agent and get_agents methods""" - def test_get_agents_empty(self): + def test_get_agents_empty(self) -> None: """Test get_agents with no registered agents""" server = AgentServer() assert server.get_agents() == [] - def test_get_agents_returns_list_of_tuples(self): + def test_get_agents_returns_list_of_tuples(self) -> None: """Test get_agents returns route-agent tuples""" server = AgentServer() agent = SimpleTestAgent() @@ -181,7 +183,7 @@ def test_get_agents_returns_list_of_tuples(self): assert result[0][0] == "/test" assert result[0][1] is agent - def test_get_agents_multiple(self): + def test_get_agents_multiple(self) -> None: """Test get_agents with multiple agents""" server = AgentServer() a1 = SimpleTestAgent(name="a1") @@ -194,7 +196,7 @@ def test_get_agents_multiple(self): assert "/one" in routes assert "/two" in routes - def test_get_agent_by_route(self): + def test_get_agent_by_route(self) -> None: """Test get_agent returns the correct agent""" server = AgentServer() agent = SimpleTestAgent() @@ -202,7 +204,7 @@ def test_get_agent_by_route(self): result = server.get_agent("/myagent") assert result is agent - def test_get_agent_normalizes_route(self): + def test_get_agent_normalizes_route(self) -> None: """Test get_agent normalizes routes (adds slash, strips trailing)""" server = AgentServer() agent = SimpleTestAgent() @@ -212,7 +214,7 @@ def test_get_agent_normalizes_route(self): # With trailing slash assert server.get_agent("/myagent/") is agent - def test_get_agent_not_found(self): + def test_get_agent_not_found(self) -> None: """Test get_agent returns None for unknown route""" server = AgentServer() assert server.get_agent("/nonexistent") is None @@ -221,14 +223,14 @@ def test_get_agent_not_found(self): class TestAgentUnregistration: """Test unregister method""" - def test_unregister_existing_agent(self): + def test_unregister_existing_agent(self) -> None: """Test unregistering an existing agent returns True""" server = AgentServer() server.register(SimpleTestAgent(), "/test") assert server.unregister("/test") is True assert "/test" not in server.agents - def test_unregister_normalizes_route(self): + def test_unregister_normalizes_route(self) -> None: """Test unregister normalizes route format""" server = AgentServer() server.register(SimpleTestAgent(), "/test") @@ -236,13 +238,13 @@ def test_unregister_normalizes_route(self): assert server.unregister("test") is True assert len(server.agents) == 0 - def test_unregister_strips_trailing_slash(self): + def test_unregister_strips_trailing_slash(self) -> None: """Test unregister strips trailing slash""" server = AgentServer() server.register(SimpleTestAgent(), "/test") assert server.unregister("/test/") is True - def test_unregister_nonexistent_returns_false(self): + def test_unregister_nonexistent_returns_false(self) -> None: """Test unregistering a nonexistent route returns False""" server = AgentServer() assert server.unregister("/missing") is False @@ -251,7 +253,7 @@ def test_unregister_nonexistent_returns_false(self): class TestSipRouting: """Test SIP routing setup and username mapping""" - def test_setup_sip_routing_basic(self): + def test_setup_sip_routing_basic(self) -> None: """Test basic SIP routing setup""" server = AgentServer() server.setup_sip_routing(route="/sip") @@ -259,13 +261,13 @@ def test_setup_sip_routing_basic(self): assert server._sip_route == "/sip" assert server._sip_auto_map is True - def test_setup_sip_routing_normalizes_route(self): + def test_setup_sip_routing_normalizes_route(self) -> None: """Test SIP routing normalizes the route""" server = AgentServer() server.setup_sip_routing(route="sip/") assert server._sip_route == "/sip" - def test_setup_sip_routing_already_enabled_logs_warning(self): + def test_setup_sip_routing_already_enabled_logs_warning(self) -> None: """Test that calling setup_sip_routing twice is a no-op""" server = AgentServer() server.setup_sip_routing() @@ -273,7 +275,7 @@ def test_setup_sip_routing_already_enabled_logs_warning(self): server.setup_sip_routing() assert server._sip_routing_enabled is True - def test_setup_sip_routing_auto_map_existing_agents(self): + def test_setup_sip_routing_auto_map_existing_agents(self) -> None: """Test auto-mapping SIP usernames for existing agents""" server = AgentServer() agent = SimpleTestAgent(name="support_bot") @@ -283,7 +285,7 @@ def test_setup_sip_routing_auto_map_existing_agents(self): assert "support_bot" in server._sip_username_mapping or "supportbot" in server._sip_username_mapping assert "support" in server._sip_username_mapping - def test_setup_sip_routing_no_auto_map(self): + def test_setup_sip_routing_no_auto_map(self) -> None: """Test SIP routing without auto-mapping""" server = AgentServer() agent = SimpleTestAgent(name="support") @@ -293,7 +295,7 @@ def test_setup_sip_routing_no_auto_map(self): # Should not auto-map assert len(server._sip_username_mapping) == 0 - def test_register_sip_username(self): + def test_register_sip_username(self) -> None: """Test manual SIP username registration""" server = AgentServer() server.register(SimpleTestAgent(), "/support") @@ -301,28 +303,28 @@ def test_register_sip_username(self): server.register_sip_username("alice", "/support") assert server._sip_username_mapping["alice"] == "/support" - def test_register_sip_username_case_insensitive(self): + def test_register_sip_username_case_insensitive(self) -> None: """Test SIP usernames are stored lowercase""" server = AgentServer() server.setup_sip_routing() server.register_sip_username("ALICE", "/support") assert "alice" in server._sip_username_mapping - def test_register_sip_username_normalizes_route(self): + def test_register_sip_username_normalizes_route(self) -> None: """Test SIP username registration normalizes routes""" server = AgentServer() server.setup_sip_routing() server.register_sip_username("alice", "support/") assert server._sip_username_mapping["alice"] == "/support" - def test_register_sip_username_without_sip_routing_enabled(self): + def test_register_sip_username_without_sip_routing_enabled(self) -> None: """Test registering SIP username without enabling SIP routing first""" server = AgentServer() # Should just log a warning and return without adding server.register_sip_username("alice", "/support") assert len(server._sip_username_mapping) == 0 - def test_lookup_sip_route(self): + def test_lookup_sip_route(self) -> None: """Test _lookup_sip_route returns correct route""" server = AgentServer() server._sip_username_mapping = {"alice": "/support", "bob": "/sales"} @@ -330,13 +332,13 @@ def test_lookup_sip_route(self): assert server._lookup_sip_route("bob") == "/sales" assert server._lookup_sip_route("charlie") is None - def test_lookup_sip_route_case_insensitive(self): + def test_lookup_sip_route_case_insensitive(self) -> None: """Test _lookup_sip_route is case insensitive""" server = AgentServer() server._sip_username_mapping = {"alice": "/support"} assert server._lookup_sip_route("ALICE") == "/support" - def test_auto_map_agent_sip_usernames(self): + def test_auto_map_agent_sip_usernames(self) -> None: """Test _auto_map_agent_sip_usernames creates proper mappings""" server = AgentServer() server._sip_routing_enabled = True @@ -348,7 +350,7 @@ def test_auto_map_agent_sip_usernames(self): assert "salesbot" in server._sip_username_mapping or "sales_bot" in server._sip_username_mapping assert "sales" in server._sip_username_mapping - def test_register_agent_with_sip_routing_enabled(self): + def test_register_agent_with_sip_routing_enabled(self) -> None: """Test that registering an agent after SIP routing is set up auto-maps and registers callback""" server = AgentServer() server.setup_sip_routing(auto_map=True) @@ -356,7 +358,7 @@ def test_register_agent_with_sip_routing_enabled(self): server.register(agent, "/helpdesk") assert "helpdesk" in server._sip_username_mapping - def test_sip_routing_callback_with_valid_username(self): + def test_sip_routing_callback_with_valid_username(self) -> None: """Test the SIP routing callback resolves a known username""" server = AgentServer() agent = SimpleTestAgent(name="support") @@ -369,7 +371,7 @@ def test_sip_routing_callback_with_valid_username(self): result = server._sip_routing_callback(mock_request, body) assert result == "/support" - def test_sip_routing_callback_with_unknown_username(self): + def test_sip_routing_callback_with_unknown_username(self) -> None: """Test the SIP routing callback returns None for unknown username""" server = AgentServer() agent = SimpleTestAgent(name="support") @@ -381,12 +383,12 @@ def test_sip_routing_callback_with_unknown_username(self): result = server._sip_routing_callback(mock_request, body) assert result is None - def test_sip_routing_callback_no_sip_username(self): + def test_sip_routing_callback_no_sip_username(self) -> None: """Test the SIP routing callback with no extractable username""" server = AgentServer() server.setup_sip_routing() mock_request = Mock() - body = {} + body: dict[str, Any] = {} result = server._sip_routing_callback(mock_request, body) assert result is None @@ -395,14 +397,14 @@ class TestRunMethod: """Test the universal run method and execution mode detection""" @patch("signalwire.agent_server.uvicorn") - def test_run_server_mode(self, mock_uvicorn): + def test_run_server_mode(self, mock_uvicorn: MagicMock) -> None: """Test run() in server mode delegates to _run_server""" server = AgentServer() with patch("signalwire.core.logging_config.get_execution_mode", return_value="server"): server.run() mock_uvicorn.run.assert_called_once() - def test_run_cgi_mode(self): + def test_run_cgi_mode(self) -> None: """Test run() in CGI mode delegates to _handle_cgi_request""" server = AgentServer() with patch("signalwire.core.logging_config.get_execution_mode", return_value="cgi"): @@ -411,7 +413,7 @@ def test_run_cgi_mode(self): mock_cgi.assert_called_once() assert result == "cgi_response" - def test_run_lambda_mode(self): + def test_run_lambda_mode(self) -> None: """Test run() in Lambda mode delegates to _handle_lambda_request""" server = AgentServer() event = {"path": "/test"} @@ -427,7 +429,7 @@ class TestRunServer: """Test _run_server method""" @patch("signalwire.agent_server.uvicorn") - def test_run_server_default_host_port(self, mock_uvicorn): + def test_run_server_default_host_port(self, mock_uvicorn: MagicMock) -> None: """Test _run_server uses default host and port""" server = AgentServer(host="0.0.0.0", port=3000) server._run_server() @@ -439,7 +441,7 @@ def test_run_server_default_host_port(self, mock_uvicorn): ) @patch("signalwire.agent_server.uvicorn") - def test_run_server_override_host_port(self, mock_uvicorn): + def test_run_server_override_host_port(self, mock_uvicorn: MagicMock) -> None: """Test _run_server with overridden host and port""" server = AgentServer() server._run_server(host="127.0.0.1", port=9999) @@ -451,7 +453,7 @@ def test_run_server_override_host_port(self, mock_uvicorn): ) @patch("signalwire.agent_server.uvicorn") - def test_run_server_with_ssl(self, mock_uvicorn): + def test_run_server_with_ssl(self, mock_uvicorn: MagicMock) -> None: """Test _run_server with SSL enabled via environment variables""" with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as cert_f, \ tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as key_f: @@ -481,7 +483,7 @@ def test_run_server_with_ssl(self, mock_uvicorn): os.unlink(key_path) @patch("signalwire.agent_server.uvicorn") - def test_run_server_ssl_disabled_bad_cert(self, mock_uvicorn): + def test_run_server_ssl_disabled_bad_cert(self, mock_uvicorn: MagicMock) -> None: """Test _run_server falls back to non-SSL if cert not found""" env = { "SWML_SSL_ENABLED": "true", @@ -500,7 +502,7 @@ def test_run_server_ssl_disabled_bad_cert(self, mock_uvicorn): ) @patch("signalwire.agent_server.uvicorn") - def test_run_server_no_agents_warning(self, mock_uvicorn): + def test_run_server_no_agents_warning(self, mock_uvicorn: MagicMock) -> None: """Test _run_server with no agents logs a warning""" server = AgentServer() # Should not raise; just warns @@ -508,7 +510,7 @@ def test_run_server_no_agents_warning(self, mock_uvicorn): mock_uvicorn.run.assert_called_once() @patch("signalwire.agent_server.uvicorn") - def test_run_server_ssl_missing_key(self, mock_uvicorn): + def test_run_server_ssl_missing_key(self, mock_uvicorn: MagicMock) -> None: """Test _run_server falls back when SSL key path is missing""" with tempfile.NamedTemporaryFile(suffix=".pem", delete=False) as cert_f: cert_path = cert_f.name @@ -536,7 +538,7 @@ def test_run_server_ssl_missing_key(self, mock_uvicorn): class TestHandleLambdaRequest: """Test _handle_lambda_request method""" - def test_lambda_no_path_returns_404(self): + def test_lambda_no_path_returns_404(self) -> None: """Test Lambda request with no path returns 404""" server = AgentServer() result = server._handle_lambda_request({"path": ""}, None) @@ -544,27 +546,27 @@ def test_lambda_no_path_returns_404(self): body = json.loads(result["body"]) assert "No agent specified" in body["error"] - def test_lambda_none_event(self): + def test_lambda_none_event(self) -> None: """Test Lambda request with None event""" server = AgentServer() result = server._handle_lambda_request(None, None) assert result["statusCode"] == 404 - def test_lambda_matching_agent_returns_swml(self): + def test_lambda_matching_agent_returns_swml(self) -> None: """Test Lambda request that matches an agent returns SWML""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._render_swml = Mock(return_value={"version": "1.0.0", "sections": {}}) + agent._render_swml = Mock(return_value={"version": "1.0.0", "sections": {}}) # type: ignore[method-assign] # mock server.register(agent, "/myagent") event = {"path": "/myagent"} result = server._handle_lambda_request(event, None) assert result["statusCode"] == 200 - def test_lambda_matching_agent_render_error(self): + def test_lambda_matching_agent_render_error(self) -> None: """Test Lambda request when agent render fails returns 500""" server = AgentServer() agent = SimpleTestAgent(name="broken") - agent._render_swml = Mock(side_effect=Exception("render failed")) + agent._render_swml = Mock(side_effect=Exception("render failed")) # type: ignore[method-assign] # mock server.register(agent, "/broken") event = {"path": "/broken"} result = server._handle_lambda_request(event, None) @@ -572,7 +574,7 @@ def test_lambda_matching_agent_render_error(self): body = json.loads(result["body"]) assert "render failed" in body["error"] - def test_lambda_no_matching_agent_returns_404(self): + def test_lambda_no_matching_agent_returns_404(self) -> None: """Test Lambda request with no matching agent returns 404""" server = AgentServer() server.register(SimpleTestAgent(), "/test") @@ -582,52 +584,52 @@ def test_lambda_no_matching_agent_returns_404(self): body = json.loads(result["body"]) assert body["error"] == "Not Found" - def test_lambda_path_from_path_parameters(self): + def test_lambda_path_from_path_parameters(self) -> None: """Test Lambda extracts path from pathParameters.proxy""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._render_swml = Mock(return_value={"version": "1.0"}) + agent._render_swml = Mock(return_value={"version": "1.0"}) # type: ignore[method-assign] # mock server.register(agent, "/myagent") event = {"pathParameters": {"proxy": "myagent"}} result = server._handle_lambda_request(event, None) assert result["statusCode"] == 200 - def test_lambda_swaig_subpath(self): + def test_lambda_swaig_subpath(self) -> None: """Test Lambda request to swaig subpath""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._execute_swaig_function = Mock(return_value={"response": "ok"}) + agent._execute_swaig_function = Mock(return_value={"response": "ok"}) # type: ignore[method-assign] # mock server.register(agent, "/myagent") event = {"path": "/myagent/swaig", "body": json.dumps({"function": "test"})} result = server._handle_lambda_request(event, None) assert result["statusCode"] == 200 - def test_lambda_swaig_function_subpath(self): + def test_lambda_swaig_function_subpath(self) -> None: """Test Lambda request to swaig/ subpath""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._execute_swaig_function = Mock(return_value={"response": "ok"}) + agent._execute_swaig_function = Mock(return_value={"response": "ok"}) # type: ignore[method-assign] # mock server.register(agent, "/myagent") event = {"path": "/myagent/swaig/my_func", "body": json.dumps({})} result = server._handle_lambda_request(event, None) assert result["statusCode"] == 200 agent._execute_swaig_function.assert_called_once_with("my_func", {}, None, None) - def test_lambda_swaig_exception(self): + def test_lambda_swaig_exception(self) -> None: """Test Lambda request to swaig that raises exception returns 500""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._execute_swaig_function = Mock(side_effect=Exception("swaig error")) + agent._execute_swaig_function = Mock(side_effect=Exception("swaig error")) # type: ignore[method-assign] # mock server.register(agent, "/myagent") event = {"path": "/myagent/swaig", "body": json.dumps({})} result = server._handle_lambda_request(event, None) assert result["statusCode"] == 500 - def test_lambda_swaig_invalid_body(self): + def test_lambda_swaig_invalid_body(self) -> None: """Test Lambda request with invalid JSON body""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._execute_swaig_function = Mock(return_value={"response": "ok"}) + agent._execute_swaig_function = Mock(return_value={"response": "ok"}) # type: ignore[method-assign] # mock server.register(agent, "/myagent") event = {"path": "/myagent/swaig", "body": "not valid json"} result = server._handle_lambda_request(event, None) @@ -637,7 +639,7 @@ def test_lambda_swaig_invalid_body(self): class TestHandleCgiRequest: """Test _handle_cgi_request method""" - def test_cgi_no_path_returns_404(self): + def test_cgi_no_path_returns_404(self) -> None: """Test CGI request with no PATH_INFO returns 404""" server = AgentServer() with patch.dict(os.environ, {"PATH_INFO": ""}, clear=False): @@ -645,29 +647,29 @@ def test_cgi_no_path_returns_404(self): result = server._handle_cgi_request() assert "404 Not Found" in result - def test_cgi_matching_agent_returns_swml(self): + def test_cgi_matching_agent_returns_swml(self) -> None: """Test CGI request that matches an agent returns SWML""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._render_swml = Mock(return_value={"version": "1.0.0"}) + agent._render_swml = Mock(return_value={"version": "1.0.0"}) # type: ignore[method-assign] # mock server.register(agent, "/myagent") with patch.dict(os.environ, {"PATH_INFO": "/myagent"}, clear=False): with patch("sys.stdout", new_callable=StringIO): result = server._handle_cgi_request() assert "200 OK" in result - def test_cgi_matching_agent_render_error(self): + def test_cgi_matching_agent_render_error(self) -> None: """Test CGI request when agent render fails returns 500""" server = AgentServer() agent = SimpleTestAgent(name="broken") - agent._render_swml = Mock(side_effect=Exception("render failed")) + agent._render_swml = Mock(side_effect=Exception("render failed")) # type: ignore[method-assign] # mock server.register(agent, "/broken") with patch.dict(os.environ, {"PATH_INFO": "/broken"}, clear=False): with patch("sys.stdout", new_callable=StringIO): result = server._handle_cgi_request() assert "500 Internal Server Error" in result - def test_cgi_no_matching_agent_returns_404(self): + def test_cgi_no_matching_agent_returns_404(self) -> None: """Test CGI request with no matching agent returns 404""" server = AgentServer() server.register(SimpleTestAgent(), "/test") @@ -676,11 +678,11 @@ def test_cgi_no_matching_agent_returns_404(self): result = server._handle_cgi_request() assert "404 Not Found" in result - def test_cgi_swaig_subpath(self): + def test_cgi_swaig_subpath(self) -> None: """Test CGI request to swaig subpath with no body""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._execute_swaig_function = Mock(return_value={"response": "ok"}) + agent._execute_swaig_function = Mock(return_value={"response": "ok"}) # type: ignore[method-assign] # mock server.register(agent, "/myagent") env = {"PATH_INFO": "/myagent/swaig"} # Remove CONTENT_LENGTH to avoid stdin reading @@ -690,11 +692,11 @@ def test_cgi_swaig_subpath(self): result = server._handle_cgi_request() assert "200 OK" in result - def test_cgi_swaig_function_subpath(self): + def test_cgi_swaig_function_subpath(self) -> None: """Test CGI request to swaig/ subpath with no body""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._execute_swaig_function = Mock(return_value={"response": "ok"}) + agent._execute_swaig_function = Mock(return_value={"response": "ok"}) # type: ignore[method-assign] # mock server.register(agent, "/myagent") env = {"PATH_INFO": "/myagent/swaig/my_func"} with patch.dict(os.environ, env, clear=False): @@ -703,11 +705,11 @@ def test_cgi_swaig_function_subpath(self): result = server._handle_cgi_request() assert "200 OK" in result - def test_cgi_swaig_exception(self): + def test_cgi_swaig_exception(self) -> None: """Test CGI request when SWAIG function raises exception""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._execute_swaig_function = Mock(side_effect=Exception("swaig error")) + agent._execute_swaig_function = Mock(side_effect=Exception("swaig error")) # type: ignore[method-assign] # mock server.register(agent, "/myagent") env = {"PATH_INFO": "/myagent/swaig", "CONTENT_LENGTH": "0"} with patch.dict(os.environ, env, clear=False): @@ -715,11 +717,11 @@ def test_cgi_swaig_exception(self): result = server._handle_cgi_request() assert "500 Internal Server Error" in result - def test_cgi_swaig_function_exception(self): + def test_cgi_swaig_function_exception(self) -> None: """Test CGI request to swaig/ that raises exception""" server = AgentServer() agent = SimpleTestAgent(name="myagent") - agent._execute_swaig_function = Mock(side_effect=Exception("func error")) + agent._execute_swaig_function = Mock(side_effect=Exception("func error")) # type: ignore[method-assign] # mock server.register(agent, "/myagent") env = {"PATH_INFO": "/myagent/swaig/broken_func", "CONTENT_LENGTH": "0"} with patch.dict(os.environ, env, clear=False): @@ -731,7 +733,7 @@ def test_cgi_swaig_function_exception(self): class TestFormatCgiResponse: """Test _format_cgi_response method""" - def test_format_cgi_response_dict(self): + def test_format_cgi_response_dict(self) -> None: """Test formatting a dict response""" server = AgentServer() with patch("sys.stdout", new_callable=StringIO): @@ -740,21 +742,21 @@ def test_format_cgi_response_dict(self): assert "Content-Type: application/json" in result assert '"key": "value"' in result - def test_format_cgi_response_string(self): + def test_format_cgi_response_string(self) -> None: """Test formatting a string response""" server = AgentServer() with patch("sys.stdout", new_callable=StringIO): result = server._format_cgi_response("plain text") assert "plain text" in result - def test_format_cgi_response_custom_status(self): + def test_format_cgi_response_custom_status(self) -> None: """Test formatting with custom status""" server = AgentServer() with patch("sys.stdout", new_callable=StringIO): result = server._format_cgi_response({"error": "nope"}, status="404 Not Found") assert "Status: 404 Not Found" in result - def test_format_cgi_response_custom_content_type(self): + def test_format_cgi_response_custom_content_type(self) -> None: """Test formatting with custom content type""" server = AgentServer() with patch("sys.stdout", new_callable=StringIO): @@ -765,7 +767,7 @@ def test_format_cgi_response_custom_content_type(self): class TestGlobalRoutingCallback: """Test register_global_routing_callback method""" - def test_register_global_routing_callback(self): + def test_register_global_routing_callback(self) -> None: """Test registering a global routing callback on all agents""" server = AgentServer() agent1 = SimpleTestAgent(name="a1") @@ -780,7 +782,7 @@ def test_register_global_routing_callback(self): mock1.assert_called_once_with(callback, path="/sip") mock2.assert_called_once_with(callback, path="/sip") - def test_register_global_routing_callback_normalizes_path(self): + def test_register_global_routing_callback_normalizes_path(self) -> None: """Test that path is normalized""" server = AgentServer() agent = SimpleTestAgent(name="a1") @@ -795,7 +797,7 @@ def test_register_global_routing_callback_normalizes_path(self): class TestServeStaticFiles: """Test serve_static_files and _serve_static_file methods""" - def test_serve_static_files_valid_directory(self): + def test_serve_static_files_valid_directory(self) -> None: """Test serve_static_files with valid directory""" server = AgentServer() with tempfile.TemporaryDirectory() as tmpdir: @@ -803,33 +805,33 @@ def test_serve_static_files_valid_directory(self): assert hasattr(server, '_static_directories') assert "" in server._static_directories or "/" in server._static_directories - def test_serve_static_files_nonexistent_directory(self): + def test_serve_static_files_nonexistent_directory(self) -> None: """Test serve_static_files with nonexistent directory""" server = AgentServer() with pytest.raises(ValueError, match="does not exist"): server.serve_static_files("/nonexistent/directory") - def test_serve_static_files_file_not_directory(self): + def test_serve_static_files_file_not_directory(self) -> None: """Test serve_static_files with a file path instead of directory""" server = AgentServer() with tempfile.NamedTemporaryFile() as tmpfile: with pytest.raises(ValueError, match="not a directory"): server.serve_static_files(tmpfile.name) - def test_serve_static_files_custom_route(self): + def test_serve_static_files_custom_route(self) -> None: """Test serve_static_files with custom route prefix""" server = AgentServer() with tempfile.TemporaryDirectory() as tmpdir: server.serve_static_files(tmpdir, route="/static") assert "/static" in server._static_directories - def test_serve_static_file_no_directories_configured(self): + def test_serve_static_file_no_directories_configured(self) -> None: """Test _serve_static_file returns None when no directories configured""" server = AgentServer() result = server._serve_static_file("test.html") assert result is None - def test_serve_static_file_existing_file(self): + def test_serve_static_file_existing_file(self) -> None: """_serve_static_file returns a FileResponse pointed at the requested file, not at some other path.""" from fastapi.responses import FileResponse @@ -847,7 +849,7 @@ def test_serve_static_file_existing_file(self): # or some other accidental file. assert str(result.path) == str(test_file) - def test_serve_static_file_nonexistent_file(self): + def test_serve_static_file_nonexistent_file(self) -> None: """Test _serve_static_file returns None for nonexistent file""" server = AgentServer() with tempfile.TemporaryDirectory() as tmpdir: @@ -856,7 +858,7 @@ def test_serve_static_file_nonexistent_file(self): result = server._serve_static_file("nonexistent.txt", route="") assert result is None - def test_serve_static_file_empty_path_serves_index(self): + def test_serve_static_file_empty_path_serves_index(self) -> None: """An empty file_path argument must default to index.html — the returned FileResponse must point at index.html, not at the directory or some sibling file.""" @@ -874,7 +876,7 @@ def test_serve_static_file_empty_path_serves_index(self): assert isinstance(result, FileResponse) assert str(result.path) == str(index_file) - def test_serve_static_file_directory_with_index(self): + def test_serve_static_file_directory_with_index(self) -> None: """When file_path resolves to a DIRECTORY containing index.html, the returned FileResponse must point at /index.html specifically — not at the directory itself, and not at any @@ -895,7 +897,7 @@ def test_serve_static_file_directory_with_index(self): assert isinstance(result, FileResponse) assert str(result.path) == str(index_file) - def test_serve_static_file_route_not_found(self): + def test_serve_static_file_route_not_found(self) -> None: """Test _serve_static_file returns None for unknown route""" server = AgentServer() server._static_directories = {"/assets": Path("/tmp")} @@ -906,7 +908,7 @@ def test_serve_static_file_route_not_found(self): class TestAgentServerRouting: """Test suite for AgentServer routing behavior""" - def test_custom_route_not_overshadowed_by_catch_all(self): + def test_custom_route_not_overshadowed_by_catch_all(self) -> None: """ Test that custom routes registered after AgentServer creation are not overshadowed by the catch-all handler. @@ -919,12 +921,12 @@ def test_custom_route_not_overshadowed_by_catch_all(self): # Add a custom route AFTER server creation (like santa's /get_token) @server.app.get('/get_token') - def get_token(): + def get_token() -> dict[str, Any]: return {"token": "test-token-123", "success": True} # Add another custom route @server.app.get('/health_custom') - def health_custom(): + def health_custom() -> dict[str, Any]: return {"status": "healthy"} client = TestClient(server.app) @@ -941,7 +943,7 @@ def health_custom(): assert response.status_code == 200 assert response.json()["status"] == "healthy" - def test_health_endpoints_work(self): + def test_health_endpoints_work(self) -> None: """Test that built-in health endpoints work""" server = AgentServer() server.register(SimpleTestAgent(), "/agent") @@ -959,26 +961,26 @@ def test_health_endpoints_work(self): assert response.json()["status"] == "ready" - def test_multiple_custom_routes(self): + def test_multiple_custom_routes(self) -> None: """Test multiple custom routes all work correctly""" server = AgentServer() server.register(SimpleTestAgent(), "/agent") # Add multiple custom routes @server.app.get('/route1') - def route1(): + def route1() -> dict[str, Any]: return {"route": 1} @server.app.get('/route2') - def route2(): + def route2() -> dict[str, Any]: return {"route": 2} @server.app.post('/route3') - def route3(): + def route3() -> dict[str, Any]: return {"route": 3} @server.app.get('/nested/deep/route') - def nested(): + def nested() -> dict[str, Any]: return {"route": "nested"} client = TestClient(server.app) @@ -989,7 +991,7 @@ def nested(): assert client.post('/route3').json()["route"] == 3 assert client.get('/nested/deep/route').json()["route"] == "nested" - def test_nonexistent_route_returns_404(self): + def test_nonexistent_route_returns_404(self) -> None: """Test that truly nonexistent routes return 404""" server = AgentServer() server.register(SimpleTestAgent(), "/agent") @@ -1000,13 +1002,13 @@ def test_nonexistent_route_returns_404(self): response = client.get('/nonexistent/path') assert response.status_code == 404 - def test_post_custom_routes_work(self): + def test_post_custom_routes_work(self) -> None: """Test that POST custom routes work correctly""" server = AgentServer() server.register(SimpleTestAgent(), "/agent") @server.app.post('/webhook') - def webhook(): + def webhook() -> dict[str, Any]: return {"received": True} client = TestClient(server.app) @@ -1024,7 +1026,7 @@ class TestAgentServerGunicornCompatibility: instead of server.run(). """ - def test_app_property_works_for_gunicorn(self): + def test_app_property_works_for_gunicorn(self) -> None: """Test that server.app can be used directly like gunicorn does""" server = AgentServer() server.register(SimpleTestAgent(), "/agent") @@ -1034,7 +1036,7 @@ def test_app_property_works_for_gunicorn(self): # Add routes to the app (like santa does) @app.get('/get_token') - def get_token(): + def get_token() -> dict[str, Any]: return {"token": "gunicorn-test"} client = TestClient(app) @@ -1048,22 +1050,22 @@ def get_token(): response = client.get('/health') assert response.status_code == 200 - def test_custom_routes_work_with_gunicorn_pattern(self): + def test_custom_routes_work_with_gunicorn_pattern(self) -> None: """Test custom routes work when using server.app (gunicorn pattern)""" server = AgentServer() server.register(SimpleTestAgent(), "/agent") # Add multiple custom endpoints like a real app would @server.app.get('/get_credentials') - def get_credentials(): + def get_credentials() -> dict[str, Any]: return {"user": "test", "pass": "secret"} @server.app.get('/get_resource_info') - def get_resource_info(): + def get_resource_info() -> dict[str, Any]: return {"resource_id": "123"} @server.app.post('/webhook') - def webhook(): + def webhook() -> dict[str, Any]: return {"status": "received"} # Use server.app directly like gunicorn diff --git a/tests/unit/core/test_auth_handler.py b/tests/unit/core/test_auth_handler.py index 938075c4..3baed1d2 100644 --- a/tests/unit/core/test_auth_handler.py +++ b/tests/unit/core/test_auth_handler.py @@ -13,21 +13,28 @@ import asyncio import secrets +from collections.abc import Coroutine +from typing import Any, TYPE_CHECKING, TypeVar import pytest from unittest.mock import Mock, patch, MagicMock +if TYPE_CHECKING: + from signalwire.core.auth_handler import AuthHandler + +_T = TypeVar("_T") + # --------------------------------------------------------------------------- # Helpers: build a mock SecurityConfig that behaves like the real one # --------------------------------------------------------------------------- def _make_security_config( - username="testuser", - password="testpass", - bearer_token=None, - api_key=None, - api_key_header="X-API-Key", -): + username: str = "testuser", + password: str = "testpass", + bearer_token: str | None = None, + api_key: str | None = None, + api_key_header: str = "X-API-Key", +) -> Mock: """ Return a mock SecurityConfig with the given auth settings. """ @@ -39,7 +46,7 @@ def _make_security_config( return cfg -def _run_async(coro): +def _run_async(coro: Coroutine[Any, Any, _T]) -> _T: """Run an async coroutine synchronously so we don't need pytest-asyncio.""" return asyncio.run(coro) @@ -51,7 +58,7 @@ def _run_async(coro): class TestAuthHandlerInit: """Test AuthHandler construction and _setup_auth_methods.""" - def test_basic_init_with_basic_auth_only(self): + def test_basic_init_with_basic_auth_only(self) -> None: """AuthHandler initializes with basic auth from SecurityConfig.""" from signalwire.core.auth_handler import AuthHandler @@ -65,7 +72,7 @@ def test_basic_init_with_basic_auth_only(self): assert 'bearer' not in handler.auth_methods assert 'api_key' not in handler.auth_methods - def test_init_with_bearer_token(self): + def test_init_with_bearer_token(self) -> None: """AuthHandler registers bearer method when token is configured.""" from signalwire.core.auth_handler import AuthHandler @@ -76,7 +83,7 @@ def test_init_with_bearer_token(self): assert handler.auth_methods['bearer']['enabled'] is True assert handler.auth_methods['bearer']['token'] == "my-token-123" - def test_init_with_api_key(self): + def test_init_with_api_key(self) -> None: """AuthHandler registers api_key method when key is configured.""" from signalwire.core.auth_handler import AuthHandler @@ -87,7 +94,7 @@ def test_init_with_api_key(self): assert handler.auth_methods['api_key']['enabled'] is True assert handler.auth_methods['api_key']['key'] == "ak_abc123" - def test_init_with_custom_api_key_header(self): + def test_init_with_custom_api_key_header(self) -> None: """AuthHandler respects a custom api_key_header from SecurityConfig.""" from signalwire.core.auth_handler import AuthHandler @@ -96,7 +103,7 @@ def test_init_with_custom_api_key_header(self): assert handler.auth_methods['api_key']['header'] == "X-Custom-Key" - def test_init_with_default_api_key_header(self): + def test_init_with_default_api_key_header(self) -> None: """When api_key_header attribute is missing, default to X-API-Key.""" from signalwire.core.auth_handler import AuthHandler @@ -110,7 +117,7 @@ def test_init_with_default_api_key_header(self): assert handler.auth_methods['api_key']['header'] == "X-API-Key" - def test_init_with_all_methods(self): + def test_init_with_all_methods(self) -> None: """AuthHandler initializes all three auth methods when all configured.""" from signalwire.core.auth_handler import AuthHandler @@ -124,7 +131,7 @@ def test_init_with_all_methods(self): assert 'bearer' in handler.auth_methods assert 'api_key' in handler.auth_methods - def test_bearer_not_registered_when_none(self): + def test_bearer_not_registered_when_none(self) -> None: """Bearer method is not added when bearer_token is None.""" from signalwire.core.auth_handler import AuthHandler @@ -132,7 +139,7 @@ def test_bearer_not_registered_when_none(self): handler = AuthHandler(cfg) assert 'bearer' not in handler.auth_methods - def test_bearer_not_registered_when_empty_string(self): + def test_bearer_not_registered_when_empty_string(self) -> None: """Bearer method is not added when bearer_token is empty string (falsy).""" from signalwire.core.auth_handler import AuthHandler @@ -140,7 +147,7 @@ def test_bearer_not_registered_when_empty_string(self): handler = AuthHandler(cfg) assert 'bearer' not in handler.auth_methods - def test_api_key_not_registered_when_none(self): + def test_api_key_not_registered_when_none(self) -> None: """api_key method is not added when api_key is None.""" from signalwire.core.auth_handler import AuthHandler @@ -148,7 +155,7 @@ def test_api_key_not_registered_when_none(self): handler = AuthHandler(cfg) assert 'api_key' not in handler.auth_methods - def test_auto_error_false_on_http_basic(self): + def test_auto_error_false_on_http_basic(self) -> None: """HTTPBasic is created with auto_error=False so credentials are optional.""" from signalwire.core.auth_handler import AuthHandler @@ -158,7 +165,7 @@ def test_auto_error_false_on_http_basic(self): handler = AuthHandler(cfg) mock_cls.assert_called_once_with(auto_error=False) - def test_auto_error_false_on_http_bearer(self): + def test_auto_error_false_on_http_bearer(self) -> None: """HTTPBearer is created with auto_error=False so credentials are optional.""" from signalwire.core.auth_handler import AuthHandler @@ -168,7 +175,7 @@ def test_auto_error_false_on_http_bearer(self): handler = AuthHandler(cfg) mock_cls.assert_called_once_with(auto_error=False) - def test_basic_auth_always_enabled(self): + def test_basic_auth_always_enabled(self) -> None: """Basic auth is always marked as enabled, even when username is empty.""" from signalwire.core.auth_handler import AuthHandler @@ -184,54 +191,54 @@ def test_basic_auth_always_enabled(self): class TestVerifyBasicAuth: """Test the verify_basic_auth method, including timing-safe comparison.""" - def _make_handler(self, username="user", password="pass"): + def _make_handler(self, username: str = "user", password: str = "pass") -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(username=username, password=password) return AuthHandler(cfg) - def _make_creds(self, username, password): + def _make_creds(self, username: str, password: str) -> Mock: creds = Mock() creds.username = username creds.password = password return creds - def test_correct_credentials(self): + def test_correct_credentials(self) -> None: """Valid username+password returns True.""" handler = self._make_handler("admin", "secret") creds = self._make_creds("admin", "secret") assert handler.verify_basic_auth(creds) is True - def test_wrong_username(self): + def test_wrong_username(self) -> None: """Wrong username returns False.""" handler = self._make_handler("admin", "secret") creds = self._make_creds("wrong", "secret") assert handler.verify_basic_auth(creds) is False - def test_wrong_password(self): + def test_wrong_password(self) -> None: """Wrong password returns False.""" handler = self._make_handler("admin", "secret") creds = self._make_creds("admin", "wrong") assert handler.verify_basic_auth(creds) is False - def test_both_wrong(self): + def test_both_wrong(self) -> None: """Both wrong returns False.""" handler = self._make_handler("admin", "secret") creds = self._make_creds("wrong", "wrong") assert handler.verify_basic_auth(creds) is False - def test_empty_credentials(self): + def test_empty_credentials(self) -> None: """Empty strings for credentials are rejected when config values differ.""" handler = self._make_handler("admin", "secret") creds = self._make_creds("", "") assert handler.verify_basic_auth(creds) is False - def test_empty_credentials_match_empty_config(self): + def test_empty_credentials_match_empty_config(self) -> None: """Empty credentials match when config has empty username/password.""" handler = self._make_handler("", "") creds = self._make_creds("", "") assert handler.verify_basic_auth(creds) is True - def test_uses_secrets_compare_digest(self): + def test_uses_secrets_compare_digest(self) -> None: """Verify that secrets.compare_digest is used (timing-safe comparison).""" from signalwire.core.auth_handler import AuthHandler @@ -246,7 +253,7 @@ def test_uses_secrets_compare_digest(self): mock_cd.assert_any_call("admin", "admin") mock_cd.assert_any_call("secret", "secret") - def test_timing_safe_even_on_username_mismatch(self): + def test_timing_safe_even_on_username_mismatch(self) -> None: """Both username and password are checked even when username fails. This ensures the comparison doesn't short-circuit in a way that leaks @@ -265,7 +272,7 @@ def test_timing_safe_even_on_username_mismatch(self): # Both comparisons should still occur (no short-circuit) assert mock_cd.call_count == 2 - def test_timing_safe_even_on_password_mismatch(self): + def test_timing_safe_even_on_password_mismatch(self) -> None: """Both fields are compared even when only the password is wrong.""" from signalwire.core.auth_handler import AuthHandler @@ -278,7 +285,7 @@ def test_timing_safe_even_on_password_mismatch(self): assert result is False assert mock_cd.call_count == 2 - def test_basic_auth_disabled(self): + def test_basic_auth_disabled(self) -> None: """When basic auth is disabled, verify_basic_auth returns False.""" from signalwire.core.auth_handler import AuthHandler @@ -290,7 +297,7 @@ def test_basic_auth_disabled(self): creds = self._make_creds("testuser", "testpass") assert handler.verify_basic_auth(creds) is False - def test_basic_auth_missing_from_methods(self): + def test_basic_auth_missing_from_methods(self) -> None: """When 'basic' key is entirely absent, verify_basic_auth returns False.""" from signalwire.core.auth_handler import AuthHandler @@ -301,13 +308,13 @@ def test_basic_auth_missing_from_methods(self): creds = self._make_creds("testuser", "testpass") assert handler.verify_basic_auth(creds) is False - def test_case_sensitive_username(self): + def test_case_sensitive_username(self) -> None: """Username comparison is case-sensitive.""" handler = self._make_handler("Admin", "secret") creds = self._make_creds("admin", "secret") assert handler.verify_basic_auth(creds) is False - def test_case_sensitive_password(self): + def test_case_sensitive_password(self) -> None: """Password comparison is case-sensitive.""" handler = self._make_handler("admin", "Secret") creds = self._make_creds("admin", "secret") @@ -321,35 +328,35 @@ def test_case_sensitive_password(self): class TestVerifyBearerToken: """Test the verify_bearer_token method.""" - def _make_handler(self, bearer_token="tok_abc"): + def _make_handler(self, bearer_token: str = "tok_abc") -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(bearer_token=bearer_token) return AuthHandler(cfg) - def _make_bearer_creds(self, token): + def _make_bearer_creds(self, token: str) -> Mock: creds = Mock() creds.credentials = token return creds - def test_correct_token(self): + def test_correct_token(self) -> None: """Valid bearer token returns True.""" handler = self._make_handler("my-token") creds = self._make_bearer_creds("my-token") assert handler.verify_bearer_token(creds) is True - def test_wrong_token(self): + def test_wrong_token(self) -> None: """Invalid bearer token returns False.""" handler = self._make_handler("my-token") creds = self._make_bearer_creds("wrong-token") assert handler.verify_bearer_token(creds) is False - def test_empty_token(self): + def test_empty_token(self) -> None: """Empty token string does not match a non-empty config token.""" handler = self._make_handler("my-token") creds = self._make_bearer_creds("") assert handler.verify_bearer_token(creds) is False - def test_bearer_not_configured(self): + def test_bearer_not_configured(self) -> None: """When no bearer token is configured, returns False.""" from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(bearer_token=None) @@ -358,7 +365,7 @@ def test_bearer_not_configured(self): creds = self._make_bearer_creds("anything") assert handler.verify_bearer_token(creds) is False - def test_bearer_disabled(self): + def test_bearer_disabled(self) -> None: """When bearer is configured but disabled, returns False.""" handler = self._make_handler("tok") handler.auth_methods['bearer']['enabled'] = False @@ -366,7 +373,7 @@ def test_bearer_disabled(self): creds = self._make_bearer_creds("tok") assert handler.verify_bearer_token(creds) is False - def test_uses_secrets_compare_digest(self): + def test_uses_secrets_compare_digest(self) -> None: """Ensure timing-safe comparison via secrets.compare_digest.""" handler = self._make_handler("tok_xyz") creds = self._make_bearer_creds("tok_xyz") @@ -376,7 +383,7 @@ def test_uses_secrets_compare_digest(self): assert result is True mock_cd.assert_called_once_with("tok_xyz", "tok_xyz") - def test_bearer_missing_from_methods(self): + def test_bearer_missing_from_methods(self) -> None: """When 'bearer' key is absent from auth_methods, returns False.""" from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(bearer_token="tok") @@ -386,7 +393,7 @@ def test_bearer_missing_from_methods(self): creds = self._make_bearer_creds("tok") assert handler.verify_bearer_token(creds) is False - def test_token_case_sensitive(self): + def test_token_case_sensitive(self) -> None: """Token comparison is case-sensitive.""" handler = self._make_handler("MyToken") creds = self._make_bearer_creds("mytoken") @@ -400,47 +407,47 @@ def test_token_case_sensitive(self): class TestVerifyApiKey: """Test the verify_api_key method.""" - def _make_handler(self, api_key="ak_secret"): + def _make_handler(self, api_key: str = "ak_secret") -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(api_key=api_key) return AuthHandler(cfg) - def test_correct_api_key(self): + def test_correct_api_key(self) -> None: """Correct API key returns True.""" handler = self._make_handler("ak_123") assert handler.verify_api_key("ak_123") is True - def test_wrong_api_key(self): + def test_wrong_api_key(self) -> None: """Wrong API key returns False.""" handler = self._make_handler("ak_123") assert handler.verify_api_key("wrong") is False - def test_empty_api_key(self): + def test_empty_api_key(self) -> None: """Empty API key string returns False when configured key is non-empty.""" handler = self._make_handler("ak_123") assert handler.verify_api_key("") is False - def test_api_key_not_configured(self): + def test_api_key_not_configured(self) -> None: """When no API key configured, returns False.""" from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(api_key=None) handler = AuthHandler(cfg) assert handler.verify_api_key("anything") is False - def test_api_key_disabled(self): + def test_api_key_disabled(self) -> None: """When api_key is configured but disabled, returns False.""" handler = self._make_handler("ak_123") handler.auth_methods['api_key']['enabled'] = False assert handler.verify_api_key("ak_123") is False - def test_uses_secrets_compare_digest(self): + def test_uses_secrets_compare_digest(self) -> None: """Ensure timing-safe comparison via secrets.compare_digest.""" handler = self._make_handler("ak_xyz") with patch("signalwire.core.auth_handler.secrets.compare_digest", wraps=secrets.compare_digest) as mock_cd: handler.verify_api_key("ak_xyz") mock_cd.assert_called_once_with("ak_xyz", "ak_xyz") - def test_api_key_missing_from_methods(self): + def test_api_key_missing_from_methods(self) -> None: """When 'api_key' key is absent from auth_methods, returns False.""" from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(api_key="ak") @@ -448,7 +455,7 @@ def test_api_key_missing_from_methods(self): del handler.auth_methods['api_key'] assert handler.verify_api_key("ak") is False - def test_api_key_case_sensitive(self): + def test_api_key_case_sensitive(self) -> None: """API key comparison is case-sensitive.""" handler = self._make_handler("AK_Secret") assert handler.verify_api_key("ak_secret") is False @@ -461,27 +468,28 @@ def test_api_key_case_sensitive(self): class TestGetFastapiDependency: """Test the FastAPI dependency factory.""" - def _make_handler(self, **kwargs): + def _make_handler(self, **kwargs: Any) -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(**kwargs) return AuthHandler(cfg) - def test_returns_callable(self): + def test_returns_callable(self) -> None: """get_fastapi_dependency returns a callable.""" handler = self._make_handler() dep = handler.get_fastapi_dependency() assert callable(dep) - def test_returns_callable_optional(self): + def test_returns_callable_optional(self) -> None: """get_fastapi_dependency(optional=True) also returns a callable.""" handler = self._make_handler() dep = handler.get_fastapi_dependency(optional=True) assert callable(dep) - def test_basic_auth_succeeds(self): + def test_basic_auth_succeeds(self) -> None: """Auth dependency accepts valid basic credentials.""" handler = self._make_handler(username="u", password="p") dep = handler.get_fastapi_dependency() + assert dep is not None basic_creds = Mock() basic_creds.username = "u" @@ -491,12 +499,13 @@ def test_basic_auth_succeeds(self): assert result['authenticated'] is True assert result['method'] == 'basic' - def test_basic_auth_fails_raises_401(self): + def test_basic_auth_fails_raises_401(self) -> None: """Auth dependency raises HTTPException(401) on bad basic creds.""" - from signalwire.core.auth_handler import HTTPException + from signalwire.core.auth_handler import HTTPException # type: ignore[attr-defined] # conditionally-defined module attr handler = self._make_handler(username="u", password="p") dep = handler.get_fastapi_dependency(optional=False) + assert dep is not None bad_creds = Mock() bad_creds.username = "bad" @@ -506,30 +515,33 @@ def test_basic_auth_fails_raises_401(self): _run_async(dep(basic_credentials=bad_creds, bearer_credentials=None, api_key=None)) assert exc_info.value.status_code == 401 - def test_no_credentials_raises_401(self): + def test_no_credentials_raises_401(self) -> None: """Auth dependency raises 401 when no credentials are provided.""" - from signalwire.core.auth_handler import HTTPException + from signalwire.core.auth_handler import HTTPException # type: ignore[attr-defined] # conditionally-defined module attr handler = self._make_handler() dep = handler.get_fastapi_dependency(optional=False) + assert dep is not None with pytest.raises(HTTPException) as exc_info: _run_async(dep(basic_credentials=None, bearer_credentials=None, api_key=None)) assert exc_info.value.status_code == 401 - def test_no_credentials_optional_returns_unauthenticated(self): + def test_no_credentials_optional_returns_unauthenticated(self) -> None: """When optional=True, missing credentials returns unauthenticated result.""" handler = self._make_handler() dep = handler.get_fastapi_dependency(optional=True) + assert dep is not None result = _run_async(dep(basic_credentials=None, bearer_credentials=None, api_key=None)) assert result['authenticated'] is False assert result['method'] is None - def test_bearer_auth_succeeds(self): + def test_bearer_auth_succeeds(self) -> None: """Auth dependency accepts valid bearer token.""" handler = self._make_handler(bearer_token="my_tok") dep = handler.get_fastapi_dependency() + assert dep is not None bearer_creds = Mock() bearer_creds.credentials = "my_tok" @@ -538,10 +550,11 @@ def test_bearer_auth_succeeds(self): assert result['authenticated'] is True assert result['method'] == 'bearer' - def test_bearer_takes_precedence_over_basic(self): + def test_bearer_takes_precedence_over_basic(self) -> None: """When both bearer and basic credentials are provided, bearer wins.""" handler = self._make_handler(username="u", password="p", bearer_token="tok") dep = handler.get_fastapi_dependency() + assert dep is not None bearer_creds = Mock() bearer_creds.credentials = "tok" @@ -552,10 +565,11 @@ def test_bearer_takes_precedence_over_basic(self): result = _run_async(dep(basic_credentials=basic_creds, bearer_credentials=bearer_creds, api_key=None)) assert result['method'] == 'bearer' - def test_bad_bearer_falls_back_to_basic(self): + def test_bad_bearer_falls_back_to_basic(self) -> None: """When bearer fails but basic succeeds, result method is 'basic'.""" handler = self._make_handler(username="u", password="p", bearer_token="tok") dep = handler.get_fastapi_dependency() + assert dep is not None bad_bearer = Mock() bad_bearer.credentials = "wrong" @@ -567,45 +581,48 @@ def test_bad_bearer_falls_back_to_basic(self): assert result['authenticated'] is True assert result['method'] == 'basic' - def test_401_includes_www_authenticate_header(self): + def test_401_includes_www_authenticate_header(self) -> None: """HTTPException includes WWW-Authenticate: Basic header.""" - from signalwire.core.auth_handler import HTTPException + from signalwire.core.auth_handler import HTTPException # type: ignore[attr-defined] # conditionally-defined module attr handler = self._make_handler() dep = handler.get_fastapi_dependency(optional=False) + assert dep is not None with pytest.raises(HTTPException) as exc_info: _run_async(dep(basic_credentials=None, bearer_credentials=None, api_key=None)) assert exc_info.value.headers == {"WWW-Authenticate": "Basic"} - def test_401_detail_message(self): + def test_401_detail_message(self) -> None: """HTTPException 401 includes the expected detail message.""" - from signalwire.core.auth_handler import HTTPException + from signalwire.core.auth_handler import HTTPException # type: ignore[attr-defined] # conditionally-defined module attr handler = self._make_handler() dep = handler.get_fastapi_dependency(optional=False) + assert dep is not None with pytest.raises(HTTPException) as exc_info: _run_async(dep(basic_credentials=None, bearer_credentials=None, api_key=None)) assert exc_info.value.detail == "Invalid authentication credentials" - def test_returns_none_when_depends_unavailable(self): + def test_returns_none_when_depends_unavailable(self) -> None: """When Depends is None (FastAPI not installed), returns None.""" from signalwire.core import auth_handler - original_depends = auth_handler.Depends + original_depends = auth_handler.Depends # type: ignore[attr-defined] # conditionally-defined module attr try: - auth_handler.Depends = None + auth_handler.Depends = None # type: ignore[attr-defined,assignment] # conditionally-defined module attr cfg = _make_security_config() handler = auth_handler.AuthHandler(cfg) assert handler.get_fastapi_dependency() is None finally: - auth_handler.Depends = original_depends + auth_handler.Depends = original_depends # type: ignore[attr-defined] # conditionally-defined module attr - def test_optional_false_with_valid_basic_does_not_raise(self): + def test_optional_false_with_valid_basic_does_not_raise(self) -> None: """With optional=False and valid basic creds, no exception is raised.""" handler = self._make_handler(username="admin", password="pass") dep = handler.get_fastapi_dependency(optional=False) + assert dep is not None basic_creds = Mock() basic_creds.username = "admin" @@ -623,12 +640,12 @@ def test_optional_false_with_valid_basic_does_not_raise(self): class TestFlaskDecorator: """Test the Flask decorator for authentication.""" - def _make_handler(self, **kwargs): + def _make_handler(self, **kwargs: Any) -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(**kwargs) return AuthHandler(cfg) - def _mock_flask_request(self, auth_header=None, authorization=None, api_key_header=None, api_key_value=None): + def _mock_flask_request(self, auth_header: str | None = None, authorization: Any = None, api_key_header: str | None = None, api_key_value: str | None = None) -> Mock: """Build a mock flask request object.""" request = Mock() headers = {} @@ -644,12 +661,12 @@ def _mock_flask_request(self, auth_header=None, authorization=None, api_key_head request.path = "/test" return request - def test_bearer_auth_success(self): + def test_bearer_auth_success(self) -> None: """Flask decorator accepts valid bearer token.""" handler = self._make_handler(bearer_token="tok123") @handler.flask_decorator - def my_view(): + def my_view() -> str: return "OK" mock_request = self._mock_flask_request(auth_header="Bearer tok123") @@ -661,12 +678,12 @@ def my_view(): result = my_view() assert result == "OK" - def test_api_key_success(self): + def test_api_key_success(self) -> None: """Flask decorator accepts valid API key.""" handler = self._make_handler(api_key="ak_secret", api_key_header="X-API-Key") @handler.flask_decorator - def my_view(): + def my_view() -> str: return "OK" mock_request = self._mock_flask_request(api_key_header="X-API-Key", api_key_value="ak_secret") @@ -678,12 +695,12 @@ def my_view(): result = my_view() assert result == "OK" - def test_basic_auth_success(self): + def test_basic_auth_success(self) -> None: """Flask decorator accepts valid basic auth.""" handler = self._make_handler(username="admin", password="pass") @handler.flask_decorator - def my_view(): + def my_view() -> str: return "OK" mock_auth = Mock() @@ -698,7 +715,7 @@ def my_view(): result = my_view() assert result == "OK" - def test_all_methods_fail_returns_401(self): + def test_all_methods_fail_returns_401(self) -> None: """Flask decorator returns 401 when all auth methods fail.""" handler = self._make_handler( username="admin", password="pass", @@ -706,7 +723,7 @@ def test_all_methods_fail_returns_401(self): ) @handler.flask_decorator - def my_view(): + def my_view() -> str: return "OK" mock_request = self._mock_flask_request() @@ -725,14 +742,14 @@ def my_view(): {'WWW-Authenticate': 'Basic realm="SignalWire Service"'} ) - def test_wrong_bearer_wrong_basic_returns_401(self): + def test_wrong_bearer_wrong_basic_returns_401(self) -> None: """Flask decorator returns 401 when bearer and basic both fail.""" handler = self._make_handler( username="admin", password="pass", bearer_token="tok" ) @handler.flask_decorator - def my_view(): + def my_view() -> str: return "OK" mock_auth = Mock() @@ -752,14 +769,14 @@ def my_view(): assert result is mock_response_instance - def test_bearer_priority_over_basic(self): + def test_bearer_priority_over_basic(self) -> None: """Bearer auth is tried before basic; if bearer succeeds, basic is skipped.""" handler = self._make_handler( username="admin", password="pass", bearer_token="tok" ) @handler.flask_decorator - def my_view(): + def my_view() -> str: return "OK" mock_request = self._mock_flask_request(auth_header="Bearer tok") @@ -771,22 +788,22 @@ def my_view(): result = my_view() assert result == "OK" - def test_preserves_wrapped_function_name(self): + def test_preserves_wrapped_function_name(self) -> None: """The decorator preserves the original function's name via @wraps.""" handler = self._make_handler() @handler.flask_decorator - def my_special_view(): + def my_special_view() -> str: return "OK" assert my_special_view.__name__ == "my_special_view" - def test_flask_decorator_uses_timing_safe_comparison(self): + def test_flask_decorator_uses_timing_safe_comparison(self) -> None: """Flask decorator uses secrets.compare_digest for token comparison.""" handler = self._make_handler(bearer_token="tok_safe") @handler.flask_decorator - def my_view(): + def my_view() -> str: return "OK" mock_request = self._mock_flask_request(auth_header="Bearer tok_safe") @@ -801,12 +818,12 @@ def my_view(): # Should have been called for the bearer comparison mock_cd.assert_any_call("tok_safe", "tok_safe") - def test_basic_auth_no_authorization_header(self): + def test_basic_auth_no_authorization_header(self) -> None: """When authorization is None, basic auth falls through gracefully.""" handler = self._make_handler(username="admin", password="pass") @handler.flask_decorator - def my_view(): + def my_view() -> str: return "OK" mock_request = self._mock_flask_request(authorization=None) @@ -821,13 +838,13 @@ def my_view(): # No bearer, no api_key, no authorization -> 401 assert result is mock_response_instance - def test_bearer_not_tried_if_not_configured(self): + def test_bearer_not_tried_if_not_configured(self) -> None: """When bearer auth is not configured, a Bearer header is ignored.""" handler = self._make_handler(username="admin", password="pass") # bearer is NOT configured @handler.flask_decorator - def my_view(): + def my_view() -> str: return "OK" # Provide a Bearer header, but since bearer is not enabled, @@ -844,7 +861,7 @@ def my_view(): # Should fail because no valid basic auth was provided assert result is mock_response_instance - def test_api_key_priority_over_basic(self): + def test_api_key_priority_over_basic(self) -> None: """API key auth is checked before basic auth in the Flask decorator.""" handler = self._make_handler( username="admin", password="pass", @@ -852,7 +869,7 @@ def test_api_key_priority_over_basic(self): ) @handler.flask_decorator - def my_view(): + def my_view() -> str: return "OK" # Provide only API key, no basic auth @@ -876,12 +893,12 @@ def my_view(): class TestGetAuthInfo: """Test the get_auth_info method.""" - def _make_handler(self, **kwargs): + def _make_handler(self, **kwargs: Any) -> "AuthHandler": from signalwire.core.auth_handler import AuthHandler cfg = _make_security_config(**kwargs) return AuthHandler(cfg) - def test_basic_only(self): + def test_basic_only(self) -> None: """Only basic auth info returned when that is the only method.""" handler = self._make_handler(username="admin", password="pass") info = handler.get_auth_info() @@ -892,7 +909,7 @@ def test_basic_only(self): assert 'bearer' not in info assert 'api_key' not in info - def test_bearer_info(self): + def test_bearer_info(self) -> None: """Bearer info is included when bearer token is configured.""" handler = self._make_handler(bearer_token="tok") info = handler.get_auth_info() @@ -901,7 +918,7 @@ def test_bearer_info(self): assert info['bearer']['enabled'] is True assert 'hint' in info['bearer'] - def test_api_key_info(self): + def test_api_key_info(self) -> None: """API key info includes header name and hint.""" handler = self._make_handler(api_key="ak", api_key_header="X-Custom") info = handler.get_auth_info() @@ -911,7 +928,7 @@ def test_api_key_info(self): assert info['api_key']['header'] == "X-Custom" assert "X-Custom" in info['api_key']['hint'] - def test_all_methods_info(self): + def test_all_methods_info(self) -> None: """All three methods present in info when all configured.""" handler = self._make_handler( username="u", password="p", @@ -920,7 +937,7 @@ def test_all_methods_info(self): info = handler.get_auth_info() assert set(info.keys()) == {'basic', 'bearer', 'api_key'} - def test_disabled_methods_excluded(self): + def test_disabled_methods_excluded(self) -> None: """Disabled auth methods are excluded from the info dict.""" handler = self._make_handler( username="u", password="p", @@ -934,7 +951,7 @@ def test_disabled_methods_excluded(self): assert 'bearer' not in info assert 'api_key' not in info - def test_no_password_leak_in_info(self): + def test_no_password_leak_in_info(self) -> None: """get_auth_info does not leak password or tokens in the returned dict.""" handler = self._make_handler( username="u", password="supersecret", @@ -952,7 +969,7 @@ def test_no_password_leak_in_info(self): assert 'key' not in info.get('api_key', {}) assert 'hidden_ak' not in str(info) - def test_empty_info_when_all_disabled(self): + def test_empty_info_when_all_disabled(self) -> None: """An empty dict is returned when all auth methods are disabled.""" handler = self._make_handler( username="u", password="p", @@ -973,7 +990,7 @@ def test_empty_info_when_all_disabled(self): class TestEdgeCases: """Edge cases around credentials and handler behavior.""" - def test_very_long_credentials(self): + def test_very_long_credentials(self) -> None: """Very long credential strings are handled correctly.""" from signalwire.core.auth_handler import AuthHandler @@ -987,7 +1004,7 @@ def test_very_long_credentials(self): creds.password = long_pass assert handler.verify_basic_auth(creds) is True - def test_credentials_with_special_characters(self): + def test_credentials_with_special_characters(self) -> None: """Special characters (colons, slashes, etc.) in credentials.""" from signalwire.core.auth_handler import AuthHandler @@ -999,7 +1016,7 @@ def test_credentials_with_special_characters(self): creds.password = "p@ss/w0rd!#$%" assert handler.verify_basic_auth(creds) is True - def test_bearer_token_with_jwt_format(self): + def test_bearer_token_with_jwt_format(self) -> None: """Bearer tokens with JWT-like format are handled correctly.""" from signalwire.core.auth_handler import AuthHandler @@ -1011,7 +1028,7 @@ def test_bearer_token_with_jwt_format(self): creds.credentials = token assert handler.verify_bearer_token(creds) is True - def test_unicode_credentials_raise_type_error(self): + def test_unicode_credentials_raise_type_error(self) -> None: """Non-ASCII strings raise TypeError from secrets.compare_digest. secrets.compare_digest only accepts ASCII strings or bytes. @@ -1029,7 +1046,7 @@ def test_unicode_credentials_raise_type_error(self): with pytest.raises(TypeError): handler.verify_basic_auth(creds) - def test_setup_auth_methods_called_on_init(self): + def test_setup_auth_methods_called_on_init(self) -> None: """_setup_auth_methods is called during __init__.""" from signalwire.core.auth_handler import AuthHandler @@ -1038,7 +1055,7 @@ def test_setup_auth_methods_called_on_init(self): handler = AuthHandler(cfg) mock_setup.assert_called_once() - def test_handler_stores_security_config(self): + def test_handler_stores_security_config(self) -> None: """The handler retains a reference to the security config.""" from signalwire.core.auth_handler import AuthHandler @@ -1046,7 +1063,7 @@ def test_handler_stores_security_config(self): handler = AuthHandler(cfg) assert handler.security_config is cfg - def test_verify_basic_auth_requires_both_fields_correct(self): + def test_verify_basic_auth_requires_both_fields_correct(self) -> None: """Only one matching field (username OR password) is not enough.""" from signalwire.core.auth_handler import AuthHandler @@ -1064,7 +1081,7 @@ def test_verify_basic_auth_requires_both_fields_correct(self): creds.password = "secret" assert handler.verify_basic_auth(creds) is False - def test_multiple_handler_instances_independent(self): + def test_multiple_handler_instances_independent(self) -> None: """Multiple AuthHandler instances have independent auth_methods.""" from signalwire.core.auth_handler import AuthHandler @@ -1079,7 +1096,7 @@ def test_multiple_handler_instances_independent(self): assert 'bearer' not in handler1.auth_methods assert 'bearer' in handler2.auth_methods - def test_api_key_header_defaults_when_attr_missing(self): + def test_api_key_header_defaults_when_attr_missing(self) -> None: """When security_config has no api_key_header attr, defaults to X-API-Key.""" from signalwire.core.auth_handler import AuthHandler @@ -1092,7 +1109,7 @@ def test_api_key_header_defaults_when_attr_missing(self): handler = AuthHandler(cfg) assert handler.auth_methods['api_key']['header'] == "X-API-Key" - def test_whitespace_credentials_not_stripped(self): + def test_whitespace_credentials_not_stripped(self) -> None: """Leading/trailing whitespace in credentials is significant.""" from signalwire.core.auth_handler import AuthHandler @@ -1104,7 +1121,7 @@ def test_whitespace_credentials_not_stripped(self): creds.password = "secret " assert handler.verify_basic_auth(creds) is False - def test_null_bearer_token_in_auth_methods(self): + def test_null_bearer_token_in_auth_methods(self) -> None: """Verifying bearer token when bearer method exists but enabled is False.""" from signalwire.core.auth_handler import AuthHandler @@ -1116,7 +1133,7 @@ def test_null_bearer_token_in_auth_methods(self): creds.credentials = "tok" assert handler.verify_bearer_token(creds) is False - def test_handler_basic_auth_has_basic_auth_and_bearer_fields(self): + def test_handler_basic_auth_has_basic_auth_and_bearer_fields(self) -> None: """Handler stores both basic_auth and bearer_auth scheme objects.""" from signalwire.core.auth_handler import AuthHandler @@ -1126,28 +1143,28 @@ def test_handler_basic_auth_has_basic_auth_and_bearer_fields(self): assert hasattr(handler, 'basic_auth') assert hasattr(handler, 'bearer_auth') - def test_handler_with_none_httpbasic(self): + def test_handler_with_none_httpbasic(self) -> None: """When HTTPBasic is None (not installed), basic_auth attribute is None.""" from signalwire.core import auth_handler - original = auth_handler.HTTPBasic + original = auth_handler.HTTPBasic # type: ignore[attr-defined] # conditionally-defined module attr try: - auth_handler.HTTPBasic = None + auth_handler.HTTPBasic = None # type: ignore[attr-defined,assignment] # conditionally-defined module attr cfg = _make_security_config() handler = auth_handler.AuthHandler(cfg) assert handler.basic_auth is None finally: - auth_handler.HTTPBasic = original + auth_handler.HTTPBasic = original # type: ignore[attr-defined] # conditionally-defined module attr - def test_handler_with_none_httpbearer(self): + def test_handler_with_none_httpbearer(self) -> None: """When HTTPBearer is None (not installed), bearer_auth attribute is None.""" from signalwire.core import auth_handler - original = auth_handler.HTTPBearer + original = auth_handler.HTTPBearer # type: ignore[attr-defined] # conditionally-defined module attr try: - auth_handler.HTTPBearer = None + auth_handler.HTTPBearer = None # type: ignore[attr-defined,assignment] # conditionally-defined module attr cfg = _make_security_config() handler = auth_handler.AuthHandler(cfg) assert handler.bearer_auth is None finally: - auth_handler.HTTPBearer = original + auth_handler.HTTPBearer = original # type: ignore[attr-defined] # conditionally-defined module attr diff --git a/tests/unit/core/test_contexts.py b/tests/unit/core/test_contexts.py index 840db438..13de840f 100644 --- a/tests/unit/core/test_contexts.py +++ b/tests/unit/core/test_contexts.py @@ -28,7 +28,7 @@ class TestStep: """Test Step functionality""" - def test_basic_initialization(self): + def test_basic_initialization(self) -> None: """Test basic Step initialization""" step = Step("greeting") @@ -39,7 +39,7 @@ def test_basic_initialization(self): assert step._valid_steps is None assert step._sections == [] - def test_set_text(self): + def test_set_text(self) -> None: """Test setting step text""" step = Step("greeting") @@ -48,7 +48,7 @@ def test_set_text(self): assert result is step # Should return self for chaining assert step._text == "Hello, how can I help you today?" - def test_add_section(self): + def test_add_section(self) -> None: """Test adding POM sections""" step = Step("greeting") @@ -59,7 +59,7 @@ def test_add_section(self): assert step._sections[0]["title"] == "Introduction" assert step._sections[0]["body"] == "Welcome to our service" - def test_add_bullets(self): + def test_add_bullets(self) -> None: """Test adding bullet sections""" step = Step("greeting") bullets = ["First point", "Second point", "Third point"] @@ -71,7 +71,7 @@ def test_add_bullets(self): assert step._sections[0]["title"] == "Key Points" assert step._sections[0]["bullets"] == bullets - def test_set_step_criteria(self): + def test_set_step_criteria(self) -> None: """Test setting step criteria""" step = Step("greeting") @@ -80,7 +80,7 @@ def test_set_step_criteria(self): assert result is step # Should return self for chaining assert step._step_criteria == "User has provided their name" - def test_set_functions(self): + def test_set_functions(self) -> None: """Test setting available functions""" step = Step("greeting") @@ -91,9 +91,9 @@ def test_set_functions(self): # Test with "none" step.set_functions("none") - assert step._functions == "none" + assert step._functions == "none" # type: ignore[comparison-overlap] # testing "none" sentinel - def test_set_valid_steps(self): + def test_set_valid_steps(self) -> None: """Test setting valid steps""" step = Step("greeting") valid_steps = ["next", "collect_info", "end"] @@ -103,7 +103,7 @@ def test_set_valid_steps(self): assert result is step # Should return self for chaining assert step._valid_steps == valid_steps - def test_text_and_sections_conflict(self): + def test_text_and_sections_conflict(self) -> None: """Test that text and sections cannot be mixed""" step = Step("greeting") @@ -117,7 +117,7 @@ def test_text_and_sections_conflict(self): with pytest.raises(ValueError, match="Cannot add POM sections when set_text"): step.add_bullets("Title", ["bullet"]) - def test_sections_and_text_conflict(self): + def test_sections_and_text_conflict(self) -> None: """Test that sections and text cannot be mixed""" step = Step("greeting") @@ -128,7 +128,7 @@ def test_sections_and_text_conflict(self): with pytest.raises(ValueError, match="Cannot use set_text\\(\\) when POM sections"): step.set_text("Hello") - def test_render_text_with_text(self): + def test_render_text_with_text(self) -> None: """Test rendering text when text is set""" step = Step("greeting") step.set_text("Hello, how can I help you?") @@ -137,7 +137,7 @@ def test_render_text_with_text(self): assert rendered == "Hello, how can I help you?" - def test_render_text_with_sections(self): + def test_render_text_with_sections(self) -> None: """Test rendering text from POM sections""" step = Step("greeting") step.add_section("Welcome", "Hello there!") @@ -151,14 +151,14 @@ def test_render_text_with_sections(self): assert "- Option 1" in rendered assert "- Option 2" in rendered - def test_render_text_no_content(self): + def test_render_text_no_content(self) -> None: """Test rendering text when no content is set""" step = Step("greeting") with pytest.raises(ValueError, match="Step 'greeting' has no text or POM sections"): step._render_text() - def test_to_dict_basic(self): + def test_to_dict_basic(self) -> None: """Test converting step to dictionary""" step = Step("greeting") step.set_text("Hello!") @@ -170,7 +170,7 @@ def test_to_dict_basic(self): assert "functions" not in result assert "valid_steps" not in result - def test_to_dict_complete(self): + def test_to_dict_complete(self) -> None: """Test converting step with all fields to dictionary""" step = Step("greeting") step.set_text("Hello!") @@ -189,7 +189,7 @@ def test_to_dict_complete(self): class TestContext: """Test Context functionality""" - def test_basic_initialization(self): + def test_basic_initialization(self) -> None: """Test basic Context initialization""" context = Context("customer_service") @@ -198,7 +198,7 @@ def test_basic_initialization(self): assert context._step_order == [] assert context._valid_contexts is None - def test_add_step(self): + def test_add_step(self) -> None: """Test adding steps to context""" context = Context("customer_service") @@ -209,7 +209,7 @@ def test_add_step(self): assert "greeting" in context._steps assert context._step_order == ["greeting"] - def test_add_multiple_steps(self): + def test_add_multiple_steps(self) -> None: """Test adding multiple steps""" context = Context("customer_service") @@ -221,7 +221,7 @@ def test_add_multiple_steps(self): assert context._step_order == ["greeting", "collect_info", "provide_solution"] assert all(isinstance(step, Step) for step in [step1, step2, step3]) - def test_add_duplicate_step(self): + def test_add_duplicate_step(self) -> None: """Test adding duplicate step names""" context = Context("customer_service") @@ -230,7 +230,7 @@ def test_add_duplicate_step(self): with pytest.raises(ValueError, match="Step 'greeting' already exists"): context.add_step("greeting") - def test_set_valid_contexts(self): + def test_set_valid_contexts(self) -> None: """Test setting valid contexts""" context = Context("customer_service") valid_contexts = ["sales", "technical_support"] @@ -240,7 +240,7 @@ def test_set_valid_contexts(self): assert result is context # Should return self for chaining assert context._valid_contexts == valid_contexts - def test_to_dict_basic(self): + def test_to_dict_basic(self) -> None: """Test converting context to dictionary""" context = Context("customer_service") step = context.add_step("greeting") @@ -253,7 +253,7 @@ def test_to_dict_basic(self): assert result["steps"][0]["text"] == "Hello!" assert "valid_contexts" not in result - def test_to_dict_with_valid_contexts(self): + def test_to_dict_with_valid_contexts(self) -> None: """Test converting context with valid contexts""" context = Context("customer_service") step = context.add_step("greeting") @@ -265,7 +265,7 @@ def test_to_dict_with_valid_contexts(self): assert "steps" in result assert result["valid_contexts"] == ["sales"] - def test_to_dict_no_steps(self): + def test_to_dict_no_steps(self) -> None: """Test converting context with no steps""" context = Context("customer_service") @@ -276,7 +276,7 @@ def test_to_dict_no_steps(self): class TestContextBuilder: """Test ContextBuilder functionality""" - def test_basic_initialization(self): + def test_basic_initialization(self) -> None: """Test basic ContextBuilder initialization""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -284,7 +284,7 @@ def test_basic_initialization(self): # ContextBuilder doesn't store agent reference, just uses it during init assert builder._contexts == {} - def test_add_context(self): + def test_add_context(self) -> None: """Test adding a context""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -293,7 +293,7 @@ def test_add_context(self): assert isinstance(context, Context) assert "customer_service" in builder._contexts - def test_add_duplicate_context(self): + def test_add_duplicate_context(self) -> None: """Test adding duplicate context raises error""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -304,7 +304,7 @@ def test_add_duplicate_context(self): with pytest.raises(ValueError, match="Context 'customer_service' already exists"): builder.add_context("customer_service") - def test_validate_success(self): + def test_validate_success(self) -> None: """Test successful validation with default context — returns None and the validated config is reachable via to_dict() with the right shape.""" @@ -316,7 +316,7 @@ def test_validate_success(self): step.set_text("Hello!") # validate() returns None when the configuration is valid. - result = builder.validate() + result = builder.validate() # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising assert result is None # And the validated structure round-trips through to_dict. d = builder.to_dict() @@ -326,7 +326,7 @@ def test_validate_success(self): step_names = [s["name"] for s in d["default"]["steps"]] assert "greeting" in step_names - def test_validate_no_contexts(self): + def test_validate_no_contexts(self) -> None: """Test validation with no contexts""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -334,7 +334,7 @@ def test_validate_no_contexts(self): with pytest.raises(ValueError, match="At least one context must be defined"): builder.validate() - def test_validate_context_no_steps(self): + def test_validate_context_no_steps(self) -> None: """Test validation with context having no steps""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -344,7 +344,7 @@ def test_validate_context_no_steps(self): with pytest.raises(ValueError, match="Context 'default' must have at least one step"): builder.validate() - def test_to_dict(self): + def test_to_dict(self) -> None: """Test converting builder to dictionary""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -361,14 +361,14 @@ def test_to_dict(self): class TestCreateSimpleContext: """Test create_simple_context factory function""" - def test_create_simple_context_default(self): + def test_create_simple_context_default(self) -> None: """Test creating simple context with default name""" context = create_simple_context() assert isinstance(context, Context) assert context.name == "default" - def test_create_simple_context_custom_name(self): + def test_create_simple_context_custom_name(self) -> None: """Test creating simple context with custom name""" context = create_simple_context("my_context") @@ -379,7 +379,7 @@ def test_create_simple_context_custom_name(self): class TestContextIntegration: """Test context integration scenarios""" - def test_complete_context_workflow(self): + def test_complete_context_workflow(self) -> None: """Test complete context building workflow with multiple contexts""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -430,7 +430,7 @@ def test_complete_context_workflow(self): assert "technical_support" in result assert len(result["customer_service"]["steps"]) == 3 - def test_multiple_contexts(self): + def test_multiple_contexts(self) -> None: """Test building multiple contexts""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -455,7 +455,7 @@ def test_multiple_contexts(self): assert len(result["sales"]["steps"]) == 1 assert len(result["support"]["steps"]) == 1 - def test_complex_step_configuration(self): + def test_complex_step_configuration(self) -> None: """Test complex step configuration with all features""" context = Context("complex") @@ -490,42 +490,42 @@ def test_complex_step_configuration(self): class TestStepResetAndContextNavigation: """Tests for Step reset parameters and context navigation features""" - def test_set_valid_contexts(self): + def test_set_valid_contexts(self) -> None: """Test setting valid contexts on a step""" step = Step("transfer") result = step.set_valid_contexts(["sales", "support"]) assert result is step assert step._valid_contexts == ["sales", "support"] - def test_set_reset_system_prompt(self): + def test_set_reset_system_prompt(self) -> None: """Test setting reset system prompt""" step = Step("transfer") result = step.set_reset_system_prompt("You are a sales agent.") assert result is step assert step._reset_system_prompt == "You are a sales agent." - def test_set_reset_user_prompt(self): + def test_set_reset_user_prompt(self) -> None: """Test setting reset user prompt""" step = Step("transfer") result = step.set_reset_user_prompt("Please help me buy something.") assert result is step assert step._reset_user_prompt == "Please help me buy something." - def test_set_reset_consolidate(self): + def test_set_reset_consolidate(self) -> None: """Test setting reset consolidate flag""" step = Step("transfer") result = step.set_reset_consolidate(True) assert result is step assert step._reset_consolidate is True - def test_set_reset_full_reset(self): + def test_set_reset_full_reset(self) -> None: """Test setting reset full_reset flag""" step = Step("transfer") result = step.set_reset_full_reset(True) assert result is step assert step._reset_full_reset is True - def test_to_dict_with_valid_contexts(self): + def test_to_dict_with_valid_contexts(self) -> None: """Test to_dict includes valid_contexts when set""" step = Step("transfer") step.set_text("Transferring you now.") @@ -533,7 +533,7 @@ def test_to_dict_with_valid_contexts(self): result = step.to_dict() assert result["valid_contexts"] == ["sales", "support"] - def test_to_dict_with_partial_reset(self): + def test_to_dict_with_partial_reset(self) -> None: """Test to_dict includes reset object with only system_prompt""" step = Step("transfer") step.set_text("Transferring you now.") @@ -545,7 +545,7 @@ def test_to_dict_with_partial_reset(self): assert "consolidate" not in result["reset"] assert "full_reset" not in result["reset"] - def test_to_dict_with_full_reset_object(self): + def test_to_dict_with_full_reset_object(self) -> None: """Test to_dict includes reset object with all fields""" step = Step("transfer") step.set_text("Transferring you now.") @@ -559,7 +559,7 @@ def test_to_dict_with_full_reset_object(self): assert result["reset"]["consolidate"] is True assert result["reset"]["full_reset"] is True - def test_to_dict_no_reset_when_defaults(self): + def test_to_dict_no_reset_when_defaults(self) -> None: """Test to_dict omits reset when all reset fields are defaults""" step = Step("greeting") step.set_text("Hello!") @@ -570,49 +570,49 @@ def test_to_dict_no_reset_when_defaults(self): class TestContextEntryParameters: """Tests for Context entry parameters (system_prompt, consolidate, etc.)""" - def test_set_post_prompt(self): + def test_set_post_prompt(self) -> None: """Test setting post prompt""" context = Context("sales") result = context.set_post_prompt("Summarize the conversation") assert result is context assert context._post_prompt == "Summarize the conversation" - def test_set_system_prompt(self): + def test_set_system_prompt(self) -> None: """Test setting system prompt""" context = Context("sales") result = context.set_system_prompt("You are a sales agent.") assert result is context assert context._system_prompt == "You are a sales agent." - def test_set_system_prompt_conflict_with_sections(self): + def test_set_system_prompt_conflict_with_sections(self) -> None: """Test that set_system_prompt raises when sections already exist""" context = Context("sales") context.add_system_section("Role", "You are a sales agent.") with pytest.raises(ValueError, match="Cannot use set_system_prompt"): context.set_system_prompt("You are a sales agent.") - def test_set_consolidate(self): + def test_set_consolidate(self) -> None: """Test setting consolidate flag""" context = Context("sales") result = context.set_consolidate(True) assert result is context assert context._consolidate is True - def test_set_full_reset(self): + def test_set_full_reset(self) -> None: """Test setting full_reset flag""" context = Context("sales") result = context.set_full_reset(True) assert result is context assert context._full_reset is True - def test_set_user_prompt(self): + def test_set_user_prompt(self) -> None: """Test setting user prompt""" context = Context("sales") result = context.set_user_prompt("I want to buy something.") assert result is context assert context._user_prompt == "I want to buy something." - def test_set_isolated(self): + def test_set_isolated(self) -> None: """Test setting isolated flag""" context = Context("sales") result = context.set_isolated(True) @@ -623,7 +623,7 @@ def test_set_isolated(self): class TestContextSystemPromptSections: """Tests for Context system prompt POM sections""" - def test_add_system_section(self): + def test_add_system_section(self) -> None: """Test adding a POM section to system prompt""" context = Context("sales") result = context.add_system_section("Role", "You are a sales agent.") @@ -632,14 +632,14 @@ def test_add_system_section(self): assert context._system_prompt_sections[0]["title"] == "Role" assert context._system_prompt_sections[0]["body"] == "You are a sales agent." - def test_add_system_section_conflict_with_set_system_prompt(self): + def test_add_system_section_conflict_with_set_system_prompt(self) -> None: """Test that add_system_section raises when set_system_prompt already used""" context = Context("sales") context.set_system_prompt("You are a sales agent.") with pytest.raises(ValueError, match="Cannot add POM sections for system prompt"): context.add_system_section("Role", "You are a sales agent.") - def test_add_system_bullets(self): + def test_add_system_bullets(self) -> None: """Test adding bullet points to system prompt""" context = Context("sales") result = context.add_system_bullets("Rules", ["Be polite", "Be helpful"]) @@ -647,32 +647,33 @@ def test_add_system_bullets(self): assert len(context._system_prompt_sections) == 1 assert context._system_prompt_sections[0]["bullets"] == ["Be polite", "Be helpful"] - def test_add_system_bullets_conflict_with_set_system_prompt(self): + def test_add_system_bullets_conflict_with_set_system_prompt(self) -> None: """Test that add_system_bullets raises when set_system_prompt already used""" context = Context("sales") context.set_system_prompt("You are a sales agent.") with pytest.raises(ValueError, match="Cannot add POM sections for system prompt"): context.add_system_bullets("Rules", ["Be polite"]) - def test_render_system_prompt_with_text(self): + def test_render_system_prompt_with_text(self) -> None: """Test _render_system_prompt returns text when set""" context = Context("sales") context.set_system_prompt("You are a sales agent.") assert context._render_system_prompt() == "You are a sales agent." - def test_render_system_prompt_with_sections(self): + def test_render_system_prompt_with_sections(self) -> None: """Test _render_system_prompt renders POM sections""" context = Context("sales") context.add_system_section("Role", "You are a sales agent.") context.add_system_bullets("Rules", ["Be polite", "Be helpful"]) rendered = context._render_system_prompt() + assert rendered is not None assert "## Role" in rendered assert "You are a sales agent." in rendered assert "## Rules" in rendered assert "- Be polite" in rendered assert "- Be helpful" in rendered - def test_render_system_prompt_none(self): + def test_render_system_prompt_none(self) -> None: """Test _render_system_prompt returns None when nothing is set""" context = Context("sales") assert context._render_system_prompt() is None @@ -681,21 +682,21 @@ def test_render_system_prompt_none(self): class TestContextPromptSections: """Tests for Context prompt (separate from system_prompt) POM sections""" - def test_set_prompt(self): + def test_set_prompt(self) -> None: """Test setting context prompt text""" context = Context("sales") result = context.set_prompt("Welcome to the sales department.") assert result is context assert context._prompt_text == "Welcome to the sales department." - def test_set_prompt_conflict_with_sections(self): + def test_set_prompt_conflict_with_sections(self) -> None: """Test that set_prompt raises when sections already exist""" context = Context("sales") context.add_section("Greeting", "Welcome!") with pytest.raises(ValueError, match="Cannot use set_prompt"): context.set_prompt("Welcome!") - def test_add_section(self): + def test_add_section(self) -> None: """Test adding a section to context prompt""" context = Context("sales") result = context.add_section("Greeting", "Welcome to our store!") @@ -704,14 +705,14 @@ def test_add_section(self): assert context._prompt_sections[0]["title"] == "Greeting" assert context._prompt_sections[0]["body"] == "Welcome to our store!" - def test_add_section_conflict_with_set_prompt(self): + def test_add_section_conflict_with_set_prompt(self) -> None: """Test that add_section raises when set_prompt already used""" context = Context("sales") context.set_prompt("Welcome!") with pytest.raises(ValueError, match="Cannot add POM sections when set_prompt"): context.add_section("Greeting", "Welcome!") - def test_add_bullets(self): + def test_add_bullets(self) -> None: """Test adding bullet points to context prompt""" context = Context("sales") result = context.add_bullets("Products", ["Widget A", "Widget B"]) @@ -719,32 +720,33 @@ def test_add_bullets(self): assert len(context._prompt_sections) == 1 assert context._prompt_sections[0]["bullets"] == ["Widget A", "Widget B"] - def test_add_bullets_conflict_with_set_prompt(self): + def test_add_bullets_conflict_with_set_prompt(self) -> None: """Test that add_bullets raises when set_prompt already used""" context = Context("sales") context.set_prompt("Welcome!") with pytest.raises(ValueError, match="Cannot add POM sections when set_prompt"): context.add_bullets("Products", ["Widget A"]) - def test_render_prompt_with_text(self): + def test_render_prompt_with_text(self) -> None: """Test _render_prompt returns text when set""" context = Context("sales") context.set_prompt("Welcome to sales.") assert context._render_prompt() == "Welcome to sales." - def test_render_prompt_with_sections(self): + def test_render_prompt_with_sections(self) -> None: """Test _render_prompt renders POM sections""" context = Context("sales") context.add_section("Greeting", "Hello!") context.add_bullets("Items", ["Item 1", "Item 2"]) rendered = context._render_prompt() + assert rendered is not None assert "## Greeting" in rendered assert "Hello!" in rendered assert "## Items" in rendered assert "- Item 1" in rendered assert "- Item 2" in rendered - def test_render_prompt_none(self): + def test_render_prompt_none(self) -> None: """Test _render_prompt returns None when nothing is set""" context = Context("sales") assert context._render_prompt() is None @@ -753,7 +755,7 @@ def test_render_prompt_none(self): class TestContextFillers: """Tests for Context enter/exit filler functionality""" - def test_set_enter_fillers(self): + def test_set_enter_fillers(self) -> None: """Test setting enter fillers""" context = Context("sales") fillers = {"en-US": ["Welcome!", "Hello!"], "default": ["Hi!"]} @@ -761,21 +763,21 @@ def test_set_enter_fillers(self): assert result is context assert context._enter_fillers == fillers - def test_set_enter_fillers_empty_dict(self): + def test_set_enter_fillers_empty_dict(self) -> None: """Test that empty dict does not set enter fillers""" context = Context("sales") result = context.set_enter_fillers({}) assert result is context assert context._enter_fillers is None - def test_set_enter_fillers_non_dict(self): + def test_set_enter_fillers_non_dict(self) -> None: """Test that non-dict does not set enter fillers""" context = Context("sales") - result = context.set_enter_fillers(None) + result = context.set_enter_fillers(None) # type: ignore[arg-type] # intentional invalid input assert result is context assert context._enter_fillers is None - def test_set_exit_fillers(self): + def test_set_exit_fillers(self) -> None: """Test setting exit fillers""" context = Context("sales") fillers = {"en-US": ["Goodbye!", "Thank you!"], "default": ["Bye!"]} @@ -783,73 +785,73 @@ def test_set_exit_fillers(self): assert result is context assert context._exit_fillers == fillers - def test_set_exit_fillers_empty_dict(self): + def test_set_exit_fillers_empty_dict(self) -> None: """Test that empty dict does not set exit fillers""" context = Context("sales") result = context.set_exit_fillers({}) assert result is context assert context._exit_fillers is None - def test_set_exit_fillers_non_dict(self): + def test_set_exit_fillers_non_dict(self) -> None: """Test that non-dict does not set exit fillers""" context = Context("sales") - result = context.set_exit_fillers(None) + result = context.set_exit_fillers(None) # type: ignore[arg-type] # intentional invalid input assert result is context assert context._exit_fillers is None - def test_add_enter_filler(self): + def test_add_enter_filler(self) -> None: """Test adding enter fillers for a specific language""" context = Context("sales") result = context.add_enter_filler("en-US", ["Welcome!", "Hello!"]) assert result is context assert context._enter_fillers == {"en-US": ["Welcome!", "Hello!"]} - def test_add_enter_filler_multiple_languages(self): + def test_add_enter_filler_multiple_languages(self) -> None: """Test adding enter fillers for multiple languages""" context = Context("sales") context.add_enter_filler("en-US", ["Welcome!"]) context.add_enter_filler("es", ["Bienvenido!"]) assert context._enter_fillers == {"en-US": ["Welcome!"], "es": ["Bienvenido!"]} - def test_add_enter_filler_invalid_inputs(self): + def test_add_enter_filler_invalid_inputs(self) -> None: """Test that invalid inputs to add_enter_filler are ignored""" context = Context("sales") context.add_enter_filler("", ["Hello!"]) assert context._enter_fillers is None context.add_enter_filler("en-US", []) assert context._enter_fillers is None - context.add_enter_filler("en-US", None) + context.add_enter_filler("en-US", None) # type: ignore[arg-type] # intentional invalid input assert context._enter_fillers is None - def test_add_exit_filler(self): + def test_add_exit_filler(self) -> None: """Test adding exit fillers for a specific language""" context = Context("sales") result = context.add_exit_filler("en-US", ["Goodbye!", "See you!"]) assert result is context assert context._exit_fillers == {"en-US": ["Goodbye!", "See you!"]} - def test_add_exit_filler_multiple_languages(self): + def test_add_exit_filler_multiple_languages(self) -> None: """Test adding exit fillers for multiple languages""" context = Context("sales") context.add_exit_filler("en-US", ["Goodbye!"]) context.add_exit_filler("es", ["Adios!"]) assert context._exit_fillers == {"en-US": ["Goodbye!"], "es": ["Adios!"]} - def test_add_exit_filler_invalid_inputs(self): + def test_add_exit_filler_invalid_inputs(self) -> None: """Test that invalid inputs to add_exit_filler are ignored""" context = Context("sales") context.add_exit_filler("", ["Bye!"]) assert context._exit_fillers is None context.add_exit_filler("en-US", []) assert context._exit_fillers is None - context.add_exit_filler("en-US", None) + context.add_exit_filler("en-US", None) # type: ignore[arg-type] # intentional invalid input assert context._exit_fillers is None class TestContextToDictComprehensive: """Tests for Context.to_dict with various combinations of parameters""" - def test_to_dict_with_post_prompt(self): + def test_to_dict_with_post_prompt(self) -> None: """Test to_dict includes post_prompt""" context = Context("sales") context.add_step("greeting").set_text("Hello!") @@ -857,7 +859,7 @@ def test_to_dict_with_post_prompt(self): result = context.to_dict() assert result["post_prompt"] == "Summarize the conversation" - def test_to_dict_with_system_prompt(self): + def test_to_dict_with_system_prompt(self) -> None: """Test to_dict includes system_prompt""" context = Context("sales") context.add_step("greeting").set_text("Hello!") @@ -865,7 +867,7 @@ def test_to_dict_with_system_prompt(self): result = context.to_dict() assert result["system_prompt"] == "You are a sales agent." - def test_to_dict_with_system_sections(self): + def test_to_dict_with_system_sections(self) -> None: """Test to_dict renders system prompt from POM sections""" context = Context("sales") context.add_step("greeting").set_text("Hello!") @@ -874,7 +876,7 @@ def test_to_dict_with_system_sections(self): assert "system_prompt" in result assert "## Role" in result["system_prompt"] - def test_to_dict_with_consolidate_and_full_reset(self): + def test_to_dict_with_consolidate_and_full_reset(self) -> None: """Test to_dict includes consolidate and full_reset""" context = Context("sales") context.add_step("greeting").set_text("Hello!") @@ -884,7 +886,7 @@ def test_to_dict_with_consolidate_and_full_reset(self): assert result["consolidate"] is True assert result["full_reset"] is True - def test_to_dict_with_user_prompt(self): + def test_to_dict_with_user_prompt(self) -> None: """Test to_dict includes user_prompt""" context = Context("sales") context.add_step("greeting").set_text("Hello!") @@ -892,7 +894,7 @@ def test_to_dict_with_user_prompt(self): result = context.to_dict() assert result["user_prompt"] == "I want to buy something." - def test_to_dict_with_isolated(self): + def test_to_dict_with_isolated(self) -> None: """Test to_dict includes isolated""" context = Context("sales") context.add_step("greeting").set_text("Hello!") @@ -900,7 +902,7 @@ def test_to_dict_with_isolated(self): result = context.to_dict() assert result["isolated"] is True - def test_to_dict_with_prompt_text(self): + def test_to_dict_with_prompt_text(self) -> None: """Test to_dict includes prompt when set_prompt used""" context = Context("sales") context.add_step("greeting").set_text("Hello!") @@ -909,7 +911,7 @@ def test_to_dict_with_prompt_text(self): assert result["prompt"] == "Welcome to the sales department." assert "pom" not in result - def test_to_dict_with_prompt_sections(self): + def test_to_dict_with_prompt_sections(self) -> None: """Test to_dict includes pom when sections added""" context = Context("sales") context.add_step("greeting").set_text("Hello!") @@ -920,7 +922,7 @@ def test_to_dict_with_prompt_sections(self): assert "prompt" not in result assert len(result["pom"]) == 2 - def test_to_dict_with_enter_and_exit_fillers(self): + def test_to_dict_with_enter_and_exit_fillers(self) -> None: """Test to_dict includes fillers""" context = Context("sales") context.add_step("greeting").set_text("Hello!") @@ -930,7 +932,7 @@ def test_to_dict_with_enter_and_exit_fillers(self): assert result["enter_fillers"] == {"en-US": ["Welcome!"]} assert result["exit_fillers"] == {"en-US": ["Goodbye!"]} - def test_to_dict_omits_defaults(self): + def test_to_dict_omits_defaults(self) -> None: """Test to_dict omits fields that are at default values""" context = Context("sales") context.add_step("greeting").set_text("Hello!") @@ -950,7 +952,7 @@ def test_to_dict_omits_defaults(self): class TestContextBuilderValidation: """Tests for ContextBuilder validation edge cases""" - def test_validate_single_context_not_named_default(self): + def test_validate_single_context_not_named_default(self) -> None: """Test validation fails when single context is not named 'default'""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -959,7 +961,7 @@ def test_validate_single_context_not_named_default(self): with pytest.raises(ValueError, match="single context, it must be named 'default'"): builder.validate() - def test_validate_invalid_step_reference(self): + def test_validate_invalid_step_reference(self) -> None: """Test validation fails when valid_steps references unknown step""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -970,7 +972,7 @@ def test_validate_invalid_step_reference(self): with pytest.raises(ValueError, match="references unknown step 'nonexistent_step'"): builder.validate() - def test_validate_next_is_allowed_in_valid_steps(self): + def test_validate_next_is_allowed_in_valid_steps(self) -> None: """Test validation allows 'next' as a valid step reference — it is a reserved keyword that the validator must accept without resolving it as a real step name.""" @@ -981,12 +983,12 @@ def test_validate_next_is_allowed_in_valid_steps(self): step.set_text("Hello!") step.set_valid_steps(["next"]) # validate() returns None and "next" was preserved on the step. - assert builder.validate() is None + assert builder.validate() is None # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising d = builder.to_dict() greeting = d["default"]["steps"][0] assert greeting["valid_steps"] == ["next"] - def test_validate_invalid_context_reference_at_context_level(self): + def test_validate_invalid_context_reference_at_context_level(self) -> None: """Test validation fails for unknown context in context-level valid_contexts""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -998,7 +1000,7 @@ def test_validate_invalid_context_reference_at_context_level(self): with pytest.raises(ValueError, match="Context 'ctx1' references unknown context 'nonexistent_context'"): builder.validate() - def test_validate_invalid_context_reference_at_step_level(self): + def test_validate_invalid_context_reference_at_step_level(self) -> None: """Test validation fails for unknown context in step-level valid_contexts""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -1011,7 +1013,7 @@ def test_validate_invalid_context_reference_at_step_level(self): with pytest.raises(ValueError, match="references unknown context 'nonexistent_context'"): builder.validate() - def test_validate_valid_context_references(self): + def test_validate_valid_context_references(self) -> None: """Test validation passes with valid context references at BOTH the context level and the step level. The to_dict() output must reflect both references (proving validation didn't strip them).""" @@ -1025,12 +1027,12 @@ def test_validate_valid_context_references(self): ctx2 = builder.add_context("ctx2") ctx2.add_step("s2").set_text("Hi!") # Returns None on success; both refs survive serialisation. - assert builder.validate() is None + assert builder.validate() is None # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising d = builder.to_dict() assert d["ctx1"]["valid_contexts"] == ["ctx2"] assert d["ctx1"]["steps"][0]["valid_contexts"] == ["ctx2"] - def test_to_dict_preserves_order(self): + def test_to_dict_preserves_order(self) -> None: """Test that to_dict preserves context insertion order""" mock_agent = Mock() builder = ContextBuilder(mock_agent) @@ -1051,12 +1053,12 @@ def test_to_dict_preserves_order(self): class TestGatherQuestion: """Test GatherQuestion class""" - def test_basic_question(self): + def test_basic_question(self) -> None: q = GatherQuestion(key="name", question="What is your name?") d = q.to_dict() assert d == {"key": "name", "question": "What is your name?"} - def test_question_with_all_fields(self): + def test_question_with_all_fields(self) -> None: q = GatherQuestion( key="email", question="Email?", type="string", confirm=True, prompt="Be precise", functions=["validate_email"] @@ -1067,11 +1069,11 @@ def test_question_with_all_fields(self): assert d["prompt"] == "Be precise" assert d["functions"] == ["validate_email"] - def test_default_type_not_included(self): + def test_default_type_not_included(self) -> None: q = GatherQuestion(key="x", question="Q?") assert "type" not in q.to_dict() - def test_non_default_type_included(self): + def test_non_default_type_included(self) -> None: q = GatherQuestion(key="age", question="Age?", type="integer") assert q.to_dict()["type"] == "integer" @@ -1079,14 +1081,14 @@ def test_non_default_type_included(self): class TestGatherInfo: """Test GatherInfo class""" - def test_basic_gather_info(self): + def test_basic_gather_info(self) -> None: gi = GatherInfo() gi.add_question("name", "What is your name?") d = gi.to_dict() assert "questions" in d assert len(d["questions"]) == 1 - def test_gather_info_with_all_params(self): + def test_gather_info_with_all_params(self) -> None: gi = GatherInfo(output_key="profile", completion_action="next_step", prompt="Welcome!") gi.add_question("name", "Name?") @@ -1095,18 +1097,18 @@ def test_gather_info_with_all_params(self): assert d["completion_action"] == "next_step" assert d["prompt"] == "Welcome!" - def test_gather_info_no_questions_raises(self): + def test_gather_info_no_questions_raises(self) -> None: gi = GatherInfo() with pytest.raises(ValueError, match="at least one question"): gi.to_dict() - def test_method_chaining(self): + def test_method_chaining(self) -> None: gi = GatherInfo() result = gi.add_question("a", "Q1?").add_question("b", "Q2?") assert result is gi assert len(gi._questions) == 2 - def test_completion_action_passed_as_is(self): + def test_completion_action_passed_as_is(self) -> None: gi = GatherInfo(completion_action="my_custom_step") gi.add_question("x", "Q?") assert gi.to_dict()["completion_action"] == "my_custom_step" @@ -1115,7 +1117,7 @@ def test_completion_action_passed_as_is(self): class TestStepGatherInfo: """Test Step gather_info integration""" - def test_set_gather_info_and_add_questions(self): + def test_set_gather_info_and_add_questions(self) -> None: step = Step("intake") step.set_text("Collect info") step.set_gather_info(output_key="data", completion_action="next_step") @@ -1126,12 +1128,12 @@ def test_set_gather_info_and_add_questions(self): assert d["gather_info"]["output_key"] == "data" assert len(d["gather_info"]["questions"]) == 2 - def test_add_gather_question_without_set_gather_info_raises(self): + def test_add_gather_question_without_set_gather_info_raises(self) -> None: step = Step("s") with pytest.raises(ValueError, match="Must call set_gather_info"): step.add_gather_question("key", "Q?") - def test_gather_info_completion_action_named_step(self): + def test_gather_info_completion_action_named_step(self) -> None: step = Step("s") step.set_text("Go") step.set_gather_info(completion_action="review") @@ -1143,10 +1145,10 @@ def test_gather_info_completion_action_named_step(self): class TestGatherInfoValidation: """Test ContextBuilder validation of gather_info completion_action""" - def _make_builder(self): + def _make_builder(self) -> ContextBuilder: return ContextBuilder(Mock()) - def test_next_step_valid_when_following_step_exists(self): + def test_next_step_valid_when_following_step_exists(self) -> None: """When `completion_action='next_step'` is set on a step that has a following step in the same context, validate() must accept it.""" builder = self._make_builder() @@ -1157,12 +1159,12 @@ def test_next_step_valid_when_following_step_exists(self): .add_gather_question("name", "Name?") ctx.add_step("process").set_text("Process") # validate() returns None and to_dict() preserves the action verbatim. - assert builder.validate() is None + assert builder.validate() is None # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising d = builder.to_dict() gather_step = d["default"]["steps"][0] assert gather_step["gather_info"]["completion_action"] == "next_step" - def test_next_step_invalid_on_last_step(self): + def test_next_step_invalid_on_last_step(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") ctx.add_step("only_step") \ @@ -1172,7 +1174,7 @@ def test_next_step_invalid_on_last_step(self): with pytest.raises(ValueError, match="last step"): builder.validate() - def test_named_step_valid(self): + def test_named_step_valid(self) -> None: """When `completion_action` names a step that exists ANYWHERE in the same context (not necessarily the next one), validate() must accept it.""" @@ -1184,7 +1186,7 @@ def test_named_step_valid(self): .add_gather_question("name", "Name?") ctx.add_step("middle").set_text("Middle") ctx.add_step("review").set_text("Review") - assert builder.validate() is None + assert builder.validate() is None # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising d = builder.to_dict() # The named step is preserved and the target step exists in the dict. step_names = [s["name"] for s in d["default"]["steps"]] @@ -1192,7 +1194,7 @@ def test_named_step_valid(self): gather_step = next(s for s in d["default"]["steps"] if s["name"] == "gather") assert gather_step["gather_info"]["completion_action"] == "review" - def test_named_step_invalid_when_not_defined(self): + def test_named_step_invalid_when_not_defined(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") ctx.add_step("gather") \ @@ -1203,7 +1205,7 @@ def test_named_step_invalid_when_not_defined(self): with pytest.raises(ValueError, match="is not a step in this context"): builder.validate() - def test_no_completion_action_always_valid(self): + def test_no_completion_action_always_valid(self) -> None: """When no `completion_action` is set on gather_info, validate() must always accept it — even on the last (only) step in the context. The gather_info dict in the output must NOT contain a @@ -1214,13 +1216,13 @@ def test_no_completion_action_always_valid(self): .set_text("Gather") \ .set_gather_info() \ .add_gather_question("name", "Name?") - assert builder.validate() is None + assert builder.validate() is None # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising d = builder.to_dict() only_step = d["default"]["steps"][0] # No completion_action was set, so it must not appear in the output. assert "completion_action" not in only_step["gather_info"] - def test_next_step_valid_not_last_in_multi_step(self): + def test_next_step_valid_not_last_in_multi_step(self) -> None: """In a context with three steps where step1 and step2 both use `completion_action='next_step'`, validate() must accept BOTH because each has a following step.""" @@ -1235,7 +1237,7 @@ def test_next_step_valid_not_last_in_multi_step(self): .set_gather_info(completion_action="next_step") \ .add_gather_question("b", "Q?") ctx.add_step("step3").set_text("S3") - assert builder.validate() is None + assert builder.validate() is None # type: ignore[func-returns-value] # validate() returns None; asserting it ran without raising d = builder.to_dict() names = [s["name"] for s in d["default"]["steps"]] # Both gather steps remain present; step3 (the terminal) was @@ -1244,7 +1246,7 @@ def test_next_step_valid_not_last_in_multi_step(self): assert d["default"]["steps"][0]["gather_info"]["completion_action"] == "next_step" assert d["default"]["steps"][1]["gather_info"]["completion_action"] == "next_step" - def test_second_to_last_next_step_valid_last_next_step_invalid(self): + def test_second_to_last_next_step_valid_last_next_step_invalid(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") ctx.add_step("s1").set_text("S1") @@ -1260,7 +1262,7 @@ def test_second_to_last_next_step_valid_last_next_step_invalid(self): class TestStepFunctionsSerialization: """Pinning tests for Step.set_functions semantics in to_dict output.""" - def test_omitted_functions_key_absent_from_dict(self): + def test_omitted_functions_key_absent_from_dict(self) -> None: """If set_functions() is never called, the resulting step dict must NOT contain a 'functions' key — that omission is what tells the C runtime to inherit the previous step's active set. @@ -1273,7 +1275,7 @@ def test_omitted_functions_key_absent_from_dict(self): "so the runtime applies inheritance semantics." ) - def test_explicit_empty_list_persists(self): + def test_explicit_empty_list_persists(self) -> None: """functions=[] is the documented disable-all form. It must round-trip into the dict so the runtime sees the empty list.""" step = Step("s") @@ -1282,7 +1284,7 @@ def test_explicit_empty_list_persists(self): d = step.to_dict() assert d["functions"] == [] - def test_none_string_persists_as_synonym(self): + def test_none_string_persists_as_synonym(self) -> None: """functions="none" is a Python convenience; it should appear in the dict so the runtime treats it like an empty list.""" step = Step("s") @@ -1291,7 +1293,7 @@ def test_none_string_persists_as_synonym(self): d = step.to_dict() assert d["functions"] == "none" - def test_explicit_list_round_trips(self): + def test_explicit_list_round_trips(self) -> None: step = Step("s") step.set_text("hi") step.set_functions(["a", "b"]) @@ -1303,7 +1305,7 @@ class TestReservedToolNameValidation: """ContextBuilder.validate() must reject user tools that collide with reserved native tool names (next_step / change_context / gather_submit).""" - def _make_agent_with_tools(self, tool_names): + def _make_agent_with_tools(self, tool_names: List[str]) -> Mock: """Build a mock agent that exposes a real dict of registered tools at agent._tool_registry._swaig_functions, matching the structure the production code reads from.""" @@ -1312,41 +1314,41 @@ def _make_agent_with_tools(self, tool_names): agent._tool_registry._swaig_functions = {name: Mock() for name in tool_names} return agent - def _builder_with_minimal_context(self, agent): + def _builder_with_minimal_context(self, agent: Mock) -> ContextBuilder: builder = ContextBuilder(agent) ctx = builder.add_context("default") ctx.add_step("only").set_text("Step text") return builder - def test_collision_with_next_step_rejected(self): + def test_collision_with_next_step_rejected(self) -> None: agent = self._make_agent_with_tools(["next_step", "lookup"]) builder = self._builder_with_minimal_context(agent) with pytest.raises(ValueError, match="next_step"): builder.validate() - def test_collision_with_change_context_rejected(self): + def test_collision_with_change_context_rejected(self) -> None: agent = self._make_agent_with_tools(["change_context"]) builder = self._builder_with_minimal_context(agent) with pytest.raises(ValueError, match="reserved"): builder.validate() - def test_collision_with_gather_submit_rejected(self): + def test_collision_with_gather_submit_rejected(self) -> None: agent = self._make_agent_with_tools(["gather_submit", "search"]) builder = self._builder_with_minimal_context(agent) with pytest.raises(ValueError, match="gather_submit"): builder.validate() - def test_no_collision_passes(self): + def test_no_collision_passes(self) -> None: agent = self._make_agent_with_tools(["lookup_account", "send_email"]) builder = self._builder_with_minimal_context(agent) builder.validate() # should not raise - def test_empty_tool_registry_passes(self): + def test_empty_tool_registry_passes(self) -> None: agent = self._make_agent_with_tools([]) builder = self._builder_with_minimal_context(agent) builder.validate() # should not raise - def test_mock_agent_does_not_trigger_check(self): + def test_mock_agent_does_not_trigger_check(self) -> None: """Plain Mock() agents (used throughout the existing test suite) must not accidentally trip the collision check — the real check only fires when _swaig_functions is an actual dict.""" @@ -1357,10 +1359,10 @@ def test_mock_agent_does_not_trigger_check(self): class TestImprovedCompletionActionErrorMessage: """The completion_action validation errors should be actionable.""" - def _make_builder(self): + def _make_builder(self) -> ContextBuilder: return ContextBuilder(Mock()) - def test_next_step_on_last_step_error_lists_remediations(self): + def test_next_step_on_last_step_error_lists_remediations(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") ctx.add_step("only") \ @@ -1376,7 +1378,7 @@ def test_next_step_on_last_step_error_lists_remediations(self): assert "add another step" in msg assert "completion_action=None" in msg - def test_unknown_step_error_lists_available_steps(self): + def test_unknown_step_error_lists_available_steps(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") ctx.add_step("alpha").set_text("A") @@ -1398,10 +1400,10 @@ def test_unknown_step_error_lists_available_steps(self): class TestInitialStep: """Tests for Context.set_initial_step and its to_dict / validation.""" - def _make_builder(self): + def _make_builder(self) -> ContextBuilder: return ContextBuilder(Mock()) - def test_set_initial_step_round_trips_to_dict(self): + def test_set_initial_step_round_trips_to_dict(self) -> None: ctx = Context("default") ctx.add_step("greeting").set_text("Hello") ctx.add_step("triage").set_text("What?") @@ -1409,13 +1411,13 @@ def test_set_initial_step_round_trips_to_dict(self): d = ctx.to_dict() assert d["initial_step"] == "triage" - def test_omitted_initial_step_absent_from_dict(self): + def test_omitted_initial_step_absent_from_dict(self) -> None: ctx = Context("default") ctx.add_step("greeting").set_text("Hello") d = ctx.to_dict() assert "initial_step" not in d - def test_validation_accepts_valid_initial_step(self): + def test_validation_accepts_valid_initial_step(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") ctx.add_step("a").set_text("A") @@ -1423,7 +1425,7 @@ def test_validation_accepts_valid_initial_step(self): ctx.set_initial_step("b") builder.validate() # should not raise - def test_validation_rejects_invalid_initial_step(self): + def test_validation_rejects_invalid_initial_step(self) -> None: builder = self._make_builder() ctx = builder.add_context("default") ctx.add_step("a").set_text("A") @@ -1435,12 +1437,12 @@ def test_validation_rejects_invalid_initial_step(self): class TestContextBuilderReset: """Tests for ContextBuilder.reset() and AgentBase.reset_contexts().""" - def _make_builder(self): + def _make_builder(self) -> ContextBuilder: agent = Mock() agent._tool_registry = None return ContextBuilder(agent) - def test_reset_clears_all_contexts(self): + def test_reset_clears_all_contexts(self) -> None: builder = self._make_builder() builder.add_context("default").add_step("s").set_text("T") assert len(builder._contexts) > 0 @@ -1448,12 +1450,12 @@ def test_reset_clears_all_contexts(self): assert len(builder._contexts) == 0 assert len(builder._context_order) == 0 - def test_reset_returns_self(self): + def test_reset_returns_self(self) -> None: builder = self._make_builder() result = builder.reset() assert result is builder - def test_reset_allows_rebuilding(self): + def test_reset_allows_rebuilding(self) -> None: builder = self._make_builder() builder.add_context("default").add_step("s1").set_text("First") builder.reset() @@ -1466,7 +1468,7 @@ def test_reset_allows_rebuilding(self): assert "s2" in step_names assert "s1" not in step_names - def test_reset_on_empty_builder(self): + def test_reset_on_empty_builder(self) -> None: builder = self._make_builder() builder.reset() # should not raise assert len(builder._contexts) == 0 \ No newline at end of file diff --git a/tests/unit/core/test_data_map.py b/tests/unit/core/test_data_map.py index 70de2f77..7edc169b 100644 --- a/tests/unit/core/test_data_map.py +++ b/tests/unit/core/test_data_map.py @@ -24,7 +24,7 @@ class TestDataMapBasic: """Test basic DataMap functionality""" - def test_basic_creation(self): + def test_basic_creation(self) -> None: """Test creating a basic DataMap""" data_map = DataMap("test_function") @@ -34,7 +34,7 @@ def test_basic_creation(self): assert data_map._expressions == [] assert data_map._webhooks == [] - def test_creation_with_purpose(self): + def test_creation_with_purpose(self) -> None: """Test creating DataMap with purpose""" data_map = DataMap("test_function") data_map.purpose("Test function description") @@ -42,14 +42,14 @@ def test_creation_with_purpose(self): assert data_map.function_name == "test_function" assert data_map._purpose == "Test function description" - def test_creation_with_description_alias(self): + def test_creation_with_description_alias(self) -> None: """Test using description as alias for purpose""" data_map = DataMap("test_function") data_map.description("Test function description") assert data_map._purpose == "Test function description" - def test_parameter_addition(self): + def test_parameter_addition(self) -> None: """Test adding parameters""" data_map = DataMap("test_function") data_map.parameter("location", "string", "City name", required=True) @@ -65,7 +65,7 @@ def test_parameter_addition(self): class TestDataMapExpressions: """Test expression functionality""" - def test_add_expression_with_string_pattern(self): + def test_add_expression_with_string_pattern(self) -> None: """Test adding expression with string pattern""" data_map = DataMap("test_function") output = FunctionResult("Pattern matched") @@ -78,7 +78,7 @@ def test_add_expression_with_string_pattern(self): assert expr["pattern"] == r"start.*" assert expr["output"] == output.to_dict() - def test_add_expression_with_compiled_pattern(self): + def test_add_expression_with_compiled_pattern(self) -> None: """Test adding expression with compiled regex pattern""" data_map = DataMap("test_function") output = FunctionResult("Pattern matched") @@ -89,7 +89,7 @@ def test_add_expression_with_compiled_pattern(self): expr = data_map._expressions[0] assert expr["pattern"] == r"stop.*" - def test_add_expression_with_nomatch_output(self): + def test_add_expression_with_nomatch_output(self) -> None: """Test adding expression with nomatch output""" data_map = DataMap("test_function") match_output = FunctionResult("Matched") @@ -105,7 +105,7 @@ def test_add_expression_with_nomatch_output(self): class TestDataMapWebhooks: """Test webhook functionality""" - def test_add_basic_webhook(self): + def test_add_basic_webhook(self) -> None: """Test adding basic webhook""" data_map = DataMap("test_function") @@ -116,7 +116,7 @@ def test_add_basic_webhook(self): assert webhook["method"] == "GET" assert webhook["url"] == "https://api.example.com/data" - def test_add_webhook_with_headers(self): + def test_add_webhook_with_headers(self) -> None: """Test adding webhook with headers""" data_map = DataMap("test_function") headers = {"Authorization": "Bearer token", "Content-Type": "application/json"} @@ -126,7 +126,7 @@ def test_add_webhook_with_headers(self): webhook = data_map._webhooks[0] assert webhook["headers"] == headers - def test_add_webhook_with_options(self): + def test_add_webhook_with_options(self) -> None: """Test adding webhook with various options""" data_map = DataMap("test_function") @@ -143,7 +143,7 @@ def test_add_webhook_with_options(self): assert webhook["input_args_as_params"] is True assert webhook["require_args"] == ["location"] - def test_webhook_body_and_params(self): + def test_webhook_body_and_params(self) -> None: """Test adding body and params to webhook""" data_map = DataMap("test_function") @@ -159,7 +159,7 @@ def test_webhook_body_and_params(self): class TestDataMapOutput: """Test output functionality""" - def test_set_output(self): + def test_set_output(self) -> None: """Test setting output""" data_map = DataMap("test_function") output = FunctionResult("API call successful: ${response.data}") @@ -171,7 +171,7 @@ def test_set_output(self): # Output should be stored in the webhook assert data_map._webhooks[0]["output"] == output.to_dict() - def test_set_fallback_output(self): + def test_set_fallback_output(self) -> None: """Test setting fallback output""" data_map = DataMap("test_function") fallback = FunctionResult("API unavailable") @@ -185,7 +185,7 @@ def test_set_fallback_output(self): class TestDataMapSerialization: """Test serialization functionality""" - def test_to_swaig_function_basic(self): + def test_to_swaig_function_basic(self) -> None: """Test basic to_swaig_function conversion""" data_map = DataMap("test_function") data_map.purpose("Test function") @@ -199,7 +199,7 @@ def test_to_swaig_function_basic(self): assert "properties" in swaig_func["parameters"] assert "location" in swaig_func["parameters"]["properties"] - def test_to_swaig_function_with_expressions(self): + def test_to_swaig_function_with_expressions(self) -> None: """Test to_swaig_function with expressions""" data_map = DataMap("test_function") data_map.purpose("Test function") @@ -212,7 +212,7 @@ def test_to_swaig_function_with_expressions(self): assert "expressions" in swaig_func["data_map"] assert len(swaig_func["data_map"]["expressions"]) == 1 - def test_to_swaig_function_with_webhooks(self): + def test_to_swaig_function_with_webhooks(self) -> None: """Test to_swaig_function with webhooks""" data_map = DataMap("test_function") data_map.purpose("Test function") @@ -230,7 +230,7 @@ def test_to_swaig_function_with_webhooks(self): class TestDataMapChaining: """Test method chaining functionality""" - def test_method_chaining(self): + def test_method_chaining(self) -> None: """Test that methods return self for chaining""" output = FunctionResult("Chained result") @@ -245,7 +245,7 @@ def test_method_chaining(self): assert "param1" in data_map._parameters assert len(data_map._webhooks) == 1 - def test_complex_chaining(self): + def test_complex_chaining(self) -> None: """Test complex method chaining""" result = FunctionResult() result.say("Complex result") @@ -264,7 +264,7 @@ def test_complex_chaining(self): class TestDataMapFactoryFunctions: """Test factory functions""" - def test_create_simple_api_tool(self): + def test_create_simple_api_tool(self) -> None: """Test create_simple_api_tool factory""" data_map = create_simple_api_tool( name="weather_tool", @@ -275,7 +275,7 @@ def test_create_simple_api_tool(self): assert isinstance(data_map, DataMap) assert data_map.function_name == "weather_tool" - def test_create_simple_api_tool_with_parameters(self): + def test_create_simple_api_tool_with_parameters(self) -> None: """Test create_simple_api_tool with parameters""" parameters = { "location": {"type": "string", "description": "City name"} @@ -290,7 +290,7 @@ def test_create_simple_api_tool_with_parameters(self): assert isinstance(data_map, DataMap) - def test_create_expression_tool(self): + def test_create_expression_tool(self) -> None: """Test create_expression_tool factory""" patterns = { "${args.command}": ("start", FunctionResult().add_action("start", True)), @@ -302,7 +302,7 @@ def test_create_expression_tool(self): assert isinstance(data_map, DataMap) assert data_map.function_name == "control_tool" - def test_create_expression_tool_with_parameters(self): + def test_create_expression_tool_with_parameters(self) -> None: """Test create_expression_tool with parameters""" patterns = { "${args.input}": ("test", FunctionResult("Test result")) @@ -319,19 +319,19 @@ def test_create_expression_tool_with_parameters(self): class TestDataMapErrorHandling: """Test error handling and edge cases""" - def test_empty_function_name(self): + def test_empty_function_name(self) -> None: """Test creating DataMap with empty function name""" # Should not raise error, just store empty string data_map = DataMap("") assert data_map.function_name == "" - def test_none_function_name(self): + def test_none_function_name(self) -> None: """Test creating DataMap with None function name""" # Should not raise error, just store None - data_map = DataMap(None) + data_map = DataMap(None) # type: ignore[arg-type] # intentional invalid input for validation test assert data_map.function_name is None - def test_invalid_parameter_type(self): + def test_invalid_parameter_type(self) -> None: """Test adding parameter with invalid type""" data_map = DataMap("test_function") @@ -341,7 +341,7 @@ def test_invalid_parameter_type(self): param = data_map._parameters["test_param"] assert param["type"] == "invalid_type" - def test_duplicate_parameter_names(self): + def test_duplicate_parameter_names(self) -> None: """Test adding parameters with duplicate names""" data_map = DataMap("test_function") @@ -353,7 +353,7 @@ def test_duplicate_parameter_names(self): assert param["type"] == "number" assert param["description"] == "Second description" - def test_output_without_webhook(self): + def test_output_without_webhook(self) -> None: """Test setting output without webhook raises error""" data_map = DataMap("test_function") output = FunctionResult("Test output") @@ -365,7 +365,7 @@ def test_output_without_webhook(self): class TestDataMapTemplateVariables: """Test template variable handling""" - def test_env_variables_in_webhooks(self): + def test_env_variables_in_webhooks(self) -> None: """Test environment variables in webhook URLs""" data_map = DataMap("test_function") @@ -374,7 +374,7 @@ def test_env_variables_in_webhooks(self): webhook = data_map._webhooks[0] assert "${ENV.API_KEY}" in webhook["url"] - def test_args_variables_in_body(self): + def test_args_variables_in_body(self) -> None: """Test argument variables in request body""" data_map = DataMap("test_function") @@ -384,7 +384,7 @@ def test_args_variables_in_body(self): # Body should be stored for processing assert len(data_map._webhooks) == 1 - def test_response_variables_in_output(self): + def test_response_variables_in_output(self) -> None: """Test response variables in output templates""" data_map = DataMap("test_function") output = FunctionResult("Result: ${response.data.title}") @@ -398,7 +398,7 @@ def test_response_variables_in_output(self): class TestDataMapIntegration: """Test integration with other components""" - def test_agent_integration(self): + def test_agent_integration(self) -> None: """Test DataMap integration with agent""" data_map = DataMap("test_tool") data_map.purpose("Test integration") @@ -411,7 +411,7 @@ def test_agent_integration(self): assert "description" in swaig_func assert "parameters" in swaig_func - def test_swaig_function_compatibility(self): + def test_swaig_function_compatibility(self) -> None: """Test compatibility with FunctionResult""" data_map = DataMap("test_function") result = FunctionResult("Test response") @@ -423,7 +423,7 @@ def test_swaig_function_compatibility(self): # Should store the result properly assert data_map._webhooks[0]["output"] == result.to_dict() - def test_json_serialization(self): + def test_json_serialization(self) -> None: """Test JSON serialization of complete DataMap""" import json diff --git a/tests/unit/core/test_function_result.py b/tests/unit/core/test_function_result.py index eb3d8b10..0803de39 100644 --- a/tests/unit/core/test_function_result.py +++ b/tests/unit/core/test_function_result.py @@ -13,6 +13,7 @@ import pytest import json +from typing import Any, Dict, List from unittest.mock import Mock, patch from signalwire.core.function_result import FunctionResult @@ -21,7 +22,7 @@ class TestFunctionResultBasic: """Test basic FunctionResult functionality""" - def test_basic_response_creation(self): + def test_basic_response_creation(self) -> None: """Test creating a basic response""" result = FunctionResult(response="Hello, world!") @@ -33,7 +34,7 @@ def test_basic_response_creation(self): result_dict = result.to_dict() assert result_dict["response"] == "Hello, world!" - def test_response_with_action(self): + def test_response_with_action(self) -> None: """Test creating response with action""" result = FunctionResult(response="Processing request") result.add_action("transfer", "+15551234567") @@ -46,7 +47,7 @@ def test_response_with_action(self): assert result_dict["response"] == "Processing request" assert result_dict["action"] == [{"transfer": "+15551234567"}] - def test_empty_response(self): + def test_empty_response(self) -> None: """Test creating empty response""" result = FunctionResult() @@ -55,7 +56,7 @@ def test_empty_response(self): # Empty response gets default message assert result_dict["response"] == "Action completed." - def test_post_process_setting(self): + def test_post_process_setting(self) -> None: """Test setting post_process flag""" result = FunctionResult(post_process=True) result.add_action("test", "value") # Need action for post_process to appear @@ -69,7 +70,7 @@ def test_post_process_setting(self): class TestFunctionResultActions: """Test action-related methods""" - def test_add_action(self): + def test_add_action(self) -> None: """Test adding a single action""" result = FunctionResult() result.add_action("play", {"url": "https://example.com/audio.mp3"}) @@ -77,10 +78,10 @@ def test_add_action(self): assert len(result.action) == 1 assert result.action[0] == {"play": {"url": "https://example.com/audio.mp3"}} - def test_add_multiple_actions(self): + def test_add_multiple_actions(self) -> None: """Test adding multiple actions""" result = FunctionResult() - actions = [ + actions: List[Dict[str, Any]] = [ {"play": {"url": "https://example.com/audio.mp3"}}, {"transfer": "+15551234567"} ] @@ -89,7 +90,7 @@ def test_add_multiple_actions(self): assert len(result.action) == 2 assert result.action == actions - def test_connect_action(self): + def test_connect_action(self) -> None: """Test the connect action helper""" result = FunctionResult() result.connect("+15551234567", final=True) @@ -102,7 +103,7 @@ def test_connect_action(self): swml = action["SWML"] assert swml["sections"]["main"][0]["connect"]["to"] == "+15551234567" - def test_connect_with_from_addr(self): + def test_connect_with_from_addr(self) -> None: """Test connect action with from address""" result = FunctionResult() result.connect("+15551234567", final=False, from_addr="+15559876543") @@ -118,7 +119,7 @@ def test_connect_with_from_addr(self): class TestFunctionResultSWMLMethods: """Test SWML-specific methods""" - def test_say_method(self): + def test_say_method(self) -> None: """Test the say method""" result = FunctionResult() result.say("Hello there") @@ -126,7 +127,7 @@ def test_say_method(self): assert len(result.action) == 1 assert result.action[0] == {"say": "Hello there"} - def test_hangup_method(self): + def test_hangup_method(self) -> None: """Test the hangup method""" result = FunctionResult() result.hangup() @@ -134,7 +135,7 @@ def test_hangup_method(self): assert len(result.action) == 1 assert result.action[0] == {"hangup": True} - def test_hold_method(self): + def test_hold_method(self) -> None: """Test the hold method""" result = FunctionResult() result.hold(timeout=60) @@ -142,7 +143,7 @@ def test_hold_method(self): assert len(result.action) == 1 assert result.action[0] == {"hold": 60} - def test_stop_method(self): + def test_stop_method(self) -> None: """Test the stop method""" result = FunctionResult() result.stop() @@ -150,7 +151,7 @@ def test_stop_method(self): assert len(result.action) == 1 assert result.action[0] == {"stop": True} - def test_wait_for_user_method(self): + def test_wait_for_user_method(self) -> None: """Test the wait_for_user method""" result = FunctionResult() result.wait_for_user(enabled=True, timeout=30) @@ -165,7 +166,7 @@ def test_wait_for_user_method(self): class TestFunctionResultChaining: """Test method chaining functionality""" - def test_method_chaining(self): + def test_method_chaining(self) -> None: """Test that methods return self for chaining""" result = FunctionResult("Initial response") @@ -180,7 +181,7 @@ def test_method_chaining(self): assert result.post_process is True assert len(result.action) == 1 - def test_complex_chaining(self): + def test_complex_chaining(self) -> None: """Test complex method chaining""" result = (FunctionResult("Welcome") .say("Please hold") @@ -195,7 +196,7 @@ def test_complex_chaining(self): class TestFunctionResultAdvanced: """Test advanced functionality""" - def test_update_global_data(self): + def test_update_global_data(self) -> None: """Test updating global data""" result = FunctionResult() result.update_global_data({"user_id": "123", "session": "abc"}) @@ -207,7 +208,7 @@ def test_update_global_data(self): assert action["set_global_data"]["user_id"] == "123" assert action["set_global_data"]["session"] == "abc" - def test_execute_swml(self): + def test_execute_swml(self) -> None: """Test executing custom SWML""" swml_content = { "sections": { @@ -223,7 +224,7 @@ def test_execute_swml(self): assert "SWML" in action assert action["SWML"] == swml_content - def test_switch_context(self): + def test_switch_context(self) -> None: """Test switching context""" result = FunctionResult() result.switch_context( @@ -245,7 +246,7 @@ def test_switch_context(self): class TestFunctionResultSerialization: """Test serialization and deserialization""" - def test_to_dict_basic(self): + def test_to_dict_basic(self) -> None: """Test basic to_dict conversion""" result = FunctionResult(response="Test response") result_dict = result.to_dict() @@ -254,7 +255,7 @@ def test_to_dict_basic(self): assert "response" in result_dict assert result_dict["response"] == "Test response" - def test_to_dict_with_actions(self): + def test_to_dict_with_actions(self) -> None: """Test to_dict with actions""" result = FunctionResult("Test") result.add_action("play", {"url": "test.mp3"}) @@ -265,7 +266,7 @@ def test_to_dict_with_actions(self): assert isinstance(result_dict["action"], list) assert len(result_dict["action"]) == 1 - def test_to_dict_with_all_fields(self): + def test_to_dict_with_all_fields(self) -> None: """Test to_dict with all possible fields""" result = FunctionResult( response="Complete response", @@ -280,7 +281,7 @@ def test_to_dict_with_all_fields(self): assert "action" in result_dict assert len(result_dict["action"]) == 1 - def test_json_serialization(self): + def test_json_serialization(self) -> None: """Test JSON serialization""" result = FunctionResult("Hello JSON") result.say("Additional message") @@ -299,13 +300,13 @@ def test_json_serialization(self): class TestFunctionResultErrorHandling: """Test error handling and edge cases""" - def test_none_response(self): + def test_none_response(self) -> None: """Test handling of None response""" result = FunctionResult(response=None) # Should convert to empty string assert result.response == "" - def test_empty_actions(self): + def test_empty_actions(self) -> None: """Test handling when no actions are present""" result = FunctionResult(response="No actions") result_dict = result.to_dict() @@ -314,7 +315,7 @@ def test_empty_actions(self): assert "response" in result_dict assert "action" not in result_dict or result_dict.get("action") == [] - def test_invalid_action_data(self): + def test_invalid_action_data(self) -> None: """Test adding action with various data types""" result = FunctionResult() @@ -336,7 +337,7 @@ def test_invalid_action_data(self): class TestFunctionResultFactoryMethods: """Test factory-like usage patterns""" - def test_success_response(self): + def test_success_response(self) -> None: """Test creating success response""" result = FunctionResult("Operation successful") @@ -344,13 +345,13 @@ def test_success_response(self): result_dict = result.to_dict() assert result_dict["response"] == "Operation successful" - def test_error_response(self): + def test_error_response(self) -> None: """Test creating error response""" result = FunctionResult("Error occurred") assert result.response == "Error occurred" - def test_transfer_response(self): + def test_transfer_response(self) -> None: """Test creating transfer response""" result = FunctionResult("Transferring you now") result.connect("+15551234567") @@ -359,7 +360,7 @@ def test_transfer_response(self): assert "action" in result_dict assert len(result_dict["action"]) == 1 - def test_information_response(self): + def test_information_response(self) -> None: """Test creating informational response""" result = FunctionResult("Here is the information you requested") @@ -369,19 +370,19 @@ def test_information_response(self): class TestFunctionResultIntegration: """Test integration with other components""" - def test_agent_integration(self): + def test_agent_integration(self) -> None: """Test integration with agent tools""" # This would typically be tested in integration tests # but we can test the interface here - def mock_tool_handler(): + def mock_tool_handler() -> FunctionResult: return FunctionResult("Tool executed successfully") result = mock_tool_handler() assert isinstance(result, FunctionResult) assert result.response == "Tool executed successfully" - def test_datamap_integration(self): + def test_datamap_integration(self) -> None: """Test integration with DataMap responses""" result = FunctionResult("DataMap response") result_dict = result.to_dict() @@ -390,7 +391,7 @@ def test_datamap_integration(self): assert "response" in result_dict assert isinstance(result_dict, dict) - def test_webhook_response_format(self): + def test_webhook_response_format(self) -> None: """Test webhook response format compatibility""" result = FunctionResult( response="Webhook processed", @@ -413,7 +414,7 @@ def test_webhook_response_format(self): class TestSwmlTransfer: """Test swml_transfer() method""" - def test_swml_transfer_final(self): + def test_swml_transfer_final(self) -> None: """Test swml_transfer with final=True (permanent transfer)""" result = FunctionResult("Transferring") result.swml_transfer("https://example.com/swml", "Goodbye!", final=True) @@ -427,7 +428,7 @@ def test_swml_transfer_final(self): assert main_section[0] == {"set": {"ai_response": "Goodbye!"}} assert main_section[1] == {"transfer": {"dest": "https://example.com/swml"}} - def test_swml_transfer_temporary(self): + def test_swml_transfer_temporary(self) -> None: """Test swml_transfer with final=False (temporary transfer)""" result = FunctionResult("Hold on") result.swml_transfer("sip:support@company.com", "Welcome back!", final=False) @@ -437,12 +438,12 @@ def test_swml_transfer_temporary(self): main_section = action["SWML"]["sections"]["main"] assert main_section[1]["transfer"]["dest"] == "sip:support@company.com" - def test_swml_transfer_default_final(self): + def test_swml_transfer_default_final(self) -> None: """Test swml_transfer default final=True""" result = FunctionResult().swml_transfer("https://dest.com", "bye") assert result.action[0]["transfer"] == "true" - def test_swml_transfer_chaining(self): + def test_swml_transfer_chaining(self) -> None: """Test swml_transfer returns self for chaining""" result = FunctionResult("msg") ret = result.swml_transfer("dest", "resp") @@ -452,7 +453,7 @@ def test_swml_transfer_chaining(self): class TestSwmlUserEvent: """Test swml_user_event() method""" - def test_swml_user_event_basic(self): + def test_swml_user_event_basic(self) -> None: """Test sending a user event with event data dict""" event_data = {"type": "cards_dealt", "player_hand": ["Ace", "King"], "score": 21} result = FunctionResult("Blackjack!").swml_user_event(event_data) @@ -465,7 +466,7 @@ def test_swml_user_event_basic(self): user_event = swml["sections"]["main"][0]["user_event"] assert user_event["event"] == event_data - def test_swml_user_event_chaining(self): + def test_swml_user_event_chaining(self) -> None: """Test swml_user_event returns self for chaining""" result = FunctionResult() ret = result.swml_user_event({"type": "test"}) @@ -475,14 +476,14 @@ def test_swml_user_event_chaining(self): class TestSwmlChangeStep: """Test swml_change_step() method""" - def test_swml_change_step(self): + def test_swml_change_step(self) -> None: """Test changing the conversation step""" result = FunctionResult("New hand").swml_change_step("betting") assert len(result.action) == 1 assert result.action[0] == {"change_step": "betting"} - def test_swml_change_step_chaining(self): + def test_swml_change_step_chaining(self) -> None: """Test swml_change_step returns self for chaining""" result = FunctionResult() ret = result.swml_change_step("step1") @@ -492,14 +493,14 @@ def test_swml_change_step_chaining(self): class TestSwmlChangeContext: """Test swml_change_context() method""" - def test_swml_change_context(self): + def test_swml_change_context(self) -> None: """Test changing the conversation context""" result = FunctionResult("Switching").swml_change_context("technical_support") assert len(result.action) == 1 assert result.action[0] == {"change_context": "technical_support"} - def test_swml_change_context_chaining(self): + def test_swml_change_context_chaining(self) -> None: """Test swml_change_context returns self for chaining""" result = FunctionResult() ret = result.swml_change_context("ctx") @@ -509,7 +510,7 @@ def test_swml_change_context_chaining(self): class TestExecuteSwml: """Test execute_swml() method""" - def test_execute_swml_string_valid_json(self): + def test_execute_swml_string_valid_json(self) -> None: """Test execute_swml with a valid JSON string input""" swml_json = json.dumps({"version": "1.0.0", "sections": {"main": []}}) result = FunctionResult().execute_swml(swml_json) @@ -518,7 +519,7 @@ def test_execute_swml_string_valid_json(self): assert "SWML" in action assert action["SWML"]["version"] == "1.0.0" - def test_execute_swml_string_invalid_json(self): + def test_execute_swml_string_invalid_json(self) -> None: """Test execute_swml with an invalid JSON string (falls back to raw_swml)""" result = FunctionResult().execute_swml("not valid json {{{") @@ -526,7 +527,7 @@ def test_execute_swml_string_invalid_json(self): assert "SWML" in action assert action["SWML"]["raw_swml"] == "not valid json {{{" - def test_execute_swml_dict_input(self): + def test_execute_swml_dict_input(self) -> None: """Test execute_swml with a dict input""" swml_dict = {"version": "1.0.0", "sections": {"main": [{"play": "test.mp3"}]}} result = FunctionResult().execute_swml(swml_dict) @@ -534,7 +535,7 @@ def test_execute_swml_dict_input(self): action = result.action[0] assert action["SWML"] == swml_dict - def test_execute_swml_dict_does_not_mutate_original(self): + def test_execute_swml_dict_does_not_mutate_original(self) -> None: """Test execute_swml makes a copy of the dict to avoid mutating caller data""" original = {"version": "1.0.0", "sections": {"main": []}} result = FunctionResult().execute_swml(original, transfer=True) @@ -544,11 +545,11 @@ def test_execute_swml_dict_does_not_mutate_original(self): # But the action's SWML should have it assert result.action[0]["SWML"]["transfer"] == "true" - def test_execute_swml_sdk_object_with_to_dict(self): + def test_execute_swml_sdk_object_with_to_dict(self) -> None: """Test execute_swml with an SDK object that has to_dict()""" class MockSwmlObject: - def to_dict(self): + def to_dict(self) -> dict[str, Any]: return {"version": "1.0.0", "sections": {"main": [{"ai": {}}]}} result = FunctionResult().execute_swml(MockSwmlObject()) @@ -556,17 +557,17 @@ def to_dict(self): assert action["SWML"]["version"] == "1.0.0" assert action["SWML"]["sections"]["main"][0] == {"ai": {}} - def test_execute_swml_invalid_type_raises_type_error(self): + def test_execute_swml_invalid_type_raises_type_error(self) -> None: """Test execute_swml with invalid type raises TypeError""" with pytest.raises(TypeError, match="swml_content must be string, dict, or SWML object"): FunctionResult().execute_swml(12345) - def test_execute_swml_invalid_type_list(self): + def test_execute_swml_invalid_type_list(self) -> None: """Test execute_swml with list raises TypeError""" with pytest.raises(TypeError): FunctionResult().execute_swml([1, 2, 3]) - def test_execute_swml_with_transfer_true(self): + def test_execute_swml_with_transfer_true(self) -> None: """Test execute_swml with transfer=True adds transfer key""" swml_dict = {"version": "1.0.0", "sections": {"main": []}} result = FunctionResult().execute_swml(swml_dict, transfer=True) @@ -574,7 +575,7 @@ def test_execute_swml_with_transfer_true(self): action = result.action[0] assert action["SWML"]["transfer"] == "true" - def test_execute_swml_with_transfer_false(self): + def test_execute_swml_with_transfer_false(self) -> None: """Test execute_swml with transfer=False does not add transfer key""" swml_dict = {"version": "1.0.0", "sections": {"main": []}} result = FunctionResult().execute_swml(swml_dict, transfer=False) @@ -582,7 +583,7 @@ def test_execute_swml_with_transfer_false(self): action = result.action[0] assert "transfer" not in action["SWML"] - def test_execute_swml_chaining(self): + def test_execute_swml_chaining(self) -> None: """Test execute_swml returns self for chaining""" result = FunctionResult() ret = result.execute_swml({"version": "1.0.0"}) @@ -592,37 +593,37 @@ def test_execute_swml_chaining(self): class TestHold: """Test hold() method with timeout clamping""" - def test_hold_default_timeout(self): + def test_hold_default_timeout(self) -> None: """Test hold with default timeout of 300""" result = FunctionResult().hold() assert result.action[0] == {"hold": 300} - def test_hold_custom_timeout(self): + def test_hold_custom_timeout(self) -> None: """Test hold with custom timeout""" result = FunctionResult().hold(timeout=120) assert result.action[0] == {"hold": 120} - def test_hold_negative_timeout_clamped_to_zero(self): + def test_hold_negative_timeout_clamped_to_zero(self) -> None: """Test hold with negative timeout is clamped to 0""" result = FunctionResult().hold(timeout=-50) assert result.action[0] == {"hold": 0} - def test_hold_timeout_above_900_clamped(self): + def test_hold_timeout_above_900_clamped(self) -> None: """Test hold with timeout above 900 is clamped to 900""" result = FunctionResult().hold(timeout=1500) assert result.action[0] == {"hold": 900} - def test_hold_timeout_exactly_900(self): + def test_hold_timeout_exactly_900(self) -> None: """Test hold with timeout exactly 900""" result = FunctionResult().hold(timeout=900) assert result.action[0] == {"hold": 900} - def test_hold_timeout_exactly_zero(self): + def test_hold_timeout_exactly_zero(self) -> None: """Test hold with timeout exactly 0""" result = FunctionResult().hold(timeout=0) assert result.action[0] == {"hold": 0} - def test_hold_chaining(self): + def test_hold_chaining(self) -> None: """Test hold returns self for chaining""" result = FunctionResult() ret = result.hold() @@ -632,42 +633,42 @@ def test_hold_chaining(self): class TestWaitForUser: """Test wait_for_user() method""" - def test_wait_for_user_answer_first(self): + def test_wait_for_user_answer_first(self) -> None: """Test wait_for_user with answer_first mode""" result = FunctionResult().wait_for_user(answer_first=True) assert result.action[0] == {"wait_for_user": "answer_first"} - def test_wait_for_user_enabled_only(self): + def test_wait_for_user_enabled_only(self) -> None: """Test wait_for_user with enabled=True only""" result = FunctionResult().wait_for_user(enabled=True) assert result.action[0] == {"wait_for_user": True} - def test_wait_for_user_enabled_false(self): + def test_wait_for_user_enabled_false(self) -> None: """Test wait_for_user with enabled=False""" result = FunctionResult().wait_for_user(enabled=False) assert result.action[0] == {"wait_for_user": False} - def test_wait_for_user_timeout_only(self): + def test_wait_for_user_timeout_only(self) -> None: """Test wait_for_user with timeout only""" result = FunctionResult().wait_for_user(timeout=60) assert result.action[0] == {"wait_for_user": 60} - def test_wait_for_user_no_args(self): + def test_wait_for_user_no_args(self) -> None: """Test wait_for_user with no arguments defaults to True""" result = FunctionResult().wait_for_user() assert result.action[0] == {"wait_for_user": True} - def test_wait_for_user_answer_first_takes_priority(self): + def test_wait_for_user_answer_first_takes_priority(self) -> None: """Test that answer_first takes priority over other args""" result = FunctionResult().wait_for_user(enabled=True, timeout=30, answer_first=True) assert result.action[0] == {"wait_for_user": "answer_first"} - def test_wait_for_user_timeout_takes_priority_over_enabled(self): + def test_wait_for_user_timeout_takes_priority_over_enabled(self) -> None: """Test that timeout takes priority over enabled when both set""" result = FunctionResult().wait_for_user(enabled=True, timeout=45) assert result.action[0] == {"wait_for_user": 45} - def test_wait_for_user_chaining(self): + def test_wait_for_user_chaining(self) -> None: """Test wait_for_user returns self for chaining""" result = FunctionResult() ret = result.wait_for_user() @@ -677,22 +678,22 @@ def test_wait_for_user_chaining(self): class TestPlayBackgroundFile: """Test play_background_file() method""" - def test_play_background_file_without_wait(self): + def test_play_background_file_without_wait(self) -> None: """Test play_background_file with wait=False (default)""" result = FunctionResult().play_background_file("music.mp3") assert result.action[0] == {"playback_bg": "music.mp3"} - def test_play_background_file_with_wait_true(self): + def test_play_background_file_with_wait_true(self) -> None: """Test play_background_file with wait=True returns dict form""" result = FunctionResult().play_background_file("music.mp3", wait=True) assert result.action[0] == {"playback_bg": {"file": "music.mp3", "wait": True}} - def test_play_background_file_with_wait_false_explicit(self): + def test_play_background_file_with_wait_false_explicit(self) -> None: """Test play_background_file with explicit wait=False""" result = FunctionResult().play_background_file("video.mp4", wait=False) assert result.action[0] == {"playback_bg": "video.mp4"} - def test_play_background_file_chaining(self): + def test_play_background_file_chaining(self) -> None: """Test play_background_file returns self for chaining""" result = FunctionResult() ret = result.play_background_file("test.mp3") @@ -702,12 +703,12 @@ def test_play_background_file_chaining(self): class TestStopBackgroundFile: """Test stop_background_file() method""" - def test_stop_background_file(self): + def test_stop_background_file(self) -> None: """Test stop_background_file adds correct action""" result = FunctionResult().stop_background_file() assert result.action[0] == {"stop_playback_bg": True} - def test_stop_background_file_chaining(self): + def test_stop_background_file_chaining(self) -> None: """Test stop_background_file returns self for chaining""" result = FunctionResult() ret = result.stop_background_file() @@ -717,17 +718,17 @@ def test_stop_background_file_chaining(self): class TestRemoveGlobalData: """Test remove_global_data() method""" - def test_remove_global_data_single_string(self): + def test_remove_global_data_single_string(self) -> None: """Test remove_global_data with a single string key""" result = FunctionResult().remove_global_data("user_id") assert result.action[0] == {"unset_global_data": "user_id"} - def test_remove_global_data_list_of_keys(self): + def test_remove_global_data_list_of_keys(self) -> None: """Test remove_global_data with a list of keys""" result = FunctionResult().remove_global_data(["user_id", "session", "token"]) assert result.action[0] == {"unset_global_data": ["user_id", "session", "token"]} - def test_remove_global_data_chaining(self): + def test_remove_global_data_chaining(self) -> None: """Test remove_global_data returns self for chaining""" result = FunctionResult() ret = result.remove_global_data("key") @@ -737,13 +738,13 @@ def test_remove_global_data_chaining(self): class TestSetMetadata: """Test set_metadata() method""" - def test_set_metadata_dict(self): + def test_set_metadata_dict(self) -> None: """Test set_metadata with a dict""" data = {"key1": "value1", "key2": 42} result = FunctionResult().set_metadata(data) assert result.action[0] == {"set_meta_data": data} - def test_set_metadata_chaining(self): + def test_set_metadata_chaining(self) -> None: """Test set_metadata returns self for chaining""" result = FunctionResult() ret = result.set_metadata({"k": "v"}) @@ -753,17 +754,17 @@ def test_set_metadata_chaining(self): class TestRemoveMetadata: """Test remove_metadata() method""" - def test_remove_metadata_single_string(self): + def test_remove_metadata_single_string(self) -> None: """Test remove_metadata with a single string key""" result = FunctionResult().remove_metadata("key1") assert result.action[0] == {"unset_meta_data": "key1"} - def test_remove_metadata_list_of_keys(self): + def test_remove_metadata_list_of_keys(self) -> None: """Test remove_metadata with a list of keys""" result = FunctionResult().remove_metadata(["key1", "key2"]) assert result.action[0] == {"unset_meta_data": ["key1", "key2"]} - def test_remove_metadata_chaining(self): + def test_remove_metadata_chaining(self) -> None: """Test remove_metadata returns self for chaining""" result = FunctionResult() ret = result.remove_metadata("k") @@ -773,7 +774,7 @@ def test_remove_metadata_chaining(self): class TestPay: """Test pay() method""" - def test_pay_default_params(self): + def test_pay_default_params(self) -> None: """Test pay with only the required payment_connector_url""" result = FunctionResult().pay("https://pay.example.com/connector") @@ -799,7 +800,7 @@ def test_pay_default_params(self): assert pay_params["voice"] == "woman" assert pay_params["valid_card_types"] == "visa mastercard amex" - def test_pay_all_custom_params(self): + def test_pay_all_custom_params(self) -> None: """Test pay with all custom parameters""" result = FunctionResult().pay( payment_connector_url="https://pay.example.com", @@ -840,7 +841,7 @@ def test_pay_all_custom_params(self): ai_response = result.action[0]["SWML"]["sections"]["main"][0]["set"]["ai_response"] assert ai_response == "Payment processed." - def test_pay_with_prompts_and_parameters(self): + def test_pay_with_prompts_and_parameters(self) -> None: """Test pay with custom prompts and parameters""" prompts = [{"for": "payment-card-number", "actions": [{"type": "Say", "phrase": "Enter card"}]}] parameters = [{"name": "store_id", "value": "123"}] @@ -854,7 +855,7 @@ def test_pay_with_prompts_and_parameters(self): assert pay_params["prompts"] == prompts assert pay_params["parameters"] == parameters - def test_pay_postal_code_boolean_false(self): + def test_pay_postal_code_boolean_false(self) -> None: """Test pay with postal_code as boolean False""" result = FunctionResult().pay( payment_connector_url="https://pay.example.com", @@ -863,7 +864,7 @@ def test_pay_postal_code_boolean_false(self): pay_params = result.action[0]["SWML"]["sections"]["main"][1]["pay"] assert pay_params["postal_code"] == "false" - def test_pay_chaining(self): + def test_pay_chaining(self) -> None: """Test pay returns self for chaining""" result = FunctionResult() ret = result.pay("https://pay.example.com") @@ -873,7 +874,7 @@ def test_pay_chaining(self): class TestJoinConference: """Test join_conference() method""" - def test_join_conference_simple_name_all_defaults(self): + def test_join_conference_simple_name_all_defaults(self) -> None: """Test join_conference with just a name (all defaults) uses simple form""" result = FunctionResult().join_conference("my-conference") @@ -882,7 +883,7 @@ def test_join_conference_simple_name_all_defaults(self): # Simple form: just the conference name string assert join_params == "my-conference" - def test_join_conference_complex_params(self): + def test_join_conference_complex_params(self) -> None: """Test join_conference with non-default params uses object form""" result = FunctionResult().join_conference( name="team-meeting", @@ -927,57 +928,57 @@ def test_join_conference_complex_params(self): assert join_params["recording_status_callback_event"] == "in-progress" assert join_params["result"] == {"key": "value"} - def test_join_conference_invalid_beep(self): + def test_join_conference_invalid_beep(self) -> None: """Test join_conference with invalid beep raises ValueError""" with pytest.raises(ValueError, match="beep must be one of"): FunctionResult().join_conference("conf", beep="invalid") - def test_join_conference_max_participants_too_high(self): + def test_join_conference_max_participants_too_high(self) -> None: """Test join_conference with max_participants > 250 raises ValueError""" with pytest.raises(ValueError, match="max_participants must be a positive integer <= 250"): FunctionResult().join_conference("conf", max_participants=300) - def test_join_conference_max_participants_zero(self): + def test_join_conference_max_participants_zero(self) -> None: """Test join_conference with max_participants=0 raises ValueError""" with pytest.raises(ValueError, match="max_participants must be a positive integer <= 250"): FunctionResult().join_conference("conf", max_participants=0) - def test_join_conference_max_participants_negative(self): + def test_join_conference_max_participants_negative(self) -> None: """Test join_conference with negative max_participants raises ValueError""" with pytest.raises(ValueError, match="max_participants must be a positive integer <= 250"): FunctionResult().join_conference("conf", max_participants=-5) - def test_join_conference_invalid_record(self): + def test_join_conference_invalid_record(self) -> None: """Test join_conference with invalid record raises ValueError""" with pytest.raises(ValueError, match="record must be one of"): FunctionResult().join_conference("conf", record="always") - def test_join_conference_invalid_trim(self): + def test_join_conference_invalid_trim(self) -> None: """Test join_conference with invalid trim raises ValueError""" with pytest.raises(ValueError, match="trim must be one of"): FunctionResult().join_conference("conf", trim="bad-value") - def test_join_conference_empty_name(self): + def test_join_conference_empty_name(self) -> None: """Test join_conference with empty name raises ValueError""" with pytest.raises(ValueError, match="name cannot be empty"): FunctionResult().join_conference("", muted=True) - def test_join_conference_whitespace_name(self): + def test_join_conference_whitespace_name(self) -> None: """Test join_conference with whitespace-only name raises ValueError""" with pytest.raises(ValueError, match="name cannot be empty"): FunctionResult().join_conference(" ", muted=True) - def test_join_conference_invalid_status_callback_method(self): + def test_join_conference_invalid_status_callback_method(self) -> None: """Test join_conference with invalid status_callback_method raises ValueError""" with pytest.raises(ValueError, match="status_callback_method must be one of"): FunctionResult().join_conference("conf", status_callback_method="PUT") - def test_join_conference_invalid_recording_status_callback_method(self): + def test_join_conference_invalid_recording_status_callback_method(self) -> None: """Test join_conference with invalid recording_status_callback_method raises ValueError""" with pytest.raises(ValueError, match="recording_status_callback_method must be one of"): FunctionResult().join_conference("conf", recording_status_callback_method="DELETE") - def test_join_conference_chaining(self): + def test_join_conference_chaining(self) -> None: """Test join_conference returns self for chaining""" result = FunctionResult() ret = result.join_conference("conf") @@ -987,7 +988,7 @@ def test_join_conference_chaining(self): class TestTap: """Test tap() method""" - def test_tap_default_params(self): + def test_tap_default_params(self) -> None: """Test tap with only required URI (all defaults)""" result = FunctionResult().tap("rtp://192.168.1.1:5000") @@ -999,7 +1000,7 @@ def test_tap_default_params(self): assert "codec" not in tap_params assert "rtp_ptime" not in tap_params - def test_tap_custom_params(self): + def test_tap_custom_params(self) -> None: """Test tap with all custom parameters""" result = FunctionResult().tap( uri="ws://example.com/tap", @@ -1018,33 +1019,33 @@ def test_tap_custom_params(self): assert tap_params["rtp_ptime"] == 30 assert tap_params["status_url"] == "https://example.com/status" - def test_tap_invalid_direction(self): + def test_tap_invalid_direction(self) -> None: """Test tap with invalid direction raises ValueError""" with pytest.raises(ValueError, match="direction must be one of"): - FunctionResult().tap("rtp://1.2.3.4:5000", direction="invalid") + FunctionResult().tap("rtp://1.2.3.4:5000", direction="invalid") # type: ignore[arg-type] # intentional invalid input for validation test - def test_tap_invalid_codec(self): + def test_tap_invalid_codec(self) -> None: """Test tap with invalid codec raises ValueError""" with pytest.raises(ValueError, match="codec must be one of"): - FunctionResult().tap("rtp://1.2.3.4:5000", codec="G729") + FunctionResult().tap("rtp://1.2.3.4:5000", codec="G729") # type: ignore[arg-type] # intentional invalid input for validation test - def test_tap_invalid_rtp_ptime(self): + def test_tap_invalid_rtp_ptime(self) -> None: """Test tap with invalid rtp_ptime raises ValueError""" with pytest.raises(ValueError, match="rtp_ptime must be a positive integer"): FunctionResult().tap("rtp://1.2.3.4:5000", rtp_ptime=0) - def test_tap_negative_rtp_ptime(self): + def test_tap_negative_rtp_ptime(self) -> None: """Test tap with negative rtp_ptime raises ValueError""" with pytest.raises(ValueError, match="rtp_ptime must be a positive integer"): FunctionResult().tap("rtp://1.2.3.4:5000", rtp_ptime=-10) - def test_tap_direction_hear(self): + def test_tap_direction_hear(self) -> None: """Test tap with direction=hear""" result = FunctionResult().tap("rtp://1.2.3.4:5000", direction="hear") tap_params = result.action[0]["SWML"]["sections"]["main"][0]["tap"] assert tap_params["direction"] == "hear" - def test_tap_chaining(self): + def test_tap_chaining(self) -> None: """Test tap returns self for chaining""" result = FunctionResult() ret = result.tap("rtp://1.2.3.4:5000") @@ -1054,7 +1055,7 @@ def test_tap_chaining(self): class TestStopTap: """Test stop_tap() method""" - def test_stop_tap_with_control_id(self): + def test_stop_tap_with_control_id(self) -> None: """Test stop_tap with a control_id""" result = FunctionResult().stop_tap(control_id="my-tap-1") @@ -1062,7 +1063,7 @@ def test_stop_tap_with_control_id(self): stop_params = swml["sections"]["main"][0]["stop_tap"] assert stop_params["control_id"] == "my-tap-1" - def test_stop_tap_without_control_id(self): + def test_stop_tap_without_control_id(self) -> None: """Test stop_tap without control_id (stops last tap)""" result = FunctionResult().stop_tap() @@ -1070,7 +1071,7 @@ def test_stop_tap_without_control_id(self): stop_params = swml["sections"]["main"][0]["stop_tap"] assert stop_params == {} - def test_stop_tap_chaining(self): + def test_stop_tap_chaining(self) -> None: """Test stop_tap returns self for chaining""" result = FunctionResult() ret = result.stop_tap() @@ -1080,7 +1081,7 @@ def test_stop_tap_chaining(self): class TestRecordCall: """Test record_call() method""" - def test_record_call_default_params(self): + def test_record_call_default_params(self) -> None: """Test record_call with all default parameters""" result = FunctionResult().record_call() @@ -1093,7 +1094,7 @@ def test_record_call_default_params(self): assert rec_params["input_sensitivity"] == 44.0 assert "control_id" not in rec_params - def test_record_call_custom_params(self): + def test_record_call_custom_params(self) -> None: """Test record_call with all custom parameters""" result = FunctionResult().record_call( control_id="rec-1", @@ -1122,36 +1123,36 @@ def test_record_call_custom_params(self): assert rec_params["max_length"] == 600.0 assert rec_params["status_url"] == "https://example.com/rec-status" - def test_record_call_invalid_format(self): + def test_record_call_invalid_format(self) -> None: """Test record_call with invalid format raises ValueError""" with pytest.raises(ValueError, match="format must be 'wav', 'mp3', or 'mp4'"): - FunctionResult().record_call(format="ogg") + FunctionResult().record_call(format="ogg") # type: ignore[arg-type] # intentional invalid input for validation test - def test_record_call_format_mp4(self): + def test_record_call_format_mp4(self) -> None: """Test record_call accepts mp4 (SWML record_call verb schema allows wav/mp3/mp4; the validator used to wrongly reject mp4).""" result = FunctionResult().record_call(format="mp4") rec_params = result.action[0]["SWML"]["sections"]["main"][0]["record_call"] assert rec_params["format"] == "mp4" - def test_record_call_invalid_direction(self): + def test_record_call_invalid_direction(self) -> None: """Test record_call with invalid direction raises ValueError""" with pytest.raises(ValueError, match="direction must be 'speak', 'listen', or 'both'"): - FunctionResult().record_call(direction="left") + FunctionResult().record_call(direction="left") # type: ignore[arg-type] # intentional invalid input for validation test - def test_record_call_direction_listen(self): + def test_record_call_direction_listen(self) -> None: """Test record_call with direction=listen""" result = FunctionResult().record_call(direction="listen") rec_params = result.action[0]["SWML"]["sections"]["main"][0]["record_call"] assert rec_params["direction"] == "listen" - def test_record_call_chaining(self): + def test_record_call_chaining(self) -> None: """Test record_call returns self for chaining""" result = FunctionResult() ret = result.record_call() assert ret is result - def test_validated_closed_sets_declared_as_literal(self): + def test_validated_closed_sets_declared_as_literal(self) -> None: """Wave-1 (record/tap): the 4 ENFORCED closed sets are declared as typing.Literal — explicit set + mypy/IDE checking, AND the audit oracle emits ``enum<...>`` for them so every port must type them (not a bare @@ -1168,7 +1169,7 @@ def test_validated_closed_sets_declared_as_literal(self): (FunctionResult.tap, "codec", ("PCMU", "PCMA")), ] for fn, param, expected in literal_cases: - ann = inspect.signature(fn).parameters[param].annotation + ann = inspect.signature(fn).parameters[param].annotation # type: ignore[arg-type] # method object from heterogeneous tuple introspection assert typing.get_origin(ann) is typing.Literal, \ f"{fn.__name__}.{param} should be Literal, got {ann!r}" assert typing.get_args(ann) == expected, \ @@ -1187,7 +1188,7 @@ def test_validated_closed_sets_declared_as_literal(self): class TestStopRecordCall: """Test stop_record_call() method""" - def test_stop_record_call_with_control_id(self): + def test_stop_record_call_with_control_id(self) -> None: """Test stop_record_call with a control_id""" result = FunctionResult().stop_record_call(control_id="rec-1") @@ -1195,7 +1196,7 @@ def test_stop_record_call_with_control_id(self): stop_params = swml["sections"]["main"][0]["stop_record_call"] assert stop_params["control_id"] == "rec-1" - def test_stop_record_call_without_control_id(self): + def test_stop_record_call_without_control_id(self) -> None: """Test stop_record_call without control_id""" result = FunctionResult().stop_record_call() @@ -1203,7 +1204,7 @@ def test_stop_record_call_without_control_id(self): stop_params = swml["sections"]["main"][0]["stop_record_call"] assert stop_params == {} - def test_stop_record_call_chaining(self): + def test_stop_record_call_chaining(self) -> None: """Test stop_record_call returns self for chaining""" result = FunctionResult() ret = result.stop_record_call() @@ -1213,7 +1214,7 @@ def test_stop_record_call_chaining(self): class TestSendSms: """Test send_sms() method""" - def test_send_sms_with_body(self): + def test_send_sms_with_body(self) -> None: """Test send_sms with body text""" result = FunctionResult().send_sms( to_number="+15551234567", @@ -1228,7 +1229,7 @@ def test_send_sms_with_body(self): assert sms_params["body"] == "Hello from AI" assert "media" not in sms_params - def test_send_sms_with_media(self): + def test_send_sms_with_media(self) -> None: """Test send_sms with media URLs""" result = FunctionResult().send_sms( to_number="+15551234567", @@ -1240,7 +1241,7 @@ def test_send_sms_with_media(self): assert "body" not in sms_params assert sms_params["media"] == ["https://example.com/image.png"] - def test_send_sms_with_body_and_media(self): + def test_send_sms_with_body_and_media(self) -> None: """Test send_sms with both body and media""" result = FunctionResult().send_sms( to_number="+15551234567", @@ -1253,7 +1254,7 @@ def test_send_sms_with_body_and_media(self): assert sms_params["body"] == "Check this out" assert sms_params["media"] == ["https://example.com/image.png"] - def test_send_sms_missing_both_raises_value_error(self): + def test_send_sms_missing_both_raises_value_error(self) -> None: """Test send_sms with neither body nor media raises ValueError""" with pytest.raises(ValueError, match="Either body or media must be provided"): FunctionResult().send_sms( @@ -1261,7 +1262,7 @@ def test_send_sms_missing_both_raises_value_error(self): from_number="+15559876543" ) - def test_send_sms_with_tags_and_region(self): + def test_send_sms_with_tags_and_region(self) -> None: """Test send_sms with tags and region""" result = FunctionResult().send_sms( to_number="+15551234567", @@ -1275,7 +1276,7 @@ def test_send_sms_with_tags_and_region(self): assert sms_params["tags"] == ["support", "urgent"] assert sms_params["region"] == "us-east" - def test_send_sms_chaining(self): + def test_send_sms_chaining(self) -> None: """Test send_sms returns self for chaining""" result = FunctionResult() ret = result.send_sms("+1555", "+1556", body="hi") @@ -1285,7 +1286,7 @@ def test_send_sms_chaining(self): class TestSipRefer: """Test sip_refer() method""" - def test_sip_refer_basic(self): + def test_sip_refer_basic(self) -> None: """Test sip_refer basic usage""" result = FunctionResult().sip_refer("sip:alice@example.com") @@ -1293,7 +1294,7 @@ def test_sip_refer_basic(self): refer_params = swml["sections"]["main"][0]["sip_refer"] assert refer_params["to_uri"] == "sip:alice@example.com" - def test_sip_refer_chaining(self): + def test_sip_refer_chaining(self) -> None: """Test sip_refer returns self for chaining""" result = FunctionResult() ret = result.sip_refer("sip:bob@example.com") @@ -1303,7 +1304,7 @@ def test_sip_refer_chaining(self): class TestJoinRoom: """Test join_room() method""" - def test_join_room_basic(self): + def test_join_room_basic(self) -> None: """Test join_room basic usage""" result = FunctionResult().join_room("my-room") @@ -1311,7 +1312,7 @@ def test_join_room_basic(self): join_params = swml["sections"]["main"][0]["join_room"] assert join_params["name"] == "my-room" - def test_join_room_chaining(self): + def test_join_room_chaining(self) -> None: """Test join_room returns self for chaining""" result = FunctionResult() ret = result.join_room("room1") @@ -1321,7 +1322,7 @@ def test_join_room_chaining(self): class TestExecuteRpc: """Test execute_rpc() method""" - def test_execute_rpc_method_only(self): + def test_execute_rpc_method_only(self) -> None: """Test execute_rpc with just the method""" result = FunctionResult().execute_rpc(method="ping") @@ -1332,7 +1333,7 @@ def test_execute_rpc_method_only(self): assert "node_id" not in rpc_params assert "params" not in rpc_params - def test_execute_rpc_with_all_params(self): + def test_execute_rpc_with_all_params(self) -> None: """Test execute_rpc with all parameters""" result = FunctionResult().execute_rpc( method="ai_message", @@ -1347,7 +1348,7 @@ def test_execute_rpc_with_all_params(self): assert rpc_params["node_id"] == "node-456" assert rpc_params["params"] == {"role": "system", "message_text": "Hello"} - def test_execute_rpc_with_call_id_only(self): + def test_execute_rpc_with_call_id_only(self) -> None: """Test execute_rpc with call_id but no params""" result = FunctionResult().execute_rpc(method="status", call_id="call-789") @@ -1355,7 +1356,7 @@ def test_execute_rpc_with_call_id_only(self): assert rpc_params["call_id"] == "call-789" assert "params" not in rpc_params - def test_execute_rpc_chaining(self): + def test_execute_rpc_chaining(self) -> None: """Test execute_rpc returns self for chaining""" result = FunctionResult() ret = result.execute_rpc("test") @@ -1365,7 +1366,7 @@ def test_execute_rpc_chaining(self): class TestRpcDial: """Test rpc_dial() method""" - def test_rpc_dial_basic(self): + def test_rpc_dial_basic(self) -> None: """Test rpc_dial basic usage""" result = FunctionResult().rpc_dial( to_number="+15551234567", @@ -1381,7 +1382,7 @@ def test_rpc_dial_basic(self): assert params["devices"]["params"]["to_number"] == "+15551234567" assert params["devices"]["params"]["from_number"] == "+15559876543" - def test_rpc_dial_custom_device_type(self): + def test_rpc_dial_custom_device_type(self) -> None: """Test rpc_dial with custom device_type""" result = FunctionResult().rpc_dial( to_number="+15551234567", @@ -1393,7 +1394,7 @@ def test_rpc_dial_custom_device_type(self): params = result.action[0]["SWML"]["sections"]["main"][0]["execute_rpc"]["params"] assert params["devices"]["type"] == "sip" - def test_rpc_dial_chaining(self): + def test_rpc_dial_chaining(self) -> None: """Test rpc_dial returns self for chaining""" result = FunctionResult() ret = result.rpc_dial("+1555", "+1556", "https://dest.com") @@ -1403,7 +1404,7 @@ def test_rpc_dial_chaining(self): class TestRpcAiMessage: """Test rpc_ai_message() method""" - def test_rpc_ai_message_basic(self): + def test_rpc_ai_message_basic(self) -> None: """Test rpc_ai_message basic usage""" result = FunctionResult().rpc_ai_message( call_id="call-abc", @@ -1416,7 +1417,7 @@ def test_rpc_ai_message_basic(self): assert rpc_params["params"]["role"] == "system" assert rpc_params["params"]["message_text"] == "Please take a message." - def test_rpc_ai_message_custom_role(self): + def test_rpc_ai_message_custom_role(self) -> None: """Test rpc_ai_message with custom role""" result = FunctionResult().rpc_ai_message( call_id="call-xyz", @@ -1427,7 +1428,7 @@ def test_rpc_ai_message_custom_role(self): params = result.action[0]["SWML"]["sections"]["main"][0]["execute_rpc"]["params"] assert params["role"] == "user" - def test_rpc_ai_message_chaining(self): + def test_rpc_ai_message_chaining(self) -> None: """Test rpc_ai_message returns self for chaining""" result = FunctionResult() ret = result.rpc_ai_message("call-1", "msg") @@ -1437,7 +1438,7 @@ def test_rpc_ai_message_chaining(self): class TestRpcAiUnhold: """Test rpc_ai_unhold() method""" - def test_rpc_ai_unhold_basic(self): + def test_rpc_ai_unhold_basic(self) -> None: """Test rpc_ai_unhold basic usage""" result = FunctionResult().rpc_ai_unhold(call_id="call-abc") @@ -1447,7 +1448,7 @@ def test_rpc_ai_unhold_basic(self): # empty dict params={} is falsy, so execute_rpc does not include "params" key assert "params" not in rpc_params - def test_rpc_ai_unhold_chaining(self): + def test_rpc_ai_unhold_chaining(self) -> None: """Test rpc_ai_unhold returns self for chaining""" result = FunctionResult() ret = result.rpc_ai_unhold("call-1") @@ -1457,7 +1458,7 @@ def test_rpc_ai_unhold_chaining(self): class TestCreatePaymentPrompt: """Test create_payment_prompt() static method""" - def test_create_payment_prompt_basic(self): + def test_create_payment_prompt_basic(self) -> None: """Test create_payment_prompt without card_type or error_type""" actions = [{"type": "Say", "phrase": "Enter your card number"}] prompt = FunctionResult.create_payment_prompt("payment-card-number", actions) @@ -1467,7 +1468,7 @@ def test_create_payment_prompt_basic(self): assert "card_type" not in prompt assert "error_type" not in prompt - def test_create_payment_prompt_with_card_type(self): + def test_create_payment_prompt_with_card_type(self) -> None: """Test create_payment_prompt with card_type""" actions = [{"type": "Say", "phrase": "Enter card"}] prompt = FunctionResult.create_payment_prompt( @@ -1476,7 +1477,7 @@ def test_create_payment_prompt_with_card_type(self): assert prompt["card_type"] == "visa mastercard" - def test_create_payment_prompt_with_error_type(self): + def test_create_payment_prompt_with_error_type(self) -> None: """Test create_payment_prompt with error_type""" actions = [{"type": "Say", "phrase": "Invalid card"}] prompt = FunctionResult.create_payment_prompt( @@ -1485,7 +1486,7 @@ def test_create_payment_prompt_with_error_type(self): assert prompt["error_type"] == "invalid-card-number" - def test_create_payment_prompt_with_both(self): + def test_create_payment_prompt_with_both(self) -> None: """Test create_payment_prompt with both card_type and error_type""" actions = [{"type": "Say", "phrase": "Try again"}] prompt = FunctionResult.create_payment_prompt( @@ -1500,12 +1501,12 @@ def test_create_payment_prompt_with_both(self): class TestCreatePaymentAction: """Test create_payment_action() static method""" - def test_create_payment_action_say(self): + def test_create_payment_action_say(self) -> None: """Test create_payment_action with Say type""" action = FunctionResult.create_payment_action("Say", "Enter card number") assert action == {"type": "Say", "phrase": "Enter card number"} - def test_create_payment_action_play(self): + def test_create_payment_action_play(self) -> None: """Test create_payment_action with Play type""" action = FunctionResult.create_payment_action("Play", "https://example.com/prompt.mp3") assert action == {"type": "Play", "phrase": "https://example.com/prompt.mp3"} @@ -1514,12 +1515,12 @@ def test_create_payment_action_play(self): class TestCreatePaymentParameter: """Test create_payment_parameter() static method""" - def test_create_payment_parameter_basic(self): + def test_create_payment_parameter_basic(self) -> None: """Test create_payment_parameter basic usage""" param = FunctionResult.create_payment_parameter("store_id", "abc-123") assert param == {"name": "store_id", "value": "abc-123"} - def test_create_payment_parameter_empty_value(self): + def test_create_payment_parameter_empty_value(self) -> None: """Test create_payment_parameter with empty value""" param = FunctionResult.create_payment_parameter("key", "") assert param == {"name": "key", "value": ""} @@ -1528,13 +1529,13 @@ def test_create_payment_parameter_empty_value(self): class TestToDictEdgeCases: """Test to_dict() edge cases""" - def test_to_dict_empty_result(self): + def test_to_dict_empty_result(self) -> None: """Test to_dict with no response and no actions returns default""" result = FunctionResult() d = result.to_dict() assert d == {"response": "Action completed."} - def test_to_dict_response_only(self): + def test_to_dict_response_only(self) -> None: """Test to_dict with response only""" result = FunctionResult("Hello") d = result.to_dict() @@ -1542,7 +1543,7 @@ def test_to_dict_response_only(self): assert "action" not in d assert "post_process" not in d - def test_to_dict_actions_only(self): + def test_to_dict_actions_only(self) -> None: """Test to_dict with actions but empty response""" result = FunctionResult() result.add_action("hangup", True) @@ -1552,21 +1553,21 @@ def test_to_dict_actions_only(self): # Empty string response should not appear assert "response" not in d - def test_to_dict_post_process_without_actions_not_included(self): + def test_to_dict_post_process_without_actions_not_included(self) -> None: """Test to_dict: post_process=True but no actions means post_process should not appear""" result = FunctionResult("Response", post_process=True) d = result.to_dict() assert "post_process" not in d assert d["response"] == "Response" - def test_to_dict_post_process_with_actions(self): + def test_to_dict_post_process_with_actions(self) -> None: """Test to_dict: post_process=True with actions includes post_process""" result = FunctionResult("Response", post_process=True) result.add_action("stop", True) d = result.to_dict() assert d["post_process"] is True - def test_to_dict_post_process_false_with_actions(self): + def test_to_dict_post_process_false_with_actions(self) -> None: """Test to_dict: post_process=False with actions does not include post_process""" result = FunctionResult("Response", post_process=False) result.add_action("stop", True) @@ -1577,12 +1578,12 @@ def test_to_dict_post_process_false_with_actions(self): class TestSetEndOfSpeechTimeout: """Test set_end_of_speech_timeout() method""" - def test_set_end_of_speech_timeout(self): + def test_set_end_of_speech_timeout(self) -> None: """Test setting end of speech timeout""" result = FunctionResult().set_end_of_speech_timeout(500) assert result.action[0] == {"end_of_speech_timeout": 500} - def test_set_end_of_speech_timeout_chaining(self): + def test_set_end_of_speech_timeout_chaining(self) -> None: """Test set_end_of_speech_timeout returns self""" result = FunctionResult() ret = result.set_end_of_speech_timeout(300) @@ -1592,12 +1593,12 @@ def test_set_end_of_speech_timeout_chaining(self): class TestSetSpeechEventTimeout: """Test set_speech_event_timeout() method""" - def test_set_speech_event_timeout(self): + def test_set_speech_event_timeout(self) -> None: """Test setting speech event timeout""" result = FunctionResult().set_speech_event_timeout(1000) assert result.action[0] == {"speech_event_timeout": 1000} - def test_set_speech_event_timeout_chaining(self): + def test_set_speech_event_timeout_chaining(self) -> None: """Test set_speech_event_timeout returns self""" result = FunctionResult() ret = result.set_speech_event_timeout(200) @@ -1607,7 +1608,7 @@ def test_set_speech_event_timeout_chaining(self): class TestToggleFunctions: """Test toggle_functions() method""" - def test_toggle_functions(self): + def test_toggle_functions(self) -> None: """Test toggling functions""" toggles = [ {"function": "get_weather", "active": True}, @@ -1616,7 +1617,7 @@ def test_toggle_functions(self): result = FunctionResult().toggle_functions(toggles) assert result.action[0] == {"toggle_functions": toggles} - def test_toggle_functions_chaining(self): + def test_toggle_functions_chaining(self) -> None: """Test toggle_functions returns self""" result = FunctionResult() ret = result.toggle_functions([]) @@ -1626,17 +1627,17 @@ def test_toggle_functions_chaining(self): class TestEnableFunctionsOnTimeout: """Test enable_functions_on_timeout() method""" - def test_enable_functions_on_timeout_default(self): + def test_enable_functions_on_timeout_default(self) -> None: """Test enable_functions_on_timeout with default True""" result = FunctionResult().enable_functions_on_timeout() assert result.action[0] == {"functions_on_speaker_timeout": True} - def test_enable_functions_on_timeout_false(self): + def test_enable_functions_on_timeout_false(self) -> None: """Test enable_functions_on_timeout with False""" result = FunctionResult().enable_functions_on_timeout(False) assert result.action[0] == {"functions_on_speaker_timeout": False} - def test_enable_functions_on_timeout_chaining(self): + def test_enable_functions_on_timeout_chaining(self) -> None: """Test enable_functions_on_timeout returns self""" result = FunctionResult() ret = result.enable_functions_on_timeout() @@ -1646,17 +1647,17 @@ def test_enable_functions_on_timeout_chaining(self): class TestEnableExtensiveData: """Test enable_extensive_data() method""" - def test_enable_extensive_data_default(self): + def test_enable_extensive_data_default(self) -> None: """Test enable_extensive_data with default True""" result = FunctionResult().enable_extensive_data() assert result.action[0] == {"extensive_data": True} - def test_enable_extensive_data_false(self): + def test_enable_extensive_data_false(self) -> None: """Test enable_extensive_data with False""" result = FunctionResult().enable_extensive_data(False) assert result.action[0] == {"extensive_data": False} - def test_enable_extensive_data_chaining(self): + def test_enable_extensive_data_chaining(self) -> None: """Test enable_extensive_data returns self""" result = FunctionResult() ret = result.enable_extensive_data() @@ -1666,7 +1667,7 @@ def test_enable_extensive_data_chaining(self): class TestUpdateSettings: """Test update_settings() method""" - def test_update_settings(self): + def test_update_settings(self) -> None: """Test updating agent runtime settings""" settings = { "temperature": 0.7, @@ -1676,7 +1677,7 @@ def test_update_settings(self): result = FunctionResult().update_settings(settings) assert result.action[0] == {"settings": settings} - def test_update_settings_chaining(self): + def test_update_settings_chaining(self) -> None: """Test update_settings returns self""" result = FunctionResult() ret = result.update_settings({"temperature": 0.5}) @@ -1686,12 +1687,12 @@ def test_update_settings_chaining(self): class TestSimulateUserInput: """Test simulate_user_input() method""" - def test_simulate_user_input(self): + def test_simulate_user_input(self) -> None: """Test simulating user input""" result = FunctionResult().simulate_user_input("I want to book a flight") assert result.action[0] == {"user_input": "I want to book a flight"} - def test_simulate_user_input_chaining(self): + def test_simulate_user_input_chaining(self) -> None: """Test simulate_user_input returns self""" result = FunctionResult() ret = result.simulate_user_input("text") @@ -1701,12 +1702,12 @@ def test_simulate_user_input_chaining(self): class TestSwitchContextEdgeCases: """Test switch_context() edge cases""" - def test_switch_context_simple_string_only(self): + def test_switch_context_simple_string_only(self) -> None: """Test switch_context with only system_prompt uses simple string form""" result = FunctionResult().switch_context(system_prompt="You are a helpful bot") assert result.action[0] == {"context_switch": "You are a helpful bot"} - def test_switch_context_full_object_with_all_params(self): + def test_switch_context_full_object_with_all_params(self) -> None: """Test switch_context with all parameters uses object form""" result = FunctionResult().switch_context( system_prompt="New prompt", @@ -1722,7 +1723,7 @@ def test_switch_context_full_object_with_all_params(self): assert ctx["consolidate"] is True assert ctx["full_reset"] is True - def test_switch_context_with_full_reset_only(self): + def test_switch_context_with_full_reset_only(self) -> None: """Test switch_context with full_reset triggers object form""" result = FunctionResult().switch_context(full_reset=True) @@ -1731,7 +1732,7 @@ def test_switch_context_with_full_reset_only(self): assert ctx["full_reset"] is True assert "system_prompt" not in ctx - def test_switch_context_system_and_user_prompt(self): + def test_switch_context_system_and_user_prompt(self) -> None: """Test switch_context with system_prompt and user_prompt uses object form""" result = FunctionResult().switch_context( system_prompt="Sys", @@ -1743,7 +1744,7 @@ def test_switch_context_system_and_user_prompt(self): assert ctx["system_prompt"] == "Sys" assert ctx["user_prompt"] == "User" - def test_switch_context_system_prompt_with_consolidate(self): + def test_switch_context_system_prompt_with_consolidate(self) -> None: """Test switch_context with system_prompt and consolidate uses object form""" result = FunctionResult().switch_context( system_prompt="New prompt", @@ -1755,14 +1756,14 @@ def test_switch_context_system_prompt_with_consolidate(self): assert ctx["system_prompt"] == "New prompt" assert ctx["consolidate"] is True - def test_switch_context_no_args(self): + def test_switch_context_no_args(self) -> None: """Test switch_context with no args produces empty dict form""" result = FunctionResult().switch_context() ctx = result.action[0]["context_switch"] assert isinstance(ctx, dict) assert ctx == {} - def test_switch_context_chaining(self): + def test_switch_context_chaining(self) -> None: """Test switch_context returns self for chaining""" result = FunctionResult() ret = result.switch_context(system_prompt="test") @@ -1779,26 +1780,26 @@ class TestFunctionResultReplaceInHistory: cross-language audit can detect any port that doesn't support both calling shapes.""" - def test_replace_in_history_default_true(self): + def test_replace_in_history_default_true(self) -> None: """Default arg ``True`` emits replace_in_history action with True.""" result = FunctionResult().replace_in_history() action = result.action[0] assert "replace_in_history" in action assert action["replace_in_history"] is True - def test_replace_in_history_with_string(self): + def test_replace_in_history_with_string(self) -> None: """Passing a string emits that text into the history slot.""" result = FunctionResult().replace_in_history("I've saved your data.") action = result.action[0] assert action["replace_in_history"] == "I've saved your data." - def test_replace_in_history_with_false(self): + def test_replace_in_history_with_false(self) -> None: """Passing ``False`` is a valid form (suppresses the placeholder).""" result = FunctionResult().replace_in_history(False) action = result.action[0] assert action["replace_in_history"] is False - def test_replace_in_history_chaining(self): + def test_replace_in_history_chaining(self) -> None: """Returns self for fluent chaining.""" result = FunctionResult() ret = result.replace_in_history() diff --git a/tests/unit/core/test_logging_config.py b/tests/unit/core/test_logging_config.py index 256a4ce1..cec21c66 100644 --- a/tests/unit/core/test_logging_config.py +++ b/tests/unit/core/test_logging_config.py @@ -16,6 +16,7 @@ import json import os import sys +from collections.abc import Iterator from unittest.mock import patch from io import StringIO @@ -34,7 +35,7 @@ # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) -def _reset_logging(monkeypatch): +def _reset_logging(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: """Reset logging state before every test.""" reset_logging_configuration() # Also clear any handlers left from previous tests @@ -57,27 +58,27 @@ def _reset_logging(monkeypatch): class TestGetExecutionMode: """Test execution mode detection""" - def test_cgi_mode_detection(self): + def test_cgi_mode_detection(self) -> None: with patch.dict(os.environ, {'GATEWAY_INTERFACE': 'CGI/1.1'}, clear=False): assert get_execution_mode() == 'cgi' - def test_lambda_mode_detection(self): + def test_lambda_mode_detection(self) -> None: with patch.dict(os.environ, {'AWS_LAMBDA_FUNCTION_NAME': 'test-function'}, clear=False): assert get_execution_mode() == 'lambda' - def test_lambda_mode_detection_with_task_root(self): + def test_lambda_mode_detection_with_task_root(self) -> None: with patch.dict(os.environ, {'LAMBDA_TASK_ROOT': '/var/task'}, clear=False): assert get_execution_mode() == 'lambda' - def test_google_cloud_function_detection(self): + def test_google_cloud_function_detection(self) -> None: with patch.dict(os.environ, {'FUNCTION_TARGET': 'my_function'}, clear=False): assert get_execution_mode() == 'google_cloud_function' - def test_azure_function_detection(self): + def test_azure_function_detection(self) -> None: with patch.dict(os.environ, {'AZURE_FUNCTIONS_ENVIRONMENT': 'Production'}, clear=False): assert get_execution_mode() == 'azure_function' - def test_server_mode_default(self): + def test_server_mode_default(self) -> None: env_vars_to_clear = [ 'GATEWAY_INTERFACE', 'AWS_LAMBDA_FUNCTION_NAME', 'LAMBDA_TASK_ROOT', 'FUNCTION_TARGET', 'K_SERVICE', @@ -98,7 +99,7 @@ def test_server_mode_default(self): class TestGetLogger: """Test get_logger function""" - def test_returns_structlog_bound_logger(self): + def test_returns_structlog_bound_logger(self) -> None: logger = get_logger("test_logger") # structlog BoundLoggers should have bind() assert hasattr(logger, 'bind') @@ -107,12 +108,12 @@ def test_returns_structlog_bound_logger(self): assert hasattr(logger, 'warning') assert hasattr(logger, 'error') - def test_different_names_create_different_loggers(self): + def test_different_names_create_different_loggers(self) -> None: logger1 = get_logger("logger_a") logger2 = get_logger("logger_b") assert logger1 is not logger2 - def test_triggers_configure_logging(self): + def test_triggers_configure_logging(self) -> None: with patch('signalwire.core.logging_config.configure_logging') as mock_conf: get_logger("test") mock_conf.assert_called_once() @@ -126,7 +127,7 @@ def test_triggers_configure_logging(self): class TestConfigureLogging: """Test logging configuration""" - def test_idempotent(self): + def test_idempotent(self) -> None: """configure_logging should only run once until reset.""" configure_logging() # Grab the handler count after first call @@ -136,14 +137,14 @@ def test_idempotent(self): configure_logging() assert len(sw.handlers) == handler_count - def test_default_mode(self): + def test_default_mode(self) -> None: """Default (no env var) should attach a handler to signalwire.""" configure_logging() sw = logging.getLogger("signalwire") assert len(sw.handlers) == 1 assert sw.propagate is False - def test_off_mode(self): + def test_off_mode(self) -> None: with patch.dict(os.environ, {'SIGNALWIRE_LOG_MODE': 'off'}): configure_logging() sw = logging.getLogger("signalwire") @@ -151,21 +152,23 @@ def test_off_mode(self): assert sw.level > logging.CRITICAL assert len(sw.handlers) == 0 - def test_stderr_mode(self): + def test_stderr_mode(self) -> None: with patch.dict(os.environ, {'SIGNALWIRE_LOG_MODE': 'stderr'}): configure_logging() sw = logging.getLogger("signalwire") assert len(sw.handlers) == 1 - assert sw.handlers[0].stream is sys.stderr + handler = sw.handlers[0] + assert isinstance(handler, logging.StreamHandler) + assert handler.stream is sys.stderr - def test_auto_mode_cgi(self): + def test_auto_mode_cgi(self) -> None: with patch.dict(os.environ, {'SIGNALWIRE_LOG_MODE': 'auto', 'GATEWAY_INTERFACE': 'CGI/1.1'}): configure_logging() sw = logging.getLogger("signalwire") # CGI → off mode assert sw.level > logging.CRITICAL - def test_auto_mode_server(self): + def test_auto_mode_server(self) -> None: env = {'SIGNALWIRE_LOG_MODE': 'auto'} removals = ['GATEWAY_INTERFACE', 'AWS_LAMBDA_FUNCTION_NAME', 'LAMBDA_TASK_ROOT'] with patch.dict(os.environ, env, clear=False): @@ -175,13 +178,13 @@ def test_auto_mode_server(self): sw = logging.getLogger("signalwire") assert len(sw.handlers) == 1 - def test_log_level_env(self): + def test_log_level_env(self) -> None: with patch.dict(os.environ, {'SIGNALWIRE_LOG_LEVEL': 'debug'}): configure_logging() sw = logging.getLogger("signalwire") assert sw.level == logging.DEBUG - def test_json_format(self): + def test_json_format(self) -> None: with patch.dict(os.environ, {'SIGNALWIRE_LOG_FORMAT': 'json'}): configure_logging() sw = logging.getLogger("signalwire") @@ -199,14 +202,16 @@ def test_json_format(self): class TestStructuredLogging: """Test that structlog features work end-to-end through stdlib.""" - def test_bind_adds_context(self): + def test_bind_adds_context(self) -> None: """bound fields should appear in the log output.""" with patch.dict(os.environ, {'SIGNALWIRE_LOG_FORMAT': 'json', 'SIGNALWIRE_LOG_LEVEL': 'debug'}): configure_logging() buf = StringIO() sw = logging.getLogger("signalwire") - sw.handlers[0].stream = buf + handler = sw.handlers[0] + assert isinstance(handler, logging.StreamHandler) + handler.stream = buf log = get_logger("signalwire.test_bind") bound = log.bind(request_id="abc123") @@ -217,14 +222,16 @@ def test_bind_adds_context(self): assert data["request_id"] == "abc123" assert data["event"] == "hello" - def test_nested_bind(self): + def test_nested_bind(self) -> None: """Multiple bind() calls should stack context.""" with patch.dict(os.environ, {'SIGNALWIRE_LOG_FORMAT': 'json', 'SIGNALWIRE_LOG_LEVEL': 'debug'}): configure_logging() buf = StringIO() sw = logging.getLogger("signalwire") - sw.handlers[0].stream = buf + handler = sw.handlers[0] + assert isinstance(handler, logging.StreamHandler) + handler.stream = buf log = get_logger("signalwire.test_nested") log2 = log.bind(a="1").bind(b="2") @@ -236,14 +243,16 @@ def test_nested_bind(self): assert data["b"] == "2" assert data["event"] == "nested" - def test_exc_info_produces_traceback(self): + def test_exc_info_produces_traceback(self) -> None: """exc_info should produce a real traceback, not 'exc_info=True' string.""" with patch.dict(os.environ, {'SIGNALWIRE_LOG_FORMAT': 'json', 'SIGNALWIRE_LOG_LEVEL': 'debug'}): configure_logging() buf = StringIO() sw = logging.getLogger("signalwire") - sw.handlers[0].stream = buf + handler = sw.handlers[0] + assert isinstance(handler, logging.StreamHandler) + handler.stream = buf log = get_logger("signalwire.test_exc") @@ -267,7 +276,7 @@ def test_exc_info_produces_traceback(self): class TestOffModeNoFdLeak: """Off mode should not open /dev/null or any file.""" - def test_no_file_handles_opened(self): + def test_no_file_handles_opened(self) -> None: with patch.dict(os.environ, {'SIGNALWIRE_LOG_MODE': 'off'}): with patch('builtins.open') as mock_open: configure_logging() @@ -282,7 +291,7 @@ def test_no_file_handles_opened(self): class TestNoRootLoggerHijacking: """Verify root logger handlers are not modified.""" - def test_root_logger_unchanged(self): + def test_root_logger_unchanged(self) -> None: root = logging.getLogger() original_handlers = list(root.handlers) original_level = root.level @@ -301,14 +310,16 @@ def test_root_logger_unchanged(self): class TestJsonMode: """Verify JSON mode produces parseable output with structured fields.""" - def test_json_output(self): + def test_json_output(self) -> None: with patch.dict(os.environ, {'SIGNALWIRE_LOG_FORMAT': 'json', 'SIGNALWIRE_LOG_LEVEL': 'debug'}): configure_logging() buf = StringIO() sw = logging.getLogger("signalwire") # Replace handler's stream with our buffer - sw.handlers[0].stream = buf + handler = sw.handlers[0] + assert isinstance(handler, logging.StreamHandler) + handler.stream = buf log = get_logger("signalwire.test_json") bound = log.bind(user="alice") @@ -329,12 +340,12 @@ def test_json_output(self): class TestColorDetection: """Verify color auto-detection logic.""" - def test_no_colors_when_not_tty(self): + def test_no_colors_when_not_tty(self) -> None: from signalwire.core.logging_config import _detect_colors with patch.object(sys, 'stdout', new_callable=StringIO): assert _detect_colors() is False - def test_no_colors_when_dump_swml(self): + def test_no_colors_when_dump_swml(self) -> None: from signalwire.core.logging_config import _detect_colors original_argv = sys.argv[:] try: @@ -343,7 +354,7 @@ def test_no_colors_when_dump_swml(self): finally: sys.argv[:] = original_argv - def test_no_colors_when_raw(self): + def test_no_colors_when_raw(self) -> None: from signalwire.core.logging_config import _detect_colors original_argv = sys.argv[:] try: @@ -361,7 +372,7 @@ def test_no_colors_when_raw(self): class TestResetLogging: """Test that reset allows reconfiguration.""" - def test_reset_allows_reconfigure(self): + def test_reset_allows_reconfigure(self) -> None: configure_logging() sw = logging.getLogger("signalwire") assert len(sw.handlers) >= 1 diff --git a/tests/unit/core/test_pom_builder.py b/tests/unit/core/test_pom_builder.py index a87de327..e9dbb9e2 100644 --- a/tests/unit/core/test_pom_builder.py +++ b/tests/unit/core/test_pom_builder.py @@ -22,7 +22,7 @@ class TestPomBuilder: """Test PomBuilder functionality""" - def test_basic_initialization(self): + def test_basic_initialization(self) -> None: """Test basic PomBuilder initialization""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: builder = PomBuilder() @@ -30,7 +30,7 @@ def test_basic_initialization(self): assert mock_pom.called assert builder._sections == {} - def test_add_section_basic(self): + def test_add_section_basic(self) -> None: """Test adding a basic section""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -49,7 +49,7 @@ def test_add_section_basic(self): ) assert builder._sections["Introduction"] == mock_section - def test_add_section_with_options(self): + def test_add_section_with_options(self) -> None: """Test adding a section with various options""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -75,14 +75,14 @@ def test_add_section_with_options(self): numberedBullets=True ) - def test_add_section_with_subsections(self): + def test_add_section_with_subsections(self) -> None: """Test adding a section with subsections""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() mock_pom.return_value.add_section.return_value = mock_section builder = PomBuilder() - subsections = [ + subsections: List[Dict[str, Any]] = [ {"title": "Sub 1", "body": "Content 1"}, {"title": "Sub 2", "body": "Content 2", "bullets": ["bullet1"]} ] @@ -102,7 +102,7 @@ def test_add_section_with_subsections(self): bullets=["bullet1"] ) - def test_add_to_section_new_section(self): + def test_add_to_section_new_section(self) -> None: """Test adding content to a new section (auto-vivification)""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -118,7 +118,7 @@ def test_add_to_section_new_section(self): mock_pom.return_value.add_section.assert_called() assert mock_section.body == "Some content" - def test_add_to_section_existing_section(self): + def test_add_to_section_existing_section(self) -> None: """Test adding content to an existing section""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -134,7 +134,7 @@ def test_add_to_section_existing_section(self): assert result is builder assert mock_section.body == "Existing content\n\nAdditional content" - def test_add_to_section_bullets(self): + def test_add_to_section_bullets(self) -> None: """Test adding bullets to a section""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -153,7 +153,7 @@ def test_add_to_section_bullets(self): builder.add_to_section("Test Section", bullets=["Bullet 1", "Bullet 2"]) mock_section.bullets.extend.assert_called_with(["Bullet 1", "Bullet 2"]) - def test_add_subsection(self): + def test_add_subsection(self) -> None: """Test adding subsections""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_parent_section = Mock() @@ -178,7 +178,7 @@ def test_add_subsection(self): bullets=["bullet1", "bullet2"] ) - def test_add_subsection_auto_vivification(self): + def test_add_subsection_auto_vivification(self) -> None: """Test adding subsection with auto-creation of parent""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_parent_section = Mock() @@ -193,7 +193,7 @@ def test_add_subsection_auto_vivification(self): mock_pom.return_value.add_section.assert_called() mock_parent_section.add_subsection.assert_called() - def test_has_section(self): + def test_has_section(self) -> None: """Test checking if section exists""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -206,7 +206,7 @@ def test_has_section(self): builder.add_section("Existing Section") assert builder.has_section("Existing Section") - def test_get_section(self): + def test_get_section(self) -> None: """Test getting section by title""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -219,7 +219,7 @@ def test_get_section(self): builder.add_section("Test Section") assert builder.get_section("Test Section") == mock_section - def test_render_methods(self): + def test_render_methods(self) -> None: """Test rendering methods""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_pom.return_value.render_markdown.return_value = "# Markdown" @@ -233,7 +233,7 @@ def test_render_methods(self): mock_pom.return_value.render_markdown.assert_called_once() mock_pom.return_value.render_xml.assert_called_once() - def test_to_dict_and_to_json(self): + def test_to_dict_and_to_json(self) -> None: """Test conversion methods""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_dict = [{"title": "Section", "body": "Content"}] @@ -249,7 +249,7 @@ def test_to_dict_and_to_json(self): mock_pom.return_value.to_dict.assert_called_once() mock_pom.return_value.to_json.assert_called_once() - def test_from_sections_classmethod(self): + def test_from_sections_classmethod(self) -> None: """Test creating PomBuilder from sections""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_pom_instance = Mock() @@ -263,7 +263,7 @@ def test_from_sections_classmethod(self): assert builder.pom is mock_pom_instance mock_pom.from_json.assert_called_once_with(sections) - def test_method_chaining(self): + def test_method_chaining(self) -> None: """Test method chaining functionality""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -286,7 +286,7 @@ def test_method_chaining(self): class TestPomBuilderIntegration: """Test PomBuilder integration scenarios""" - def test_complex_document_building(self): + def test_complex_document_building(self) -> None: """Test building a complex document structure""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -320,7 +320,7 @@ def test_complex_document_building(self): assert "Getting Started" in builder._sections assert "Advanced Topics" in builder._sections - def test_agent_prompt_building(self): + def test_agent_prompt_building(self) -> None: """Test building agent prompts using PomBuilder""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -360,7 +360,7 @@ def test_agent_prompt_building(self): assert builder.has_section("Guidelines") assert builder.has_section("Escalation") - def test_documentation_generation(self): + def test_documentation_generation(self) -> None: """Test generating API documentation""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() @@ -402,7 +402,7 @@ def test_documentation_generation(self): assert builder.has_section("Endpoints") assert builder.has_section("Examples") - def test_error_recovery_and_flexibility(self): + def test_error_recovery_and_flexibility(self) -> None: """Test error recovery and flexible usage patterns""" with patch('signalwire.core.pom_builder.PromptObjectModel') as mock_pom: mock_section = Mock() diff --git a/tests/unit/core/test_security_config.py b/tests/unit/core/test_security_config.py index b7959f46..bf33e35e 100644 --- a/tests/unit/core/test_security_config.py +++ b/tests/unit/core/test_security_config.py @@ -13,9 +13,13 @@ import os import secrets +from typing import TYPE_CHECKING, Any import pytest from unittest.mock import Mock, patch, MagicMock +if TYPE_CHECKING: + from signalwire.core.security_config import SecurityConfig + # All SecurityConfig instantiation must happen with env and config loader mocked, # because __init__ calls load_from_env() and _load_config_file() immediately. @@ -30,12 +34,12 @@ ] -def _clean_env(): +def _clean_env() -> dict[str, str]: """Return a dict suitable for patch.dict that removes all SWML_ keys.""" return {k: v for k, v in os.environ.items() if k not in ENV_CLEAR_KEYS} -def _make_config(**env_overrides): +def _make_config(**env_overrides: Any) -> "SecurityConfig": """ Create a SecurityConfig with a clean environment and no config file. Any env_overrides are applied on top of the clean environment. @@ -54,7 +58,7 @@ def _make_config(**env_overrides): class TestSecurityConfigClassAttributes: """Test that class-level constants are defined correctly.""" - def test_ssl_env_var_names(self): + def test_ssl_env_var_names(self) -> None: from signalwire.core.security_config import SecurityConfig assert SecurityConfig.SSL_ENABLED == 'SWML_SSL_ENABLED' assert SecurityConfig.SSL_CERT_PATH == 'SWML_SSL_CERT_PATH' @@ -62,7 +66,7 @@ def test_ssl_env_var_names(self): assert SecurityConfig.SSL_DOMAIN == 'SWML_DOMAIN' assert SecurityConfig.SSL_VERIFY_MODE == 'SWML_SSL_VERIFY_MODE' - def test_additional_env_var_names(self): + def test_additional_env_var_names(self) -> None: from signalwire.core.security_config import SecurityConfig assert SecurityConfig.ALLOWED_HOSTS == 'SWML_ALLOWED_HOSTS' assert SecurityConfig.CORS_ORIGINS == 'SWML_CORS_ORIGINS' @@ -72,12 +76,12 @@ def test_additional_env_var_names(self): assert SecurityConfig.USE_HSTS == 'SWML_USE_HSTS' assert SecurityConfig.HSTS_MAX_AGE == 'SWML_HSTS_MAX_AGE' - def test_auth_env_var_names(self): + def test_auth_env_var_names(self) -> None: from signalwire.core.security_config import SecurityConfig assert SecurityConfig.BASIC_AUTH_USER == 'SWML_BASIC_AUTH_USER' assert SecurityConfig.BASIC_AUTH_PASSWORD == 'SWML_BASIC_AUTH_PASSWORD' - def test_defaults_dict_contains_expected_keys(self): + def test_defaults_dict_contains_expected_keys(self) -> None: from signalwire.core.security_config import SecurityConfig defaults = SecurityConfig.DEFAULTS assert defaults['SWML_SSL_ENABLED'] is False @@ -94,7 +98,7 @@ def test_defaults_dict_contains_expected_keys(self): class TestSecurityConfigDefaults: """Test SecurityConfig initialization with default values (no env vars, no config file).""" - def test_ssl_defaults(self): + def test_ssl_defaults(self) -> None: cfg = _make_config() assert cfg.ssl_enabled is False assert cfg.ssl_cert_path is None @@ -102,23 +106,23 @@ def test_ssl_defaults(self): assert cfg.domain is None assert cfg.ssl_verify_mode == 'CERT_REQUIRED' - def test_host_and_cors_defaults(self): + def test_host_and_cors_defaults(self) -> None: cfg = _make_config() assert cfg.allowed_hosts == ['*'] assert cfg.cors_origins == ['*'] - def test_numeric_defaults(self): + def test_numeric_defaults(self) -> None: cfg = _make_config() assert cfg.max_request_size == 10 * 1024 * 1024 assert cfg.rate_limit == 60 assert cfg.request_timeout == 30 - def test_hsts_defaults(self): + def test_hsts_defaults(self) -> None: cfg = _make_config() assert cfg.use_hsts is True assert cfg.hsts_max_age == 31536000 - def test_auth_defaults(self): + def test_auth_defaults(self) -> None: cfg = _make_config() assert cfg.basic_auth_user is None assert cfg.basic_auth_password is None @@ -127,41 +131,41 @@ def test_auth_defaults(self): class TestParseList: """Test the _parse_list helper method.""" - def _get_instance(self): + def _get_instance(self) -> "SecurityConfig": return _make_config() - def test_wildcard_string(self): + def test_wildcard_string(self) -> None: cfg = self._get_instance() assert cfg._parse_list('*') == ['*'] - def test_single_value(self): + def test_single_value(self) -> None: cfg = self._get_instance() assert cfg._parse_list('example.com') == ['example.com'] - def test_comma_separated(self): + def test_comma_separated(self) -> None: cfg = self._get_instance() result = cfg._parse_list('a.com,b.com,c.com') assert result == ['a.com', 'b.com', 'c.com'] - def test_comma_separated_with_spaces(self): + def test_comma_separated_with_spaces(self) -> None: cfg = self._get_instance() result = cfg._parse_list(' a.com , b.com , c.com ') assert result == ['a.com', 'b.com', 'c.com'] - def test_list_input_passthrough(self): + def test_list_input_passthrough(self) -> None: cfg = self._get_instance() input_list = ['x.com', 'y.com'] assert cfg._parse_list(input_list) is input_list - def test_empty_string(self): + def test_empty_string(self) -> None: cfg = self._get_instance() assert cfg._parse_list('') == [] - def test_only_commas(self): + def test_only_commas(self) -> None: cfg = self._get_instance() assert cfg._parse_list(',,,') == [] - def test_trailing_comma(self): + def test_trailing_comma(self) -> None: cfg = self._get_instance() assert cfg._parse_list('a.com,b.com,') == ['a.com', 'b.com'] @@ -169,35 +173,35 @@ def test_trailing_comma(self): class TestLoadFromEnv: """Test loading configuration from environment variables.""" - def test_ssl_enabled_true(self): + def test_ssl_enabled_true(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='true') assert cfg.ssl_enabled is True - def test_ssl_enabled_1(self): + def test_ssl_enabled_1(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='1') assert cfg.ssl_enabled is True - def test_ssl_enabled_yes(self): + def test_ssl_enabled_yes(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='yes') assert cfg.ssl_enabled is True - def test_ssl_enabled_false(self): + def test_ssl_enabled_false(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='false') assert cfg.ssl_enabled is False - def test_ssl_enabled_empty(self): + def test_ssl_enabled_empty(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='') assert cfg.ssl_enabled is False - def test_ssl_enabled_case_insensitive(self): + def test_ssl_enabled_case_insensitive(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='TRUE') assert cfg.ssl_enabled is True - def test_ssl_enabled_yes_uppercase(self): + def test_ssl_enabled_yes_uppercase(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='YES') assert cfg.ssl_enabled is True - def test_ssl_cert_and_key_paths(self): + def test_ssl_cert_and_key_paths(self) -> None: cfg = _make_config( SWML_SSL_CERT_PATH='/path/to/cert.pem', SWML_SSL_KEY_PATH='/path/to/key.pem', @@ -205,60 +209,60 @@ def test_ssl_cert_and_key_paths(self): assert cfg.ssl_cert_path == '/path/to/cert.pem' assert cfg.ssl_key_path == '/path/to/key.pem' - def test_domain(self): + def test_domain(self) -> None: cfg = _make_config(SWML_DOMAIN='example.com') assert cfg.domain == 'example.com' - def test_ssl_verify_mode(self): + def test_ssl_verify_mode(self) -> None: cfg = _make_config(SWML_SSL_VERIFY_MODE='CERT_OPTIONAL') assert cfg.ssl_verify_mode == 'CERT_OPTIONAL' - def test_allowed_hosts(self): + def test_allowed_hosts(self) -> None: cfg = _make_config(SWML_ALLOWED_HOSTS='a.com,b.com') assert cfg.allowed_hosts == ['a.com', 'b.com'] - def test_cors_origins(self): + def test_cors_origins(self) -> None: cfg = _make_config(SWML_CORS_ORIGINS='http://localhost:3000,http://app.com') assert cfg.cors_origins == ['http://localhost:3000', 'http://app.com'] - def test_max_request_size(self): + def test_max_request_size(self) -> None: cfg = _make_config(SWML_MAX_REQUEST_SIZE='5242880') assert cfg.max_request_size == 5242880 - def test_rate_limit(self): + def test_rate_limit(self) -> None: cfg = _make_config(SWML_RATE_LIMIT='120') assert cfg.rate_limit == 120 - def test_request_timeout(self): + def test_request_timeout(self) -> None: cfg = _make_config(SWML_REQUEST_TIMEOUT='60') assert cfg.request_timeout == 60 - def test_hsts_max_age(self): + def test_hsts_max_age(self) -> None: cfg = _make_config(SWML_HSTS_MAX_AGE='86400') assert cfg.hsts_max_age == 86400 - def test_use_hsts_false(self): + def test_use_hsts_false(self) -> None: cfg = _make_config(SWML_USE_HSTS='false') assert cfg.use_hsts is False - def test_use_hsts_non_false_value(self): + def test_use_hsts_non_false_value(self) -> None: cfg = _make_config(SWML_USE_HSTS='true') assert cfg.use_hsts is True - def test_use_hsts_arbitrary_string(self): + def test_use_hsts_arbitrary_string(self) -> None: """Non-'false' strings should result in truthy use_hsts.""" cfg = _make_config(SWML_USE_HSTS='anything') assert cfg.use_hsts is True - def test_basic_auth_user(self): + def test_basic_auth_user(self) -> None: cfg = _make_config(SWML_BASIC_AUTH_USER='admin') assert cfg.basic_auth_user == 'admin' - def test_basic_auth_password(self): + def test_basic_auth_password(self) -> None: cfg = _make_config(SWML_BASIC_AUTH_PASSWORD='secret123') assert cfg.basic_auth_password == 'secret123' - def test_basic_auth_both(self): + def test_basic_auth_both(self) -> None: cfg = _make_config( SWML_BASIC_AUTH_USER='myuser', SWML_BASIC_AUTH_PASSWORD='mypass', @@ -270,7 +274,9 @@ def test_basic_auth_both(self): class TestLoadConfigFile: """Test loading configuration from a config file.""" - def _make_config_with_file(self, security_section, has_config=True): + def _make_config_with_file( + self, security_section: Any, has_config: bool = True + ) -> "SecurityConfig": """Helper: create SecurityConfig that loads from a mocked config file.""" mock_config_loader_instance = MagicMock() mock_config_loader_instance.has_config.return_value = has_config @@ -289,29 +295,29 @@ def _make_config_with_file(self, security_section, has_config=True): from signalwire.core.security_config import SecurityConfig return SecurityConfig() - def test_no_config_file_found(self): + def test_no_config_file_found(self) -> None: """When find_config_file returns None, config file loading is skipped.""" cfg = _make_config() # uses find_config_file returning None # Should still have defaults assert cfg.ssl_enabled is False assert cfg.rate_limit == 60 - def test_config_file_has_no_config(self): + def test_config_file_has_no_config(self) -> None: """When ConfigLoader.has_config() returns False, no settings are applied.""" cfg = self._make_config_with_file({}, has_config=False) assert cfg.ssl_enabled is False - def test_empty_security_section(self): + def test_empty_security_section(self) -> None: """Empty security section should not change defaults.""" cfg = self._make_config_with_file({}) assert cfg.ssl_enabled is False assert cfg.rate_limit == 60 - def test_ssl_enabled_from_config(self): + def test_ssl_enabled_from_config(self) -> None: cfg = self._make_config_with_file({'ssl_enabled': True}) assert cfg.ssl_enabled is True - def test_ssl_cert_key_from_config(self): + def test_ssl_cert_key_from_config(self) -> None: cfg = self._make_config_with_file({ 'ssl_cert_path': '/config/cert.pem', 'ssl_key_path': '/config/key.pem', @@ -319,27 +325,27 @@ def test_ssl_cert_key_from_config(self): assert cfg.ssl_cert_path == '/config/cert.pem' assert cfg.ssl_key_path == '/config/key.pem' - def test_domain_from_config(self): + def test_domain_from_config(self) -> None: cfg = self._make_config_with_file({'domain': 'config.example.com'}) assert cfg.domain == 'config.example.com' - def test_ssl_verify_mode_from_config(self): + def test_ssl_verify_mode_from_config(self) -> None: cfg = self._make_config_with_file({'ssl_verify_mode': 'CERT_NONE'}) assert cfg.ssl_verify_mode == 'CERT_NONE' - def test_allowed_hosts_from_config_string(self): + def test_allowed_hosts_from_config_string(self) -> None: cfg = self._make_config_with_file({'allowed_hosts': 'host1.com,host2.com'}) assert cfg.allowed_hosts == ['host1.com', 'host2.com'] - def test_allowed_hosts_from_config_list(self): + def test_allowed_hosts_from_config_list(self) -> None: cfg = self._make_config_with_file({'allowed_hosts': ['host1.com', 'host2.com']}) assert cfg.allowed_hosts == ['host1.com', 'host2.com'] - def test_cors_origins_from_config(self): + def test_cors_origins_from_config(self) -> None: cfg = self._make_config_with_file({'cors_origins': 'http://app.com'}) assert cfg.cors_origins == ['http://app.com'] - def test_numeric_settings_from_config(self): + def test_numeric_settings_from_config(self) -> None: cfg = self._make_config_with_file({ 'max_request_size': '2097152', 'rate_limit': '100', @@ -351,11 +357,11 @@ def test_numeric_settings_from_config(self): assert cfg.request_timeout == 45 assert cfg.hsts_max_age == 7200 - def test_use_hsts_from_config(self): + def test_use_hsts_from_config(self) -> None: cfg = self._make_config_with_file({'use_hsts': False}) assert cfg.use_hsts is False - def test_auth_from_config(self): + def test_auth_from_config(self) -> None: cfg = self._make_config_with_file({ 'auth': { 'basic': { @@ -367,7 +373,7 @@ def test_auth_from_config(self): assert cfg.basic_auth_user == 'config_user' assert cfg.basic_auth_password == 'config_pass' - def test_auth_partial_user_only(self): + def test_auth_partial_user_only(self) -> None: cfg = self._make_config_with_file({ 'auth': { 'basic': { @@ -378,7 +384,7 @@ def test_auth_partial_user_only(self): assert cfg.basic_auth_user == 'just_user' assert cfg.basic_auth_password is None - def test_auth_partial_password_only(self): + def test_auth_partial_password_only(self) -> None: cfg = self._make_config_with_file({ 'auth': { 'basic': { @@ -389,19 +395,19 @@ def test_auth_partial_password_only(self): assert cfg.basic_auth_user is None assert cfg.basic_auth_password == 'just_pass' - def test_auth_not_dict_ignored(self): + def test_auth_not_dict_ignored(self) -> None: """If auth is not a dict, it should be ignored gracefully.""" cfg = self._make_config_with_file({'auth': 'not_a_dict'}) assert cfg.basic_auth_user is None assert cfg.basic_auth_password is None - def test_auth_basic_not_dict_ignored(self): + def test_auth_basic_not_dict_ignored(self) -> None: """If auth.basic is not a dict, it should be ignored gracefully.""" cfg = self._make_config_with_file({'auth': {'basic': 'not_a_dict'}}) assert cfg.basic_auth_user is None assert cfg.basic_auth_password is None - def test_config_file_overrides_env(self): + def test_config_file_overrides_env(self) -> None: """Config file settings should override environment variable settings.""" mock_config_loader_instance = MagicMock() mock_config_loader_instance.has_config.return_value = True @@ -430,7 +436,7 @@ def test_config_file_overrides_env(self): assert cfg.rate_limit == 200 assert cfg.domain == 'config-domain.com' - def test_explicit_config_file_path(self): + def test_explicit_config_file_path(self) -> None: """When config_file is passed explicitly, find_config_file should not be called.""" mock_config_loader_instance = MagicMock() mock_config_loader_instance.has_config.return_value = True @@ -451,7 +457,7 @@ def test_explicit_config_file_path(self): mock_find.assert_not_called() assert cfg.rate_limit == 999 - def test_service_name_passed_to_find_config(self): + def test_service_name_passed_to_find_config(self) -> None: """service_name should be forwarded to find_config_file when no config_file given.""" clean = {k: v for k, v in os.environ.items() if k not in ENV_CLEAR_KEYS} with patch.dict(os.environ, clean, clear=True): @@ -468,47 +474,51 @@ def test_service_name_passed_to_find_config(self): class TestValidateSSLConfig: """Test SSL configuration validation.""" - def test_ssl_disabled_always_valid(self): + def test_ssl_disabled_always_valid(self) -> None: cfg = _make_config() is_valid, error = cfg.validate_ssl_config() assert is_valid is True assert error is None - def test_ssl_enabled_missing_cert_path(self): + def test_ssl_enabled_missing_cert_path(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='true') cfg.ssl_cert_path = None cfg.ssl_key_path = '/path/to/key.pem' is_valid, error = cfg.validate_ssl_config() assert is_valid is False + assert error is not None assert 'SWML_SSL_CERT_PATH' in error - def test_ssl_enabled_missing_key_path(self): + def test_ssl_enabled_missing_key_path(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='true') cfg.ssl_cert_path = '/path/to/cert.pem' cfg.ssl_key_path = None is_valid, error = cfg.validate_ssl_config() assert is_valid is False + assert error is not None assert 'SWML_SSL_KEY_PATH' in error - def test_ssl_enabled_cert_file_not_found(self): + def test_ssl_enabled_cert_file_not_found(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='true') cfg.ssl_cert_path = '/nonexistent/cert.pem' cfg.ssl_key_path = '/nonexistent/key.pem' with patch('os.path.exists', side_effect=lambda p: p != '/nonexistent/cert.pem'): is_valid, error = cfg.validate_ssl_config() assert is_valid is False + assert error is not None assert 'certificate file not found' in error - def test_ssl_enabled_key_file_not_found(self): + def test_ssl_enabled_key_file_not_found(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='true') cfg.ssl_cert_path = '/exists/cert.pem' cfg.ssl_key_path = '/nonexistent/key.pem' with patch('os.path.exists', side_effect=lambda p: p == '/exists/cert.pem'): is_valid, error = cfg.validate_ssl_config() assert is_valid is False + assert error is not None assert 'key file not found' in error - def test_ssl_enabled_both_files_exist(self): + def test_ssl_enabled_both_files_exist(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='true') cfg.ssl_cert_path = '/exists/cert.pem' cfg.ssl_key_path = '/exists/key.pem' @@ -521,11 +531,11 @@ def test_ssl_enabled_both_files_exist(self): class TestGetSSLContextKwargs: """Test get_ssl_context_kwargs method.""" - def test_ssl_disabled_returns_empty(self): + def test_ssl_disabled_returns_empty(self) -> None: cfg = _make_config() assert cfg.get_ssl_context_kwargs() == {} - def test_ssl_enabled_valid_returns_kwargs(self): + def test_ssl_enabled_valid_returns_kwargs(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='true') cfg.ssl_cert_path = '/exists/cert.pem' cfg.ssl_key_path = '/exists/key.pem' @@ -536,14 +546,14 @@ def test_ssl_enabled_valid_returns_kwargs(self): 'ssl_keyfile': '/exists/key.pem', } - def test_ssl_enabled_invalid_returns_empty(self): + def test_ssl_enabled_invalid_returns_empty(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='true') cfg.ssl_cert_path = None cfg.ssl_key_path = None result = cfg.get_ssl_context_kwargs() assert result == {} - def test_ssl_enabled_invalid_logs_error(self): + def test_ssl_enabled_invalid_logs_error(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='true') cfg.ssl_cert_path = None with patch('signalwire.core.security_config.logger') as mock_logger: @@ -554,23 +564,23 @@ def test_ssl_enabled_invalid_logs_error(self): class TestGetBasicAuth: """Test get_basic_auth credential generation and caching.""" - def test_default_username(self): + def test_default_username(self) -> None: cfg = _make_config() username, _ = cfg.get_basic_auth() assert username == 'signalwire' - def test_custom_username(self): + def test_custom_username(self) -> None: cfg = _make_config(SWML_BASIC_AUTH_USER='custom_user') username, _ = cfg.get_basic_auth() assert username == 'custom_user' - def test_generates_password_when_not_set(self): + def test_generates_password_when_not_set(self) -> None: cfg = _make_config() _, password = cfg.get_basic_auth() assert password is not None assert len(password) > 0 - def test_password_is_url_safe_token(self): + def test_password_is_url_safe_token(self) -> None: """Verify the generated password comes from secrets.token_urlsafe.""" cfg = _make_config() with patch('signalwire.core.security_config.secrets.token_urlsafe', @@ -579,7 +589,7 @@ def test_password_is_url_safe_token(self): mock_token.assert_called_once_with(32) assert password == 'mock_token_abc' - def test_password_caching_stability_same_instance(self): + def test_password_caching_stability_same_instance(self) -> None: """Multiple calls to get_basic_auth on the same instance return the same password.""" cfg = _make_config() _, password1 = cfg.get_basic_auth() @@ -588,7 +598,7 @@ def test_password_caching_stability_same_instance(self): assert password1 == password2 assert password2 == password3 - def test_password_caching_does_not_regenerate(self): + def test_password_caching_does_not_regenerate(self) -> None: """After the first call generates a password, subsequent calls must not call secrets again.""" cfg = _make_config() with patch('signalwire.core.security_config.secrets.token_urlsafe', @@ -603,12 +613,12 @@ def test_password_caching_does_not_regenerate(self): mock_token.assert_not_called() assert pw2 == 'first_token' - def test_preset_password_not_overwritten(self): + def test_preset_password_not_overwritten(self) -> None: cfg = _make_config(SWML_BASIC_AUTH_PASSWORD='env_password') _, password = cfg.get_basic_auth() assert password == 'env_password' - def test_preset_password_stability(self): + def test_preset_password_stability(self) -> None: """Pre-set password stays the same across calls.""" cfg = _make_config(SWML_BASIC_AUTH_PASSWORD='stable') _, pw1 = cfg.get_basic_auth() @@ -616,20 +626,20 @@ def test_preset_password_stability(self): assert pw1 == 'stable' assert pw2 == 'stable' - def test_externally_set_password_preserved(self): + def test_externally_set_password_preserved(self) -> None: """Setting basic_auth_password directly is respected by get_basic_auth.""" cfg = _make_config() cfg.basic_auth_password = 'manual_password' _, password = cfg.get_basic_auth() assert password == 'manual_password' - def test_returns_tuple(self): + def test_returns_tuple(self) -> None: cfg = _make_config() result = cfg.get_basic_auth() assert isinstance(result, tuple) assert len(result) == 2 - def test_generated_passwords_differ_between_instances(self): + def test_generated_passwords_differ_between_instances(self) -> None: """Different SecurityConfig instances should generate different passwords.""" cfg1 = _make_config() cfg2 = _make_config() @@ -644,7 +654,7 @@ def test_generated_passwords_differ_between_instances(self): class TestGetSecurityHeaders: """Test get_security_headers method.""" - def test_http_headers_no_hsts(self): + def test_http_headers_no_hsts(self) -> None: cfg = _make_config() headers = cfg.get_security_headers(is_https=False) assert 'X-Content-Type-Options' in headers @@ -654,32 +664,32 @@ def test_http_headers_no_hsts(self): assert headers['Referrer-Policy'] == 'strict-origin-when-cross-origin' assert 'Strict-Transport-Security' not in headers - def test_https_with_hsts_enabled(self): + def test_https_with_hsts_enabled(self) -> None: cfg = _make_config() headers = cfg.get_security_headers(is_https=True) assert 'Strict-Transport-Security' in headers assert '31536000' in headers['Strict-Transport-Security'] assert 'includeSubDomains' in headers['Strict-Transport-Security'] - def test_https_with_hsts_disabled(self): + def test_https_with_hsts_disabled(self) -> None: cfg = _make_config() cfg.use_hsts = False headers = cfg.get_security_headers(is_https=True) assert 'Strict-Transport-Security' not in headers - def test_https_custom_hsts_max_age(self): + def test_https_custom_hsts_max_age(self) -> None: cfg = _make_config() cfg.hsts_max_age = 86400 headers = cfg.get_security_headers(is_https=True) assert 'max-age=86400' in headers['Strict-Transport-Security'] - def test_default_is_http(self): + def test_default_is_http(self) -> None: """Default is_https=False.""" cfg = _make_config() headers = cfg.get_security_headers() assert 'Strict-Transport-Security' not in headers - def test_always_includes_base_headers(self): + def test_always_includes_base_headers(self) -> None: """Base security headers are always present regardless of HTTPS status.""" cfg = _make_config() for is_https in (True, False): @@ -693,26 +703,26 @@ def test_always_includes_base_headers(self): class TestShouldAllowHost: """Test should_allow_host method.""" - def test_wildcard_allows_all(self): + def test_wildcard_allows_all(self) -> None: cfg = _make_config() assert cfg.should_allow_host('anything.com') is True assert cfg.should_allow_host('') is True assert cfg.should_allow_host('localhost') is True - def test_specific_host_allowed(self): + def test_specific_host_allowed(self) -> None: cfg = _make_config(SWML_ALLOWED_HOSTS='example.com,api.example.com') assert cfg.should_allow_host('example.com') is True assert cfg.should_allow_host('api.example.com') is True - def test_host_not_in_list(self): + def test_host_not_in_list(self) -> None: cfg = _make_config(SWML_ALLOWED_HOSTS='example.com') assert cfg.should_allow_host('other.com') is False - def test_empty_host_not_allowed_when_specific(self): + def test_empty_host_not_allowed_when_specific(self) -> None: cfg = _make_config(SWML_ALLOWED_HOSTS='example.com') assert cfg.should_allow_host('') is False - def test_case_sensitive_matching(self): + def test_case_sensitive_matching(self) -> None: """Host matching is case-sensitive (as set by the environment).""" cfg = _make_config(SWML_ALLOWED_HOSTS='Example.com') assert cfg.should_allow_host('Example.com') is True @@ -722,7 +732,7 @@ def test_case_sensitive_matching(self): class TestGetCorsConfig: """Test get_cors_config method.""" - def test_default_cors_config(self): + def test_default_cors_config(self) -> None: cfg = _make_config() cors = cfg.get_cors_config() assert cors['allow_origins'] == ['*'] @@ -730,12 +740,12 @@ def test_default_cors_config(self): assert cors['allow_methods'] == ['*'] assert cors['allow_headers'] == ['*'] - def test_custom_cors_origins(self): + def test_custom_cors_origins(self) -> None: cfg = _make_config(SWML_CORS_ORIGINS='http://localhost:3000,http://app.com') cors = cfg.get_cors_config() assert cors['allow_origins'] == ['http://localhost:3000', 'http://app.com'] - def test_cors_config_keys(self): + def test_cors_config_keys(self) -> None: cfg = _make_config() cors = cfg.get_cors_config() assert set(cors.keys()) == {'allow_origins', 'allow_credentials', 'allow_methods', 'allow_headers'} @@ -744,11 +754,11 @@ def test_cors_config_keys(self): class TestGetUrlScheme: """Test get_url_scheme method.""" - def test_http_when_ssl_disabled(self): + def test_http_when_ssl_disabled(self) -> None: cfg = _make_config() assert cfg.get_url_scheme() == 'http' - def test_https_when_ssl_enabled(self): + def test_https_when_ssl_enabled(self) -> None: cfg = _make_config(SWML_SSL_ENABLED='true') assert cfg.get_url_scheme() == 'https' @@ -756,13 +766,13 @@ def test_https_when_ssl_enabled(self): class TestLogConfig: """Test log_config method.""" - def test_log_config_calls_logger(self): + def test_log_config_calls_logger(self) -> None: cfg = _make_config() with patch('signalwire.core.security_config.logger') as mock_logger: cfg.log_config('test_service') mock_logger.info.assert_called_once() - def test_log_config_includes_service_name(self): + def test_log_config_includes_service_name(self) -> None: cfg = _make_config() with patch('signalwire.core.security_config.logger') as mock_logger: cfg.log_config('my_service') @@ -772,7 +782,7 @@ def test_log_config_includes_service_name(self): # Keyword args should include service assert call_kwargs[1]['service'] == 'my_service' - def test_log_config_includes_key_fields(self): + def test_log_config_includes_key_fields(self) -> None: cfg = _make_config() with patch('signalwire.core.security_config.logger') as mock_logger: cfg.log_config('svc') @@ -786,7 +796,7 @@ def test_log_config_includes_key_fields(self): assert 'use_hsts' in kwargs assert 'has_basic_auth' in kwargs - def test_log_config_has_basic_auth_true(self): + def test_log_config_has_basic_auth_true(self) -> None: cfg = _make_config( SWML_BASIC_AUTH_USER='user', SWML_BASIC_AUTH_PASSWORD='pass', @@ -796,7 +806,7 @@ def test_log_config_has_basic_auth_true(self): kwargs = mock_logger.info.call_args[1] assert kwargs['has_basic_auth'] is True - def test_log_config_has_basic_auth_false(self): + def test_log_config_has_basic_auth_false(self) -> None: cfg = _make_config() with patch('signalwire.core.security_config.logger') as mock_logger: cfg.log_config('svc') @@ -807,12 +817,12 @@ def test_log_config_has_basic_auth_false(self): class TestGlobalInstance: """Test the module-level global security_config instance.""" - def test_global_instance_exists(self): + def test_global_instance_exists(self) -> None: from signalwire.core.security_config import security_config from signalwire.core.security_config import SecurityConfig assert isinstance(security_config, SecurityConfig) - def test_global_instance_is_same_on_reimport(self): + def test_global_instance_is_same_on_reimport(self) -> None: from signalwire.core.security_config import security_config as sc1 from signalwire.core.security_config import security_config as sc2 assert sc1 is sc2 @@ -821,7 +831,7 @@ def test_global_instance_is_same_on_reimport(self): class TestInitOrder: """Test that __init__ applies configuration in the correct priority order.""" - def test_defaults_then_env_then_config_file(self): + def test_defaults_then_env_then_config_file(self) -> None: """ Verify: defaults are set first, then env overrides them, then config file overrides env. @@ -855,29 +865,29 @@ def test_defaults_then_env_then_config_file(self): class TestEdgeCases: """Test edge cases and boundary conditions.""" - def test_zero_rate_limit(self): + def test_zero_rate_limit(self) -> None: cfg = _make_config(SWML_RATE_LIMIT='0') assert cfg.rate_limit == 0 - def test_zero_request_timeout(self): + def test_zero_request_timeout(self) -> None: cfg = _make_config(SWML_REQUEST_TIMEOUT='0') assert cfg.request_timeout == 0 - def test_zero_max_request_size(self): + def test_zero_max_request_size(self) -> None: cfg = _make_config(SWML_MAX_REQUEST_SIZE='0') assert cfg.max_request_size == 0 - def test_zero_hsts_max_age(self): + def test_zero_hsts_max_age(self) -> None: cfg = _make_config(SWML_HSTS_MAX_AGE='0') assert cfg.hsts_max_age == 0 headers = cfg.get_security_headers(is_https=True) assert 'max-age=0' in headers['Strict-Transport-Security'] - def test_very_large_max_request_size(self): + def test_very_large_max_request_size(self) -> None: cfg = _make_config(SWML_MAX_REQUEST_SIZE='1073741824') # 1GB assert cfg.max_request_size == 1073741824 - def test_ssl_validate_after_manual_state_change(self): + def test_ssl_validate_after_manual_state_change(self) -> None: """Validate SSL after manually changing attributes.""" cfg = _make_config() cfg.ssl_enabled = True @@ -887,25 +897,25 @@ def test_ssl_validate_after_manual_state_change(self): is_valid, error = cfg.validate_ssl_config() assert is_valid is True - def test_allowed_hosts_single_entry(self): + def test_allowed_hosts_single_entry(self) -> None: cfg = _make_config(SWML_ALLOWED_HOSTS='only-this-host.com') assert cfg.allowed_hosts == ['only-this-host.com'] assert cfg.should_allow_host('only-this-host.com') is True assert cfg.should_allow_host('other.com') is False - def test_parse_list_with_whitespace_only_items(self): + def test_parse_list_with_whitespace_only_items(self) -> None: cfg = _make_config() result = cfg._parse_list('a, , b, ,c') assert result == ['a', 'b', 'c'] - def test_get_basic_auth_empty_string_user(self): + def test_get_basic_auth_empty_string_user(self) -> None: """Empty string user from env should be treated as falsy, defaulting to 'signalwire'.""" cfg = _make_config() cfg.basic_auth_user = '' username, _ = cfg.get_basic_auth() assert username == 'signalwire' - def test_get_basic_auth_empty_string_password_generates_new(self): + def test_get_basic_auth_empty_string_password_generates_new(self) -> None: """Empty string password should be treated as falsy, generating a new one.""" cfg = _make_config() cfg.basic_auth_password = '' @@ -913,7 +923,7 @@ def test_get_basic_auth_empty_string_password_generates_new(self): assert len(password) > 0 assert password != '' - def test_multiple_env_vars_combined(self): + def test_multiple_env_vars_combined(self) -> None: """Test setting many environment variables simultaneously.""" cfg = _make_config( SWML_SSL_ENABLED='true', diff --git a/tests/unit/core/test_session_manager.py b/tests/unit/core/test_session_manager.py index 164bf8df..18a6bf4e 100644 --- a/tests/unit/core/test_session_manager.py +++ b/tests/unit/core/test_session_manager.py @@ -25,7 +25,7 @@ class TestSessionManager: """Test SessionManager functionality""" - def test_basic_initialization(self): + def test_basic_initialization(self) -> None: """Test basic SessionManager initialization""" manager = SessionManager() @@ -33,7 +33,7 @@ def test_basic_initialization(self): assert len(manager.secret_key) >= 32 # Should be secure length assert manager.token_expiry_secs == 900 # Default 15 minutes - def test_initialization_with_custom_params(self): + def test_initialization_with_custom_params(self) -> None: """Test initialization with custom parameters""" custom_secret = "my_custom_secret_key_that_is_long_enough" custom_expiry = 7200 # 2 hours @@ -46,7 +46,7 @@ def test_initialization_with_custom_params(self): assert manager.secret_key == custom_secret assert manager.token_expiry_secs == custom_expiry - def test_create_session(self): + def test_create_session(self) -> None: """Test session creation""" manager = SessionManager() @@ -60,7 +60,7 @@ def test_create_session(self): assert len(auto_call_id) > 0 assert isinstance(auto_call_id, str) - def test_generate_token_basic(self): + def test_generate_token_basic(self) -> None: """Test basic token generation""" manager = SessionManager() @@ -77,7 +77,7 @@ def test_generate_token_basic(self): except Exception: pytest.fail("Token should be valid base64") - def test_create_tool_token_alias(self): + def test_create_tool_token_alias(self) -> None: """Test create_tool_token alias""" manager = SessionManager() @@ -90,7 +90,7 @@ def test_create_tool_token_alias(self): assert len(token1) > 0 assert len(token2) > 0 - def test_validate_token_valid(self): + def test_validate_token_valid(self) -> None: """Test validating valid token""" manager = SessionManager() @@ -99,7 +99,7 @@ def test_validate_token_valid(self): # Should be valid immediately assert manager.validate_token("call_123", "test_function", token) is True - def test_validate_token_wrong_function(self): + def test_validate_token_wrong_function(self) -> None: """Test validating token with wrong function name""" manager = SessionManager() @@ -108,7 +108,7 @@ def test_validate_token_wrong_function(self): # Should be invalid for different function assert manager.validate_token("call_123", "other_function", token) is False - def test_validate_token_wrong_call_id(self): + def test_validate_token_wrong_call_id(self) -> None: """Test validating token with wrong call ID""" manager = SessionManager() @@ -117,7 +117,7 @@ def test_validate_token_wrong_call_id(self): # Should be invalid for different call_id assert manager.validate_token("other_call", "test_function", token) is False - def test_validate_token_expired(self): + def test_validate_token_expired(self) -> None: """Test validating expired token""" manager = SessionManager(token_expiry_secs=1) # 1 second expiry @@ -129,7 +129,7 @@ def test_validate_token_expired(self): # Should be invalid due to expiry assert manager.validate_token("call_123", "test_function", token) is False - def test_validate_token_invalid_signature(self): + def test_validate_token_invalid_signature(self) -> None: """Test validating token with invalid signature""" manager1 = SessionManager(secret_key="secret1" + "x" * 24) manager2 = SessionManager(secret_key="secret2" + "x" * 24) @@ -139,7 +139,7 @@ def test_validate_token_invalid_signature(self): # Should be invalid with different secret assert manager2.validate_token("call_123", "test_function", token) is False - def test_validate_token_malformed(self): + def test_validate_token_malformed(self) -> None: """Test validating malformed token""" manager = SessionManager() @@ -155,7 +155,7 @@ def test_validate_token_malformed(self): for token in malformed_tokens: assert manager.validate_token("call_123", "test_function", token) is False - def test_validate_token_empty_call_id(self): + def test_validate_token_empty_call_id(self) -> None: """Test validating token with empty call_id (special case)""" manager = SessionManager() @@ -163,9 +163,9 @@ def test_validate_token_empty_call_id(self): # Should reject with empty call_id (no longer falls back to token's call_id) assert manager.validate_token("", "test_function", token) is False - assert manager.validate_token(None, "test_function", token) is False + assert manager.validate_token(None, "test_function", token) is False # type: ignore[arg-type] # intentional invalid input for validation test - def test_validate_tool_token_alias(self): + def test_validate_tool_token_alias(self) -> None: """Test validate_tool_token alias""" manager = SessionManager() @@ -175,7 +175,7 @@ def test_validate_tool_token_alias(self): assert manager.validate_tool_token("test_function", token, "call_123") is True assert manager.validate_tool_token("wrong_function", token, "call_123") is False - def test_debug_token(self): + def test_debug_token(self) -> None: """Test token debugging functionality""" manager = SessionManager() manager._debug_mode = True @@ -202,7 +202,7 @@ def test_debug_token(self): assert isinstance(status["expires_in_seconds"], int) assert isinstance(status["is_expired"], bool) - def test_debug_token_invalid(self): + def test_debug_token_invalid(self) -> None: """Test debugging invalid token""" manager = SessionManager() manager._debug_mode = True @@ -214,7 +214,7 @@ def test_debug_token_invalid(self): assert "valid_format" in debug_info assert debug_info["valid_format"] is False - def test_legacy_methods(self): + def test_legacy_methods(self) -> None: """Test legacy API compatibility methods""" manager = SessionManager() @@ -232,7 +232,7 @@ def test_legacy_methods(self): class TestSessionManagerErrorHandling: """Test error handling in SessionManager""" - def test_token_generation_edge_cases(self): + def test_token_generation_edge_cases(self) -> None: """Test token generation with edge cases""" manager = SessionManager() @@ -246,7 +246,7 @@ def test_token_generation_edge_cases(self): assert isinstance(token, str) assert manager.validate_token("", "test_func", token) is False - def test_validation_with_corrupted_token(self): + def test_validation_with_corrupted_token(self) -> None: """Test validation with corrupted token data""" manager = SessionManager() @@ -257,7 +257,7 @@ def test_validation_with_corrupted_token(self): corrupted = token[:-5] + "XXXXX" assert manager.validate_token("call_123", "test_function", corrupted) is False - def test_time_manipulation_resistance(self): + def test_time_manipulation_resistance(self) -> None: """Test resistance to time manipulation attacks""" manager = SessionManager(token_expiry_secs=3600) @@ -273,7 +273,7 @@ def test_time_manipulation_resistance(self): class TestSessionManagerIntegration: """Test integration scenarios""" - def test_complete_token_workflow(self): + def test_complete_token_workflow(self) -> None: """Test complete token management workflow""" manager = SessionManager() manager._debug_mode = True @@ -299,7 +299,7 @@ def test_complete_token_workflow(self): assert manager.activate_session(call_id) is True assert manager.end_session(call_id) is True - def test_multiple_function_tokens(self): + def test_multiple_function_tokens(self) -> None: """Test managing tokens for multiple functions""" manager = SessionManager() call_id = "multi_func_call" @@ -320,7 +320,7 @@ def test_multiple_function_tokens(self): if other_func != func: assert manager.validate_token(call_id, other_func, token) is False - def test_concurrent_sessions(self): + def test_concurrent_sessions(self) -> None: """Test managing multiple concurrent sessions""" manager = SessionManager() @@ -340,7 +340,7 @@ def test_concurrent_sessions(self): if other_call_id != call_id: assert manager.validate_token(other_call_id, "test_function", session_data["token"]) is False - def test_token_expiry_workflow(self): + def test_token_expiry_workflow(self) -> None: """Test token expiry workflow""" manager = SessionManager(token_expiry_secs=2) # 2 second expiry @@ -362,7 +362,7 @@ def test_token_expiry_workflow(self): new_token = manager.generate_token("test_function", call_id) assert manager.validate_token(call_id, "test_function", new_token) is True - def test_security_isolation(self): + def test_security_isolation(self) -> None: """Test security isolation between managers""" manager1 = SessionManager(secret_key="secret1" + "x" * 24) manager2 = SessionManager(secret_key="secret2" + "x" * 24) @@ -388,7 +388,7 @@ def test_security_isolation(self): # Should be invalid with manager1 assert manager1.validate_token(call_id, function_name, token2) is False - def test_performance_with_many_tokens(self): + def test_performance_with_many_tokens(self) -> None: """Test performance with many token operations""" manager = SessionManager() @@ -410,7 +410,7 @@ def test_performance_with_many_tokens(self): if i != j: assert manager.validate_token(other_call_id, other_function_name, token) is False - def test_token_structure_consistency(self): + def test_token_structure_consistency(self) -> None: """Test token structure consistency""" manager = SessionManager() manager._debug_mode = True diff --git a/tests/unit/core/test_skill_manager.py b/tests/unit/core/test_skill_manager.py index 988a1d3c..f80e22da 100644 --- a/tests/unit/core/test_skill_manager.py +++ b/tests/unit/core/test_skill_manager.py @@ -13,11 +13,13 @@ import pytest import os +from typing import Any, ClassVar from unittest.mock import Mock, patch, MagicMock from pathlib import Path from signalwire.core.skill_manager import SkillManager from signalwire.core.skill_base import SkillBase +from signalwire.core.agent_base import AgentBase class MockSkill(SkillBase): @@ -25,13 +27,13 @@ class MockSkill(SkillBase): SKILL_NAME = "mock_skill" SKILL_DESCRIPTION = "A mock skill for testing" SKILL_VERSION = "1.0.0" - REQUIRED_PACKAGES = [] - REQUIRED_ENV_VARS = [] + REQUIRED_PACKAGES: ClassVar[list[str]] = [] + REQUIRED_ENV_VARS: ClassVar[list[str]] = [] SUPPORTS_MULTIPLE_INSTANCES = False @classmethod - def get_parameter_schema(cls): - schema = super().get_parameter_schema() + def get_parameter_schema(cls) -> dict[str, Any]: + schema: dict[str, Any] = super().get_parameter_schema() schema["mock_param"] = { "type": "string", "description": "A mock parameter", @@ -40,17 +42,17 @@ def get_parameter_schema(cls): } return schema - def __init__(self, agent, params=None): + def __init__(self, agent: AgentBase, params: dict[str, Any] | None = None) -> None: super().__init__(agent, params) self.setup_called = False self.register_tools_called = False self.cleanup_called = False - def setup(self): + def setup(self) -> bool: self.setup_called = True return True - def register_tools(self): + def register_tools(self) -> None: self.register_tools_called = True self.agent.define_tool( name="mock_tool", @@ -68,8 +70,8 @@ class FailingMockSkill(SkillBase): SUPPORTS_MULTIPLE_INSTANCES = False @classmethod - def get_parameter_schema(cls): - schema = super().get_parameter_schema() + def get_parameter_schema(cls) -> dict[str, Any]: + schema: dict[str, Any] = super().get_parameter_schema() schema["fail_param"] = { "type": "string", "description": "A fail parameter", @@ -77,24 +79,24 @@ def get_parameter_schema(cls): } return schema - def setup(self): + def setup(self) -> bool: return False - def register_tools(self): + def register_tools(self) -> None: pass class TestSkillManagerBasic: """Test basic SkillManager functionality""" - def test_initialization(self, mock_agent): + def test_initialization(self, mock_agent: AgentBase) -> None: """Test SkillManager initialization""" skill_manager = SkillManager(mock_agent) assert skill_manager.agent is mock_agent assert skill_manager.loaded_skills == {} - def test_agent_reference(self, mock_agent): + def test_agent_reference(self, mock_agent: AgentBase) -> None: """Test that skill manager maintains agent reference""" skill_manager = SkillManager(mock_agent) @@ -104,7 +106,7 @@ def test_agent_reference(self, mock_agent): class TestSkillManagerLoading: """Test skill loading functionality""" - def test_load_skill_success(self, mock_agent): + def test_load_skill_success(self, mock_agent: AgentBase) -> None: """Test successful skill loading""" skill_manager = SkillManager(mock_agent) @@ -120,7 +122,7 @@ def test_load_skill_success(self, mock_agent): assert skill_instance.setup_called is True assert skill_instance.register_tools_called is True - def test_load_skill_with_params(self, mock_agent): + def test_load_skill_with_params(self, mock_agent: AgentBase) -> None: """Test loading skill with parameters""" skill_manager = SkillManager(mock_agent) @@ -131,7 +133,7 @@ def test_load_skill_with_params(self, mock_agent): skill_instance = list(skill_manager.loaded_skills.values())[0] assert skill_instance.params == params - def test_load_skill_setup_failure(self, mock_agent): + def test_load_skill_setup_failure(self, mock_agent: AgentBase) -> None: """Test loading skill that fails setup""" skill_manager = SkillManager(mock_agent) @@ -141,7 +143,7 @@ def test_load_skill_setup_failure(self, mock_agent): assert "Failed to setup skill" in error assert len(skill_manager.loaded_skills) == 0 - def test_load_already_loaded_skill(self, mock_agent): + def test_load_already_loaded_skill(self, mock_agent: AgentBase) -> None: """Test loading skill that's already loaded""" skill_manager = SkillManager(mock_agent) @@ -154,7 +156,7 @@ def test_load_already_loaded_skill(self, mock_agent): assert success2 is False assert "already loaded" in error2 - def test_load_skill_initialization_error(self, mock_agent): + def test_load_skill_initialization_error(self, mock_agent: AgentBase) -> None: """Test loading skill that fails during initialization""" skill_manager = SkillManager(mock_agent) @@ -164,18 +166,18 @@ class BrokenSkill(SkillBase): SUPPORTS_MULTIPLE_INSTANCES = False @classmethod - def get_parameter_schema(cls): - schema = super().get_parameter_schema() + def get_parameter_schema(cls) -> dict[str, Any]: + schema: dict[str, Any] = super().get_parameter_schema() schema["broken_param"] = {"type": "string", "description": "A param", "required": False} return schema - def __init__(self, agent, params=None): + def __init__(self, agent: AgentBase, params: dict[str, Any] | None = None) -> None: raise Exception("Initialization failed") - def setup(self): + def setup(self) -> bool: return True - def register_tools(self): + def register_tools(self) -> None: pass success, error = skill_manager.load_skill("broken_skill", BrokenSkill) @@ -183,7 +185,7 @@ def register_tools(self): assert success is False assert "Error loading skill" in error - def test_load_skill_without_class_registry_missing(self, mock_agent): + def test_load_skill_without_class_registry_missing(self, mock_agent: AgentBase) -> None: """Test loading skill without providing class when registry is missing""" skill_manager = SkillManager(mock_agent) @@ -199,7 +201,7 @@ def test_load_skill_without_class_registry_missing(self, mock_agent): class TestSkillManagerUnloading: """Test skill unloading functionality""" - def test_unload_skill_success(self, mock_agent): + def test_unload_skill_success(self, mock_agent: AgentBase) -> None: """Test successful skill unloading""" skill_manager = SkillManager(mock_agent) @@ -216,7 +218,7 @@ def test_unload_skill_success(self, mock_agent): assert success is True assert len(skill_manager.loaded_skills) == 0 - def test_unload_nonexistent_skill(self, mock_agent): + def test_unload_nonexistent_skill(self, mock_agent: AgentBase) -> None: """Test unloading non-existent skill""" skill_manager = SkillManager(mock_agent) @@ -224,14 +226,14 @@ def test_unload_nonexistent_skill(self, mock_agent): assert success is False - def test_unload_skill_cleanup_called(self, mock_agent): + def test_unload_skill_cleanup_called(self, mock_agent: AgentBase) -> None: """Test that cleanup is called during unloading""" skill_manager = SkillManager(mock_agent) class CleanupSkill(MockSkill): SKILL_NAME = "cleanup_skill" - - def cleanup(self): + + def cleanup(self) -> None: self.cleanup_called = True # Load and unload skill @@ -241,13 +243,13 @@ def cleanup(self): skill_manager.unload_skill(instance_key) - assert skill_instance.cleanup_called is True + assert skill_instance.cleanup_called is True # type: ignore[attr-defined] # dynamic attr on CleanupSkill subclass class TestSkillManagerQueries: """Test skill query functionality""" - def test_list_loaded_skills(self, mock_agent): + def test_list_loaded_skills(self, mock_agent: AgentBase) -> None: """Test listing loaded skills""" skill_manager = SkillManager(mock_agent) @@ -264,7 +266,7 @@ def test_list_loaded_skills(self, mock_agent): # Only one skill should be loaded due to single instance restriction assert len(loaded) == 1 - def test_has_skill_loaded(self, mock_agent): + def test_has_skill_loaded(self, mock_agent: AgentBase) -> None: """Test checking if skill is loaded""" skill_manager = SkillManager(mock_agent) @@ -280,13 +282,13 @@ def test_has_skill_loaded(self, mock_agent): skill_manager.unload_skill(instance_key) assert skill_manager.has_skill("mock_skill") is False - def test_has_skill_nonexistent(self, mock_agent): + def test_has_skill_nonexistent(self, mock_agent: AgentBase) -> None: """Test checking for non-existent skill""" skill_manager = SkillManager(mock_agent) assert skill_manager.has_skill("nonexistent_skill") is False - def test_get_skill_instance(self, mock_agent): + def test_get_skill_instance(self, mock_agent: AgentBase) -> None: """Test getting skill instance""" skill_manager = SkillManager(mock_agent) @@ -298,7 +300,7 @@ def test_get_skill_instance(self, mock_agent): assert isinstance(instance, MockSkill) assert instance.agent is mock_agent - def test_get_skill_instance_not_loaded(self, mock_agent): + def test_get_skill_instance_not_loaded(self, mock_agent: AgentBase) -> None: """Test getting instance of non-loaded skill""" skill_manager = SkillManager(mock_agent) @@ -309,19 +311,19 @@ def test_get_skill_instance_not_loaded(self, mock_agent): class TestSkillManagerValidation: """Test skill validation functionality""" - def test_validate_skill_requirements_success(self, mock_agent): + def test_validate_skill_requirements_success(self, mock_agent: AgentBase) -> None: """Test successful skill requirement validation""" skill_manager = SkillManager(mock_agent) class ValidSkill(MockSkill): SKILL_NAME = "valid_skill" - REQUIRED_PACKAGES = [] # No requirements - REQUIRED_ENV_VARS = [] + REQUIRED_PACKAGES: ClassVar[list[str]] = [] # No requirements + REQUIRED_ENV_VARS: ClassVar[list[str]] = [] success, error = skill_manager.load_skill("valid_skill", ValidSkill) assert success is True - def test_validate_skill_missing_env_vars(self, mock_agent): + def test_validate_skill_missing_env_vars(self, mock_agent: AgentBase) -> None: """Test skill with missing environment variables""" skill_manager = SkillManager(mock_agent) @@ -333,7 +335,7 @@ class EnvSkill(MockSkill): assert success is False assert "Missing required environment variables" in error - def test_validate_skill_with_env_vars(self, mock_agent, mock_env_vars): + def test_validate_skill_with_env_vars(self, mock_agent: AgentBase, mock_env_vars: dict[str, str]) -> None: """Test skill with required environment variables present""" skill_manager = SkillManager(mock_agent) @@ -344,7 +346,7 @@ class EnvSkill(MockSkill): success, error = skill_manager.load_skill("env_skill", EnvSkill) assert success is True - def test_validate_skill_missing_packages(self, mock_agent): + def test_validate_skill_missing_packages(self, mock_agent: AgentBase) -> None: """Test skill with missing packages""" skill_manager = SkillManager(mock_agent) @@ -360,14 +362,14 @@ class PackageSkill(MockSkill): class TestSkillManagerErrorHandling: """Test error handling and edge cases""" - def test_load_skill_exception_during_setup(self, mock_agent): + def test_load_skill_exception_during_setup(self, mock_agent: AgentBase) -> None: """Test loading skill that raises exception during setup""" skill_manager = SkillManager(mock_agent) class ExceptionSkill(MockSkill): SKILL_NAME = "exception_skill" - - def setup(self): + + def setup(self) -> bool: raise Exception("Setup failed") success, error = skill_manager.load_skill("exception_skill", ExceptionSkill) @@ -375,14 +377,14 @@ def setup(self): assert success is False assert "Error loading skill" in error - def test_load_skill_exception_during_register_tools(self, mock_agent): + def test_load_skill_exception_during_register_tools(self, mock_agent: AgentBase) -> None: """Test loading skill that raises exception during tool registration""" skill_manager = SkillManager(mock_agent) class ExceptionSkill(MockSkill): SKILL_NAME = "exception_skill" - - def register_tools(self): + + def register_tools(self) -> None: raise Exception("Tool registration failed") success, error = skill_manager.load_skill("exception_skill", ExceptionSkill) @@ -394,12 +396,12 @@ def register_tools(self): class TestSkillManagerIntegration: """Test integration with other components""" - def test_skill_tool_registration_with_agent(self, mock_agent): + def test_skill_tool_registration_with_agent(self, mock_agent: AgentBase) -> None: """Test that skill tools are properly registered with agent""" skill_manager = SkillManager(mock_agent) # Mock the agent's define_tool method - mock_agent.define_tool = Mock() + mock_agent.define_tool = Mock() # type: ignore[method-assign] # mock success, error = skill_manager.load_skill("mock_skill", MockSkill) @@ -407,14 +409,14 @@ def test_skill_tool_registration_with_agent(self, mock_agent): # Should have called agent.define_tool mock_agent.define_tool.assert_called_once() - def test_multiple_skills_loaded(self, mock_agent): + def test_multiple_skills_loaded(self, mock_agent: AgentBase) -> None: """Test loading multiple skills with different names""" skill_manager = SkillManager(mock_agent) class Skill1(MockSkill): SKILL_NAME = "skill1" - - def register_tools(self): + + def register_tools(self) -> None: self.register_tools_called = True self.agent.define_tool( name="skill1_tool", @@ -422,11 +424,11 @@ def register_tools(self): parameters={"type": "object", "properties": {}}, handler=lambda: {"result": "skill1"} ) - + class Skill2(MockSkill): SKILL_NAME = "skill2" - - def register_tools(self): + + def register_tools(self) -> None: self.register_tools_called = True self.agent.define_tool( name="skill2_tool", @@ -434,7 +436,7 @@ def register_tools(self): parameters={"type": "object", "properties": {}}, handler=lambda: {"result": "skill2"} ) - + # Load both skills - should work since they have different names and tools success1, _ = skill_manager.load_skill("skill1", Skill1) success2, _ = skill_manager.load_skill("skill2", Skill2) @@ -443,27 +445,27 @@ def register_tools(self): assert success2 is True assert len(skill_manager.list_loaded_skills()) == 2 - def test_skill_unload_cleanup_order(self, mock_agent): + def test_skill_unload_cleanup_order(self, mock_agent: AgentBase) -> None: """Test that skills are cleaned up in proper order""" skill_manager = SkillManager(mock_agent) - cleanup_order = [] - + cleanup_order: list[str | None] = [] + class OrderedSkill(MockSkill): - def __init__(self, agent, params=None, skill_id=None): + def __init__(self, agent: AgentBase, params: dict[str, Any] | None = None, skill_id: str | None = None) -> None: super().__init__(agent, params) self.skill_id = skill_id - - def cleanup(self): + + def cleanup(self) -> None: cleanup_order.append(self.skill_id) - + # Create skill classes with different IDs and names class Skill1(OrderedSkill): SKILL_NAME = "skill1" - def __init__(self, agent, params=None): + def __init__(self, agent: AgentBase, params: dict[str, Any] | None = None) -> None: super().__init__(agent, params, "skill1") - - def register_tools(self): + + def register_tools(self) -> None: self.register_tools_called = True self.agent.define_tool( name="skill1_tool", @@ -471,13 +473,13 @@ def register_tools(self): parameters={"type": "object", "properties": {}}, handler=lambda: {"result": "skill1"} ) - + class Skill2(OrderedSkill): SKILL_NAME = "skill2" - def __init__(self, agent, params=None): + def __init__(self, agent: AgentBase, params: dict[str, Any] | None = None) -> None: super().__init__(agent, params, "skill2") - - def register_tools(self): + + def register_tools(self) -> None: self.register_tools_called = True self.agent.define_tool( name="skill2_tool", diff --git a/tests/unit/core/test_swaig_function.py b/tests/unit/core/test_swaig_function.py index d94bf885..e1bcf4b6 100644 --- a/tests/unit/core/test_swaig_function.py +++ b/tests/unit/core/test_swaig_function.py @@ -13,6 +13,7 @@ import pytest import json +from typing import Any from unittest.mock import Mock, patch from signalwire.core.swaig_function import SWAIGFunction @@ -21,9 +22,9 @@ class TestSWAIGFunctionInitialization: """Test SWAIGFunction initialization""" - def test_basic_initialization(self): + def test_basic_initialization(self) -> None: """Test basic function initialization""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -38,9 +39,9 @@ def test_handler(): assert func.parameters == {"param1": {"type": "string"}} assert func.handler == test_handler - def test_initialization_with_all_parameters(self): + def test_initialization_with_all_parameters(self) -> None: """Test initialization with all parameters""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -64,9 +65,9 @@ def test_handler(): assert func.webhook_url == "https://example.com/webhook" assert func.is_external is True - def test_initialization_with_defaults(self): + def test_initialization_with_defaults(self) -> None: """Test initialization with default values""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -81,9 +82,9 @@ def test_handler(): assert func.webhook_url is None assert func.is_external is False - def test_is_typed_handler_defaults_false(self): + def test_is_typed_handler_defaults_false(self) -> None: """Test that is_typed_handler defaults to False""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -93,9 +94,9 @@ def test_handler(): ) assert func.is_typed_handler is False - def test_is_typed_handler_set_true(self): + def test_is_typed_handler_set_true(self) -> None: """Test that is_typed_handler can be set to True""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -110,9 +111,9 @@ def test_handler(): class TestSWAIGFunctionExecution: """Test function execution""" - def test_execute_basic(self): + def test_execute_basic(self) -> None: """Test basic function execution""" - def test_handler(args, raw_data): + def test_handler(args: dict[str, Any], raw_data: Any) -> dict[str, Any]: return {"result": "success", "args": args} func = SWAIGFunction( @@ -126,11 +127,11 @@ def test_handler(args, raw_data): assert isinstance(result, dict) assert "response" in result or "result" in result - def test_execute_with_swaig_function_result(self): + def test_execute_with_swaig_function_result(self) -> None: """Test execution returning FunctionResult""" from signalwire.core.function_result import FunctionResult - def test_handler(args, raw_data): + def test_handler(args: dict[str, Any], raw_data: Any) -> FunctionResult: return FunctionResult("Function executed successfully") func = SWAIGFunction( @@ -144,9 +145,9 @@ def test_handler(args, raw_data): assert isinstance(result, dict) assert "response" in result - def test_execute_with_error_handling(self): + def test_execute_with_error_handling(self) -> None: """Test execution with error handling""" - def test_handler(args, raw_data): + def test_handler(args: dict[str, Any], raw_data: Any) -> None: raise ValueError("Test error") func = SWAIGFunction( @@ -161,9 +162,9 @@ def test_handler(args, raw_data): assert isinstance(result, dict) assert "response" in result - def test_call_method(self): + def test_call_method(self) -> None: """Test __call__ method""" - def test_handler(*args, **kwargs): + def test_handler(*args: Any, **kwargs: Any) -> dict[str, Any]: return {"args": args, "kwargs": kwargs} func = SWAIGFunction( @@ -179,9 +180,9 @@ def test_handler(*args, **kwargs): class TestSWAIGFunctionSerialization: """Test function serialization""" - def test_to_swaig_basic(self): + def test_to_swaig_basic(self) -> None: """Test basic to_swaig conversion""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -198,9 +199,9 @@ def test_handler(): assert "parameters" in swaig_dict assert "web_hook_url" in swaig_dict - def test_to_swaig_with_token(self): + def test_to_swaig_with_token(self) -> None: """Test to_swaig with token and call_id""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -214,9 +215,9 @@ def test_handler(): assert "token=test-token" in swaig_dict["web_hook_url"] assert "call_id=call-123" in swaig_dict["web_hook_url"] - def test_to_swaig_with_fillers(self): + def test_to_swaig_with_fillers(self) -> None: """Test to_swaig with fillers""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} fillers = {"thinking": ["Processing...", "Let me think..."]} @@ -232,9 +233,9 @@ def test_handler(): assert swaig_dict["fillers"] == fillers - def test_ensure_parameter_structure(self): + def test_ensure_parameter_structure(self) -> None: """Test parameter structure normalization""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} # Test with simple parameters @@ -254,7 +255,7 @@ def test_handler(): name="test_function", handler=test_handler, description="Test function", - parameters={"type": "object", "properties": {"param1": {"type": "string"}}} + parameters={"type": "object", "properties": {"param1": {"type": "string"}}} # type: ignore[dict-item] # valid mixed-value SWAIG parameter structure ) structure = func2._ensure_parameter_structure() @@ -265,9 +266,9 @@ def test_handler(): class TestSWAIGFunctionValidation: """Test function validation""" - def test_validate_args(self): + def test_validate_args(self) -> None: """Test argument validation""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -283,9 +284,9 @@ def test_handler(): is_valid, errors = func.validate_args({"invalid": "value"}) assert isinstance(is_valid, bool) - def test_function_name_validation(self): + def test_function_name_validation(self) -> None: """Test function name validation""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} # Should accept valid function names @@ -303,19 +304,19 @@ def test_handler(): class TestSWAIGFunctionErrorHandling: """Test error handling and edge cases""" - def test_none_handler(self): + def test_none_handler(self) -> None: """Test handling of None handler""" func = SWAIGFunction( name="test_function", - handler=None, + handler=None, # type: ignore[arg-type] # intentional invalid input for validation test description="Test function" ) assert func.handler is None - def test_empty_function_name(self): + def test_empty_function_name(self) -> None: """Test handling of empty function name""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -326,22 +327,22 @@ def test_handler(): assert func.name == "" - def test_none_description(self): + def test_none_description(self) -> None: """Test handling of None description""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( name="test_function", handler=test_handler, - description=None + description=None # type: ignore[arg-type] # intentional invalid input for validation test ) - + assert func.description is None - def test_execute_with_none_raw_data(self): + def test_execute_with_none_raw_data(self) -> None: """Test execution with None raw_data""" - def test_handler(args, raw_data): + def test_handler(args: dict[str, Any], raw_data: Any) -> dict[str, Any]: return {"args": args, "raw_data": raw_data} func = SWAIGFunction( @@ -358,9 +359,9 @@ def test_handler(args, raw_data): class TestSWAIGFunctionIntegration: """Test integration functionality""" - def test_external_webhook_configuration(self): + def test_external_webhook_configuration(self) -> None: """Test external webhook configuration""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -373,9 +374,9 @@ def test_handler(): assert func.is_external is True assert func.webhook_url == "https://example.com/webhook" - def test_security_configuration(self): + def test_security_configuration(self) -> None: """Test security configuration""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} # Secure function @@ -397,9 +398,9 @@ def test_handler(): assert secure_func.secure is True assert public_func.secure is False - def test_extra_swaig_fields(self): + def test_extra_swaig_fields(self) -> None: """Test extra SWAIG fields""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( @@ -415,9 +416,9 @@ def test_handler(): assert swaig_dict["custom_field"] == "custom_value" assert swaig_dict["another_field"] == {"nested": "data"} - def test_json_serialization(self): + def test_json_serialization(self) -> None: """Test JSON serialization of SWAIG output""" - def test_handler(): + def test_handler() -> dict[str, Any]: return {"result": "success"} func = SWAIGFunction( diff --git a/tests/unit/core/test_swml_builder.py b/tests/unit/core/test_swml_builder.py index bd60f8ed..840f15de 100644 --- a/tests/unit/core/test_swml_builder.py +++ b/tests/unit/core/test_swml_builder.py @@ -22,14 +22,14 @@ class TestSWMLBuilder: """Test SWMLBuilder functionality""" - def test_basic_initialization(self): + def test_basic_initialization(self) -> None: """Test basic SWMLBuilder initialization""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) assert builder.service is mock_service - def test_answer_verb(self): + def test_answer_verb(self) -> None: """Test adding answer verb""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -39,7 +39,7 @@ def test_answer_verb(self): assert result is builder # Should return self for chaining mock_service.add_verb.assert_called_once_with("answer", {}) - def test_answer_verb_with_params(self): + def test_answer_verb_with_params(self) -> None: """Test adding answer verb with parameters""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -49,7 +49,7 @@ def test_answer_verb_with_params(self): assert result is builder mock_service.add_verb.assert_called_once_with("answer", {"max_duration": 30, "codecs": "PCMU,PCMA"}) - def test_hangup_verb(self): + def test_hangup_verb(self) -> None: """Test adding hangup verb""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -59,7 +59,7 @@ def test_hangup_verb(self): assert result is builder mock_service.add_verb.assert_called_once_with("hangup", {}) - def test_hangup_verb_with_reason(self): + def test_hangup_verb_with_reason(self) -> None: """Test adding hangup verb with reason""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -69,7 +69,7 @@ def test_hangup_verb_with_reason(self): assert result is builder mock_service.add_verb.assert_called_once_with("hangup", {"reason": "completed"}) - def test_ai_verb_basic(self): + def test_ai_verb_basic(self) -> None: """Test adding basic AI verb""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -77,9 +77,9 @@ def test_ai_verb_basic(self): result = builder.ai(prompt_text="You are helpful") assert result is builder - mock_service.add_verb.assert_called_once_with("ai", {"prompt": "You are helpful"}) + mock_service.add_verb.assert_called_once_with("ai", {"prompt": {"text": "You are helpful"}}) - def test_ai_verb_with_pom(self): + def test_ai_verb_with_pom(self) -> None: """Test adding AI verb with POM""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -88,9 +88,9 @@ def test_ai_verb_with_pom(self): result = builder.ai(prompt_pom=pom_data) assert result is builder - mock_service.add_verb.assert_called_once_with("ai", {"prompt": pom_data}) + mock_service.add_verb.assert_called_once_with("ai", {"prompt": {"pom": pom_data}}) - def test_ai_verb_with_swaig(self): + def test_ai_verb_with_swaig(self) -> None: """Test adding AI verb with SWAIG configuration""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -102,11 +102,11 @@ def test_ai_verb_with_swaig(self): assert result is builder mock_service.add_verb.assert_called_once_with("ai", { - "prompt": "You are helpful", + "prompt": {"text": "You are helpful"}, "SWAIG": swaig_config }) - def test_ai_verb_with_kwargs(self): + def test_ai_verb_with_kwargs(self) -> None: """Test adding AI verb with additional parameters""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -119,12 +119,12 @@ def test_ai_verb_with_kwargs(self): assert result is builder mock_service.add_verb.assert_called_once_with("ai", { - "prompt": "You are helpful", + "prompt": {"text": "You are helpful"}, "temperature": 0.7, "max_tokens": 150 }) - def test_play_verb_with_url(self): + def test_play_verb_with_url(self) -> None: """Test adding play verb with single URL""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -134,7 +134,7 @@ def test_play_verb_with_url(self): assert result is builder mock_service.add_verb.assert_called_once_with("play", {"url": "test.mp3"}) - def test_play_verb_with_urls(self): + def test_play_verb_with_urls(self) -> None: """Test adding play verb with multiple URLs""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -145,7 +145,7 @@ def test_play_verb_with_urls(self): assert result is builder mock_service.add_verb.assert_called_once_with("play", {"urls": urls}) - def test_play_verb_with_options(self): + def test_play_verb_with_options(self) -> None: """Test adding play verb with options""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -166,7 +166,7 @@ def test_play_verb_with_options(self): } mock_service.add_verb.assert_called_once_with("play", expected_config) - def test_play_verb_no_url_error(self): + def test_play_verb_no_url_error(self) -> None: """Test play verb raises error when no URL provided""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -174,7 +174,7 @@ def test_play_verb_no_url_error(self): with pytest.raises(ValueError, match="Either url or urls must be provided"): builder.play() - def test_say_verb(self): + def test_say_verb(self) -> None: """Test adding say verb""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -184,7 +184,7 @@ def test_say_verb(self): assert result is builder mock_service.add_verb.assert_called_once_with("play", {"url": "say:Hello world"}) - def test_say_verb_with_options(self): + def test_say_verb_with_options(self) -> None: """Test adding say verb with options""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -205,7 +205,7 @@ def test_say_verb_with_options(self): } mock_service.add_verb.assert_called_once_with("play", expected_config) - def test_add_section(self): + def test_add_section(self) -> None: """Test adding section""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -215,7 +215,7 @@ def test_add_section(self): assert result is builder mock_service.add_section.assert_called_once_with("greeting") - def test_build(self): + def test_build(self) -> None: """Test building document""" mock_service = Mock(spec=SWMLService) mock_service.get_document.return_value = {"version": "1.0.0", "sections": {"main": []}} @@ -226,7 +226,7 @@ def test_build(self): assert result == {"version": "1.0.0", "sections": {"main": []}} mock_service.get_document.assert_called_once() - def test_render(self): + def test_render(self) -> None: """Test rendering document""" mock_service = Mock(spec=SWMLService) mock_service.render_document.return_value = '{"version": "1.0.0"}' @@ -237,7 +237,7 @@ def test_render(self): assert result == '{"version": "1.0.0"}' mock_service.render_document.assert_called_once() - def test_reset(self): + def test_reset(self) -> None: """Test resetting document""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -247,7 +247,7 @@ def test_reset(self): assert result is builder mock_service.reset_document.assert_called_once() - def test_method_chaining(self): + def test_method_chaining(self) -> None: """Test method chaining functionality""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -272,18 +272,18 @@ def test_method_chaining(self): class TestSWMLBuilderErrorHandling: """Test error handling in SWMLBuilder""" - def test_initialization_without_service(self): + def test_initialization_without_service(self) -> None: """Test initialization without service raises error""" with pytest.raises(TypeError): - SWMLBuilder() + SWMLBuilder() # type: ignore[call-arg] # intentional invalid input for validation test - def test_initialization_with_none_service(self): + def test_initialization_with_none_service(self) -> None: """Test initialization with None service""" # SWMLBuilder should accept None but it will fail when methods are called - builder = SWMLBuilder(None) + builder = SWMLBuilder(None) # type: ignore[arg-type] # intentional invalid input for validation test assert builder.service is None - def test_service_method_errors_propagate(self): + def test_service_method_errors_propagate(self) -> None: """Test that service method errors propagate""" mock_service = Mock(spec=SWMLService) mock_service.add_verb.side_effect = ValueError("Invalid prompt") @@ -296,7 +296,7 @@ def test_service_method_errors_propagate(self): class TestSWMLBuilderIntegration: """Test integration scenarios""" - def test_complete_agent_workflow(self): + def test_complete_agent_workflow(self) -> None: """Test complete agent building workflow""" mock_service = Mock(spec=SWMLService) mock_service.get_document.return_value = { @@ -305,7 +305,7 @@ def test_complete_agent_workflow(self): "main": [ {"answer": {}}, {"play": {"url": "say:Welcome!"}}, - {"ai": {"prompt": "You are helpful"}}, + {"ai": {"prompt": {"text": "You are helpful"}}}, {"hangup": {"reason": "completed"}} ] } @@ -327,7 +327,7 @@ def test_complete_agent_workflow(self): assert "main" in result["sections"] assert len(result["sections"]["main"]) == 4 - def test_multi_section_workflow(self): + def test_multi_section_workflow(self) -> None: """Test multi-section workflow""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -350,7 +350,7 @@ def test_multi_section_workflow(self): mock_service.add_section.assert_any_call("main") mock_service.add_section.assert_any_call("goodbye") - def test_complex_ai_configuration(self): + def test_complex_ai_configuration(self) -> None: """Test complex AI configuration""" mock_service = Mock(spec=SWMLService) builder = SWMLBuilder(mock_service) @@ -382,15 +382,15 @@ def test_complex_ai_configuration(self): assert result is builder mock_service.add_verb.assert_called_once_with("ai", { - "prompt": "You are a weather assistant", - "post_prompt": "Summarize the weather information provided", + "prompt": {"text": "You are a weather assistant"}, + "post_prompt": {"text": "Summarize the weather information provided"}, "post_prompt_url": "https://example.com/summary", "SWAIG": swaig_config, "temperature": 0.7, "max_tokens": 150 }) - def test_service_delegation(self): + def test_service_delegation(self) -> None: """Test that builder properly delegates to service""" real_service = SWMLService(name="test_service", schema_validation=False) builder = SWMLBuilder(real_service) diff --git a/tests/unit/core/test_swml_handler.py b/tests/unit/core/test_swml_handler.py index d8a42df4..e3a2a095 100644 --- a/tests/unit/core/test_swml_handler.py +++ b/tests/unit/core/test_swml_handler.py @@ -37,20 +37,20 @@ def validate_config(self, config: Dict[str, Any]) -> Tuple[bool, List[str]]: return False, ["Missing required_field"] return True, [] - def build_config(self, **kwargs) -> Dict[str, Any]: + def build_config(self, **kwargs: Any) -> Dict[str, Any]: return {"mock_config": True, **kwargs} class TestSWMLVerbHandlerInterface: """Test SWMLVerbHandler abstract interface""" - def test_abstract_methods_exist(self): + def test_abstract_methods_exist(self) -> None: """Test that SWMLVerbHandler defines required abstract methods""" # Should not be able to instantiate abstract class directly with pytest.raises(TypeError): - SWMLVerbHandler() + SWMLVerbHandler() # type: ignore[abstract] # intentional: testing abstract base raises - def test_mock_implementation(self): + def test_mock_implementation(self) -> None: """Test mock implementation of SWMLVerbHandler""" handler = MockVerbHandler("test_verb") @@ -78,12 +78,12 @@ def test_mock_implementation(self): class TestAIVerbHandler: """Test AIVerbHandler implementation""" - def test_initialization(self): + def test_initialization(self) -> None: """Test AIVerbHandler initialization""" handler = AIVerbHandler() assert handler.get_verb_name() == "ai" - def test_validate_config_valid_prompt_text(self): + def test_validate_config_valid_prompt_text(self) -> None: """Test validation with valid prompt text configuration""" handler = AIVerbHandler() @@ -97,7 +97,7 @@ def test_validate_config_valid_prompt_text(self): assert is_valid is True assert errors == [] - def test_validate_config_valid_prompt_pom(self): + def test_validate_config_valid_prompt_pom(self) -> None: """Test validation with valid prompt POM configuration""" handler = AIVerbHandler() @@ -114,7 +114,7 @@ def test_validate_config_valid_prompt_pom(self): assert is_valid is True assert errors == [] - def test_validate_config_valid_contexts(self): + def test_validate_config_valid_contexts(self) -> None: """Test validation with contexts requires base prompt (text or pom)""" handler = AIVerbHandler() @@ -135,7 +135,7 @@ def test_validate_config_valid_contexts(self): assert is_valid is False assert "'prompt' must contain either 'text' or 'pom' as base prompt" in errors - def test_validate_config_text_with_contexts(self): + def test_validate_config_text_with_contexts(self) -> None: """Test validation with text and contexts combined""" handler = AIVerbHandler() @@ -156,7 +156,7 @@ def test_validate_config_text_with_contexts(self): assert is_valid is True assert errors == [] - def test_validate_config_missing_prompt(self): + def test_validate_config_missing_prompt(self) -> None: """Test validation fails when prompt is missing""" handler = AIVerbHandler() @@ -168,7 +168,7 @@ def test_validate_config_missing_prompt(self): assert is_valid is False assert "Missing required field 'prompt'" in errors - def test_validate_config_both_prompt_options(self): + def test_validate_config_both_prompt_options(self) -> None: """Test validation fails when multiple prompt options are specified""" handler = AIVerbHandler() @@ -183,7 +183,7 @@ def test_validate_config_both_prompt_options(self): assert is_valid is False assert "'prompt' can only contain one of: 'text' or 'pom' (mutually exclusive)" in errors - def test_validate_config_invalid_prompt_structure(self): + def test_validate_config_invalid_prompt_structure(self) -> None: """Test validation fails with invalid prompt structure""" handler = AIVerbHandler() @@ -195,7 +195,7 @@ def test_validate_config_invalid_prompt_structure(self): assert is_valid is False assert "'prompt' must be an object" in errors - def test_validate_config_prompt_missing_content(self): + def test_validate_config_prompt_missing_content(self) -> None: """Test validation fails when prompt dict has no valid content""" handler = AIVerbHandler() @@ -207,7 +207,7 @@ def test_validate_config_prompt_missing_content(self): assert is_valid is False assert "'prompt' must contain either 'text' or 'pom' as base prompt" in errors - def test_validate_config_invalid_contexts_structure(self): + def test_validate_config_invalid_contexts_structure(self) -> None: """Test validation fails with invalid contexts structure""" handler = AIVerbHandler() @@ -221,7 +221,7 @@ def test_validate_config_invalid_contexts_structure(self): assert is_valid is False assert "'prompt.contexts' must be an object" in errors - def test_build_config_with_prompt_text(self): + def test_build_config_with_prompt_text(self) -> None: """Test building config with prompt text""" handler = AIVerbHandler() @@ -235,7 +235,7 @@ def test_build_config_with_prompt_text(self): assert config["post_prompt"]["text"] == "Provide a summary" assert config["post_prompt_url"] == "https://example.com/summary" - def test_build_config_with_prompt_pom(self): + def test_build_config_with_prompt_pom(self) -> None: """Test building config with prompt POM""" handler = AIVerbHandler() @@ -252,7 +252,7 @@ def test_build_config_with_prompt_pom(self): assert config["prompt"]["pom"] == pom_data assert config["post_prompt"]["text"] == "Summary" - def test_build_config_with_contexts(self): + def test_build_config_with_contexts(self) -> None: """Test building config with contexts combined with text""" handler = AIVerbHandler() @@ -274,7 +274,7 @@ def test_build_config_with_contexts(self): assert config["prompt"]["contexts"] == contexts_data assert config["post_prompt"]["text"] == "Summary" - def test_build_config_with_swaig(self): + def test_build_config_with_swaig(self) -> None: """Test building config with SWAIG object""" handler = AIVerbHandler() @@ -292,7 +292,7 @@ def test_build_config_with_swaig(self): assert config["prompt"]["text"] == "You are helpful" assert config["SWAIG"] == swaig_data - def test_build_config_minimal(self): + def test_build_config_minimal(self) -> None: """Test building minimal config""" handler = AIVerbHandler() @@ -303,7 +303,7 @@ def test_build_config_minimal(self): assert "post_prompt_url" not in config assert "SWAIG" not in config - def test_build_config_validation_error(self): + def test_build_config_validation_error(self) -> None: """Test that build_config raises error for invalid configuration""" handler = AIVerbHandler() @@ -311,7 +311,7 @@ def test_build_config_validation_error(self): with pytest.raises(ValueError, match="Either prompt_text or prompt_pom must be provided as base prompt"): handler.build_config() - def test_build_config_conflicting_prompts(self): + def test_build_config_conflicting_prompts(self) -> None: """Test that build_config raises error for conflicting prompt types""" handler = AIVerbHandler() @@ -322,7 +322,7 @@ def test_build_config_conflicting_prompts(self): prompt_pom=[{"title": "POM section"}] ) - def test_build_config_prompt_and_contexts_combined(self): + def test_build_config_prompt_and_contexts_combined(self) -> None: """Test that build_config allows combining text with contexts""" handler = AIVerbHandler() @@ -335,7 +335,7 @@ def test_build_config_prompt_and_contexts_combined(self): assert config["prompt"]["text"] == "Text prompt" assert config["prompt"]["contexts"] == {"context1": {"steps": []}} - def test_build_config_with_additional_params(self): + def test_build_config_with_additional_params(self) -> None: """Test building config with additional parameters""" handler = AIVerbHandler() @@ -359,7 +359,7 @@ def test_build_config_with_additional_params(self): class TestVerbHandlerRegistry: """Test VerbHandlerRegistry functionality""" - def test_initialization(self): + def test_initialization(self) -> None: """Test registry initialization""" registry = VerbHandlerRegistry() @@ -368,7 +368,7 @@ def test_initialization(self): ai_handler = registry.get_handler("ai") assert isinstance(ai_handler, AIVerbHandler) - def test_register_handler(self): + def test_register_handler(self) -> None: """Test registering a new handler""" registry = VerbHandlerRegistry() mock_handler = MockVerbHandler("custom_verb") @@ -379,7 +379,7 @@ def test_register_handler(self): retrieved_handler = registry.get_handler("custom_verb") assert retrieved_handler is mock_handler - def test_get_handler_existing(self): + def test_get_handler_existing(self) -> None: """Test getting an existing handler""" registry = VerbHandlerRegistry() @@ -387,26 +387,26 @@ def test_get_handler_existing(self): assert handler is not None assert isinstance(handler, AIVerbHandler) - def test_get_handler_nonexistent(self): + def test_get_handler_nonexistent(self) -> None: """Test getting a non-existent handler""" registry = VerbHandlerRegistry() handler = registry.get_handler("nonexistent_verb") assert handler is None - def test_has_handler_existing(self): + def test_has_handler_existing(self) -> None: """Test checking for existing handler""" registry = VerbHandlerRegistry() assert registry.has_handler("ai") is True - def test_has_handler_nonexistent(self): + def test_has_handler_nonexistent(self) -> None: """Test checking for non-existent handler""" registry = VerbHandlerRegistry() assert registry.has_handler("nonexistent_verb") is False - def test_override_existing_handler(self): + def test_override_existing_handler(self) -> None: """Test overriding an existing handler""" registry = VerbHandlerRegistry() @@ -419,7 +419,7 @@ def test_override_existing_handler(self): assert retrieved_handler is custom_ai_handler assert isinstance(retrieved_handler, MockVerbHandler) - def test_multiple_handlers(self): + def test_multiple_handlers(self) -> None: """Test registering multiple handlers""" registry = VerbHandlerRegistry() @@ -446,7 +446,7 @@ def test_multiple_handlers(self): class TestSWMLHandlerIntegration: """Test integration scenarios for SWML handlers""" - def test_ai_handler_complete_workflow(self): + def test_ai_handler_complete_workflow(self) -> None: """Test complete workflow with AI handler""" handler = AIVerbHandler() @@ -478,7 +478,7 @@ def test_ai_handler_complete_workflow(self): assert "SWAIG" in config assert len(config["SWAIG"]["functions"]) == 1 - def test_registry_with_custom_handlers(self): + def test_registry_with_custom_handlers(self) -> None: """Test registry with custom handlers""" registry = VerbHandlerRegistry() @@ -507,12 +507,12 @@ def test_registry_with_custom_handlers(self): is_valid, errors = handler.validate_config(config) assert is_valid is True - def test_handler_error_scenarios(self): + def test_handler_error_scenarios(self) -> None: """Test error handling scenarios""" handler = AIVerbHandler() # Test various invalid configurations - invalid_configs = [ + invalid_configs: List[Dict[str, Any]] = [ {}, # Empty config - missing prompt {"prompt": {}}, # Empty prompt - no content {"prompt": {"invalid": "field"}}, # Invalid prompt field @@ -524,7 +524,7 @@ def test_handler_error_scenarios(self): assert is_valid is False assert len(errors) > 0 - def test_handler_with_complex_swaig(self): + def test_handler_with_complex_swaig(self) -> None: """Test handler with complex SWAIG configuration""" handler = AIVerbHandler() @@ -586,7 +586,7 @@ def test_handler_with_complex_swaig(self): class TestSWMLHandlerEdgeCases: """Test edge cases and error conditions""" - def test_handler_with_none_values(self): + def test_handler_with_none_values(self) -> None: """Test handler behavior with None values""" handler = AIVerbHandler() @@ -603,7 +603,7 @@ def test_handler_with_none_values(self): assert "post_prompt_url" not in config assert "SWAIG" not in config - def test_handler_with_empty_strings(self): + def test_handler_with_empty_strings(self) -> None: """Test handler behavior with empty strings""" handler = AIVerbHandler() @@ -618,7 +618,7 @@ def test_handler_with_empty_strings(self): assert config["post_prompt"]["text"] == "" assert config["post_prompt_url"] == "" # Empty URL is included if provided - def test_registry_with_invalid_handler(self): + def test_registry_with_invalid_handler(self) -> None: """Test registry behavior with invalid handler""" registry = VerbHandlerRegistry() @@ -627,9 +627,9 @@ def test_registry_with_invalid_handler(self): # Should raise AttributeError when trying to get verb name with pytest.raises(AttributeError): - registry.register_handler(invalid_handler) + registry.register_handler(invalid_handler) # type: ignore[arg-type] # intentional invalid input for validation test - def test_mock_handler_edge_cases(self): + def test_mock_handler_edge_cases(self) -> None: """Test mock handler edge cases""" handler = MockVerbHandler() @@ -640,7 +640,7 @@ def test_mock_handler_edge_cases(self): # Test with None config with pytest.raises(TypeError): - handler.validate_config(None) + handler.validate_config(None) # type: ignore[arg-type] # intentional: tests None input # Test build_config with no arguments config = handler.build_config() diff --git a/tests/unit/core/test_swml_renderer.py b/tests/unit/core/test_swml_renderer.py index e8a76b16..fdcb484e 100644 --- a/tests/unit/core/test_swml_renderer.py +++ b/tests/unit/core/test_swml_renderer.py @@ -13,7 +13,7 @@ import pytest import json -from unittest.mock import patch +from unittest.mock import patch, MagicMock from typing import Dict, List, Any, Optional from signalwire.core.swml_renderer import SwmlRenderer @@ -21,7 +21,7 @@ from signalwire.utils.schema_utils import SchemaValidationError -def _make_service(): +def _make_service() -> SWMLService: """Create a real SWMLService with schema validation disabled for renderer tests""" return SWMLService(name="test_renderer", schema_validation=False) @@ -29,10 +29,10 @@ def _make_service(): class TestSwmlRenderer: """Test SwmlRenderer functionality""" - def test_render_swml_basic(self): + def test_render_swml_basic(self) -> None: """Test basic SWML rendering""" service = _make_service() - result = SwmlRenderer.render_swml({"text": "You are a helpful assistant"}, service) + result = SwmlRenderer.render_swml("You are a helpful assistant", service) assert isinstance(result, str) parsed = json.loads(result) @@ -46,11 +46,11 @@ def test_render_swml_basic(self): ai_verb = parsed["sections"]["main"][0] assert "ai" in ai_verb - def test_render_swml_with_post_prompt(self): + def test_render_swml_with_post_prompt(self) -> None: """Test SWML rendering with post prompt""" service = _make_service() result = SwmlRenderer.render_swml( - {"text": "You are helpful"}, + "You are helpful", service, post_prompt="Provide a summary" ) @@ -60,7 +60,7 @@ def test_render_swml_with_post_prompt(self): assert "post_prompt" in ai_verb["ai"] - def test_render_swml_with_swaig_functions(self): + def test_render_swml_with_swaig_functions(self) -> None: """Test SWML rendering with SWAIG functions""" functions = [ { @@ -77,7 +77,7 @@ def test_render_swml_with_swaig_functions(self): service = _make_service() result = SwmlRenderer.render_swml( - {"text": "You are helpful"}, + "You are helpful", service, swaig_functions=functions ) @@ -90,7 +90,7 @@ def test_render_swml_with_swaig_functions(self): assert len(ai_verb["ai"]["SWAIG"]["functions"]) == 1 assert ai_verb["ai"]["SWAIG"]["functions"][0]["function"] == "get_weather" - def test_render_swml_with_pom(self): + def test_render_swml_with_pom(self) -> None: """Test SWML rendering with POM format""" pom_data = [ {"title": "Section 1", "body": "Content 1"}, @@ -99,7 +99,7 @@ def test_render_swml_with_pom(self): service = _make_service() result = SwmlRenderer.render_swml( - {"pom": pom_data}, + pom_data, service, prompt_is_pom=True ) @@ -109,11 +109,11 @@ def test_render_swml_with_pom(self): assert "ai" in ai_verb - def test_render_swml_with_hooks(self): + def test_render_swml_with_hooks(self) -> None: """Test SWML rendering with startup and hangup hooks""" service = _make_service() result = SwmlRenderer.render_swml( - {"text": "You are helpful"}, + "You are helpful", service, startup_hook_url="https://example.com/startup", hangup_hook_url="https://example.com/hangup" @@ -125,11 +125,11 @@ def test_render_swml_with_hooks(self): assert "SWAIG" in ai_verb["ai"] assert "functions" in ai_verb["ai"]["SWAIG"] - def test_render_swml_with_default_webhook(self): + def test_render_swml_with_default_webhook(self) -> None: """Test SWML rendering with default webhook URL""" service = _make_service() result = SwmlRenderer.render_swml( - {"text": "You are helpful"}, + "You are helpful", service, default_webhook_url="https://example.com/webhook" ) @@ -142,13 +142,13 @@ def test_render_swml_with_default_webhook(self): assert ai_verb["ai"]["SWAIG"]["defaults"]["web_hook_url"] == "https://example.com/webhook" @patch('yaml.dump') - def test_render_swml_yaml_format(self, mock_yaml_dump): + def test_render_swml_yaml_format(self, mock_yaml_dump: MagicMock) -> None: """Test SWML rendering in YAML format""" mock_yaml_dump.return_value = "version: 1.0.0\nsections:\n main: []" service = _make_service() result = SwmlRenderer.render_swml( - {"text": "You are helpful"}, + "You are helpful", service, format="yaml" ) @@ -159,7 +159,7 @@ def test_render_swml_yaml_format(self, mock_yaml_dump): assert "main:" in result mock_yaml_dump.assert_called_once() - def test_render_function_response_swml_basic(self): + def test_render_function_response_swml_basic(self) -> None: """Test rendering function response SWML""" service = _make_service() result = SwmlRenderer.render_function_response_swml("Hello there!", service) @@ -172,7 +172,7 @@ def test_render_function_response_swml_basic(self): assert "main" in parsed["sections"] assert len(parsed["sections"]["main"]) == 1 - def test_render_function_response_swml_with_actions(self): + def test_render_function_response_swml_with_actions(self) -> None: """Test rendering function response SWML with actions""" actions = [ {"play": {"url": "test.mp3"}}, @@ -193,7 +193,7 @@ def test_render_function_response_swml_with_actions(self): assert len(main_section) == 3 # response + 2 actions @patch('yaml.dump') - def test_render_function_response_swml_yaml(self, mock_yaml_dump): + def test_render_function_response_swml_yaml(self, mock_yaml_dump: MagicMock) -> None: """Test rendering function response SWML in YAML format""" mock_yaml_dump.return_value = "version: 1.0.0\nsections:\n main: []" @@ -213,34 +213,34 @@ def test_render_function_response_swml_yaml(self, mock_yaml_dump): class TestSwmlRendererErrorHandling: """Test error handling in SwmlRenderer""" - def test_render_swml_empty_prompt(self): + def test_render_swml_empty_prompt(self) -> None: """Test rendering with empty prompt""" service = _make_service() - result = SwmlRenderer.render_swml({"text": ""}, service) + result = SwmlRenderer.render_swml("", service) parsed = json.loads(result) ai_verb = parsed["sections"]["main"][0] assert "ai" in ai_verb - def test_render_swml_none_prompt(self): + def test_render_swml_none_prompt(self) -> None: """Test rendering with None prompt raises validation error""" service = _make_service() # None prompt means neither prompt_text nor prompt_pom is set in builder.ai(), # so config has no "prompt" key, which AIVerbHandler rejects with pytest.raises(SchemaValidationError): - SwmlRenderer.render_swml(None, service) + SwmlRenderer.render_swml(None, service) # type: ignore[arg-type] # intentional invalid input for validation test - def test_render_swml_invalid_format(self): + def test_render_swml_invalid_format(self) -> None: """Test rendering with invalid format""" service = _make_service() - result = SwmlRenderer.render_swml({"text": "Hello"}, service, format="invalid") + result = SwmlRenderer.render_swml("Hello", service, format="invalid") # Should still be valid JSON (falls through to default) parsed = json.loads(result) assert parsed["version"] == "1.0.0" - def test_render_function_response_empty_text(self): + def test_render_function_response_empty_text(self) -> None: """Test rendering function response with empty text""" service = _make_service() result = SwmlRenderer.render_function_response_swml("", service) @@ -257,7 +257,7 @@ def test_render_function_response_empty_text(self): class TestSwmlRendererIntegration: """Test integration scenarios""" - def test_complete_ai_agent_swml(self): + def test_complete_ai_agent_swml(self) -> None: """Test rendering complete AI agent SWML""" functions = [ { @@ -288,7 +288,7 @@ def test_complete_ai_agent_swml(self): service = _make_service() result = SwmlRenderer.render_swml( - prompt={"text": "You are a banking assistant. Help users with their account needs."}, + prompt="You are a banking assistant. Help users with their account needs.", service=service, post_prompt="Summarize the conversation and any actions taken.", post_prompt_url="https://bank.example.com/conversation-summary", @@ -317,9 +317,9 @@ def test_complete_ai_agent_swml(self): assert "get_account_balance" in function_names assert "transfer_funds" in function_names - def test_pom_based_agent_swml(self): + def test_pom_based_agent_swml(self) -> None: """Test rendering POM-based agent SWML""" - pom_sections = [ + pom_sections: list[dict[str, Any]] = [ { "title": "Role", "body": "You are a customer service representative for TechCorp." @@ -340,7 +340,7 @@ def test_pom_based_agent_swml(self): service = _make_service() result = SwmlRenderer.render_swml( - prompt={"pom": pom_sections}, + prompt=pom_sections, service=service, prompt_is_pom=True, post_prompt="Provide a brief summary of how you helped the customer." @@ -351,7 +351,7 @@ def test_pom_based_agent_swml(self): assert "ai" in ai_verb - def test_function_response_workflow(self): + def test_function_response_workflow(self) -> None: """Test function response workflow""" response_text = "I found your account balance: $1,234.56" actions = [ @@ -372,13 +372,13 @@ def test_function_response_workflow(self): assert len(main_section) == 2 @patch('yaml.dump') - def test_yaml_output_format(self, mock_yaml_dump): + def test_yaml_output_format(self, mock_yaml_dump: MagicMock) -> None: """Test YAML output format""" mock_yaml_dump.return_value = "version: 1.0.0\nsections:\n main: []" service = _make_service() result = SwmlRenderer.render_swml( - {"text": "You are helpful"}, + "You are helpful", service, swaig_functions=[{ "function": "test", diff --git a/tests/unit/core/test_swml_service.py b/tests/unit/core/test_swml_service.py index cf01209d..f30e0a69 100644 --- a/tests/unit/core/test_swml_service.py +++ b/tests/unit/core/test_swml_service.py @@ -13,6 +13,7 @@ import pytest import json +from typing import Any from unittest.mock import Mock, patch, MagicMock from signalwire.core.swml_service import SWMLService @@ -21,7 +22,7 @@ class TestSWMLServiceInitialization: """Test SWMLService initialization""" - def test_basic_initialization(self): + def test_basic_initialization(self) -> None: """Test basic service initialization""" service = SWMLService( name="test_service", @@ -35,7 +36,7 @@ def test_basic_initialization(self): assert service.host == "127.0.0.1" assert service.port == 3001 - def test_initialization_with_defaults(self): + def test_initialization_with_defaults(self) -> None: """Test initialization with default values""" service = SWMLService(name="test_service") @@ -44,7 +45,7 @@ def test_initialization_with_defaults(self): assert service.host == "0.0.0.0" assert service.port == 3000 - def test_initialization_with_schema_path(self): + def test_initialization_with_schema_path(self) -> None: """Test initialization with schema path""" service = SWMLService( name="test_service", @@ -54,7 +55,7 @@ def test_initialization_with_schema_path(self): assert service.name == "test_service" assert hasattr(service, 'schema_utils') - def test_initialization_with_basic_auth(self): + def test_initialization_with_basic_auth(self) -> None: """Test initialization with basic auth""" service = SWMLService( name="test_service", @@ -67,14 +68,14 @@ def test_initialization_with_basic_auth(self): class TestSWMLServiceVerbMethods: """Test SWML verb method functionality""" - def test_add_verb_basic(self, mock_swml_service): + def test_add_verb_basic(self, mock_swml_service: SWMLService) -> None: """Test adding a basic verb""" result = mock_swml_service.add_verb("play", {"url": "test.mp3"}) # Should return boolean indicating success assert isinstance(result, bool) - def test_add_verb_with_config(self, mock_swml_service): + def test_add_verb_with_config(self, mock_swml_service: SWMLService) -> None: """Test adding verb with configuration""" config = { "url": "https://example.com/audio.mp3", @@ -87,14 +88,14 @@ def test_add_verb_with_config(self, mock_swml_service): # Should return boolean assert isinstance(result, bool) - def test_add_verb_with_integer_config(self, mock_swml_service): + def test_add_verb_with_integer_config(self, mock_swml_service: SWMLService) -> None: """Test adding verb with integer configuration (like sleep)""" result = mock_swml_service.add_verb("sleep", 5000) # Should return boolean assert isinstance(result, bool) - def test_add_verb_to_section(self, mock_swml_service): + def test_add_verb_to_section(self, mock_swml_service: SWMLService) -> None: """Test adding verb to specific section""" # First add a section mock_swml_service.add_section("custom_section") @@ -109,7 +110,7 @@ def test_add_verb_to_section(self, mock_swml_service): class TestSWMLServiceDocumentManagement: """Test SWML document management""" - def test_reset_document(self, mock_swml_service): + def test_reset_document(self, mock_swml_service: SWMLService) -> None: """Test resetting the document""" # Add some verbs first mock_swml_service.add_verb("say", {"text": "Hello"}) @@ -121,7 +122,7 @@ def test_reset_document(self, mock_swml_service): # Document should be reset (we can't directly check content, but method should not raise) assert True # If we get here, reset worked - def test_get_document(self, mock_swml_service): + def test_get_document(self, mock_swml_service: SWMLService) -> None: """Test getting the document""" mock_swml_service.add_verb("say", {"text": "Hello World"}) @@ -131,7 +132,7 @@ def test_get_document(self, mock_swml_service): assert "version" in document assert "sections" in document - def test_render_document(self, mock_swml_service): + def test_render_document(self, mock_swml_service: SWMLService) -> None: """Test rendering document to JSON string""" mock_swml_service.add_verb("say", {"text": "Hello"}) mock_swml_service.add_verb("play", {"url": "test.mp3"}) @@ -144,7 +145,7 @@ def test_render_document(self, mock_swml_service): assert "version" in swml_dict assert "sections" in swml_dict - def test_add_section(self, mock_swml_service): + def test_add_section(self, mock_swml_service: SWMLService) -> None: """Test adding a new section""" result = mock_swml_service.add_section("custom_section") @@ -155,14 +156,14 @@ def test_add_section(self, mock_swml_service): class TestSWMLServiceUtilityMethods: """Test utility methods""" - def test_basic_properties(self, mock_swml_service): + def test_basic_properties(self, mock_swml_service: SWMLService) -> None: """Test basic property access""" assert mock_swml_service.name == "test_service" assert mock_swml_service.route == "/test" assert mock_swml_service.host == "127.0.0.1" assert mock_swml_service.port == 3001 - def test_basic_auth_credentials(self, mock_swml_service): + def test_basic_auth_credentials(self, mock_swml_service: SWMLService) -> None: """Test getting basic auth credentials""" credentials = mock_swml_service.get_basic_auth_credentials() @@ -171,7 +172,7 @@ def test_basic_auth_credentials(self, mock_swml_service): assert isinstance(credentials[0], str) # username assert isinstance(credentials[1], str) # password - def test_basic_auth_credentials_with_source(self, mock_swml_service): + def test_basic_auth_credentials_with_source(self, mock_swml_service: SWMLService) -> None: """Test getting basic auth credentials with source""" credentials = mock_swml_service.get_basic_auth_credentials(include_source=True) @@ -185,19 +186,19 @@ def test_basic_auth_credentials_with_source(self, mock_swml_service): class TestSWMLServiceSpecialVerbs: """Test special SWML verb methods via add_verb""" - def test_add_answer_verb(self, mock_swml_service): + def test_add_answer_verb(self, mock_swml_service: SWMLService) -> None: """Test adding answer verb via add_verb""" result = mock_swml_service.add_verb("answer", {"max_duration": 30}) assert isinstance(result, bool) - def test_add_hangup_verb(self, mock_swml_service): + def test_add_hangup_verb(self, mock_swml_service: SWMLService) -> None: """Test adding hangup verb via add_verb""" result = mock_swml_service.add_verb("hangup", {"reason": "completed"}) assert isinstance(result, bool) - def test_add_ai_verb(self, mock_swml_service): + def test_add_ai_verb(self, mock_swml_service: SWMLService) -> None: """Test adding AI verb via add_verb""" result = mock_swml_service.add_verb("ai", { "prompt": { @@ -214,28 +215,28 @@ def test_add_ai_verb(self, mock_swml_service): class TestSWMLServiceErrorHandling: """Test error handling and edge cases""" - def test_add_verb_with_none_config(self, mock_swml_service): + def test_add_verb_with_none_config(self, mock_swml_service: SWMLService) -> None: """Test adding verb with None configuration""" - result = mock_swml_service.add_verb("hangup", None) - + result = mock_swml_service.add_verb("hangup", None) # type: ignore[arg-type] # intentional invalid input + # Should return boolean (likely False due to invalid config) assert isinstance(result, bool) - def test_add_verb_with_empty_config(self, mock_swml_service): + def test_add_verb_with_empty_config(self, mock_swml_service: SWMLService) -> None: """Test adding verb with empty configuration""" result = mock_swml_service.add_verb("hangup", {}) # Should return boolean assert isinstance(result, bool) - def test_invalid_verb_name(self, mock_swml_service): + def test_invalid_verb_name(self, mock_swml_service: SWMLService) -> None: """Test handling of invalid verb names""" result = mock_swml_service.add_verb("", {"test": "value"}) # Should return boolean (likely False due to invalid verb) assert isinstance(result, bool) - def test_add_verb_to_nonexistent_section(self, mock_swml_service): + def test_add_verb_to_nonexistent_section(self, mock_swml_service: SWMLService) -> None: """Test adding verb to non-existent section""" result = mock_swml_service.add_verb_to_section("nonexistent", "play", {"url": "test.mp3"}) @@ -246,9 +247,9 @@ def test_add_verb_to_nonexistent_section(self, mock_swml_service): class TestSWMLServiceRouting: """Test routing and callback functionality""" - def test_register_routing_callback(self, mock_swml_service): + def test_register_routing_callback(self, mock_swml_service: SWMLService) -> None: """Test registering routing callback""" - def test_callback(request, data): + def test_callback(request: Any, data: dict[str, Any]) -> str | None: return "test_response" # Should not raise error @@ -257,7 +258,7 @@ def test_callback(request, data): # Callback should be registered assert "/test" in mock_swml_service._routing_callbacks - def test_extract_sip_username(self): + def test_extract_sip_username(self) -> None: """Test SIP username extraction""" request_body = { "from": "sip:testuser@example.com", @@ -273,7 +274,7 @@ def test_extract_sip_username(self): class TestSWMLServiceIntegration: """Test integration functionality""" - def test_as_router(self, mock_swml_service): + def test_as_router(self, mock_swml_service: SWMLService) -> None: """Test getting as FastAPI router""" router = mock_swml_service.as_router() @@ -281,7 +282,7 @@ def test_as_router(self, mock_swml_service): assert router is not None assert hasattr(router, 'routes') - def test_on_request_handling(self, mock_swml_service): + def test_on_request_handling(self, mock_swml_service: SWMLService) -> None: """Test request handling""" test_data = {"call_id": "test-123", "from": "+1234567890"} @@ -291,7 +292,7 @@ def test_on_request_handling(self, mock_swml_service): # Result can be None or dict assert result is None or isinstance(result, dict) - def test_manual_proxy_url_setting(self, mock_swml_service): + def test_manual_proxy_url_setting(self, mock_swml_service: SWMLService) -> None: """Test manual proxy URL setting""" proxy_url = "https://example.ngrok.io" @@ -302,13 +303,13 @@ def test_manual_proxy_url_setting(self, mock_swml_service): assert mock_swml_service._proxy_url_base == proxy_url assert mock_swml_service._proxy_detection_done is True - def test_verb_handler_registry(self, mock_swml_service): + def test_verb_handler_registry(self, mock_swml_service: SWMLService) -> None: """Test verb handler registry""" # Should have verb registry assert hasattr(mock_swml_service, 'verb_registry') assert mock_swml_service.verb_registry is not None - def test_schema_utils_integration(self, mock_swml_service): + def test_schema_utils_integration(self, mock_swml_service: SWMLService) -> None: """Test schema utilities integration""" # Should have schema utils assert hasattr(mock_swml_service, 'schema_utils') @@ -318,7 +319,7 @@ def test_schema_utils_integration(self, mock_swml_service): verb_names = mock_swml_service.schema_utils.get_all_verb_names() assert isinstance(verb_names, list) - def test_json_serialization_of_document(self, mock_swml_service): + def test_json_serialization_of_document(self, mock_swml_service: SWMLService) -> None: """Test JSON serialization of SWML document""" mock_swml_service.add_verb("say", {"text": "Test message"}) @@ -343,7 +344,7 @@ def test_json_serialization_of_document(self, mock_swml_service): class TestCreateVerbMethods: """Test dynamic method creation for SWML verbs.""" - def test_verb_methods_cache_populated(self, mock_swml_service): + def test_verb_methods_cache_populated(self, mock_swml_service: SWMLService) -> None: """The verb method cache should be populated during __init__.""" assert isinstance(mock_swml_service._verb_methods_cache, dict) # Even with schema_validation=False, the schema is still loaded and @@ -352,7 +353,7 @@ def test_verb_methods_cache_populated(self, mock_swml_service): if mock_swml_service.schema_utils and mock_swml_service.schema_utils.get_all_verb_names(): assert len(mock_swml_service._verb_methods_cache) > 0 - def test_known_verbs_exist_as_attributes(self, mock_swml_service): + def test_known_verbs_exist_as_attributes(self, mock_swml_service: SWMLService) -> None: """Common SWML verbs should be accessible as attributes after init.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() if not verb_names: @@ -360,7 +361,7 @@ def test_known_verbs_exist_as_attributes(self, mock_swml_service): for vn in verb_names[:5]: assert hasattr(mock_swml_service, vn), f"Verb '{vn}' should be an attribute" - def test_verb_method_is_callable(self, mock_swml_service): + def test_verb_method_is_callable(self, mock_swml_service: SWMLService) -> None: """Dynamically created verb methods should be callable.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() if not verb_names: @@ -369,7 +370,7 @@ def test_verb_method_is_callable(self, mock_swml_service): method = getattr(mock_swml_service, vn) assert callable(method), f"Verb '{vn}' attribute should be callable" - def test_verb_method_adds_verb_to_document(self, mock_swml_service): + def test_verb_method_adds_verb_to_document(self, mock_swml_service: SWMLService) -> None: """Calling a dynamically created verb method should add the verb to the document.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() # Pick a verb that isn't 'sleep' (which has special handling) @@ -386,7 +387,7 @@ def test_verb_method_adds_verb_to_document(self, mock_swml_service): assert len(doc["sections"]["main"]) == 1 assert vn in doc["sections"]["main"][0] - def test_verb_method_passes_kwargs(self, mock_swml_service): + def test_verb_method_passes_kwargs(self, mock_swml_service: SWMLService) -> None: """Keyword arguments should end up in the verb config dict.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() non_sleep = [v for v in verb_names if v != "sleep"] @@ -400,7 +401,7 @@ def test_verb_method_passes_kwargs(self, mock_swml_service): config = doc["sections"]["main"][0][vn] assert config.get("some_key") == "some_value" - def test_verb_method_strips_none_kwargs(self, mock_swml_service): + def test_verb_method_strips_none_kwargs(self, mock_swml_service: SWMLService) -> None: """None-valued kwargs should be stripped from the config.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() non_sleep = [v for v in verb_names if v != "sleep"] @@ -415,7 +416,7 @@ def test_verb_method_strips_none_kwargs(self, mock_swml_service): assert "present" in config assert "absent" not in config - def test_sleep_verb_method_exists(self, mock_swml_service): + def test_sleep_verb_method_exists(self, mock_swml_service: SWMLService) -> None: """Sleep verb should be created with special handling.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() if "sleep" not in verb_names: @@ -423,7 +424,7 @@ def test_sleep_verb_method_exists(self, mock_swml_service): assert hasattr(mock_swml_service, "sleep") assert callable(mock_swml_service.sleep) - def test_sleep_verb_takes_duration(self, mock_swml_service): + def test_sleep_verb_takes_duration(self, mock_swml_service: SWMLService) -> None: """Sleep verb method should accept a duration argument.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() if "sleep" not in verb_names: @@ -433,7 +434,7 @@ def test_sleep_verb_takes_duration(self, mock_swml_service): doc = mock_swml_service.get_document() assert {"sleep": 5000} in doc["sections"]["main"] - def test_sleep_verb_raises_without_duration(self, mock_swml_service): + def test_sleep_verb_raises_without_duration(self, mock_swml_service: SWMLService) -> None: """Sleep verb method should raise TypeError when no duration given.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() if "sleep" not in verb_names: @@ -441,7 +442,7 @@ def test_sleep_verb_raises_without_duration(self, mock_swml_service): with pytest.raises(TypeError, match="missing required argument"): mock_swml_service.sleep() - def test_sleep_verb_accepts_kwargs_fallback(self, mock_swml_service): + def test_sleep_verb_accepts_kwargs_fallback(self, mock_swml_service: SWMLService) -> None: """Sleep verb should accept value via kwargs when duration is None.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() if "sleep" not in verb_names: @@ -451,7 +452,7 @@ def test_sleep_verb_accepts_kwargs_fallback(self, mock_swml_service): doc = mock_swml_service.get_document() assert {"sleep": 3000} in doc["sections"]["main"] - def test_verb_method_has_docstring(self, mock_swml_service): + def test_verb_method_has_docstring(self, mock_swml_service: SWMLService) -> None: """Dynamically created verb methods should have a docstring.""" verb_names = mock_swml_service.schema_utils.get_all_verb_names() non_sleep = [v for v in verb_names if v != "sleep"] @@ -463,7 +464,7 @@ def test_verb_method_has_docstring(self, mock_swml_service): assert method.__doc__ is not None assert vn in method.__doc__ - def test_existing_method_not_overwritten(self): + def test_existing_method_not_overwritten(self) -> None: """If a method already exists on the class, _create_verb_methods should skip it.""" service = SWMLService( name="test_no_overwrite", @@ -483,12 +484,12 @@ def test_existing_method_not_overwritten(self): class TestGetAttr: """Test fallback verb method resolution via __getattr__.""" - def test_invalid_attribute_raises(self, mock_swml_service): + def test_invalid_attribute_raises(self, mock_swml_service: SWMLService) -> None: """Accessing a truly invalid attribute should raise AttributeError.""" with pytest.raises(AttributeError): _ = mock_swml_service.totally_bogus_attribute_xyz - def test_getattr_returns_callable_for_valid_verb(self): + def test_getattr_returns_callable_for_valid_verb(self) -> None: """__getattr__ should create a callable for a valid verb name.""" service = SWMLService( name="getattr_test", @@ -508,7 +509,7 @@ def test_getattr_returns_callable_for_valid_verb(self): method = getattr(service, vn) assert callable(method) - def test_getattr_caches_method(self): + def test_getattr_caches_method(self) -> None: """__getattr__ should cache the method in _verb_methods_cache.""" service = SWMLService( name="getattr_cache", @@ -527,7 +528,7 @@ def test_getattr_caches_method(self): _ = getattr(service, vn) assert vn in service._verb_methods_cache - def test_getattr_uses_cache_on_second_access(self): + def test_getattr_uses_cache_on_second_access(self) -> None: """Second access via __getattr__ should return the cached method.""" service = SWMLService( name="getattr_second", @@ -549,7 +550,7 @@ def test_getattr_uses_cache_on_second_access(self): assert callable(first) assert callable(second) - def test_getattr_sleep_verb(self): + def test_getattr_sleep_verb(self) -> None: """__getattr__ should handle sleep verb specially.""" service = SWMLService( name="getattr_sleep", @@ -570,7 +571,7 @@ def test_getattr_sleep_verb(self): doc = service.get_document() assert {"sleep": 2000} in doc["sections"]["main"] - def test_getattr_no_schema_raises(self): + def test_getattr_no_schema_raises(self) -> None: """If schema_utils is None, __getattr__ should raise AttributeError.""" service = SWMLService( name="no_schema", @@ -579,11 +580,11 @@ def test_getattr_no_schema_raises(self): port=3001, schema_validation=False, ) - service.schema_utils = None + service.schema_utils = None # type: ignore[assignment] # intentional: exercise missing-schema path with pytest.raises(AttributeError, match="no schema available"): _ = service.some_verb - def test_getattr_error_message_includes_class_name(self, mock_swml_service): + def test_getattr_error_message_includes_class_name(self, mock_swml_service: SWMLService) -> None: """The AttributeError message should include the class name.""" with pytest.raises(AttributeError, match="SWMLService"): _ = mock_swml_service.nonexistent_xyz_123 @@ -592,7 +593,7 @@ def test_getattr_error_message_includes_class_name(self, mock_swml_service): class TestProxyDetection: """Test _detect_proxy_from_request() with various header combinations.""" - def _make_request(self, headers=None, url="http://127.0.0.1:3001/test"): + def _make_request(self, headers: dict[str, str] | None = None, url: str = "http://127.0.0.1:3001/test") -> Mock: """Helper to create a mock FastAPI request.""" request = Mock() _headers = headers or {} @@ -602,7 +603,7 @@ def _make_request(self, headers=None, url="http://127.0.0.1:3001/test"): request.url.__str__ = Mock(return_value=url) return request - def test_x_forwarded_host_and_proto(self, mock_swml_service): + def test_x_forwarded_host_and_proto(self, mock_swml_service: SWMLService) -> None: """X-Forwarded-Host + X-Forwarded-Proto should set proxy_url_base.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={ @@ -612,7 +613,7 @@ def test_x_forwarded_host_and_proto(self, mock_swml_service): mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "https://example.ngrok.io" - def test_x_forwarded_host_default_proto(self, mock_swml_service): + def test_x_forwarded_host_default_proto(self, mock_swml_service: SWMLService) -> None: """When X-Forwarded-Proto is missing, default to http.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={ @@ -621,7 +622,7 @@ def test_x_forwarded_host_default_proto(self, mock_swml_service): mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "http://proxy.example.com" - def test_rfc7239_forwarded_header(self, mock_swml_service): + def test_rfc7239_forwarded_header(self, mock_swml_service: SWMLService) -> None: """RFC 7239 Forwarded header should be parsed correctly.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={ @@ -630,7 +631,7 @@ def test_rfc7239_forwarded_header(self, mock_swml_service): mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "https://example.com" - def test_rfc7239_forwarded_header_http_default(self, mock_swml_service): + def test_rfc7239_forwarded_header_http_default(self, mock_swml_service: SWMLService) -> None: """RFC 7239 Forwarded header without proto should default to http.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={ @@ -639,7 +640,7 @@ def test_rfc7239_forwarded_header_http_default(self, mock_swml_service): mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "http://myproxy.example.com" - def test_rfc7239_forwarded_no_host_ignored(self, mock_swml_service): + def test_rfc7239_forwarded_no_host_ignored(self, mock_swml_service: SWMLService) -> None: """Forwarded header without host= should not set proxy_url_base from that header.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={ @@ -650,7 +651,7 @@ def test_rfc7239_forwarded_no_host_ignored(self, mock_swml_service): # it falls through to other detection methods. # Result depends on other headers/URL; just verify no crash. - def test_x_original_host(self, mock_swml_service): + def test_x_original_host(self, mock_swml_service: SWMLService) -> None: """X-Original-Host should be used when other headers are absent.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={ @@ -659,7 +660,7 @@ def test_x_original_host(self, mock_swml_service): mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "http://original.example.com" - def test_host_header_with_external_host(self, mock_swml_service): + def test_host_header_with_external_host(self, mock_swml_service: SWMLService) -> None: """Host header pointing to an external host should trigger proxy detection.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={ @@ -668,7 +669,7 @@ def test_host_header_with_external_host(self, mock_swml_service): mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "http://external.example.com" - def test_host_header_with_local_host_ignored(self, mock_swml_service): + def test_host_header_with_local_host_ignored(self, mock_swml_service: SWMLService) -> None: """Host header pointing to local host should not set proxy_url_base.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={ @@ -677,14 +678,14 @@ def test_host_header_with_local_host_ignored(self, mock_swml_service): mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base is None - def test_no_proxy_headers_returns_none(self, mock_swml_service): + def test_no_proxy_headers_returns_none(self, mock_swml_service: SWMLService) -> None: """With no proxy headers and a local URL, proxy_url_base should remain None.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={}) mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base is None - def test_already_set_proxy_not_overridden(self, mock_swml_service): + def test_already_set_proxy_not_overridden(self, mock_swml_service: SWMLService) -> None: """If proxy_url_base is already set, it should not be overridden.""" mock_swml_service._proxy_url_base = "https://already.set.com" request = self._make_request(headers={ @@ -694,7 +695,7 @@ def test_already_set_proxy_not_overridden(self, mock_swml_service): mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "https://already.set.com" - def test_transparent_proxy_detection(self, mock_swml_service): + def test_transparent_proxy_detection(self, mock_swml_service: SWMLService) -> None: """When request URL itself is external, treat it as transparent proxy.""" mock_swml_service._proxy_url_base = None request = self._make_request( @@ -706,7 +707,7 @@ def test_transparent_proxy_detection(self, mock_swml_service): mock_swml_service._detect_proxy_from_request(request) assert mock_swml_service._proxy_url_base == "https://external.proxy.com:8443" - def test_x_forwarded_for_without_host_does_not_set_proxy(self, mock_swml_service): + def test_x_forwarded_for_without_host_does_not_set_proxy(self, mock_swml_service: SWMLService) -> None: """X-Forwarded-For without host info should not set proxy_url_base. Note: The production code has a structlog parameter conflict ('message' @@ -725,7 +726,7 @@ def test_x_forwarded_for_without_host_does_not_set_proxy(self, mock_swml_service # Cannot determine public URL from X-Forwarded-For alone assert mock_swml_service._proxy_url_base is None - def test_forwarded_header_parse_error_handled(self, mock_swml_service): + def test_forwarded_header_parse_error_handled(self, mock_swml_service: SWMLService) -> None: """Malformed Forwarded header should not raise — just log a warning.""" mock_swml_service._proxy_url_base = None # Provide a Forwarded value that will trigger the parsing path but has @@ -736,7 +737,7 @@ def test_forwarded_header_parse_error_handled(self, mock_swml_service): # Should not raise mock_swml_service._detect_proxy_from_request(request) - def test_multiple_proxy_hops_x_forwarded(self, mock_swml_service): + def test_multiple_proxy_hops_x_forwarded(self, mock_swml_service: SWMLService) -> None: """With multiple hops, the first X-Forwarded-Host value should win.""" mock_swml_service._proxy_url_base = None request = self._make_request(headers={ @@ -752,7 +753,7 @@ class TestServe: """Test that serve() calls uvicorn.run with correct arguments.""" @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_basic_defaults(self, mock_uvicorn_module): + def test_serve_basic_defaults(self, mock_uvicorn_module: MagicMock) -> None: """serve() should call uvicorn.run with default host and port.""" service = SWMLService( name="serve_test", @@ -770,7 +771,7 @@ def test_serve_basic_defaults(self, mock_uvicorn_module): assert call_kwargs[1].get("port", call_kwargs[1].get("port")) == 4000 @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_custom_host_port(self, mock_uvicorn_module): + def test_serve_custom_host_port(self, mock_uvicorn_module: MagicMock) -> None: """serve() should honor explicit host/port arguments.""" service = SWMLService( name="serve_custom", @@ -786,7 +787,7 @@ def test_serve_custom_host_port(self, mock_uvicorn_module): assert call_kwargs[1]["port"] == 9999 @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_with_ssl(self, mock_uvicorn_module): + def test_serve_with_ssl(self, mock_uvicorn_module: MagicMock) -> None: """serve() with SSL should pass cert/key to uvicorn.run.""" service = SWMLService( name="serve_ssl", @@ -796,7 +797,7 @@ def test_serve_with_ssl(self, mock_uvicorn_module): schema_validation=False, ) # We need to make validate_ssl_config return success - service.security.validate_ssl_config = Mock(return_value=(True, None)) + service.security.validate_ssl_config = Mock(return_value=(True, None)) # type: ignore[method-assign] # mock service.domain = "example.com" with patch.dict("sys.modules", {"uvicorn": mock_uvicorn_module}): service.serve(ssl_enabled=True, ssl_cert="/path/cert.pem", ssl_key="/path/key.pem") @@ -805,7 +806,7 @@ def test_serve_with_ssl(self, mock_uvicorn_module): assert call_kwargs[1].get("ssl_keyfile") == "/path/key.pem" @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_ssl_invalid_config_disables_ssl(self, mock_uvicorn_module): + def test_serve_ssl_invalid_config_disables_ssl(self, mock_uvicorn_module: MagicMock) -> None: """serve() should disable SSL when validation fails.""" service = SWMLService( name="serve_ssl_invalid", @@ -814,7 +815,7 @@ def test_serve_ssl_invalid_config_disables_ssl(self, mock_uvicorn_module): port=443, schema_validation=False, ) - service.security.validate_ssl_config = Mock(return_value=(False, "cert not found")) + service.security.validate_ssl_config = Mock(return_value=(False, "cert not found")) # type: ignore[method-assign] # mock with patch.dict("sys.modules", {"uvicorn": mock_uvicorn_module}): service.serve(ssl_enabled=True) # SSL should have been disabled due to invalid config @@ -823,7 +824,7 @@ def test_serve_ssl_invalid_config_disables_ssl(self, mock_uvicorn_module): assert "ssl_certfile" not in call_kwargs[1] @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_creates_fastapi_app(self, mock_uvicorn_module): + def test_serve_creates_fastapi_app(self, mock_uvicorn_module: MagicMock) -> None: """serve() should create a FastAPI app if none exists.""" service = SWMLService( name="serve_app", @@ -838,7 +839,7 @@ def test_serve_creates_fastapi_app(self, mock_uvicorn_module): assert service._app is not None @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_reuses_existing_app(self, mock_uvicorn_module): + def test_serve_reuses_existing_app(self, mock_uvicorn_module: MagicMock) -> None: """serve() should reuse an existing _app if already created.""" service = SWMLService( name="serve_reuse", @@ -857,7 +858,7 @@ def test_serve_reuses_existing_app(self, mock_uvicorn_module): assert service._app is app_first @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_prints_startup_info(self, mock_uvicorn_module, capsys): + def test_serve_prints_startup_info(self, mock_uvicorn_module: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: """serve() should print user-friendly startup info.""" service = SWMLService( name="serve_print", @@ -873,7 +874,7 @@ def test_serve_prints_startup_info(self, mock_uvicorn_module, capsys): assert "Basic Auth" in captured.out @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_ssl_without_domain_warns(self, mock_uvicorn_module): + def test_serve_ssl_without_domain_warns(self, mock_uvicorn_module: MagicMock) -> None: """serve() with SSL but no domain should still proceed (with warning).""" service = SWMLService( name="serve_no_domain", @@ -882,7 +883,7 @@ def test_serve_ssl_without_domain_warns(self, mock_uvicorn_module): port=443, schema_validation=False, ) - service.security.validate_ssl_config = Mock(return_value=(True, None)) + service.security.validate_ssl_config = Mock(return_value=(True, None)) # type: ignore[method-assign] # mock service.domain = None with patch.dict("sys.modules", {"uvicorn": mock_uvicorn_module}): service.serve(ssl_enabled=True, ssl_cert="/cert.pem", ssl_key="/key.pem") @@ -891,7 +892,7 @@ def test_serve_ssl_without_domain_warns(self, mock_uvicorn_module): assert call_kwargs[1].get("ssl_certfile") == "/cert.pem" @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_with_routing_callbacks(self, mock_uvicorn_module, capsys): + def test_serve_with_routing_callbacks(self, mock_uvicorn_module: MagicMock, capsys: pytest.CaptureFixture[str]) -> None: """serve() should print callback endpoint info when callbacks registered.""" service = SWMLService( name="serve_callbacks", @@ -907,7 +908,7 @@ def test_serve_with_routing_callbacks(self, mock_uvicorn_module, capsys): assert "Callback endpoints" in captured.out @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_root_route(self, mock_uvicorn_module): + def test_serve_root_route(self, mock_uvicorn_module: MagicMock) -> None: """serve() with root route should include router without prefix.""" service = SWMLService( name="serve_root", @@ -924,39 +925,39 @@ def test_serve_root_route(self, mock_uvicorn_module): class TestSectionManagement: """Test add_section(), add_verb_to_section(), and section ordering.""" - def test_add_section_returns_true(self, mock_swml_service): + def test_add_section_returns_true(self, mock_swml_service: SWMLService) -> None: """Adding a new section should return True.""" mock_swml_service.reset_document() result = mock_swml_service.add_section("my_section") assert result is True - def test_add_duplicate_section_returns_false(self, mock_swml_service): + def test_add_duplicate_section_returns_false(self, mock_swml_service: SWMLService) -> None: """Adding a section that already exists should return False.""" mock_swml_service.reset_document() mock_swml_service.add_section("dup_section") result = mock_swml_service.add_section("dup_section") assert result is False - def test_main_section_exists_by_default(self, mock_swml_service): + def test_main_section_exists_by_default(self, mock_swml_service: SWMLService) -> None: """The 'main' section should exist by default in a new document.""" mock_swml_service.reset_document() doc = mock_swml_service.get_document() assert "main" in doc["sections"] - def test_add_section_creates_empty_list(self, mock_swml_service): + def test_add_section_creates_empty_list(self, mock_swml_service: SWMLService) -> None: """A newly added section should be an empty list.""" mock_swml_service.reset_document() mock_swml_service.add_section("empty_section") doc = mock_swml_service.get_document() assert doc["sections"]["empty_section"] == [] - def test_add_duplicate_main_returns_false(self, mock_swml_service): + def test_add_duplicate_main_returns_false(self, mock_swml_service: SWMLService) -> None: """Trying to add 'main' again should return False.""" mock_swml_service.reset_document() result = mock_swml_service.add_section("main") assert result is False - def test_add_verb_to_named_section(self, mock_swml_service): + def test_add_verb_to_named_section(self, mock_swml_service: SWMLService) -> None: """add_verb_to_section should add verb to the specified section.""" mock_swml_service.reset_document() mock_swml_service.add_section("secondary") @@ -965,7 +966,7 @@ def test_add_verb_to_named_section(self, mock_swml_service): doc = mock_swml_service.get_document() assert {"sleep": 1000} in doc["sections"]["secondary"] - def test_add_verb_to_section_auto_creates_section(self, mock_swml_service): + def test_add_verb_to_section_auto_creates_section(self, mock_swml_service: SWMLService) -> None: """add_verb_to_section should auto-create the section if it does not exist.""" mock_swml_service.reset_document() result = mock_swml_service.add_verb_to_section("auto_created", "sleep", 500) @@ -974,14 +975,14 @@ def test_add_verb_to_section_auto_creates_section(self, mock_swml_service): assert "auto_created" in doc["sections"] assert {"sleep": 500} in doc["sections"]["auto_created"] - def test_add_verb_to_section_invalid_config_type(self, mock_swml_service): + def test_add_verb_to_section_invalid_config_type(self, mock_swml_service: SWMLService) -> None: """add_verb_to_section with non-dict non-sleep config should return False.""" mock_swml_service.reset_document() mock_swml_service.add_section("bad_section") - result = mock_swml_service.add_verb_to_section("bad_section", "play", "not_a_dict") + result = mock_swml_service.add_verb_to_section("bad_section", "play", "not_a_dict") # type: ignore[arg-type] # intentional invalid input assert result is False - def test_multiple_sections_in_document(self, mock_swml_service): + def test_multiple_sections_in_document(self, mock_swml_service: SWMLService) -> None: """Multiple sections should all appear in the document.""" mock_swml_service.reset_document() mock_swml_service.add_section("alpha") @@ -991,7 +992,7 @@ def test_multiple_sections_in_document(self, mock_swml_service): for name in ("main", "alpha", "beta", "gamma"): assert name in doc["sections"] - def test_verbs_in_different_sections_independent(self, mock_swml_service): + def test_verbs_in_different_sections_independent(self, mock_swml_service: SWMLService) -> None: """Verbs added to different sections should remain independent.""" mock_swml_service.reset_document() mock_swml_service.add_section("section_a") @@ -1003,7 +1004,7 @@ def test_verbs_in_different_sections_independent(self, mock_swml_service): assert {"sleep": 200} in doc["sections"]["section_b"] assert {"sleep": 100} not in doc["sections"]["section_b"] - def test_section_ordering_preserved(self, mock_swml_service): + def test_section_ordering_preserved(self, mock_swml_service: SWMLService) -> None: """Sections should maintain insertion order (Python 3.7+ dict ordering).""" mock_swml_service.reset_document() names = ["first", "second", "third"] @@ -1015,14 +1016,14 @@ def test_section_ordering_preserved(self, mock_swml_service): assert section_keys[0] == "main" assert section_keys[1:] == names - def test_add_verb_main_section_by_default(self, mock_swml_service): + def test_add_verb_main_section_by_default(self, mock_swml_service: SWMLService) -> None: """add_verb should add to main section.""" mock_swml_service.reset_document() mock_swml_service.add_verb("sleep", 1234) doc = mock_swml_service.get_document() assert {"sleep": 1234} in doc["sections"]["main"] - def test_reset_clears_all_sections(self, mock_swml_service): + def test_reset_clears_all_sections(self, mock_swml_service: SWMLService) -> None: """reset_document should remove custom sections.""" mock_swml_service.add_section("custom") mock_swml_service.add_verb_to_section("custom", "sleep", 100) @@ -1031,7 +1032,7 @@ def test_reset_clears_all_sections(self, mock_swml_service): assert "custom" not in doc["sections"] assert doc["sections"]["main"] == [] - def test_render_document_includes_all_sections(self, mock_swml_service): + def test_render_document_includes_all_sections(self, mock_swml_service: SWMLService) -> None: """render_document should serialize all sections.""" mock_swml_service.reset_document() mock_swml_service.add_section("extra") @@ -1041,13 +1042,13 @@ def test_render_document_includes_all_sections(self, mock_swml_service): assert "extra" in parsed["sections"] assert {"sleep": 42} in parsed["sections"]["extra"] - def test_add_verb_non_dict_config_returns_false(self, mock_swml_service): + def test_add_verb_non_dict_config_returns_false(self, mock_swml_service: SWMLService) -> None: """add_verb with a non-dict, non-sleep-int config returns False.""" mock_swml_service.reset_document() result = mock_swml_service.add_verb("play", 42) assert result is False - def test_add_verb_sleep_int_config(self, mock_swml_service): + def test_add_verb_sleep_int_config(self, mock_swml_service: SWMLService) -> None: """add_verb with verb_name='sleep' and int config should succeed.""" mock_swml_service.reset_document() result = mock_swml_service.add_verb("sleep", 3000) @@ -1055,7 +1056,7 @@ def test_add_verb_sleep_int_config(self, mock_swml_service): doc = mock_swml_service.get_document() assert {"sleep": 3000} in doc["sections"]["main"] - def test_add_verb_to_section_sleep_int_config(self, mock_swml_service): + def test_add_verb_to_section_sleep_int_config(self, mock_swml_service: SWMLService) -> None: """add_verb_to_section with sleep and int config should succeed.""" mock_swml_service.reset_document() mock_swml_service.add_section("timers") @@ -1068,7 +1069,7 @@ def test_add_verb_to_section_sleep_int_config(self, mock_swml_service): class TestCheckBasicAuth: """Test _check_basic_auth with various request headers.""" - def _make_request_with_auth(self, auth_header=None): + def _make_request_with_auth(self, auth_header: str | None = None) -> Mock: """Helper to build a mock request with an Authorization header.""" request = Mock() headers = {} @@ -1077,12 +1078,12 @@ def _make_request_with_auth(self, auth_header=None): request.headers = headers return request - def test_no_auth_header_returns_false(self, mock_swml_service): + def test_no_auth_header_returns_false(self, mock_swml_service: SWMLService) -> None: """Missing Authorization header should fail auth.""" request = self._make_request_with_auth(None) assert mock_swml_service._check_basic_auth(request) is False - def test_valid_basic_auth(self): + def test_valid_basic_auth(self) -> None: """Correct credentials should return True.""" service = SWMLService( name="auth_test", @@ -1097,7 +1098,7 @@ def test_valid_basic_auth(self): request = self._make_request_with_auth(f"Basic {creds}") assert service._check_basic_auth(request) is True - def test_wrong_password_returns_false(self): + def test_wrong_password_returns_false(self) -> None: """Wrong password should return False.""" service = SWMLService( name="auth_wrong", @@ -1112,7 +1113,7 @@ def test_wrong_password_returns_false(self): request = self._make_request_with_auth(f"Basic {creds}") assert service._check_basic_auth(request) is False - def test_wrong_username_returns_false(self): + def test_wrong_username_returns_false(self) -> None: """Wrong username should return False.""" service = SWMLService( name="auth_wrong_user", @@ -1127,7 +1128,7 @@ def test_wrong_username_returns_false(self): request = self._make_request_with_auth(f"Basic {creds}") assert service._check_basic_auth(request) is False - def test_non_basic_scheme_returns_false(self): + def test_non_basic_scheme_returns_false(self) -> None: """Non-Basic scheme (e.g., Bearer) should return False.""" service = SWMLService( name="auth_bearer", @@ -1140,7 +1141,7 @@ def test_non_basic_scheme_returns_false(self): request = self._make_request_with_auth("Bearer some-token") assert service._check_basic_auth(request) is False - def test_malformed_auth_header_returns_false(self): + def test_malformed_auth_header_returns_false(self) -> None: """Malformed auth header should return False (not crash).""" service = SWMLService( name="auth_bad", @@ -1153,7 +1154,7 @@ def test_malformed_auth_header_returns_false(self): request = self._make_request_with_auth("completelyinvalid") assert service._check_basic_auth(request) is False - def test_invalid_base64_returns_false(self): + def test_invalid_base64_returns_false(self) -> None: """Invalid base64 in auth header should return False.""" service = SWMLService( name="auth_badbase64", @@ -1170,7 +1171,7 @@ def test_invalid_base64_returns_false(self): class TestStopMethod: """Test the stop() method.""" - def test_stop_sets_running_false(self, mock_swml_service): + def test_stop_sets_running_false(self, mock_swml_service: SWMLService) -> None: """stop() should set _running to False.""" mock_swml_service._running = True mock_swml_service.stop() @@ -1180,7 +1181,7 @@ def test_stop_sets_running_false(self, mock_swml_service): class TestGetBaseUrl: """Test _get_base_url with various configurations.""" - def test_base_url_no_proxy_no_ssl(self): + def test_base_url_no_proxy_no_ssl(self) -> None: """Without proxy or SSL, should return http://localhost:port.""" service = SWMLService( name="url_test", @@ -1193,7 +1194,7 @@ def test_base_url_no_proxy_no_ssl(self): url = service._get_base_url(include_auth=False) assert url == "http://localhost:5000" - def test_base_url_no_proxy_no_ssl_with_auth(self): + def test_base_url_no_proxy_no_ssl_with_auth(self) -> None: """Without proxy or SSL, with auth should embed credentials.""" service = SWMLService( name="url_auth", @@ -1208,7 +1209,7 @@ def test_base_url_no_proxy_no_ssl_with_auth(self): assert "user:pass@" in url assert url.startswith("http://") - def test_base_url_with_proxy(self): + def test_base_url_with_proxy(self) -> None: """With proxy set, should use proxy URL base.""" service = SWMLService( name="url_proxy", @@ -1222,7 +1223,7 @@ def test_base_url_with_proxy(self): url = service._get_base_url(include_auth=False) assert url == "https://myproxy.ngrok.io" - def test_base_url_with_proxy_and_auth(self): + def test_base_url_with_proxy_and_auth(self) -> None: """Proxy URL with auth should embed credentials in the proxy URL.""" service = SWMLService( name="url_proxy_auth", @@ -1237,7 +1238,7 @@ def test_base_url_with_proxy_and_auth(self): assert "admin:secret@" in url assert "myproxy.ngrok.io" in url - def test_base_url_ssl_with_domain(self): + def test_base_url_ssl_with_domain(self) -> None: """SSL enabled with domain should produce https://domain.""" service = SWMLService( name="url_ssl", @@ -1252,7 +1253,7 @@ def test_base_url_ssl_with_domain(self): url = service._get_base_url(include_auth=False) assert url == "https://secure.example.com" - def test_base_url_ssl_with_domain_non_standard_port(self): + def test_base_url_ssl_with_domain_non_standard_port(self) -> None: """SSL with domain and non-standard port should include port.""" service = SWMLService( name="url_ssl_port", @@ -1267,7 +1268,7 @@ def test_base_url_ssl_with_domain_non_standard_port(self): url = service._get_base_url(include_auth=False) assert url == "https://secure.example.com:8443" - def test_base_url_custom_host(self): + def test_base_url_custom_host(self) -> None: """Custom host (not 0.0.0.0/localhost) should be used directly.""" service = SWMLService( name="url_custom_host", @@ -1280,7 +1281,7 @@ def test_base_url_custom_host(self): url = service._get_base_url(include_auth=False) assert "192.168.1.100:3000" in url - def test_base_url_http_port_80(self): + def test_base_url_http_port_80(self) -> None: """HTTP on port 80 should not include port number.""" service = SWMLService( name="url_port80", @@ -1299,7 +1300,7 @@ def test_base_url_http_port_80(self): class TestBuildFullUrl: """Test _build_full_url and _build_webhook_url.""" - def test_build_full_url_no_endpoint(self): + def test_build_full_url_no_endpoint(self) -> None: """No endpoint should return base + route.""" service = SWMLService( name="build_url", @@ -1313,7 +1314,7 @@ def test_build_full_url_no_endpoint(self): url = service._build_full_url(include_auth=False) assert url.endswith("/agent") - def test_build_full_url_with_endpoint(self): + def test_build_full_url_with_endpoint(self) -> None: """Endpoint should be appended with trailing slash.""" service = SWMLService( name="build_ep", @@ -1327,7 +1328,7 @@ def test_build_full_url_with_endpoint(self): url = service._build_full_url(endpoint="swaig", include_auth=False) assert "/agent/swaig/" in url - def test_build_full_url_with_query_params(self): + def test_build_full_url_with_query_params(self) -> None: """Query params should be appended to the URL.""" service = SWMLService( name="build_qp", @@ -1346,7 +1347,7 @@ def test_build_full_url_with_query_params(self): assert "token=abc123" in url assert "mode=test" in url - def test_build_full_url_query_params_filters_empty(self): + def test_build_full_url_query_params_filters_empty(self) -> None: """Empty query param values should be filtered out.""" service = SWMLService( name="build_qp_filter", @@ -1360,12 +1361,12 @@ def test_build_full_url_query_params_filters_empty(self): url = service._build_full_url( endpoint="callback", include_auth=False, - query_params={"present": "yes", "empty": "", "also_empty": None}, + query_params={"present": "yes", "empty": "", "also_empty": None}, # type: ignore[dict-item] # intentional: None value dropped ) assert "present=yes" in url assert "empty" not in url.split("?")[1] if "?" in url else True - def test_build_full_url_root_route(self): + def test_build_full_url_root_route(self) -> None: """Root route should not double-slash.""" service = SWMLService( name="build_root", @@ -1378,7 +1379,7 @@ def test_build_full_url_root_route(self): url = service._build_full_url(include_auth=False) assert not url.endswith("//") - def test_build_webhook_url(self): + def test_build_webhook_url(self) -> None: """_build_webhook_url should build authenticated URL.""" service = SWMLService( name="webhook", @@ -1397,12 +1398,12 @@ def test_build_webhook_url(self): class TestFullValidationEnabled: """Test full_validation_enabled property.""" - def test_full_validation_with_schema_utils(self, mock_swml_service): + def test_full_validation_with_schema_utils(self, mock_swml_service: SWMLService) -> None: """Property should delegate to schema_utils.""" result = mock_swml_service.full_validation_enabled assert isinstance(result, bool) - def test_full_validation_without_schema_utils(self): + def test_full_validation_without_schema_utils(self) -> None: """Property should return False when schema_utils is None.""" service = SWMLService( name="no_schema_val", @@ -1411,43 +1412,43 @@ def test_full_validation_without_schema_utils(self): port=3000, schema_validation=False, ) - service.schema_utils = None + service.schema_utils = None # type: ignore[assignment] # intentional: exercise missing-schema path assert service.full_validation_enabled is False class TestExtractSipUsername: """Test extract_sip_username static method.""" - def test_sip_uri_extraction(self): + def test_sip_uri_extraction(self) -> None: """Should extract username from sip: URI.""" body = {"call": {"to": "sip:alice@example.com"}} assert SWMLService.extract_sip_username(body) == "alice" - def test_tel_uri_extraction(self): + def test_tel_uri_extraction(self) -> None: """Should extract phone number from tel: URI.""" body = {"call": {"to": "tel:+15551234567"}} assert SWMLService.extract_sip_username(body) == "+15551234567" - def test_plain_to_field(self): + def test_plain_to_field(self) -> None: """Should return the whole 'to' field if not SIP/TEL.""" body = {"call": {"to": "some-destination"}} assert SWMLService.extract_sip_username(body) == "some-destination" - def test_no_call_key_returns_none(self): + def test_no_call_key_returns_none(self) -> None: """Should return None when 'call' key is missing.""" body = {"other": "data"} assert SWMLService.extract_sip_username(body) is None - def test_no_to_field_returns_none(self): + def test_no_to_field_returns_none(self) -> None: """Should return None when 'to' field is missing from call.""" body = {"call": {"from": "sip:bob@example.com"}} assert SWMLService.extract_sip_username(body) is None - def test_empty_body_returns_none(self): + def test_empty_body_returns_none(self) -> None: """Should return None for empty body.""" assert SWMLService.extract_sip_username({}) is None - def test_sip_uri_with_port(self): + def test_sip_uri_with_port(self) -> None: """Should extract username from SIP URI with port.""" body = {"call": {"to": "sip:bob@example.com:5060"}} assert SWMLService.extract_sip_username(body) == "bob" @@ -1456,11 +1457,11 @@ def test_sip_uri_with_port(self): class TestRegisterVerbHandler: """Test register_verb_handler method.""" - def test_register_verb_handler(self, mock_swml_service): + def test_register_verb_handler(self, mock_swml_service: SWMLService) -> None: """Should delegate to verb_registry.register_handler.""" mock_handler = Mock() mock_handler.verb_name = "custom_verb" - mock_swml_service.verb_registry.register_handler = Mock() + mock_swml_service.verb_registry.register_handler = Mock() # type: ignore[method-assign] # mock mock_swml_service.register_verb_handler(mock_handler) mock_swml_service.verb_registry.register_handler.assert_called_once_with(mock_handler) @@ -1468,7 +1469,7 @@ def test_register_verb_handler(self, mock_swml_service): class TestCreateEmptyDocument: """Test _create_empty_document method.""" - def test_structure(self, mock_swml_service): + def test_structure(self, mock_swml_service: SWMLService) -> None: """Empty document should have version and sections.main.""" doc = mock_swml_service._create_empty_document() assert doc["version"] == "1.0.0" @@ -1480,7 +1481,7 @@ def test_structure(self, mock_swml_service): class TestPortFromEnvironment: """Test port resolution from environment variable.""" - def test_port_from_env(self): + def test_port_from_env(self) -> None: """Should use PORT env var when port is not provided.""" with patch.dict("os.environ", {"PORT": "9876"}): service = SWMLService( @@ -1491,7 +1492,7 @@ def test_port_from_env(self): ) assert service.port == 9876 - def test_explicit_port_overrides_env(self): + def test_explicit_port_overrides_env(self) -> None: """Explicit port should override PORT env var.""" with patch.dict("os.environ", {"PORT": "9876"}): service = SWMLService( @@ -1507,7 +1508,7 @@ def test_explicit_port_overrides_env(self): class TestRouteNormalization: """Test route normalization during init.""" - def test_trailing_slash_stripped(self): + def test_trailing_slash_stripped(self) -> None: """Trailing slash should be stripped from route.""" service = SWMLService( name="route_test", @@ -1518,7 +1519,7 @@ def test_trailing_slash_stripped(self): ) assert service.route == "/myroute" - def test_no_trailing_slash_unchanged(self): + def test_no_trailing_slash_unchanged(self) -> None: """Route without trailing slash should remain unchanged.""" service = SWMLService( name="route_test2", @@ -1529,7 +1530,7 @@ def test_no_trailing_slash_unchanged(self): ) assert service.route == "/myroute" - def test_root_route(self): + def test_root_route(self) -> None: """Root '/' route should become empty string after rstrip.""" service = SWMLService( name="route_root", @@ -1544,18 +1545,18 @@ def test_root_route(self): class TestManualSetProxyUrl: """Test manual_set_proxy_url method.""" - def test_sets_proxy_url(self, mock_swml_service): + def test_sets_proxy_url(self, mock_swml_service: SWMLService) -> None: """Should set _proxy_url_base and _proxy_detection_done.""" mock_swml_service.manual_set_proxy_url("https://myproxy.com/") assert mock_swml_service._proxy_url_base == "https://myproxy.com" assert mock_swml_service._proxy_detection_done is True - def test_trailing_slash_stripped(self, mock_swml_service): + def test_trailing_slash_stripped(self, mock_swml_service: SWMLService) -> None: """Trailing slashes should be stripped.""" mock_swml_service.manual_set_proxy_url("https://myproxy.com///") assert mock_swml_service._proxy_url_base == "https://myproxy.com" - def test_empty_string_does_nothing(self, mock_swml_service): + def test_empty_string_does_nothing(self, mock_swml_service: SWMLService) -> None: """Empty string should not set proxy URL.""" mock_swml_service._proxy_url_base = None mock_swml_service._proxy_detection_done = False @@ -1579,7 +1580,7 @@ def test_empty_string_does_nothing(self, mock_swml_service): from signalwire.utils.schema_utils import SchemaValidationError -def _build_test_client(service, prefix=None): +def _build_test_client(service: SWMLService, prefix: str | None = None) -> TestClient: """Helper: wrap a SWMLService in a FastAPI TestClient.""" app = FastAPI(redirect_slashes=False) router = service.as_router() @@ -1590,7 +1591,7 @@ def _build_test_client(service, prefix=None): return TestClient(app, raise_server_exceptions=False) -def _auth_header(username, password): +def _auth_header(username: str, password: str) -> dict[str, str]: """Helper: produce an Authorization header dict.""" creds = base64.b64encode(f"{username}:{password}".encode()).decode() return {"Authorization": f"Basic {creds}"} @@ -1599,7 +1600,7 @@ def _auth_header(username, password): class TestHandleRequestGET: """Test _handle_request via GET requests through TestClient.""" - def test_get_returns_swml_document(self): + def test_get_returns_swml_document(self) -> None: """GET with valid auth should return the SWML document.""" svc = SWMLService( name="hr_get", route="/", host="127.0.0.1", port=3001, @@ -1612,7 +1613,7 @@ def test_get_returns_swml_document(self): assert body["version"] == "1.0.0" assert "main" in body["sections"] - def test_get_without_auth_returns_401(self): + def test_get_without_auth_returns_401(self) -> None: """GET without auth should return 401.""" svc = SWMLService( name="hr_noauth", route="/", host="127.0.0.1", port=3001, @@ -1622,7 +1623,7 @@ def test_get_without_auth_returns_401(self): resp = client.get("/") assert resp.status_code == 401 - def test_get_with_wrong_auth_returns_401(self): + def test_get_with_wrong_auth_returns_401(self) -> None: """GET with wrong credentials should return 401.""" svc = SWMLService( name="hr_wrongauth", route="/", host="127.0.0.1", port=3001, @@ -1636,7 +1637,7 @@ def test_get_with_wrong_auth_returns_401(self): class TestHandleRequestPOST: """Test _handle_request via POST requests.""" - def test_post_with_empty_body(self): + def test_post_with_empty_body(self) -> None: """POST with empty body should return SWML document.""" svc = SWMLService( name="hr_post_empty", route="/", host="127.0.0.1", port=3001, @@ -1647,7 +1648,7 @@ def test_post_with_empty_body(self): assert resp.status_code == 200 assert resp.json()["version"] == "1.0.0" - def test_post_with_json_body(self): + def test_post_with_json_body(self) -> None: """POST with JSON body should still return SWML document.""" svc = SWMLService( name="hr_post_json", route="/", host="127.0.0.1", port=3001, @@ -1660,7 +1661,7 @@ def test_post_with_json_body(self): ) assert resp.status_code == 200 - def test_post_with_invalid_json_body(self): + def test_post_with_invalid_json_body(self) -> None: """POST with invalid JSON should still return SWML (body parse error handled).""" svc = SWMLService( name="hr_post_bad", route="/", host="127.0.0.1", port=3001, @@ -1677,27 +1678,27 @@ def test_post_with_invalid_json_body(self): class TestHandleRequestOnRequestModifications: """Test _handle_request when on_request returns modifications.""" - def test_on_request_returns_modifications(self): + def test_on_request_returns_modifications(self) -> None: """When on_request returns a dict, those modifications should be applied.""" svc = SWMLService( name="hr_mod", route="/", host="127.0.0.1", port=3001, basic_auth=("u", "p"), schema_validation=False, ) # Override on_request to return modifications - svc.on_request = lambda data, cb_path: {"version": "2.0.0"} + svc.on_request = lambda data, cb_path: {"version": "2.0.0"} # type: ignore[method-assign,misc,assignment] # mock override client = _build_test_client(svc) resp = client.get("/", headers=_auth_header("u", "p")) assert resp.status_code == 200 body = resp.json() assert body["version"] == "2.0.0" - def test_on_request_returns_none_no_modification(self): + def test_on_request_returns_none_no_modification(self) -> None: """When on_request returns None, the original document should be returned.""" svc = SWMLService( name="hr_nomod", route="/", host="127.0.0.1", port=3001, basic_auth=("u", "p"), schema_validation=False, ) - svc.on_request = lambda data, cb_path: None + svc.on_request = lambda data, cb_path: None # type: ignore[method-assign,misc,assignment] # mock override client = _build_test_client(svc) resp = client.get("/", headers=_auth_header("u", "p")) assert resp.status_code == 200 @@ -1708,14 +1709,14 @@ def test_on_request_returns_none_no_modification(self): class TestHandleRequestRoutingCallback: """Test _handle_request with routing callbacks.""" - def test_routing_callback_redirect(self): + def test_routing_callback_redirect(self) -> None: """Routing callback returning a route should produce a 307 redirect.""" svc = SWMLService( name="hr_cb_redir", route="/", host="127.0.0.1", port=3001, basic_auth=("u", "p"), schema_validation=False, ) - def my_callback(request, body): + def my_callback(request: Any, body: dict[str, Any]) -> str | None: return "/other-agent" svc.register_routing_callback(my_callback, "/sip") @@ -1728,14 +1729,14 @@ def my_callback(request, body): assert resp.status_code == 307 assert resp.headers.get("location") == "/other-agent" - def test_routing_callback_returns_none_continues(self): + def test_routing_callback_returns_none_continues(self) -> None: """Routing callback returning None should produce normal SWML response.""" svc = SWMLService( name="hr_cb_none", route="/", host="127.0.0.1", port=3001, basic_auth=("u", "p"), schema_validation=False, ) - def my_callback(request, body): + def my_callback(request: Any, body: dict[str, Any]) -> str | None: return None svc.register_routing_callback(my_callback, "/sip") @@ -1747,14 +1748,14 @@ def my_callback(request, body): assert resp.status_code == 200 assert resp.json()["version"] == "1.0.0" - def test_routing_callback_exception_handled(self): + def test_routing_callback_exception_handled(self) -> None: """Routing callback that raises should be caught; normal SWML returned.""" svc = SWMLService( name="hr_cb_err", route="/", host="127.0.0.1", port=3001, basic_auth=("u", "p"), schema_validation=False, ) - def bad_callback(request, body): + def bad_callback(request: Any, body: dict[str, Any]) -> str | None: raise RuntimeError("callback exploded") svc.register_routing_callback(bad_callback, "/sip") @@ -1769,7 +1770,7 @@ def bad_callback(request, body): class TestAsRouterWithCallbacks: """Test as_router when routing callbacks are registered (lines 559-580).""" - def test_as_router_registers_callback_endpoints(self): + def test_as_router_registers_callback_endpoints(self) -> None: """as_router should register endpoints for each routing callback.""" svc = SWMLService( name="ar_cb", route="/", host="127.0.0.1", port=3001, @@ -1780,7 +1781,7 @@ def test_as_router_registers_callback_endpoints(self): paths = [r.path for r in router.routes if hasattr(r, "path")] assert "/sip" in paths or "/sip/" in paths - def test_as_router_skips_root_callback(self): + def test_as_router_skips_root_callback(self) -> None: """as_router should skip root '/' callback since root is always registered.""" svc = SWMLService( name="ar_root_cb", route="/", host="127.0.0.1", port=3001, @@ -1792,7 +1793,7 @@ def test_as_router_skips_root_callback(self): paths = [r.path for r in router.routes if hasattr(r, "path")] assert "/" in paths - def test_callback_endpoint_sets_state(self): + def test_callback_endpoint_sets_state(self) -> None: """Callback endpoint should store callback_path in request.state.""" svc = SWMLService( name="ar_state", route="/", host="127.0.0.1", port=3001, @@ -1800,18 +1801,18 @@ def test_callback_endpoint_sets_state(self): ) captured_paths = [] - def capture_callback(request, body): + def capture_callback(request: Any, body: dict[str, Any]) -> str | None: return None svc.register_routing_callback(capture_callback, "/sip") # Override on_request to capture the callback_path original_on_request = svc.on_request - def capturing_on_request(data, cb_path): + def capturing_on_request(data: dict[str, Any] | None = None, cb_path: str | None = None) -> dict[str, Any] | None: captured_paths.append(cb_path) return None - svc.on_request = capturing_on_request + svc.on_request = capturing_on_request # type: ignore[method-assign,assignment] # mock override client = _build_test_client(svc) resp = client.post( "/sip", json={"key": "value"}, @@ -1824,7 +1825,7 @@ def capturing_on_request(data, cb_path): class TestRegisterRoutingCallbackNormalization: """Test register_routing_callback path normalization (line 605).""" - def test_path_without_leading_slash_normalized(self): + def test_path_without_leading_slash_normalized(self) -> None: """Path without leading slash should get one added.""" svc = SWMLService( name="rc_norm", route="/", host="127.0.0.1", port=3001, @@ -1833,7 +1834,7 @@ def test_path_without_leading_slash_normalized(self): svc.register_routing_callback(lambda r, b: None, "sip") assert "/sip" in svc._routing_callbacks - def test_path_with_trailing_slash_stripped(self): + def test_path_with_trailing_slash_stripped(self) -> None: """Trailing slash should be stripped from callback path.""" svc = SWMLService( name="rc_trail", route="/", host="127.0.0.1", port=3001, @@ -1842,7 +1843,7 @@ def test_path_with_trailing_slash_stripped(self): svc.register_routing_callback(lambda r, b: None, "/sip/") assert "/sip" in svc._routing_callbacks - def test_path_both_normalizations(self): + def test_path_both_normalizations(self) -> None: """Path with no leading slash and trailing slash should be fully normalized.""" svc = SWMLService( name="rc_both", route="/", host="127.0.0.1", port=3001, @@ -1855,7 +1856,7 @@ def test_path_both_normalizations(self): class TestAddVerbToSectionWithHandler: """Test add_verb_to_section with registered verb handlers (lines 504-505, 511).""" - def test_add_verb_to_section_with_valid_handler(self): + def test_add_verb_to_section_with_valid_handler(self) -> None: """When a registered handler validates, verb should be added.""" svc = SWMLService( name="vts_handler", route="/", host="127.0.0.1", port=3001, @@ -1872,7 +1873,7 @@ def test_add_verb_to_section_with_valid_handler(self): assert {"custom_verb": {"key": "val"}} in doc["sections"]["sec"] mock_handler.validate_config.assert_called_once_with({"key": "val"}) - def test_add_verb_to_section_with_invalid_handler_raises(self): + def test_add_verb_to_section_with_invalid_handler_raises(self) -> None: """When a registered handler rejects, SchemaValidationError should be raised.""" svc = SWMLService( name="vts_invalid", route="/", host="127.0.0.1", port=3001, @@ -1885,14 +1886,14 @@ def test_add_verb_to_section_with_invalid_handler_raises(self): with pytest.raises(SchemaValidationError): svc.add_verb_to_section("sec", "custom_verb", {"bad": "config"}) - def test_add_verb_to_section_schema_validation_error(self): + def test_add_verb_to_section_schema_validation_error(self) -> None: """Schema-based validation failure should raise SchemaValidationError.""" svc = SWMLService( name="vts_schema", route="/", host="127.0.0.1", port=3001, schema_validation=False, ) # Mock schema_utils to return invalid - svc.schema_utils.validate_verb = Mock(return_value=(False, ["invalid config"])) + svc.schema_utils.validate_verb = Mock(return_value=(False, ["invalid config"])) # type: ignore[method-assign] # mock with pytest.raises(SchemaValidationError): svc.add_verb_to_section("new_sec", "play", {"bad": "thing"}) @@ -1900,19 +1901,19 @@ def test_add_verb_to_section_schema_validation_error(self): class TestExtractSipUsernameEdgeCases: """Test extract_sip_username exception paths (lines 644-646).""" - def test_non_string_to_field(self): + def test_non_string_to_field(self) -> None: """Non-string 'to' field should return None via AttributeError path.""" body = {"call": {"to": 12345}} result = SWMLService.extract_sip_username(body) assert result is None - def test_none_to_field(self): + def test_none_to_field(self) -> None: """None 'to' field should return None via AttributeError path.""" body = {"call": {"to": None}} result = SWMLService.extract_sip_username(body) assert result is None - def test_list_to_field(self): + def test_list_to_field(self) -> None: """List 'to' field should return None via AttributeError path.""" body = {"call": {"to": ["sip:alice@example.com"]}} result = SWMLService.extract_sip_username(body) @@ -1922,13 +1923,13 @@ def test_list_to_field(self): class TestCreateVerbMethodsNoSchema: """Test _create_verb_methods when schema_utils is falsy (lines 166-167).""" - def test_create_verb_methods_no_schema_utils(self): + def test_create_verb_methods_no_schema_utils(self) -> None: """_create_verb_methods should return early when schema_utils is None.""" svc = SWMLService( name="no_schema_verbs", route="/", host="127.0.0.1", port=3001, schema_validation=False, ) - svc.schema_utils = None + svc.schema_utils = None # type: ignore[assignment] # intentional: exercise missing-schema path # Clear cache to ensure clean state svc._verb_methods_cache = {} # Should not raise @@ -1940,7 +1941,7 @@ def test_create_verb_methods_no_schema_utils(self): class TestGetAttrCacheInit: """Test __getattr__ initializing _verb_methods_cache (line 274).""" - def test_getattr_creates_cache_if_missing(self): + def test_getattr_creates_cache_if_missing(self) -> None: """__getattr__ should create _verb_methods_cache if it doesn't exist.""" svc = SWMLService( name="getattr_cache_init", route="/", host="127.0.0.1", port=3001, @@ -1964,7 +1965,7 @@ def test_getattr_creates_cache_if_missing(self): class TestGetBasicAuthEnvironmentSource: """Test get_basic_auth_credentials environment source detection (line 940).""" - def test_env_source_detected(self): + def test_env_source_detected(self) -> None: """When credentials match env vars, source should be 'environment'.""" with patch.dict("os.environ", { "SWML_BASIC_AUTH_USER": "envuser", @@ -1975,26 +1976,26 @@ def test_env_source_detected(self): basic_auth=("envuser", "envpass"), schema_validation=False, ) - u, p, source = svc.get_basic_auth_credentials(include_source=True) + u, p, source = svc.get_basic_auth_credentials(include_source=True) # type: ignore[misc] # include_source=True returns 3-tuple assert u == "envuser" assert p == "envpass" assert source == "environment" - def test_auto_generated_source(self): + def test_auto_generated_source(self) -> None: """When credentials don't match env vars, source should be 'auto-generated'.""" svc = SWMLService( name="auth_auto_src", route="/", host="127.0.0.1", port=3001, basic_auth=("myuser", "mypass"), schema_validation=False, ) - u, p, source = svc.get_basic_auth_credentials(include_source=True) + u, p, source = svc.get_basic_auth_credentials(include_source=True) # type: ignore[misc] # include_source=True returns 3-tuple assert source == "auto-generated" class TestGetBaseUrlDomainHttp80: """Test _get_base_url with HTTP port 80 and SSL domain (line 1001).""" - def test_ssl_domain_http_port_80(self): + def test_ssl_domain_http_port_80(self) -> None: """SSL with domain and port 80 should not include :80.""" svc = SWMLService( name="url_domain_80", route="/", host="0.0.0.0", port=80, @@ -2007,7 +2008,7 @@ def test_ssl_domain_http_port_80(self): # Port 80 on HTTPS is non-standard, should be included assert "example.com" in url - def test_no_ssl_domain_port_80(self): + def test_no_ssl_domain_port_80(self) -> None: """No SSL, with domain, port 80 should produce http://domain (no port).""" svc = SWMLService( name="url_nossldom80", route="/", host="0.0.0.0", port=80, @@ -2019,7 +2020,7 @@ def test_no_ssl_domain_port_80(self): url = svc._get_base_url(include_auth=False) assert ":80" not in url - def test_ssl_domain_https_443(self): + def test_ssl_domain_https_443(self) -> None: """SSL with domain and port 443 should not include :443.""" svc = SWMLService( name="url_ssl443", route="/", host="0.0.0.0", port=443, @@ -2036,7 +2037,7 @@ def test_ssl_domain_https_443(self): class TestProxyDetectionDebug: """Test proxy debug logging (line 1170).""" - def _make_request(self, headers=None, url="http://127.0.0.1:3001/test"): + def _make_request(self, headers: dict[str, str] | None = None, url: str = "http://127.0.0.1:3001/test") -> Mock: request = Mock() _headers = headers or {} request.headers = _headers @@ -2045,7 +2046,7 @@ def _make_request(self, headers=None, url="http://127.0.0.1:3001/test"): request.url.__str__ = Mock(return_value=url) return request - def test_proxy_debug_mode_logs(self): + def test_proxy_debug_mode_logs(self) -> None: """With _proxy_debug=True and no proxy detected, should not crash.""" svc = SWMLService( name="proxy_debug", route="/test", host="127.0.0.1", port=3001, @@ -2063,7 +2064,7 @@ def test_proxy_debug_mode_logs(self): pass assert svc._proxy_url_base is None - def test_proxy_debug_mode_false(self): + def test_proxy_debug_mode_false(self) -> None: """With _proxy_debug=False, detection still works normally.""" svc = SWMLService( name="proxy_nodebug", route="/test", host="127.0.0.1", port=3001, @@ -2079,7 +2080,7 @@ def test_proxy_debug_mode_false(self): class TestForwardedHeaderParseError: """Test forwarded header parse error (lines 1131-1132).""" - def _make_request(self, headers=None, url="http://127.0.0.1:3001/test"): + def _make_request(self, headers: dict[str, str] | None = None, url: str = "http://127.0.0.1:3001/test") -> Mock: request = Mock() _headers = headers or {} request.headers = _headers @@ -2088,7 +2089,7 @@ def _make_request(self, headers=None, url="http://127.0.0.1:3001/test"): request.url.__str__ = Mock(return_value=url) return request - def test_forwarded_header_causes_exception(self): + def test_forwarded_header_causes_exception(self) -> None: """A Forwarded header that triggers a parse exception should be handled.""" svc = SWMLService( name="fwd_err", route="/test", host="127.0.0.1", port=3001, @@ -2107,7 +2108,7 @@ def test_forwarded_header_causes_exception(self): class TestProxyUrlBaseFromEnv: """Test initialization with SWML_PROXY_URL_BASE env var (line 111).""" - def test_proxy_url_base_env_sets_attribute(self): + def test_proxy_url_base_env_sets_attribute(self) -> None: """SWML_PROXY_URL_BASE env var should set _proxy_url_base.""" with patch.dict("os.environ", {"SWML_PROXY_URL_BASE": "https://my-proxy.com"}): svc = SWMLService( @@ -2117,7 +2118,7 @@ def test_proxy_url_base_env_sets_attribute(self): assert svc._proxy_url_base == "https://my-proxy.com" assert svc._proxy_url_base_from_env is True - def test_no_proxy_url_base_env(self): + def test_no_proxy_url_base_env(self) -> None: """Without SWML_PROXY_URL_BASE env var, _proxy_url_base should be None.""" with patch.dict("os.environ", {}, clear=False): # Remove the env var if it exists @@ -2133,7 +2134,7 @@ def test_no_proxy_url_base_env(self): class TestFindSchemaPath: """Test _find_schema_path fallback paths (lines 363-395).""" - def test_find_schema_path_returns_string(self): + def test_find_schema_path_returns_string(self) -> None: """_find_schema_path should return a string path when schema is found.""" svc = SWMLService( name="schema_find", route="/", host="127.0.0.1", port=3001, @@ -2145,7 +2146,7 @@ def test_find_schema_path_returns_string(self): assert isinstance(result, str) assert "schema.json" in result - def test_find_schema_path_importlib_fails_fallback(self): + def test_find_schema_path_importlib_fails_fallback(self) -> None: """When importlib.resources fails, should fall back to file search.""" svc = SWMLService( name="schema_fallback", route="/", host="127.0.0.1", port=3001, @@ -2164,7 +2165,7 @@ def test_find_schema_path_importlib_fails_fallback(self): finally: ir.files = original_files - def test_find_schema_path_nothing_found(self): + def test_find_schema_path_nothing_found(self) -> None: """When no schema file exists anywhere, should return None.""" svc = SWMLService( name="schema_none", route="/", host="127.0.0.1", port=3001, @@ -2180,7 +2181,7 @@ def test_find_schema_path_nothing_found(self): finally: ir.files = original_files - def test_find_schema_path_manual_search_finds_file(self): + def test_find_schema_path_manual_search_finds_file(self) -> None: """When importlib fails but a file exists in manual paths, it should be found.""" svc = SWMLService( name="schema_manual", route="/", host="127.0.0.1", port=3001, @@ -2192,7 +2193,7 @@ def test_find_schema_path_manual_search_finds_file(self): try: ir.files = Mock(side_effect=ImportError("mocked")) - def mock_exists(path): + def mock_exists(path: Any) -> bool: if isinstance(path, str) and path.endswith("schema.json"): return True return original_exists(path) @@ -2209,7 +2210,7 @@ class TestServeCatchAllRoute: """Test the catch-all route handler created inside serve() (lines 803-836).""" @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_catch_all_exact_route_match(self, mock_uvicorn): + def test_catch_all_exact_route_match(self, mock_uvicorn: MagicMock) -> None: """Catch-all route should handle exact route match.""" svc = SWMLService( name="catch_exact", route="/agent", host="0.0.0.0", port=3000, @@ -2219,12 +2220,13 @@ def test_catch_all_exact_route_match(self, mock_uvicorn): with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): svc.serve() # Now test using the created app + assert svc._app is not None client = TestClient(svc._app, raise_server_exceptions=False) resp = client.get("/agent", headers=_auth_header("u", "p")) assert resp.status_code == 200 @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_catch_all_route_with_trailing_slash(self, mock_uvicorn): + def test_catch_all_route_with_trailing_slash(self, mock_uvicorn: MagicMock) -> None: """Catch-all route should handle route with trailing slash.""" svc = SWMLService( name="catch_trail", route="/agent", host="0.0.0.0", port=3000, @@ -2233,12 +2235,13 @@ def test_catch_all_route_with_trailing_slash(self, mock_uvicorn): mock_uvicorn.run = Mock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): svc.serve() + assert svc._app is not None client = TestClient(svc._app, raise_server_exceptions=False) resp = client.get("/agent/", headers=_auth_header("u", "p")) assert resp.status_code == 200 @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_catch_all_no_match(self, mock_uvicorn): + def test_catch_all_no_match(self, mock_uvicorn: MagicMock) -> None: """Catch-all route should return error for unmatched paths.""" svc = SWMLService( name="catch_nomatch", route="/agent", host="0.0.0.0", port=3000, @@ -2247,6 +2250,7 @@ def test_catch_all_no_match(self, mock_uvicorn): mock_uvicorn.run = Mock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): svc.serve() + assert svc._app is not None client = TestClient(svc._app, raise_server_exceptions=False) resp = client.get("/other", headers=_auth_header("u", "p")) assert resp.status_code == 200 @@ -2254,7 +2258,7 @@ def test_catch_all_no_match(self, mock_uvicorn): assert "error" in body @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_catch_all_with_routing_callback_subpath(self, mock_uvicorn): + def test_catch_all_with_routing_callback_subpath(self, mock_uvicorn: MagicMock) -> None: """Catch-all route should forward to routing callback subpath.""" svc = SWMLService( name="catch_cb", route="/agent", host="0.0.0.0", port=3000, @@ -2264,6 +2268,7 @@ def test_catch_all_with_routing_callback_subpath(self, mock_uvicorn): mock_uvicorn.run = Mock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): svc.serve() + assert svc._app is not None client = TestClient(svc._app, raise_server_exceptions=False) resp = client.post( "/agent/sip", json={"key": "value"}, @@ -2272,7 +2277,7 @@ def test_catch_all_with_routing_callback_subpath(self, mock_uvicorn): assert resp.status_code == 200 @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_catch_all_root_route_match(self, mock_uvicorn): + def test_catch_all_root_route_match(self, mock_uvicorn: MagicMock) -> None: """When route is '/', catch-all should handle sub-paths.""" svc = SWMLService( name="catch_root", route="/", host="0.0.0.0", port=3000, @@ -2281,6 +2286,7 @@ def test_catch_all_root_route_match(self, mock_uvicorn): mock_uvicorn.run = Mock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): svc.serve() + assert svc._app is not None client = TestClient(svc._app, raise_server_exceptions=False) # Root should work resp = client.get("/", headers=_auth_header("u", "p")) @@ -2291,14 +2297,14 @@ class TestServeDomainOverride: """Test serve() domain override (line 766).""" @patch("signalwire.core.swml_service.uvicorn", create=True) - def test_serve_overrides_domain(self, mock_uvicorn): + def test_serve_overrides_domain(self, mock_uvicorn: MagicMock) -> None: """serve(domain=...) should override the service domain.""" svc = SWMLService( name="srv_domain", route="/", host="0.0.0.0", port=443, schema_validation=False, ) assert svc.domain is None or svc.domain != "new.example.com" - svc.security.validate_ssl_config = Mock(return_value=(True, None)) + svc.security.validate_ssl_config = Mock(return_value=(True, None)) # type: ignore[method-assign] # mock mock_uvicorn.run = Mock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): svc.serve(ssl_enabled=True, domain="new.example.com", @@ -2309,7 +2315,7 @@ def test_serve_overrides_domain(self, mock_uvicorn): class TestVerbMethodDocstrings: """Test verb method docstring generation (line 323 fallback).""" - def test_verb_with_no_description_in_schema(self): + def test_verb_with_no_description_in_schema(self) -> None: """Verb with no 'description' in properties should still have a docstring.""" svc = SWMLService( name="doc_test", route="/", host="127.0.0.1", port=3001, @@ -2320,7 +2326,7 @@ def test_verb_with_no_description_in_schema(self): pytest.skip("No verbs in schema") # Mock get_verb_properties to return no description original_get_props = svc.schema_utils.get_verb_properties - svc.schema_utils.get_verb_properties = Mock(return_value={}) + svc.schema_utils.get_verb_properties = Mock(return_value={}) # type: ignore[method-assign] # mock # Force __getattr__ to recreate the verb method vn = verb_names[0] svc._verb_methods_cache.pop(vn, None) @@ -2329,9 +2335,9 @@ def test_verb_with_no_description_in_schema(self): assert method.__doc__ is not None assert f"Add the {vn} verb" in method.__doc__ # Restore - svc.schema_utils.get_verb_properties = original_get_props + svc.schema_utils.get_verb_properties = original_get_props # type: ignore[method-assign] # restore mock - def test_verb_with_description_in_schema(self): + def test_verb_with_description_in_schema(self) -> None: """Verb with 'description' in properties should include it in docstring.""" svc = SWMLService( name="doc_desc_test", route="/", host="127.0.0.1", port=3001, @@ -2340,7 +2346,7 @@ def test_verb_with_description_in_schema(self): verb_names = svc.schema_utils.get_all_verb_names() if not verb_names: pytest.skip("No verbs in schema") - svc.schema_utils.get_verb_properties = Mock( + svc.schema_utils.get_verb_properties = Mock( # type: ignore[method-assign] # mock return_value={"description": "This verb does something cool"} ) vn = verb_names[0] @@ -2353,7 +2359,7 @@ def test_verb_with_description_in_schema(self): class TestSchemaNotFoundWarning: """Test schema_not_found path (line 131).""" - def test_schema_not_found_still_initializes(self): + def test_schema_not_found_still_initializes(self) -> None: """Service should still initialize when schema is not found.""" svc = SWMLService( name="no_schema_warn", route="/", host="127.0.0.1", port=3001, diff --git a/tests/unit/core/test_swml_service_swaig.py b/tests/unit/core/test_swml_service_swaig.py index 1b5e1e73..ba0ea9ee 100644 --- a/tests/unit/core/test_swml_service_swaig.py +++ b/tests/unit/core/test_swml_service_swaig.py @@ -17,13 +17,25 @@ import asyncio import base64 import json +from collections.abc import Coroutine +from typing import Any, TypeVar from unittest.mock import MagicMock +from fastapi import Response + from signalwire.core.swml_service import SWMLService from signalwire.core.function_result import FunctionResult +_T = TypeVar("_T") + -def _make_request(method="POST", headers=None, body=None, query_params=None, url_path="/swaig"): +def _make_request( + method: str = "POST", + headers: dict[str, str] | None = None, + body: dict[str, Any] | None = None, + query_params: dict[str, str] | None = None, + url_path: str = "/swaig", +) -> MagicMock: """Build a minimal request object that the SWAIG handler can consume.""" request = MagicMock() request.method = method @@ -32,10 +44,10 @@ def _make_request(method="POST", headers=None, body=None, query_params=None, url request.query_params = query_params or {} body_bytes = json.dumps(body).encode() if body is not None else b"" - async def _body(): + async def _body() -> bytes: return body_bytes - async def _json(): + async def _json() -> dict[str, Any]: return body if body is not None else {} request.body = _body @@ -44,7 +56,7 @@ async def _json(): return request -def _run(coro): +def _run(coro: Coroutine[Any, Any, _T]) -> _T: loop = asyncio.new_event_loop() try: return loop.run_until_complete(coro) @@ -52,7 +64,7 @@ def _run(coro): loop.close() -def _service(**kwargs): +def _service(**kwargs: Any) -> SWMLService: """Build a SWMLService instance with schema validation disabled for fast tests.""" return SWMLService(name="t", schema_validation=False, **kwargs) @@ -64,19 +76,19 @@ def _service(**kwargs): class TestSWMLServiceHasSWAIGCapability: """The lift gives plain SWMLService instances SWAIG-hosting capability.""" - def test_define_tool_resolves_on_swml_service(self): + def test_define_tool_resolves_on_swml_service(self) -> None: svc = _service() assert hasattr(svc, "define_tool") assert hasattr(svc, "register_swaig_function") assert hasattr(svc, "on_function_call") assert hasattr(svc, "_handle_swaig_request") - def test_tool_registry_is_initialized(self): + def test_tool_registry_is_initialized(self) -> None: svc = _service() assert svc._tool_registry is not None assert svc._tool_registry._swaig_functions == {} - def test_define_tool_registers_function(self): + def test_define_tool_registers_function(self) -> None: svc = _service() svc.define_tool( name="lookup", @@ -86,7 +98,7 @@ def test_define_tool_registers_function(self): ) assert "lookup" in svc._tool_registry._swaig_functions - def test_as_router_mounts_swaig_endpoint(self): + def test_as_router_mounts_swaig_endpoint(self) -> None: svc = _service() router = svc.as_router() paths = [r.path for r in router.routes if hasattr(r, "path")] @@ -97,11 +109,11 @@ def test_as_router_mounts_swaig_endpoint(self): class TestSWMLServiceSWAIGDispatch: """End-to-end dispatch tests: POST /swaig hits the registered handler.""" - def test_post_dispatches_to_registered_handler(self): + def test_post_dispatches_to_registered_handler(self) -> None: svc = _service() called_with = {} - def lookup(args, raw_data): + def lookup(args: dict[str, Any], raw_data: Any) -> FunctionResult: called_with["args"] = args called_with["raw"] = raw_data return FunctionResult("found it") @@ -116,7 +128,7 @@ def lookup(args, raw_data): # Auth: SWMLService uses _check_basic_auth from its own definition. With # no _basic_auth credentials configured to require, the default flow # lets this through. We patch it to avoid environmental ambiguity. - svc._check_basic_auth = MagicMock(return_value=True) + svc._check_basic_auth = MagicMock(return_value=True) # type: ignore[method-assign] # mock request = _make_request( method="POST", @@ -131,61 +143,65 @@ def lookup(args, raw_data): assert isinstance(result, dict) assert result.get("response") == "found it" - def test_get_returns_swml_document_via_render_document(self): + def test_get_returns_swml_document_via_render_document(self) -> None: """Default _swaig_render_get_response uses render_document() — i.e. the currently-built SWML doc, not a dynamically rebuilt one. This is the non-agent path.""" svc = _service() svc.add_section("main") svc.add_verb_to_section("main", "answer", {}) - svc._check_basic_auth = MagicMock(return_value=True) + svc._check_basic_auth = MagicMock(return_value=True) # type: ignore[method-assign] # mock request = _make_request(method="GET") response = _run(svc._handle_swaig_request(request, MagicMock())) + assert isinstance(response, Response) assert response.status_code == 200 - body = json.loads(response.body) + body = json.loads(bytes(response.body)) assert "sections" in body assert "main" in body["sections"] - def test_post_unknown_function_returns_error_response(self): + def test_post_unknown_function_returns_error_response(self) -> None: svc = _service() - svc._check_basic_auth = MagicMock(return_value=True) + svc._check_basic_auth = MagicMock(return_value=True) # type: ignore[method-assign] # mock request = _make_request(method="POST", body={"function": "no_such_fn"}) result = _run(svc._handle_swaig_request(request, MagicMock())) assert isinstance(result, dict) # on_function_call returns a `response` field for unknown funcs assert "no_such_fn" in result.get("response", "") - def test_post_invalid_function_name_returns_400(self): + def test_post_invalid_function_name_returns_400(self) -> None: svc = _service() - svc._check_basic_auth = MagicMock(return_value=True) + svc._check_basic_auth = MagicMock(return_value=True) # type: ignore[method-assign] # mock request = _make_request(method="POST", body={"function": "../etc/passwd"}) response = _run(svc._handle_swaig_request(request, MagicMock())) + assert isinstance(response, Response) assert response.status_code == 400 - def test_post_missing_function_returns_400(self): + def test_post_missing_function_returns_400(self) -> None: svc = _service() - svc._check_basic_auth = MagicMock(return_value=True) + svc._check_basic_auth = MagicMock(return_value=True) # type: ignore[method-assign] # mock request = _make_request(method="POST", body={}) response = _run(svc._handle_swaig_request(request, MagicMock())) + assert isinstance(response, Response) assert response.status_code == 400 - def test_unauthorized_returns_401(self): + def test_unauthorized_returns_401(self) -> None: svc = _service() - svc._check_basic_auth = MagicMock(return_value=False) + svc._check_basic_auth = MagicMock(return_value=False) # type: ignore[method-assign] # mock request = _make_request(method="POST", body={"function": "x"}) response = _run(svc._handle_swaig_request(request, MagicMock())) + assert isinstance(response, Response) assert response.status_code == 401 - def test_swmlservice_has_no_token_validation_by_default(self): + def test_swmlservice_has_no_token_validation_by_default(self) -> None: """A plain SWMLService does NOT do session-token validation. That's an AgentBase-specific extension. A token in query params is ignored.""" svc = _service() - svc._check_basic_auth = MagicMock(return_value=True) + svc._check_basic_auth = MagicMock(return_value=True) # type: ignore[method-assign] # mock called = {} - def lookup(args, raw_data): + def lookup(args: dict[str, Any], raw_data: Any) -> FunctionResult: called["yes"] = True return FunctionResult("ok") @@ -203,6 +219,7 @@ def lookup(args, raw_data): ) result = _run(svc._handle_swaig_request(request, MagicMock())) assert called.get("yes") is True + assert isinstance(result, dict) assert result.get("response") == "ok" @@ -219,7 +236,7 @@ class TestSidecarPatternViaSWMLService: These tests verify the full pattern works without any SidecarBase class. """ - def test_can_emit_ai_sidecar_verb(self): + def test_can_emit_ai_sidecar_verb(self) -> None: """SWMLService.add_verb accepts arbitrary verb dicts; ai_sidecar is just data, schema permitting.""" svc = _service() @@ -238,7 +255,7 @@ def test_can_emit_ai_sidecar_verb(self): assert "answer" in verbs assert "ai_sidecar" in verbs - def test_full_sidecar_pattern_emit_swml_register_tool_register_event_sink(self): + def test_full_sidecar_pattern_emit_swml_register_tool_register_event_sink(self) -> None: svc = _service() # 1. Build the SWML doc. @@ -246,7 +263,7 @@ def test_full_sidecar_pattern_emit_swml_register_tool_register_event_sink(self): svc.add_verb_to_section("main", "answer", {}) # 2. Register a SWAIG tool the sidecar's LLM can call. - def lookup_competitor(args, raw_data): + def lookup_competitor(args: dict[str, Any], raw_data: Any) -> FunctionResult: return FunctionResult(f"{args['competitor']} is $99/seat; we're $79.") svc.define_tool( @@ -263,7 +280,7 @@ def lookup_competitor(args, raw_data): # 3. Register an event-sink endpoint via routing callback. events_seen = [] - def on_event(request, body): + def on_event(request: Any, body: dict[str, Any]) -> None: events_seen.append(body.get("type")) return None @@ -276,7 +293,7 @@ def on_event(request, body): assert "/events" in paths or any(p.endswith("/events") for p in paths) # Verify the SWAIG dispatch works end-to-end. - svc._check_basic_auth = MagicMock(return_value=True) + svc._check_basic_auth = MagicMock(return_value=True) # type: ignore[method-assign] # mock request = _make_request( method="POST", body={ @@ -285,5 +302,6 @@ def on_event(request, body): }, ) result = _run(svc._handle_swaig_request(request, MagicMock())) + assert isinstance(result, dict) assert "ACME" in result.get("response", "") assert "$79" in result.get("response", "") diff --git a/tests/unit/livewire/test_livewire.py b/tests/unit/livewire/test_livewire.py index 09d53ccb..449b1f6b 100644 --- a/tests/unit/livewire/test_livewire.py +++ b/tests/unit/livewire/test_livewire.py @@ -11,10 +11,9 @@ Unit tests for the LiveWire compatibility module. """ -import asyncio import io -import logging import sys +from collections.abc import Iterator import pytest from unittest.mock import patch, Mock @@ -58,7 +57,7 @@ # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) -def _reset_noop_trackers(): +def _reset_noop_trackers() -> Iterator[None]: """Reset all noop trackers between tests so 'log once' does not leak.""" _global_noop.reset() _reset_logged() @@ -74,13 +73,13 @@ def _reset_noop_trackers(): class TestAgentCreation: """Test Agent class construction and properties.""" - def test_basic_creation(self): + def test_basic_creation(self) -> None: agent = Agent(instructions="You are a helpful assistant.") assert agent.instructions == "You are a helpful assistant." assert agent._tools == [] assert agent.session is None - def test_creation_with_tools(self): + def test_creation_with_tools(self) -> None: @function_tool def greet(name: str) -> str: """Say hello.""" @@ -90,7 +89,7 @@ def greet(name: str) -> str: assert len(agent._tools) == 1 assert agent._tools[0]._tool_name == "greet" - def test_creation_with_noop_params(self): + def test_creation_with_noop_params(self) -> None: """STT, TTS, VAD, turn_detection trigger noop logs.""" agent = Agent( instructions="test", @@ -104,13 +103,13 @@ def test_creation_with_noop_params(self): assert _global_noop.was_logged("agent_vad") assert _global_noop.was_logged("agent_turn_detection") - def test_creation_without_noop_params(self): + def test_creation_without_noop_params(self) -> None: """NOT_GIVEN params should NOT trigger noop logs.""" Agent(instructions="test") assert not _global_noop.was_logged("agent_stt") assert not _global_noop.was_logged("agent_tts") - def test_session_property(self): + def test_session_property(self) -> None: agent = Agent(instructions="test") session = AgentSession() agent.session = session @@ -121,29 +120,29 @@ class TestAgentLifecycleHooks: """Test Agent lifecycle hooks (async).""" @pytest.mark.asyncio - async def test_on_enter(self): + async def test_on_enter(self) -> None: """Default on_enter is a no-op — must return None and must not mutate the agent's instructions.""" agent = Agent(instructions="test") - result = await agent.on_enter() + result = await agent.on_enter() # type: ignore[func-returns-value] # asserting no-op returns None assert result is None # No-op contract: state unchanged. assert agent.instructions == "test" @pytest.mark.asyncio - async def test_on_exit(self): + async def test_on_exit(self) -> None: """Default on_exit returns None and leaves instructions intact.""" agent = Agent(instructions="test") - result = await agent.on_exit() + result = await agent.on_exit() # type: ignore[func-returns-value] # asserting no-op returns None assert result is None assert agent.instructions == "test" @pytest.mark.asyncio - async def test_on_user_turn_completed(self): + async def test_on_user_turn_completed(self) -> None: """Default on_user_turn_completed returns None and accepts the optional turn_ctx/new_message kwargs without modifying state.""" agent = Agent(instructions="test") - result = await agent.on_user_turn_completed( + result = await agent.on_user_turn_completed( # type: ignore[func-returns-value] # asserting no-op returns None turn_ctx={"role": "user"}, new_message={"text": "hi"}, ) @@ -151,13 +150,13 @@ async def test_on_user_turn_completed(self): assert agent.instructions == "test" @pytest.mark.asyncio - async def test_update_instructions(self): + async def test_update_instructions(self) -> None: agent = Agent(instructions="old") await agent.update_instructions("new") assert agent.instructions == "new" @pytest.mark.asyncio - async def test_update_tools(self): + async def test_update_tools(self) -> None: agent = Agent(instructions="test") assert agent._tools == [] await agent.update_tools(["tool1", "tool2"]) @@ -168,19 +167,19 @@ class TestAgentPipelineNodes: """Test pipeline node no-ops.""" @pytest.mark.asyncio - async def test_stt_node_noop(self): + async def test_stt_node_noop(self) -> None: agent = Agent(instructions="test") await agent.stt_node() assert _global_noop.was_logged("stt_node") @pytest.mark.asyncio - async def test_llm_node_noop(self): + async def test_llm_node_noop(self) -> None: agent = Agent(instructions="test") await agent.llm_node() assert _global_noop.was_logged("llm_node") @pytest.mark.asyncio - async def test_tts_node_noop(self): + async def test_tts_node_noop(self) -> None: agent = Agent(instructions="test") await agent.tts_node() assert _global_noop.was_logged("tts_node") @@ -193,21 +192,21 @@ async def test_tts_node_noop(self): class TestFunctionTool: """Test the @function_tool decorator.""" - def test_basic_decorator_no_args(self): + def test_basic_decorator_no_args(self) -> None: @function_tool def get_weather(location: str) -> str: """Get the weather for a location.""" return f"Sunny in {location}" - assert get_weather._livewire_tool is True - assert get_weather._tool_name == "get_weather" - assert get_weather._tool_description == "Get the weather for a location." - props = get_weather._tool_parameters["properties"] + assert get_weather._livewire_tool is True # type: ignore[attr-defined] # dynamic tool attr + assert get_weather._tool_name == "get_weather" # type: ignore[attr-defined] # dynamic tool attr + assert get_weather._tool_description == "Get the weather for a location." # type: ignore[attr-defined] # dynamic tool attr + props = get_weather._tool_parameters["properties"] # type: ignore[attr-defined] # dynamic tool attr assert "location" in props assert props["location"]["type"] == "string" - assert get_weather._tool_parameters.get("required") == ["location"] + assert get_weather._tool_parameters.get("required") == ["location"] # type: ignore[attr-defined] # dynamic tool attr - def test_decorator_with_custom_name(self): + def test_decorator_with_custom_name(self) -> None: @function_tool(name="my_tool", description="custom desc") def something(x: int) -> str: return str(x) @@ -215,42 +214,42 @@ def something(x: int) -> str: assert something._tool_name == "my_tool" assert something._tool_description == "custom desc" - def test_decorator_with_multiple_params(self): + def test_decorator_with_multiple_params(self) -> None: @function_tool def search(query: str, limit: int = 10) -> str: """Search for something.""" return query - params = search._tool_parameters + params = search._tool_parameters # type: ignore[attr-defined] # dynamic tool attr assert "query" in params["properties"] assert "limit" in params["properties"] assert params["properties"]["limit"]["type"] == "integer" # 'limit' has a default, so only 'query' is required assert params.get("required") == ["query"] - def test_decorator_preserves_callable(self): + def test_decorator_preserves_callable(self) -> None: @function_tool def echo(text: str) -> str: return text assert echo("hello") == "hello" - def test_decorator_skips_run_context_param(self): + def test_decorator_skips_run_context_param(self) -> None: @function_tool def my_tool(ctx: RunContext, city: str) -> str: """Look up city info.""" return city - params = my_tool._tool_parameters + params = my_tool._tool_parameters # type: ignore[attr-defined] # dynamic tool attr assert "ctx" not in params["properties"] assert "city" in params["properties"] - def test_type_mapping(self): + def test_type_mapping(self) -> None: @function_tool def typed(a: str, b: int, c: float, d: bool) -> str: return "ok" - props = typed._tool_parameters["properties"] + props = typed._tool_parameters["properties"] # type: ignore[attr-defined] # dynamic tool attr assert props["a"]["type"] == "string" assert props["b"]["type"] == "integer" assert props["c"]["type"] == "number" @@ -264,23 +263,23 @@ def typed(a: str, b: int, c: float, d: bool) -> str: class TestAgentSession: """Test AgentSession construction and methods.""" - def test_basic_creation(self): + def test_basic_creation(self) -> None: session = AgentSession() assert session.userdata == {} assert session.history == [] assert session._allow_interruptions is True - def test_creation_with_userdata(self): + def test_creation_with_userdata(self) -> None: session = AgentSession(userdata={"key": "value"}) assert session.userdata == {"key": "value"} - def test_userdata_setter(self): + def test_userdata_setter(self) -> None: session = AgentSession() session.userdata = {"new": "data"} assert session.userdata == {"new": "data"} @pytest.mark.asyncio - async def test_start(self): + async def test_start(self) -> None: session = AgentSession() agent = Agent(instructions="test") await session.start(agent) @@ -288,23 +287,23 @@ async def test_start(self): assert agent.session is session assert session._started is True - def test_say(self): + def test_say(self) -> None: session = AgentSession() session.say("Hello!") session.say("How can I help?") assert session._say_queue == ["Hello!", "How can I help?"] - def test_generate_reply(self): + def test_generate_reply(self) -> None: session = AgentSession() session.generate_reply(instructions="Greet the user") assert "Greet the user" in session._say_queue - def test_interrupt_noop(self): + def test_interrupt_noop(self) -> None: session = AgentSession() session.interrupt() # Should not raise assert session._noop.was_logged("interrupt") - def test_update_agent(self): + def test_update_agent(self) -> None: session = AgentSession() agent1 = Agent(instructions="first") agent2 = Agent(instructions="second") @@ -314,23 +313,23 @@ def test_update_agent(self): assert session._agent is agent2 assert agent2.session is session - def test_noop_stt(self): + def test_noop_stt(self) -> None: AgentSession(stt=DeepgramSTT()) assert _global_noop.was_logged("stt") - def test_noop_tts(self): + def test_noop_tts(self) -> None: AgentSession(tts=CartesiaTTS()) assert _global_noop.was_logged("tts") - def test_noop_vad(self): + def test_noop_vad(self) -> None: AgentSession(vad=SileroVAD.load()) assert _global_noop.was_logged("vad") - def test_noop_turn_detection(self): + def test_noop_turn_detection(self) -> None: AgentSession(turn_detection="server") assert _global_noop.was_logged("turn_detection") - def test_noop_max_tool_steps(self): + def test_noop_max_tool_steps(self) -> None: AgentSession(max_tool_steps=10) assert _global_noop.was_logged("max_tool_steps") @@ -342,17 +341,17 @@ def test_noop_max_tool_steps(self): class TestRunContext: """Test RunContext mirrors livekit RunContext.""" - def test_basic_creation(self): + def test_basic_creation(self) -> None: session = AgentSession(userdata={"foo": "bar"}) ctx = RunContext(session) assert ctx.session is session assert ctx.userdata == {"foo": "bar"} - def test_userdata_without_session(self): + def test_userdata_without_session(self) -> None: ctx = RunContext() assert ctx.userdata == {} - def test_creation_with_extras(self): + def test_creation_with_extras(self) -> None: ctx = RunContext(None, speech_handle="sh", function_call="fc") assert ctx.speech_handle == "sh" assert ctx.function_call == "fc" @@ -366,22 +365,22 @@ class TestJobContext: """Test JobContext noop methods.""" @pytest.mark.asyncio - async def test_connect_noop(self): + async def test_connect_noop(self) -> None: ctx = JobContext() await ctx.connect() assert _global_noop.was_logged("connect") @pytest.mark.asyncio - async def test_wait_for_participant_noop(self): + async def test_wait_for_participant_noop(self) -> None: ctx = JobContext() await ctx.wait_for_participant(identity="test-user") assert _global_noop.was_logged("wait_for_participant") - def test_room_name(self): + def test_room_name(self) -> None: ctx = JobContext() assert ctx.room.name == "livewire-room" - def test_proc(self): + def test_proc(self) -> None: ctx = JobContext() assert isinstance(ctx.proc, JobProcess) assert ctx.proc.userdata == {} @@ -392,12 +391,12 @@ def test_proc(self): # --------------------------------------------------------------------------- class TestRoom: - def test_name(self): + def test_name(self) -> None: assert Room.name == "livewire-room" class TestJobProcess: - def test_userdata(self): + def test_userdata(self) -> None: proc = JobProcess() proc.userdata["key"] = "val" assert proc.userdata == {"key": "val"} @@ -410,27 +409,27 @@ def test_userdata(self): class TestPluginStubs: """Test that plugin stubs construct without error.""" - def test_deepgram_stt(self): + def test_deepgram_stt(self) -> None: stt = DeepgramSTT(model="nova-2") assert stt._kwargs == {"model": "nova-2"} - def test_openai_llm(self): + def test_openai_llm(self) -> None: llm = OpenAILLM(model="gpt-4o") assert llm.model == "gpt-4o" - def test_cartesia_tts(self): + def test_cartesia_tts(self) -> None: tts = CartesiaTTS(voice="default") assert tts._kwargs == {"voice": "default"} - def test_elevenlabs_tts(self): + def test_elevenlabs_tts(self) -> None: tts = ElevenLabsTTS(voice_id="abc") assert tts._kwargs == {"voice_id": "abc"} - def test_silero_vad(self): + def test_silero_vad(self) -> None: vad = SileroVAD() assert isinstance(vad, SileroVAD) - def test_silero_vad_load(self): + def test_silero_vad_load(self) -> None: vad = SileroVAD.load() assert isinstance(vad, SileroVAD) @@ -440,17 +439,17 @@ def test_silero_vad_load(self): # --------------------------------------------------------------------------- class TestInferenceStubs: - def test_inference_stt(self): + def test_inference_stt(self) -> None: from signalwire.livewire import InferenceSTT stt = InferenceSTT("whisper-large-v3") assert stt.model == "whisper-large-v3" - def test_inference_llm(self): + def test_inference_llm(self) -> None: from signalwire.livewire import InferenceLLM llm = InferenceLLM("gpt-4o") assert llm.model == "gpt-4o" - def test_inference_tts(self): + def test_inference_tts(self) -> None: from signalwire.livewire import InferenceTTS tts = InferenceTTS("tts-1") assert tts.model == "tts-1" @@ -463,24 +462,24 @@ def test_inference_tts(self): class TestNoopLogging: """Verify that noop messages are logged at most once.""" - def test_log_once(self): + def test_log_once(self) -> None: first = _global_noop.once("test_key", "message") assert first is True second = _global_noop.once("test_key", "message again") assert second is False - def test_was_logged(self): + def test_was_logged(self) -> None: assert not _global_noop.was_logged("unique_key") _global_noop.once("unique_key", "msg") assert _global_noop.was_logged("unique_key") - def test_reset(self): + def test_reset(self) -> None: _global_noop.once("reset_key", "msg") assert _global_noop.was_logged("reset_key") _global_noop.reset() assert not _global_noop.was_logged("reset_key") - def test_agent_stt_logs_once(self): + def test_agent_stt_logs_once(self) -> None: Agent(instructions="a", stt="deepgram") assert _global_noop.was_logged("agent_stt") # Second construction should NOT log again (already logged) @@ -495,21 +494,21 @@ def test_agent_stt_logs_once(self): class TestBannerAndTips: """Test banner printing and tip selection.""" - def test_banner_contains_livewire(self): + def test_banner_contains_livewire(self) -> None: assert "LiveKit-compatible agents powered by SignalWire" in BANNER - def test_banner_contains_ascii_art(self): + def test_banner_contains_ascii_art(self) -> None: assert "LiveWire" in BANNER or "/ /___/ /" in BANNER - def test_tips_count(self): + def test_tips_count(self) -> None: assert len(TIPS) == 10 - def test_tips_are_strings(self): + def test_tips_are_strings(self) -> None: for tip in TIPS: assert isinstance(tip, str) assert len(tip) > 20 - def test_print_banner_tty(self): + def test_print_banner_tty(self) -> None: buf = io.StringIO() with patch.object(sys, "stderr", buf): with patch.object(buf, "isatty", return_value=True): @@ -518,7 +517,7 @@ def test_print_banner_tty(self): assert "\033[36m" in output # cyan assert "LiveKit-compatible" in output - def test_print_banner_no_tty(self): + def test_print_banner_no_tty(self) -> None: buf = io.StringIO() with patch.object(sys, "stderr", buf): with patch.object(buf, "isatty", return_value=False): @@ -527,7 +526,7 @@ def test_print_banner_no_tty(self): assert "\033[36m" not in output assert "LiveKit-compatible" in output - def test_print_tip(self): + def test_print_tip(self) -> None: buf = io.StringIO() with patch.object(sys, "stderr", buf): _print_tip() @@ -540,13 +539,13 @@ def test_print_tip(self): # --------------------------------------------------------------------------- class TestExceptionsAndSignals: - def test_stop_response_is_exception(self): + def test_stop_response_is_exception(self) -> None: assert issubclass(StopResponse, Exception) - def test_tool_error_is_exception(self): + def test_tool_error_is_exception(self) -> None: assert issubclass(ToolError, Exception) - def test_agent_handoff(self): + def test_agent_handoff(self) -> None: agent = Agent(instructions="test") handoff = AgentHandoff(agent, returns="result") assert handoff.agent is agent @@ -558,11 +557,11 @@ def test_agent_handoff(self): # --------------------------------------------------------------------------- class TestChatContext: - def test_basic(self): + def test_basic(self) -> None: ctx = ChatContext() assert ctx.messages == [] - def test_append(self): + def test_append(self) -> None: ctx = ChatContext() result = ctx.append(role="user", text="Hello") assert len(ctx.messages) == 1 @@ -575,19 +574,19 @@ def test_append(self): # --------------------------------------------------------------------------- class TestNamespaces: - def test_voice_namespace(self): + def test_voice_namespace(self) -> None: assert voice.Agent is Agent assert voice.AgentSession is AgentSession - def test_llm_namespace(self): + def test_llm_namespace(self) -> None: assert llm_ns.tool is function_tool assert llm_ns.ToolError is ToolError assert llm_ns.ChatContext is ChatContext - def test_cli_namespace(self): + def test_cli_namespace(self) -> None: assert cli_ns.run_app is run_app - def test_inference_namespace(self): + def test_inference_namespace(self) -> None: from signalwire.livewire import InferenceSTT, InferenceLLM, InferenceTTS assert inference.STT is InferenceSTT assert inference.LLM is InferenceLLM @@ -599,27 +598,27 @@ def test_inference_namespace(self): # --------------------------------------------------------------------------- class TestAgentServer: - def test_basic_creation(self): + def test_basic_creation(self) -> None: server = AgentServer() assert server._entrypoint is None assert server.setup_fnc is None - def test_rtc_session_decorator(self): + def test_rtc_session_decorator(self) -> None: server = AgentServer() @server.rtc_session(agent_name="test-agent") - async def entrypoint(ctx: JobContext): + async def entrypoint(ctx: JobContext) -> None: pass assert server._entrypoint is entrypoint assert server._agent_name == "test-agent" - def test_rtc_session_no_parens(self): + def test_rtc_session_no_parens(self) -> None: """rtc_session can also be used as a decorator with func as first arg.""" server = AgentServer() @server.rtc_session - async def entrypoint(ctx: JobContext): + async def entrypoint(ctx: JobContext) -> None: pass assert server._entrypoint is entrypoint @@ -630,7 +629,7 @@ async def entrypoint(ctx: JobContext): # --------------------------------------------------------------------------- class TestNotGiven: - def test_sentinel_identity(self): + def test_sentinel_identity(self) -> None: assert NOT_GIVEN is NOT_GIVEN assert NOT_GIVEN is not None assert NOT_GIVEN is not False @@ -645,7 +644,7 @@ class TestBuildSwAgent: """Test that _build_sw_agent creates a valid SignalWire AgentBase.""" @pytest.mark.asyncio - async def test_build_basic(self): + async def test_build_basic(self) -> None: session = AgentSession() agent = Agent(instructions="You are a test agent.") await session.start(agent) @@ -657,7 +656,7 @@ async def test_build_basic(self): assert session._sw_agent is sw @pytest.mark.asyncio - async def test_build_with_tools(self): + async def test_build_with_tools(self) -> None: @function_tool def ping(msg: str) -> str: """Ping back.""" @@ -676,7 +675,7 @@ def ping(msg: str) -> str: assert "ping" in tool_names @pytest.mark.asyncio - async def test_build_raises_without_start(self): + async def test_build_raises_without_start(self) -> None: session = AgentSession() with pytest.raises(RuntimeError, match="No Agent bound"): session._build_sw_agent() diff --git a/tests/unit/mcp_gateway/test_gateway_service.py b/tests/unit/mcp_gateway/test_gateway_service.py index 3ed167d5..1f8679b4 100644 --- a/tests/unit/mcp_gateway/test_gateway_service.py +++ b/tests/unit/mcp_gateway/test_gateway_service.py @@ -5,9 +5,7 @@ Licensed under the MIT License. See LICENSE file in the project root for full license information. -""" -""" Unit tests for MCP Gateway Service (gateway_service.py) Flask and flask_limiter are optional dependencies (mcp-gateway extra). @@ -21,11 +19,17 @@ import logging import threading import re +from pathlib import Path +from typing import Any, TYPE_CHECKING import pytest from unittest.mock import Mock, patch, MagicMock, call from datetime import datetime +if TYPE_CHECKING: + from signalwire.mcp_gateway.gateway_service import MCPGateway + from werkzeug.test import TestResponse + # Skip the entire module when Flask is not installed flask = pytest.importorskip("flask", reason="flask is required for MCP Gateway tests") pytest.importorskip("flask_limiter", reason="flask_limiter is required for MCP Gateway tests") @@ -37,7 +41,7 @@ # filesystem, network, or real MCP processes. # --------------------------------------------------------------------------- -def _minimal_config(): +def _minimal_config() -> dict[str, Any]: """Return a minimal valid configuration dictionary.""" return { "server": { @@ -66,7 +70,7 @@ def _minimal_config(): } -def _create_gateway(config=None): +def _create_gateway(config: dict[str, Any] | None = None) -> tuple["MCPGateway", dict[str, MagicMock]]: """ Instantiate an ``MCPGateway`` with every external dependency mocked. @@ -99,8 +103,8 @@ def _create_gateway(config=None): ), } - mocks = {} - managers = {} + mocks: dict[str, MagicMock] = {} + managers: dict[str, MagicMock] = {} for name, p in patches.items(): managers[name] = p.start() @@ -132,13 +136,13 @@ def _create_gateway(config=None): return gateway, mocks -def _auth_headers_basic(user="admin", password="secret"): +def _auth_headers_basic(user: str = "admin", password: str = "secret") -> dict[str, str]: """Return HTTP headers for Basic authentication.""" creds = base64.b64encode(f"{user}:{password}".encode()).decode() return {"Authorization": f"Basic {creds}"} -def _auth_headers_bearer(token="test-bearer-token"): +def _auth_headers_bearer(token: str = "test-bearer-token") -> dict[str, str]: """Return HTTP headers for Bearer token authentication.""" return {"Authorization": f"Bearer {token}"} @@ -150,14 +154,14 @@ def _auth_headers_bearer(token="test-bearer-token"): class TestMCPGatewayInit: """Tests for MCPGateway construction and configuration.""" - def test_init_loads_config_via_config_loader(self): + def test_init_loads_config_via_config_loader(self) -> None: """When ConfigLoader has_config() returns True, config is loaded through it.""" gateway, mocks = _create_gateway() assert mocks["config_loader"].has_config.called assert mocks["config_loader"].get_config.called assert mocks["config_loader"].substitute_vars.called - def test_init_falls_back_to_load_config_when_no_config_loader(self): + def test_init_falls_back_to_load_config_when_no_config_loader(self) -> None: """When ConfigLoader has_config() is False, _load_config is used.""" from signalwire.mcp_gateway.gateway_service import MCPGateway @@ -178,28 +182,28 @@ def test_init_falls_back_to_load_config_when_no_config_loader(self): MCPGateway("missing.json") mock_load.assert_called_once_with("missing.json") - def test_init_creates_flask_app(self): + def test_init_creates_flask_app(self) -> None: """A Flask app instance is created during init.""" gateway, _ = _create_gateway() assert gateway.app is not None assert gateway.app.test_client() is not None - def test_init_sets_max_content_length(self): + def test_init_sets_max_content_length(self) -> None: """Request body is capped at 10 MB.""" gateway, _ = _create_gateway() assert gateway.app.config["MAX_CONTENT_LENGTH"] == 10 * 1024 * 1024 - def test_init_rate_limiter_configured(self): + def test_init_rate_limiter_configured(self) -> None: """Rate limiter is wired to the Flask app.""" gateway, _ = _create_gateway() assert gateway.limiter is not None - def test_init_validates_services_on_startup(self): + def test_init_validates_services_on_startup(self) -> None: """validate_services() is called during __init__.""" gateway, mocks = _create_gateway() mocks["mcp_manager"].validate_services.assert_called_once() - def test_init_logs_warning_for_failed_validation(self): + def test_init_logs_warning_for_failed_validation(self) -> None: """A warning is logged when a service fails validation.""" from signalwire.mcp_gateway.gateway_service import MCPGateway @@ -225,21 +229,21 @@ def test_init_logs_warning_for_failed_validation(self): if "bad_svc" in str(c)] assert len(warning_calls) >= 1 - def test_init_shutdown_flags_default(self): + def test_init_shutdown_flags_default(self) -> None: """Shutdown flags start as False / None.""" gateway, _ = _create_gateway() assert gateway._shutdown_requested is False assert gateway._shutdown_cleanup_done is False assert gateway.server is None - def test_init_rate_config_uses_defaults_when_missing(self): + def test_init_rate_config_uses_defaults_when_missing(self) -> None: """When rate_limiting section is absent, sensible defaults are used.""" config = _minimal_config() del config["rate_limiting"] gateway, _ = _create_gateway(config) assert gateway.rate_config == {} - def test_init_security_config_created(self): + def test_init_security_config_created(self) -> None: """SecurityConfig is instantiated with correct parameters.""" from signalwire.mcp_gateway.gateway_service import MCPGateway @@ -269,86 +273,86 @@ class TestValidationHelpers: """Tests for _validate_service_name, _validate_session_id, _validate_tool_name.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() # -- service name ------------------------------------------------------- - def test_validate_service_name_valid(self): + def test_validate_service_name_valid(self) -> None: assert self.gateway._validate_service_name("my-service_1") == "my-service_1" - def test_validate_service_name_empty(self): + def test_validate_service_name_empty(self) -> None: with pytest.raises(ValueError, match="Invalid service name length"): self.gateway._validate_service_name("") - def test_validate_service_name_none(self): + def test_validate_service_name_none(self) -> None: with pytest.raises(ValueError, match="Invalid service name length"): - self.gateway._validate_service_name(None) + self.gateway._validate_service_name(None) # type: ignore[arg-type] # testing None input - def test_validate_service_name_too_long(self): + def test_validate_service_name_too_long(self) -> None: with pytest.raises(ValueError, match="Invalid service name length"): self.gateway._validate_service_name("a" * 65) - def test_validate_service_name_max_length(self): + def test_validate_service_name_max_length(self) -> None: """Exactly 64 chars should be accepted.""" name = "a" * 64 assert self.gateway._validate_service_name(name) == name - def test_validate_service_name_invalid_chars(self): + def test_validate_service_name_invalid_chars(self) -> None: with pytest.raises(ValueError, match="invalid characters"): self.gateway._validate_service_name("my service!") - def test_validate_service_name_injection_attempt(self): + def test_validate_service_name_injection_attempt(self) -> None: with pytest.raises(ValueError, match="invalid characters"): self.gateway._validate_service_name("service; rm -rf /") - def test_validate_service_name_path_traversal(self): + def test_validate_service_name_path_traversal(self) -> None: with pytest.raises(ValueError, match="invalid characters"): self.gateway._validate_service_name("../etc/passwd") # -- session id --------------------------------------------------------- - def test_validate_session_id_valid(self): + def test_validate_session_id_valid(self) -> None: assert self.gateway._validate_session_id("sess-123.abc_def") == "sess-123.abc_def" - def test_validate_session_id_empty(self): + def test_validate_session_id_empty(self) -> None: with pytest.raises(ValueError, match="Invalid session ID length"): self.gateway._validate_session_id("") - def test_validate_session_id_none(self): + def test_validate_session_id_none(self) -> None: with pytest.raises(ValueError, match="Invalid session ID length"): - self.gateway._validate_session_id(None) + self.gateway._validate_session_id(None) # type: ignore[arg-type] # testing None input - def test_validate_session_id_too_long(self): + def test_validate_session_id_too_long(self) -> None: with pytest.raises(ValueError, match="Invalid session ID length"): self.gateway._validate_session_id("x" * 129) - def test_validate_session_id_max_length(self): + def test_validate_session_id_max_length(self) -> None: sid = "a" * 128 assert self.gateway._validate_session_id(sid) == sid - def test_validate_session_id_invalid_chars(self): + def test_validate_session_id_invalid_chars(self) -> None: with pytest.raises(ValueError, match="invalid characters"): self.gateway._validate_session_id("sess id with spaces") # -- tool name ---------------------------------------------------------- - def test_validate_tool_name_valid(self): + def test_validate_tool_name_valid(self) -> None: assert self.gateway._validate_tool_name("add-todo_item") == "add-todo_item" - def test_validate_tool_name_empty(self): + def test_validate_tool_name_empty(self) -> None: with pytest.raises(ValueError, match="Invalid tool name length"): self.gateway._validate_tool_name("") - def test_validate_tool_name_none(self): + def test_validate_tool_name_none(self) -> None: with pytest.raises(ValueError, match="Invalid tool name length"): - self.gateway._validate_tool_name(None) + self.gateway._validate_tool_name(None) # type: ignore[arg-type] # testing None input - def test_validate_tool_name_too_long(self): + def test_validate_tool_name_too_long(self) -> None: with pytest.raises(ValueError, match="Invalid tool name length"): self.gateway._validate_tool_name("t" * 65) - def test_validate_tool_name_invalid_chars(self): + def test_validate_tool_name_invalid_chars(self) -> None: with pytest.raises(ValueError, match="invalid characters"): self.gateway._validate_tool_name("tool name!") @@ -361,10 +365,10 @@ class TestLogSecurityEvent: """Tests for _log_security_event.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() - def test_log_security_event_basic(self): + def test_log_security_event_basic(self) -> None: with patch("signalwire.mcp_gateway.gateway_service.logger") as mock_logger: self.gateway._log_security_event("auth_failed", {"ip": "1.2.3.4"}) mock_logger.info.assert_called_once() @@ -373,7 +377,7 @@ def test_log_security_event_basic(self): assert "auth_failed" in logged assert "1.2.3.4" in logged - def test_log_security_event_truncates_long_strings(self): + def test_log_security_event_truncates_long_strings(self) -> None: with patch("signalwire.mcp_gateway.gateway_service.logger") as mock_logger: long_val = "x" * 500 self.gateway._log_security_event("test", {"data": long_val}) @@ -381,7 +385,7 @@ def test_log_security_event_truncates_long_strings(self): parsed = json.loads(logged.split("SECURITY_EVENT: ")[1]) assert len(parsed["data"]) <= 256 - def test_log_security_event_strips_control_chars(self): + def test_log_security_event_strips_control_chars(self) -> None: with patch("signalwire.mcp_gateway.gateway_service.logger") as mock_logger: self.gateway._log_security_event("test", {"data": "hello\x00world\x1b"}) logged = mock_logger.info.call_args[0][0] @@ -390,14 +394,14 @@ def test_log_security_event_strips_control_chars(self): assert "\x1b" not in parsed["data"] assert "helloworld" in parsed["data"] - def test_log_security_event_includes_timestamp(self): + def test_log_security_event_includes_timestamp(self) -> None: with patch("signalwire.mcp_gateway.gateway_service.logger") as mock_logger: self.gateway._log_security_event("test", {}) logged = mock_logger.info.call_args[0][0] parsed = json.loads(logged.split("SECURITY_EVENT: ")[1]) assert "timestamp" in parsed - def test_log_security_event_preserves_non_string_values(self): + def test_log_security_event_preserves_non_string_values(self) -> None: with patch("signalwire.mcp_gateway.gateway_service.logger") as mock_logger: self.gateway._log_security_event("test", {"count": 42, "flag": True}) logged = mock_logger.info.call_args[0][0] @@ -414,17 +418,17 @@ class TestSubstituteEnvVars: """Tests for _substitute_env_vars.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() - def test_substitute_plain_string(self): + def test_substitute_plain_string(self) -> None: assert self.gateway._substitute_env_vars("hello") == "hello" - def test_substitute_env_var_present(self): + def test_substitute_env_var_present(self) -> None: with patch.dict(os.environ, {"MY_VAR": "my_value"}): assert self.gateway._substitute_env_vars("${MY_VAR}") == "my_value" - def test_substitute_env_var_missing_no_default(self): + def test_substitute_env_var_missing_no_default(self) -> None: """Missing var with no default returns the original placeholder.""" env = os.environ.copy() env.pop("MISSING_VAR_XYZ", None) @@ -432,33 +436,33 @@ def test_substitute_env_var_missing_no_default(self): result = self.gateway._substitute_env_vars("${MISSING_VAR_XYZ}") assert result == "${MISSING_VAR_XYZ}" - def test_substitute_env_var_missing_with_default(self): + def test_substitute_env_var_missing_with_default(self) -> None: env = os.environ.copy() env.pop("MISSING_VAR_XYZ", None) with patch.dict(os.environ, env, clear=True): assert self.gateway._substitute_env_vars("${MISSING_VAR_XYZ|fallback}") == "fallback" - def test_substitute_env_var_present_with_default_ignored(self): + def test_substitute_env_var_present_with_default_ignored(self) -> None: with patch.dict(os.environ, {"MY_VAR": "real"}): assert self.gateway._substitute_env_vars("${MY_VAR|fallback}") == "real" - def test_substitute_dict_recursion(self): + def test_substitute_dict_recursion(self) -> None: with patch.dict(os.environ, {"A": "val_a"}): result = self.gateway._substitute_env_vars({"key": "${A}"}) assert result == {"key": "val_a"} - def test_substitute_list_recursion(self): + def test_substitute_list_recursion(self) -> None: with patch.dict(os.environ, {"B": "val_b"}): result = self.gateway._substitute_env_vars(["${B}", "plain"]) assert result == ["val_b", "plain"] - def test_substitute_nested_structures(self): + def test_substitute_nested_structures(self) -> None: with patch.dict(os.environ, {"X": "xval"}): data = {"outer": [{"inner": "${X}"}]} result = self.gateway._substitute_env_vars(data) assert result == {"outer": [{"inner": "xval"}]} - def test_substitute_non_string_passthrough(self): + def test_substitute_non_string_passthrough(self) -> None: assert self.gateway._substitute_env_vars(42) == 42 assert self.gateway._substitute_env_vars(True) is True assert self.gateway._substitute_env_vars(None) is None @@ -472,10 +476,10 @@ class TestLoadConfig: """Tests for the fallback _load_config method.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() - def test_load_config_reads_existing_file(self, tmp_path): + def test_load_config_reads_existing_file(self, tmp_path: Path) -> None: config_data = _minimal_config() config_file = tmp_path / "config.json" config_file.write_text(json.dumps(config_data)) @@ -483,7 +487,7 @@ def test_load_config_reads_existing_file(self, tmp_path): loaded = self.gateway._load_config(str(config_file)) assert loaded["server"]["port"] == 8080 - def test_load_config_creates_default_when_nothing_exists(self, tmp_path): + def test_load_config_creates_default_when_nothing_exists(self, tmp_path: Path) -> None: config_path = str(tmp_path / "nonexistent.json") # Neither config_path nor sample_config.json exist with patch("os.path.exists", return_value=False): @@ -494,11 +498,11 @@ def test_load_config_creates_default_when_nothing_exists(self, tmp_path): assert "server" in loaded assert loaded["server"]["port"] == 8080 - def test_load_config_copies_sample_when_available(self, tmp_path): + def test_load_config_copies_sample_when_available(self, tmp_path: Path) -> None: config_path = str(tmp_path / "config.json") call_count = [0] - def exists_side_effect(path): + def exists_side_effect(path: str) -> bool: if path == config_path: # First call: config doesn't exist; after copy it does call_count[0] += 1 @@ -521,7 +525,7 @@ def exists_side_effect(path): mock_copy.assert_called_once_with("sample_config.json", config_path) - def test_load_config_converts_string_port_to_int(self, tmp_path): + def test_load_config_converts_string_port_to_int(self, tmp_path: Path) -> None: config_data = _minimal_config() config_data["server"]["port"] = "9090" config_file = tmp_path / "config.json" @@ -530,7 +534,7 @@ def test_load_config_converts_string_port_to_int(self, tmp_path): loaded = self.gateway._load_config(str(config_file)) assert loaded["server"]["port"] == 9090 - def test_load_config_handles_invalid_port_string(self, tmp_path): + def test_load_config_handles_invalid_port_string(self, tmp_path: Path) -> None: config_data = _minimal_config() config_data["server"]["port"] = "not_a_number" config_file = tmp_path / "config.json" @@ -539,7 +543,7 @@ def test_load_config_handles_invalid_port_string(self, tmp_path): loaded = self.gateway._load_config(str(config_file)) assert loaded["server"]["port"] == 8080 # falls back to default - def test_load_config_converts_session_string_values(self, tmp_path): + def test_load_config_converts_session_string_values(self, tmp_path: Path) -> None: config_data = _minimal_config() config_data["session"]["default_timeout"] = "600" config_data["session"]["max_sessions_per_service"] = "50" @@ -552,7 +556,7 @@ def test_load_config_converts_session_string_values(self, tmp_path): assert loaded["session"]["max_sessions_per_service"] == 50 assert loaded["session"]["cleanup_interval"] == 120 - def test_load_config_handles_invalid_session_values(self, tmp_path): + def test_load_config_handles_invalid_session_values(self, tmp_path: Path) -> None: config_data = _minimal_config() config_data["session"]["default_timeout"] = "nope" config_file = tmp_path / "config.json" @@ -570,64 +574,64 @@ class TestAuthentication: """Tests for _check_auth decorator and auth routes.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() self.client = self.gateway.app.test_client() - def test_bearer_token_auth_success(self): + def test_bearer_token_auth_success(self) -> None: resp = self.client.get( "/services", headers=_auth_headers_bearer("test-bearer-token"), ) assert resp.status_code == 200 - def test_bearer_token_auth_wrong_token(self): + def test_bearer_token_auth_wrong_token(self) -> None: resp = self.client.get( "/services", headers=_auth_headers_bearer("wrong-token"), ) assert resp.status_code == 401 - def test_basic_auth_success(self): + def test_basic_auth_success(self) -> None: resp = self.client.get( "/services", headers=_auth_headers_basic("admin", "secret"), ) assert resp.status_code == 200 - def test_basic_auth_wrong_password(self): + def test_basic_auth_wrong_password(self) -> None: resp = self.client.get( "/services", headers=_auth_headers_basic("admin", "wrong"), ) assert resp.status_code == 401 - def test_basic_auth_wrong_user(self): + def test_basic_auth_wrong_user(self) -> None: resp = self.client.get( "/services", headers=_auth_headers_basic("nobody", "secret"), ) assert resp.status_code == 401 - def test_no_auth_header(self): + def test_no_auth_header(self) -> None: resp = self.client.get("/services") assert resp.status_code == 401 assert "WWW-Authenticate" in resp.headers - def test_auth_failure_logs_security_event(self): + def test_auth_failure_logs_security_event(self) -> None: with patch.object(self.gateway, "_log_security_event") as mock_log: self.client.get("/services") mock_log.assert_called() event_type = mock_log.call_args[0][0] assert event_type == "auth_failed" - def test_bearer_auth_preferred_over_basic(self): + def test_bearer_auth_preferred_over_basic(self) -> None: """When a valid Bearer token is sent, Basic auth is not checked.""" headers = {"Authorization": "Bearer test-bearer-token"} resp = self.client.get("/services", headers=headers) assert resp.status_code == 200 - def test_auth_without_token_config_falls_through_to_basic(self): + def test_auth_without_token_config_falls_through_to_basic(self) -> None: """If no auth_token is configured, Bearer always fails, Basic is tried.""" config = _minimal_config() del config["server"]["auth_token"] @@ -651,22 +655,22 @@ class TestHealthEndpoint: """Tests for GET /health (no auth required).""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() self.client = self.gateway.app.test_client() - def test_health_returns_200(self): + def test_health_returns_200(self) -> None: resp = self.client.get("/health") assert resp.status_code == 200 - def test_health_returns_json(self): + def test_health_returns_json(self) -> None: resp = self.client.get("/health") data = resp.get_json() assert data["status"] == "healthy" assert "timestamp" in data assert data["version"] == "1.0.0" - def test_health_no_auth_required(self): + def test_health_no_auth_required(self) -> None: """Health endpoint must be accessible without credentials.""" resp = self.client.get("/health") assert resp.status_code == 200 @@ -680,23 +684,23 @@ class TestSecurityHeaders: """Verify that security headers are set on every response.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() self.client = self.gateway.app.test_client() - def test_x_content_type_options(self): + def test_x_content_type_options(self) -> None: resp = self.client.get("/health") assert resp.headers.get("X-Content-Type-Options") == "nosniff" - def test_x_frame_options(self): + def test_x_frame_options(self) -> None: resp = self.client.get("/health") assert resp.headers.get("X-Frame-Options") == "DENY" - def test_x_xss_protection(self): + def test_x_xss_protection(self) -> None: resp = self.client.get("/health") assert resp.headers.get("X-XSS-Protection") == "1; mode=block" - def test_content_security_policy(self): + def test_content_security_policy(self) -> None: resp = self.client.get("/health") assert "default-src 'none'" in resp.headers.get("Content-Security-Policy", "") @@ -709,11 +713,11 @@ class TestListServicesEndpoint: """Tests for GET /services.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() self.client = self.gateway.app.test_client() - def test_list_services_success(self): + def test_list_services_success(self) -> None: self.mocks["mcp_manager"].list_services.return_value = { "todo": {"description": "Todo service", "enabled": True} } @@ -722,7 +726,7 @@ def test_list_services_success(self): data = resp.get_json() assert "todo" in data - def test_list_services_empty(self): + def test_list_services_empty(self) -> None: self.mocks["mcp_manager"].list_services.return_value = {} resp = self.client.get("/services", headers=_auth_headers_basic()) assert resp.status_code == 200 @@ -737,11 +741,11 @@ class TestGetServiceToolsEndpoint: """Tests for GET /services//tools.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() self.client = self.gateway.app.test_client() - def test_get_tools_success(self): + def test_get_tools_success(self) -> None: self.mocks["mcp_manager"].get_service_tools.return_value = [ {"name": "add_todo", "description": "Add a todo"} ] @@ -754,14 +758,14 @@ def test_get_tools_success(self): assert data["service"] == "todo" assert len(data["tools"]) == 1 - def test_get_tools_invalid_service_name(self): + def test_get_tools_invalid_service_name(self) -> None: resp = self.client.get( "/services/bad%20name!/tools", headers=_auth_headers_basic(), ) assert resp.status_code == 400 - def test_get_tools_service_error(self): + def test_get_tools_service_error(self) -> None: self.mocks["mcp_manager"].get_service_tools.side_effect = RuntimeError("boom") resp = self.client.get( "/services/todo/tools", @@ -780,11 +784,16 @@ class TestCallServiceToolEndpoint: """Tests for POST /services//call.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() self.client = self.gateway.app.test_client() - def _post_call(self, service_name="todo", payload=None, headers=None): + def _post_call( + self, + service_name: str = "todo", + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> "TestResponse": if payload is None: payload = { "tool": "add_todo", @@ -802,7 +811,7 @@ def _post_call(self, service_name="todo", payload=None, headers=None): headers=headers, ) - def test_call_tool_creates_session_when_missing(self): + def test_call_tool_creates_session_when_missing(self) -> None: """When no session exists, a new one is created.""" self.mocks["session_manager"].get_session.return_value = None @@ -819,7 +828,7 @@ def test_call_tool_creates_session_when_missing(self): assert resp.status_code == 200 self.mocks["session_manager"].create_session.assert_called_once() - def test_call_tool_reuses_existing_session(self): + def test_call_tool_reuses_existing_session(self) -> None: """When session exists and matches, it is reused.""" mock_client = MagicMock() mock_client.call_tool.return_value = "ok" @@ -835,7 +844,7 @@ def test_call_tool_reuses_existing_session(self): assert data["result"] == "ok" self.mocks["session_manager"].create_session.assert_not_called() - def test_call_tool_service_mismatch(self): + def test_call_tool_service_mismatch(self) -> None: """Error when session belongs to a different service.""" mock_session = MagicMock() mock_session.service_name = "other_service" @@ -845,7 +854,7 @@ def test_call_tool_service_mismatch(self): assert resp.status_code == 400 assert "other_service" in resp.get_json()["error"] - def test_call_tool_missing_tool_parameter(self): + def test_call_tool_missing_tool_parameter(self) -> None: resp = self._post_call(payload={ "session_id": "sess-1", "arguments": {}, @@ -853,7 +862,7 @@ def test_call_tool_missing_tool_parameter(self): assert resp.status_code == 400 assert "tool" in resp.get_json()["error"].lower() - def test_call_tool_missing_session_id(self): + def test_call_tool_missing_session_id(self) -> None: resp = self._post_call(payload={ "tool": "add_todo", "arguments": {}, @@ -861,7 +870,7 @@ def test_call_tool_missing_session_id(self): assert resp.status_code == 400 assert "session_id" in resp.get_json()["error"].lower() - def test_call_tool_invalid_json_body(self): + def test_call_tool_invalid_json_body(self) -> None: headers = _auth_headers_basic() headers["Content-Type"] = "application/json" resp = self.client.post( @@ -873,7 +882,7 @@ def test_call_tool_invalid_json_body(self): # outer except Exception handler, resulting in a 500 response assert resp.status_code == 500 - def test_call_tool_invalid_arguments_type(self): + def test_call_tool_invalid_arguments_type(self) -> None: resp = self._post_call(payload={ "tool": "add_todo", "session_id": "sess-1", @@ -882,7 +891,7 @@ def test_call_tool_invalid_arguments_type(self): assert resp.status_code == 400 assert "arguments" in resp.get_json()["error"].lower() - def test_call_tool_invalid_timeout_negative(self): + def test_call_tool_invalid_timeout_negative(self) -> None: resp = self._post_call(payload={ "tool": "add_todo", "session_id": "sess-1", @@ -892,7 +901,7 @@ def test_call_tool_invalid_timeout_negative(self): assert resp.status_code == 400 assert "timeout" in resp.get_json()["error"].lower() - def test_call_tool_invalid_timeout_too_large(self): + def test_call_tool_invalid_timeout_too_large(self) -> None: resp = self._post_call(payload={ "tool": "add_todo", "session_id": "sess-1", @@ -902,7 +911,7 @@ def test_call_tool_invalid_timeout_too_large(self): assert resp.status_code == 400 assert "timeout" in resp.get_json()["error"].lower() - def test_call_tool_invalid_timeout_string(self): + def test_call_tool_invalid_timeout_string(self) -> None: resp = self._post_call(payload={ "tool": "add_todo", "session_id": "sess-1", @@ -911,7 +920,7 @@ def test_call_tool_invalid_timeout_string(self): }) assert resp.status_code == 400 - def test_call_tool_invalid_metadata_type(self): + def test_call_tool_invalid_metadata_type(self) -> None: resp = self._post_call(payload={ "tool": "add_todo", "session_id": "sess-1", @@ -921,11 +930,11 @@ def test_call_tool_invalid_metadata_type(self): assert resp.status_code == 400 assert "metadata" in resp.get_json()["error"].lower() - def test_call_tool_invalid_service_name(self): + def test_call_tool_invalid_service_name(self) -> None: resp = self._post_call(service_name="bad%20name!") assert resp.status_code in (400, 500) - def test_call_tool_extracts_mcp_text_content(self): + def test_call_tool_extracts_mcp_text_content(self) -> None: """MCP-format result with text content is unwrapped.""" mock_client = MagicMock() mock_client.call_tool.return_value = { @@ -941,7 +950,7 @@ def test_call_tool_extracts_mcp_text_content(self): data = resp.get_json() assert data["result"] == "hello world" - def test_call_tool_preserves_non_text_mcp_content(self): + def test_call_tool_preserves_non_text_mcp_content(self) -> None: """MCP content that is not text type is returned as-is.""" mock_client = MagicMock() mock_client.call_tool.return_value = { @@ -959,7 +968,7 @@ def test_call_tool_preserves_non_text_mcp_content(self): assert isinstance(data["result"], dict) assert "content" in data["result"] - def test_call_tool_empty_content_list(self): + def test_call_tool_empty_content_list(self) -> None: """MCP result with empty content list is returned as-is.""" mock_client = MagicMock() mock_client.call_tool.return_value = {"content": []} @@ -973,7 +982,7 @@ def test_call_tool_empty_content_list(self): data = resp.get_json() assert data["result"] == {"content": []} - def test_call_tool_session_creation_failure(self): + def test_call_tool_session_creation_failure(self) -> None: """Error when session creation fails.""" self.mocks["session_manager"].get_session.return_value = None self.mocks["mcp_manager"].create_client.side_effect = RuntimeError("cannot start") @@ -982,7 +991,7 @@ def test_call_tool_session_creation_failure(self): assert resp.status_code == 500 assert "session" in resp.get_json()["error"].lower() - def test_call_tool_logs_security_event(self): + def test_call_tool_logs_security_event(self) -> None: """Every tool call is logged as a security event.""" mock_client = MagicMock() mock_client.call_tool.return_value = "ok" @@ -998,7 +1007,7 @@ def test_call_tool_logs_security_event(self): tool_calls = [c for c in mock_log.call_args_list if c[0][0] == "tool_call"] assert len(tool_calls) >= 1 - def test_call_tool_uses_default_timeout(self): + def test_call_tool_uses_default_timeout(self) -> None: """When timeout is not in payload, session_manager.default_timeout is used.""" self.mocks["session_manager"].get_session.return_value = None mock_client = MagicMock() @@ -1027,11 +1036,11 @@ class TestListSessionsEndpoint: """Tests for GET /sessions.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() self.client = self.gateway.app.test_client() - def test_list_sessions_success(self): + def test_list_sessions_success(self) -> None: self.mocks["session_manager"].list_sessions.return_value = { "sess-1": {"service_name": "todo"} } @@ -1040,13 +1049,13 @@ def test_list_sessions_success(self): data = resp.get_json() assert "sess-1" in data - def test_list_sessions_empty(self): + def test_list_sessions_empty(self) -> None: self.mocks["session_manager"].list_sessions.return_value = {} resp = self.client.get("/sessions", headers=_auth_headers_basic()) assert resp.status_code == 200 assert resp.get_json() == {} - def test_list_sessions_requires_auth(self): + def test_list_sessions_requires_auth(self) -> None: resp = self.client.get("/sessions") assert resp.status_code == 401 @@ -1059,11 +1068,11 @@ class TestCloseSessionEndpoint: """Tests for DELETE /sessions/.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() self.client = self.gateway.app.test_client() - def test_close_session_success(self): + def test_close_session_success(self) -> None: self.mocks["session_manager"].close_session.return_value = True resp = self.client.delete( "/sessions/sess-123", @@ -1072,7 +1081,7 @@ def test_close_session_success(self): assert resp.status_code == 200 assert "closed" in resp.get_json()["message"].lower() - def test_close_session_not_found(self): + def test_close_session_not_found(self) -> None: self.mocks["session_manager"].close_session.return_value = False resp = self.client.delete( "/sessions/sess-999", @@ -1080,18 +1089,18 @@ def test_close_session_not_found(self): ) assert resp.status_code == 404 - def test_close_session_invalid_id(self): + def test_close_session_invalid_id(self) -> None: resp = self.client.delete( "/sessions/bad%20id!", headers=_auth_headers_basic(), ) assert resp.status_code == 400 - def test_close_session_requires_auth(self): + def test_close_session_requires_auth(self) -> None: resp = self.client.delete("/sessions/sess-1") assert resp.status_code == 401 - def test_close_session_logs_security_event(self): + def test_close_session_logs_security_event(self) -> None: self.mocks["session_manager"].close_session.return_value = True with patch.object(self.gateway, "_log_security_event") as mock_log: self.client.delete( @@ -1110,14 +1119,14 @@ class TestSignalHandler: """Tests for _signal_handler.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() - def test_signal_handler_sets_shutdown_flag(self): + def test_signal_handler_sets_shutdown_flag(self) -> None: self.gateway._signal_handler(15, None) assert self.gateway._shutdown_requested is True - def test_signal_handler_calls_server_shutdown_when_server_exists(self): + def test_signal_handler_calls_server_shutdown_when_server_exists(self) -> None: mock_server = MagicMock() self.gateway.server = mock_server @@ -1128,7 +1137,7 @@ def test_signal_handler_calls_server_shutdown_when_server_exists(self): time.sleep(0.2) mock_server.shutdown.assert_called() - def test_signal_handler_tolerates_no_server(self): + def test_signal_handler_tolerates_no_server(self) -> None: """No error when server is None.""" self.gateway.server = None self.gateway._signal_handler(15, None) # should not raise @@ -1142,39 +1151,39 @@ class TestShutdown: """Tests for shutdown().""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() - def test_shutdown_calls_session_manager_shutdown(self): + def test_shutdown_calls_session_manager_shutdown(self) -> None: self.gateway.shutdown() self.mocks["session_manager"].shutdown.assert_called_once() - def test_shutdown_calls_mcp_manager_shutdown(self): + def test_shutdown_calls_mcp_manager_shutdown(self) -> None: self.gateway.shutdown() self.mocks["mcp_manager"].shutdown.assert_called_once() - def test_shutdown_calls_server_shutdown(self): + def test_shutdown_calls_server_shutdown(self) -> None: mock_server = MagicMock() self.gateway.server = mock_server self.gateway.shutdown() mock_server.shutdown.assert_called() - def test_shutdown_idempotent(self): + def test_shutdown_idempotent(self) -> None: """Second call is a no-op.""" self.gateway.shutdown() self.gateway.shutdown() # session_manager.shutdown should only be called once assert self.mocks["session_manager"].shutdown.call_count == 1 - def test_shutdown_sets_cleanup_done_flag(self): + def test_shutdown_sets_cleanup_done_flag(self) -> None: self.gateway.shutdown() assert self.gateway._shutdown_cleanup_done is True - def test_shutdown_tolerates_timeout_on_session_manager(self): + def test_shutdown_tolerates_timeout_on_session_manager(self) -> None: """If session_manager.shutdown hangs, we still complete.""" import time as time_mod - def hang(): + def hang() -> None: time_mod.sleep(30) self.mocks["session_manager"].shutdown.side_effect = hang @@ -1182,7 +1191,7 @@ def hang(): self.gateway.shutdown() assert self.gateway._shutdown_cleanup_done is True - def test_shutdown_tolerates_no_server(self): + def test_shutdown_tolerates_no_server(self) -> None: self.gateway.server = None self.gateway.shutdown() # should not raise @@ -1195,10 +1204,10 @@ class TestRunMethod: """Tests for run().""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() - def test_run_creates_server_and_serves(self): + def test_run_creates_server_and_serves(self) -> None: mock_server = MagicMock() with patch("signalwire.mcp_gateway.gateway_service.make_server", return_value=mock_server) as mock_make, \ patch("signalwire.mcp_gateway.gateway_service.signal") as mock_signal, \ @@ -1213,7 +1222,7 @@ def test_run_creates_server_and_serves(self): assert call_kwargs[0][0] == "0.0.0.0" assert call_kwargs[0][1] == 8080 - def test_run_enables_ssl_when_cert_exists(self): + def test_run_enables_ssl_when_cert_exists(self) -> None: mock_server = MagicMock() mock_server.serve_forever.side_effect = KeyboardInterrupt() @@ -1229,7 +1238,7 @@ def test_run_enables_ssl_when_cert_exists(self): mock_ssl.SSLContext.assert_called_once() mock_ctx.load_cert_chain.assert_called_once_with("certs/server.pem") - def test_run_calls_shutdown_on_exit(self): + def test_run_calls_shutdown_on_exit(self) -> None: mock_server = MagicMock() mock_server.serve_forever.side_effect = KeyboardInterrupt() @@ -1241,7 +1250,7 @@ def test_run_calls_shutdown_on_exit(self): self.gateway.run() mock_shutdown.assert_called() - def test_run_registers_signal_handlers(self): + def test_run_registers_signal_handlers(self) -> None: mock_server = MagicMock() mock_server.serve_forever.side_effect = KeyboardInterrupt() @@ -1257,7 +1266,7 @@ def test_run_registers_signal_handlers(self): assert mock_signal_mod.SIGTERM in registered_signals assert mock_signal_mod.SIGINT in registered_signals - def test_run_uses_config_host_and_port(self): + def test_run_uses_config_host_and_port(self) -> None: config = _minimal_config() config["server"]["host"] = "127.0.0.1" config["server"]["port"] = 9999 @@ -1283,17 +1292,17 @@ class TestErrorHandler: """Tests for the generic error handler.""" @pytest.fixture(autouse=True) - def _setup(self): + def _setup(self) -> None: self.gateway, self.mocks = _create_gateway() self.client = self.gateway.app.test_client() - def test_unhandled_exception_returns_500(self): + def test_unhandled_exception_returns_500(self) -> None: """Routes that raise unexpected exceptions return a 500.""" self.mocks["mcp_manager"].list_services.side_effect = RuntimeError("unexpected") resp = self.client.get("/services", headers=_auth_headers_basic()) assert resp.status_code == 500 - def test_unknown_route_returns_error(self): + def test_unknown_route_returns_error(self) -> None: """Unknown routes are caught by the generic error handler.""" resp = self.client.get("/nonexistent", headers=_auth_headers_basic()) # The generic @app.errorhandler(Exception) catches the 404 NotFound @@ -1310,14 +1319,14 @@ def test_unknown_route_returns_error(self): class TestEdgeCases: """Miscellaneous edge case tests.""" - def test_multiple_gateway_instances_are_independent(self): + def test_multiple_gateway_instances_are_independent(self) -> None: """Two gateways do not share state.""" gw1, mocks1 = _create_gateway() gw2, mocks2 = _create_gateway() assert gw1.app is not gw2.app assert gw1.mcp_manager is not gw2.mcp_manager - def test_config_without_server_section_uses_defaults(self): + def test_config_without_server_section_uses_defaults(self) -> None: """When the [server] section is missing, the gateway must still construct successfully and the stored config must reflect the absence (no server key) — i.e., we don't silently fabricate one.""" @@ -1329,7 +1338,7 @@ def test_config_without_server_section_uses_defaults(self): # And the Flask app was still constructed. assert gateway.app is not None - def test_config_without_logging_section(self): + def test_config_without_logging_section(self) -> None: """When [logging] is missing, construction succeeds and the config dict is preserved as-given (no logging key fabricated).""" config = _minimal_config() @@ -1338,7 +1347,7 @@ def test_config_without_logging_section(self): assert "logging" not in gateway.config assert gateway.app is not None - def test_config_with_log_file(self): + def test_config_with_log_file(self) -> None: """When [logging].file is set, a FileHandler must be installed on the root logger pointed at that file path.""" config = _minimal_config() @@ -1358,7 +1367,7 @@ def test_config_with_log_file(self): root = logging.getLogger() root.handlers = [h for h in root.handlers if h is not mock_handler] - def test_call_tool_with_special_chars_in_arguments(self): + def test_call_tool_with_special_chars_in_arguments(self) -> None: """Arguments dict with varied types should be passed through.""" gateway, mocks = _create_gateway() client = gateway.app.test_client() @@ -1392,7 +1401,7 @@ def test_call_tool_with_special_chars_in_arguments(self): passed_args = mock_client.call_tool.call_args[0][1] assert "

Visible

") text = default_skill._fast_text_extract(resp) assert "var x=1" not in text assert "Visible" in text - def test_removes_style_elements(self, default_skill): + def test_removes_style_elements(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"

Visible

") text = default_skill._fast_text_extract(resp) assert "color:red" not in text assert "Visible" in text - def test_removes_nav_header_footer_aside(self, default_skill): + def test_removes_nav_header_footer_aside(self, default_skill: "SpiderSkill") -> None: html_content = ( b"" b"" @@ -437,14 +440,14 @@ def test_removes_nav_header_footer_aside(self, default_skill): assert "AsideContent" not in text assert "MainContent" in text - def test_clean_text_collapses_whitespace(self, default_skill): + def test_clean_text_collapses_whitespace(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"

Hello \n\n world

") text = default_skill._fast_text_extract(resp) # clean_text is True by default, so multiple whitespace should collapse assert "Hello world" in text - def test_no_clean_text_preserves_whitespace(self, custom_skill): + def test_no_clean_text_preserves_whitespace(self, custom_skill: "SpiderSkill") -> None: # custom_skill has clean_text=False resp = _make_mock_response( content=b"

Hello world

") @@ -453,7 +456,7 @@ def test_no_clean_text_preserves_whitespace(self, custom_skill): assert "Hello" in text assert "world" in text - def test_truncation_when_text_exceeds_max_length(self, default_skill): + def test_truncation_when_text_exceeds_max_length(self, default_skill: "SpiderSkill") -> None: # default max_text_length is 3000 long_text = "A" * 5000 resp = _make_mock_response( @@ -463,14 +466,14 @@ def test_truncation_when_text_exceeds_max_length(self, default_skill): # Text should be around max_text_length plus the truncation marker assert len(text) < 5000 + 100 - def test_no_truncation_when_within_limit(self, default_skill): + def test_no_truncation_when_within_limit(self, default_skill: "SpiderSkill") -> None: short_text = "A" * 100 resp = _make_mock_response( content=f"

{short_text}

".encode()) text = default_skill._fast_text_extract(resp) assert "[...CONTENT TRUNCATED...]" not in text - def test_returns_empty_string_on_parse_error(self, default_skill): + def test_returns_empty_string_on_parse_error(self, default_skill: "SpiderSkill") -> None: with patch("signalwire.skills.spider.skill.html.fromstring", side_effect=Exception("parse error")): resp = _make_mock_response(content=b"not valid html at all") @@ -484,20 +487,20 @@ def test_returns_empty_string_on_parse_error(self, default_skill): class TestMarkdownExtract: - def test_extracts_title(self, default_skill): + def test_extracts_title(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"My Page

Content

") text = default_skill._markdown_extract(resp) assert "# My Page" in text - def test_extracts_paragraphs(self, default_skill): + def test_extracts_paragraphs(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"

Paragraph one

Paragraph two

") text = default_skill._markdown_extract(resp) assert "Paragraph one" in text assert "Paragraph two" in text - def test_extracts_headings_with_correct_level(self, default_skill): + def test_extracts_headings_with_correct_level(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"

Heading 1

Heading 2

Heading 3

") text = default_skill._markdown_extract(resp) @@ -505,21 +508,21 @@ def test_extracts_headings_with_correct_level(self, default_skill): assert "## Heading 2" in text assert "### Heading 3" in text - def test_extracts_list_items(self, default_skill): + def test_extracts_list_items(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"
  • Item A
  • Item B
") text = default_skill._markdown_extract(resp) assert "- Item A" in text assert "- Item B" in text - def test_extracts_code_blocks(self, default_skill): + def test_extracts_code_blocks(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"
some code
") text = default_skill._markdown_extract(resp) assert "```" in text assert "some code" in text - def test_removes_unwanted_elements(self, default_skill): + def test_removes_unwanted_elements(self, default_skill: "SpiderSkill") -> None: html_content = ( b"" b"" @@ -533,20 +536,20 @@ def test_removes_unwanted_elements(self, default_skill): assert "NavStuff" not in text assert "Good content" in text - def test_truncation_with_marker(self, default_skill): + def test_truncation_with_marker(self, default_skill: "SpiderSkill") -> None: long_text = "X" * 5000 resp = _make_mock_response( content=f"

{long_text}

".encode()) text = default_skill._markdown_extract(resp) assert "[...TRUNCATED...]" in text - def test_falls_back_to_fast_text_on_import_error(self, default_skill): + def test_falls_back_to_fast_text_on_import_error(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"

Fallback content

") import builtins real_import = builtins.__import__ - def fake_import(name, *args, **kwargs): + def fake_import(name: str, *args: Any, **kwargs: Any) -> Any: if name == "bs4": raise ImportError("No bs4") return real_import(name, *args, **kwargs) @@ -563,7 +566,7 @@ def fake_import(name, *args, **kwargs): if saved_bs4 is not None: sys.modules["bs4"] = saved_bs4 - def test_falls_back_to_fast_text_on_general_error(self, default_skill): + def test_falls_back_to_fast_text_on_general_error(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"

Some content

") with patch("bs4.BeautifulSoup", side_effect=Exception("soup error")): @@ -578,38 +581,38 @@ def test_falls_back_to_fast_text_on_general_error(self, default_skill): class TestStructuredExtract: - def test_extracts_title(self, default_skill): + def test_extracts_title(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"Test Title") result = default_skill._structured_extract(resp) assert result["title"] == "Test Title" - def test_result_contains_url_and_status(self, default_skill): + def test_result_contains_url_and_status(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response(url="https://example.com/page", status_code=200) result = default_skill._structured_extract(resp) assert result["url"] == "https://example.com/page" assert result["status_code"] == 200 - def test_no_selectors_returns_empty_data(self, default_skill): + def test_no_selectors_returns_empty_data(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response() result = default_skill._structured_extract(resp) assert result["data"] == {} - def test_xpath_selector(self, default_skill): + def test_xpath_selector(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"

Hello

") result = default_skill._structured_extract(resp, selectors={"paragraph": "//p"}) assert "paragraph" in result["data"] assert "Hello" in result["data"]["paragraph"] - def test_xpath_selector_multiple_results(self, default_skill): + def test_xpath_selector_multiple_results(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"

One

Two

Three

") result = default_skill._structured_extract(resp, selectors={"items": "//p"}) assert isinstance(result["data"]["items"], list) assert len(result["data"]["items"]) == 3 - def test_xpath_selector_single_result(self, default_skill): + def test_xpath_selector_single_result(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response( content=b"

Only One

") result = default_skill._structured_extract(resp, selectors={"heading": "//h1"}) @@ -617,20 +620,20 @@ def test_xpath_selector_single_result(self, default_skill): assert isinstance(result["data"]["heading"], str) assert result["data"]["heading"] == "Only One" - def test_invalid_xpath_returns_none_for_field(self, default_skill): + def test_invalid_xpath_returns_none_for_field(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response(content=b"") result = default_skill._structured_extract( resp, selectors={"bad": "///invalid[["}) assert result["data"]["bad"] is None - def test_general_parse_error_returns_error_dict(self, default_skill): + def test_general_parse_error_returns_error_dict(self, default_skill: "SpiderSkill") -> None: with patch("signalwire.skills.spider.skill.html.fromstring", side_effect=Exception("parse failed")): resp = _make_mock_response() result = default_skill._structured_extract(resp) assert "error" in result - def test_no_title_returns_empty_string(self, default_skill): + def test_no_title_returns_empty_string(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response(content=b"

No title

") result = default_skill._structured_extract(resp) assert result["title"] == "" @@ -642,30 +645,30 @@ def test_no_title_returns_empty_string(self, default_skill): class TestScrapeUrlHandler: - def test_empty_url_returns_error_message(self, default_skill): + def test_empty_url_returns_error_message(self, default_skill: "SpiderSkill") -> None: result = default_skill._scrape_url_handler({"url": ""}, {}) assert isinstance(result, FunctionResult) assert "provide a URL" in result.response - def test_missing_url_returns_error_message(self, default_skill): + def test_missing_url_returns_error_message(self, default_skill: "SpiderSkill") -> None: result = default_skill._scrape_url_handler({}, {}) assert "provide a URL" in result.response - def test_invalid_url_no_scheme(self, default_skill): + def test_invalid_url_no_scheme(self, default_skill: "SpiderSkill") -> None: result = default_skill._scrape_url_handler({"url": "example.com"}, {}) assert "Invalid URL" in result.response - def test_invalid_url_no_netloc(self, default_skill): + def test_invalid_url_no_netloc(self, default_skill: "SpiderSkill") -> None: result = default_skill._scrape_url_handler({"url": "https://"}, {}) assert "Invalid URL" in result.response - def test_fetch_failure_returns_error(self, default_skill): + def test_fetch_failure_returns_error(self, default_skill: "SpiderSkill") -> None: with patch.object(default_skill, "_fetch_url", return_value=None): result = default_skill._scrape_url_handler( {"url": "https://example.com"}, {}) assert "Failed to fetch" in result.response - def test_successful_fast_text_extraction(self, default_skill): + def test_successful_fast_text_extraction(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response() with patch.object(default_skill, "_fetch_url", return_value=resp): with patch.object(default_skill, "_fast_text_extract", @@ -675,7 +678,7 @@ def test_successful_fast_text_extraction(self, default_skill): assert "Extracted content here" in result.response assert "Content from" in result.response - def test_successful_markdown_extraction(self, default_skill): + def test_successful_markdown_extraction(self, default_skill: "SpiderSkill") -> None: default_skill.extract_type = "markdown" resp = _make_mock_response() with patch.object(default_skill, "_fetch_url", return_value=resp): @@ -685,7 +688,7 @@ def test_successful_markdown_extraction(self, default_skill): {"url": "https://example.com"}, {}) assert "# Markdown content" in result.response - def test_structured_extraction(self, default_skill): + def test_structured_extraction(self, default_skill: "SpiderSkill") -> None: default_skill.extract_type = "structured" resp = _make_mock_response() structured_data = {"url": "https://example.com", "title": "Test", @@ -697,7 +700,7 @@ def test_structured_extraction(self, default_skill): {"url": "https://example.com"}, {}) assert "Extracted structured data" in result.response - def test_empty_content_returns_no_content_message(self, default_skill): + def test_empty_content_returns_no_content_message(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response() with patch.object(default_skill, "_fetch_url", return_value=resp): with patch.object(default_skill, "_fast_text_extract", @@ -706,7 +709,7 @@ def test_empty_content_returns_no_content_message(self, default_skill): {"url": "https://example.com"}, {}) assert "No content extracted" in result.response - def test_exception_during_extraction_returns_error(self, default_skill): + def test_exception_during_extraction_returns_error(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response() with patch.object(default_skill, "_fetch_url", return_value=resp): with patch.object(default_skill, "_fast_text_extract", @@ -715,7 +718,7 @@ def test_exception_during_extraction_returns_error(self, default_skill): {"url": "https://example.com"}, {}) assert "Error processing" in result.response - def test_uses_configured_extract_type_not_from_args(self, default_skill): + def test_uses_configured_extract_type_not_from_args(self, default_skill: "SpiderSkill") -> None: """Verify that extract_type comes from self.extract_type, not args.""" resp = _make_mock_response() with patch.object(default_skill, "_fetch_url", return_value=resp): @@ -726,7 +729,7 @@ def test_uses_configured_extract_type_not_from_args(self, default_skill): {"url": "https://example.com", "extract_type": "markdown"}, {}) mock_fast.assert_called_once() - def test_response_includes_character_count(self, default_skill): + def test_response_includes_character_count(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response() with patch.object(default_skill, "_fetch_url", return_value=resp): with patch.object(default_skill, "_fast_text_extract", @@ -735,7 +738,7 @@ def test_response_includes_character_count(self, default_skill): {"url": "https://example.com"}, {}) assert "5 characters" in result.response - def test_whitespace_url_treated_as_empty(self, default_skill): + def test_whitespace_url_treated_as_empty(self, default_skill: "SpiderSkill") -> None: result = default_skill._scrape_url_handler({"url": " "}, {}) assert "provide a URL" in result.response @@ -746,15 +749,15 @@ def test_whitespace_url_treated_as_empty(self, default_skill): class TestCrawlSiteHandler: - def test_empty_start_url_returns_error(self, default_skill): + def test_empty_start_url_returns_error(self, default_skill: "SpiderSkill") -> None: result = default_skill._crawl_site_handler({"start_url": ""}, {}) assert "provide a starting URL" in result.response - def test_missing_start_url_returns_error(self, default_skill): + def test_missing_start_url_returns_error(self, default_skill: "SpiderSkill") -> None: result = default_skill._crawl_site_handler({}, {}) assert "provide a starting URL" in result.response - def test_single_page_crawl(self, default_skill): + def test_single_page_crawl(self, default_skill: "SpiderSkill") -> None: """With max_depth=0 and max_pages=1, should crawl exactly one page.""" resp = _make_mock_response( content=b"

Page content

") @@ -765,13 +768,13 @@ def test_single_page_crawl(self, default_skill): {"start_url": "https://example.com"}, {}) assert "Crawled 1 pages" in result.response - def test_no_pages_crawled_returns_error(self, default_skill): + def test_no_pages_crawled_returns_error(self, default_skill: "SpiderSkill") -> None: with patch.object(default_skill, "_fetch_url", return_value=None): result = default_skill._crawl_site_handler( {"start_url": "https://example.com"}, {}) assert "No pages could be crawled" in result.response - def test_multi_page_crawl_respects_max_pages(self, default_skill): + def test_multi_page_crawl_respects_max_pages(self, default_skill: "SpiderSkill") -> None: default_skill.max_pages = 2 default_skill.max_depth = 1 default_skill.delay = 0 # Avoid sleep in tests @@ -794,11 +797,11 @@ def test_multi_page_crawl_respects_max_pages(self, default_skill): call_count = [0] - def mock_fetch(url): + def mock_fetch(url: str) -> Mock | None: call_count[0] += 1 if call_count[0] == 1: return resp1 - elif call_count[0] == 2: + if call_count[0] == 2: return resp2 return None @@ -807,7 +810,7 @@ def mock_fetch(url): {"start_url": "https://example.com"}, {}) assert "Crawled 2 pages" in result.response - def test_crawl_skips_already_visited_urls(self, default_skill): + def test_crawl_skips_already_visited_urls(self, default_skill: "SpiderSkill") -> None: default_skill.max_pages = 10 default_skill.max_depth = 1 default_skill.delay = 0 @@ -823,7 +826,7 @@ def test_crawl_skips_already_visited_urls(self, default_skill): fetch_calls = [] - def mock_fetch(url): + def mock_fetch(url: str) -> Mock | None: fetch_calls.append(url) return resp @@ -834,7 +837,7 @@ def mock_fetch(url): assert len(fetch_calls) == 1 assert "Crawled 1 pages" in result.response - def test_crawl_respects_max_depth(self, default_skill): + def test_crawl_respects_max_depth(self, default_skill: "SpiderSkill") -> None: default_skill.max_pages = 10 default_skill.max_depth = 0 # Only the start page default_skill.delay = 0 @@ -849,7 +852,7 @@ def test_crawl_respects_max_depth(self, default_skill): fetch_calls = [] - def mock_fetch(url): + def mock_fetch(url: str) -> Mock | None: fetch_calls.append(url) return resp @@ -859,7 +862,7 @@ def mock_fetch(url): # With max_depth=0, should not follow links assert len(fetch_calls) == 1 - def test_crawl_follows_same_domain_only(self, default_skill): + def test_crawl_follows_same_domain_only(self, default_skill: "SpiderSkill") -> None: default_skill.max_pages = 10 default_skill.max_depth = 1 default_skill.delay = 0 @@ -878,7 +881,7 @@ def test_crawl_follows_same_domain_only(self, default_skill): fetch_calls = [] - def mock_fetch(url): + def mock_fetch(url: str) -> Mock | None: fetch_calls.append(url) if "internal" in url: return resp2 @@ -890,7 +893,7 @@ def mock_fetch(url): # Should not have fetched external domain assert not any("other.com" in u for u in fetch_calls) - def test_crawl_with_follow_patterns(self, default_skill): + def test_crawl_with_follow_patterns(self, default_skill: "SpiderSkill") -> None: default_skill.max_pages = 10 default_skill.max_depth = 1 default_skill.delay = 0 @@ -911,7 +914,7 @@ def test_crawl_with_follow_patterns(self, default_skill): fetch_calls = [] - def mock_fetch(url): + def mock_fetch(url: str) -> Mock | None: fetch_calls.append(url) if "blog" in url: return blog_resp @@ -924,7 +927,7 @@ def mock_fetch(url): assert any("blog" in u for u in fetch_calls) assert not any("about" in u for u in fetch_calls) - def test_crawl_summary_contains_total_characters(self, default_skill): + def test_crawl_summary_contains_total_characters(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response() with patch.object(default_skill, "_fetch_url", return_value=resp): with patch.object(default_skill, "_fast_text_extract", @@ -934,7 +937,7 @@ def test_crawl_summary_contains_total_characters(self, default_skill): assert "Total content:" in result.response assert "characters" in result.response - def test_crawl_handles_fetch_failure_for_individual_pages(self, default_skill): + def test_crawl_handles_fetch_failure_for_individual_pages(self, default_skill: "SpiderSkill") -> None: default_skill.max_pages = 5 default_skill.max_depth = 1 default_skill.delay = 0 @@ -949,7 +952,7 @@ def test_crawl_handles_fetch_failure_for_individual_pages(self, default_skill): call_count = [0] - def mock_fetch(url): + def mock_fetch(url: str) -> Mock | None: call_count[0] += 1 if call_count[0] == 1: return resp @@ -960,7 +963,7 @@ def mock_fetch(url): {"start_url": "https://example.com"}, {}) assert "Crawled 1 pages" in result.response - def test_crawl_delays_between_requests(self, default_skill): + def test_crawl_delays_between_requests(self, default_skill: "SpiderSkill") -> None: default_skill.max_pages = 2 default_skill.max_depth = 1 default_skill.delay = 0.5 @@ -978,7 +981,7 @@ def test_crawl_delays_between_requests(self, default_skill): call_count = [0] - def mock_fetch(url): + def mock_fetch(url: str) -> Mock | None: call_count[0] += 1 if call_count[0] == 1: return resp1 @@ -990,7 +993,7 @@ def mock_fetch(url): {"start_url": "https://example.com"}, {}) mock_sleep.assert_called_with(0.5) - def test_content_summary_truncated_at_500(self, default_skill): + def test_content_summary_truncated_at_500(self, default_skill: "SpiderSkill") -> None: long_content = "A" * 1000 resp = _make_mock_response() with patch.object(default_skill, "_fetch_url", return_value=resp): @@ -1008,27 +1011,27 @@ def test_content_summary_truncated_at_500(self, default_skill): class TestExtractStructuredHandler: - def test_empty_url_returns_error(self, default_skill): + def test_empty_url_returns_error(self, default_skill: "SpiderSkill") -> None: result = default_skill._extract_structured_handler({"url": ""}, {}) assert "provide a URL" in result.response - def test_missing_url_returns_error(self, default_skill): + def test_missing_url_returns_error(self, default_skill: "SpiderSkill") -> None: result = default_skill._extract_structured_handler({}, {}) assert "provide a URL" in result.response - def test_no_selectors_configured_returns_error(self, default_skill): + def test_no_selectors_configured_returns_error(self, default_skill: "SpiderSkill") -> None: result = default_skill._extract_structured_handler( {"url": "https://example.com"}, {}) assert "No selectors configured" in result.response - def test_fetch_failure_returns_error(self, custom_skill): + def test_fetch_failure_returns_error(self, custom_skill: "SpiderSkill") -> None: # custom_skill has selectors configured with patch.object(custom_skill, "_fetch_url", return_value=None): result = custom_skill._extract_structured_handler( {"url": "https://example.com"}, {}) assert "Failed to fetch" in result.response - def test_successful_extraction(self, custom_skill): + def test_successful_extraction(self, custom_skill: "SpiderSkill") -> None: structured_result = { "url": "https://example.com", "title": "Test Page", @@ -1045,7 +1048,7 @@ def test_successful_extraction(self, custom_skill): assert "Test Page" in result.response assert "title: Extracted Title" in result.response - def test_extraction_error_in_result(self, custom_skill): + def test_extraction_error_in_result(self, custom_skill: "SpiderSkill") -> None: resp = _make_mock_response() with patch.object(custom_skill, "_fetch_url", return_value=resp): with patch.object(custom_skill, "_structured_extract", @@ -1054,7 +1057,7 @@ def test_extraction_error_in_result(self, custom_skill): {"url": "https://example.com"}, {}) assert "Error extracting data" in result.response - def test_empty_data_says_no_data_extracted(self, custom_skill): + def test_empty_data_says_no_data_extracted(self, custom_skill: "SpiderSkill") -> None: structured_result = { "url": "https://example.com", "title": "Test Page", @@ -1069,7 +1072,7 @@ def test_empty_data_says_no_data_extracted(self, custom_skill): {"url": "https://example.com"}, {}) assert "No data extracted" in result.response - def test_uses_selectors_from_params(self, custom_skill): + def test_uses_selectors_from_params(self, custom_skill: "SpiderSkill") -> None: resp = _make_mock_response() with patch.object(custom_skill, "_fetch_url", return_value=resp): with patch.object(custom_skill, "_structured_extract", @@ -1089,18 +1092,18 @@ def test_uses_selectors_from_params(self, custom_skill): class TestGetHints: - def test_returns_list(self, default_skill): + def test_returns_list(self, default_skill: "SpiderSkill") -> None: hints = default_skill.get_hints() assert isinstance(hints, list) - def test_contains_expected_hints(self, default_skill): + def test_contains_expected_hints(self, default_skill: "SpiderSkill") -> None: hints = default_skill.get_hints() assert "scrape" in hints assert "crawl" in hints assert "spider" in hints assert "website" in hints - def test_hints_are_strings(self, default_skill): + def test_hints_are_strings(self, default_skill: "SpiderSkill") -> None: hints = default_skill.get_hints() for hint in hints: assert isinstance(hint, str) @@ -1112,16 +1115,18 @@ def test_hints_are_strings(self, default_skill): class TestCleanup: - def test_closes_session(self, default_skill): + def test_closes_session(self, default_skill: "SpiderSkill") -> None: default_skill.cleanup() - default_skill.session.close.assert_called_once() + default_skill.session.close.assert_called_once() # type: ignore[attr-defined] # mock attr - def test_clears_cache(self, default_skill): - default_skill._cache["https://example.com"] = "cached" + def test_clears_cache(self, default_skill: "SpiderSkill") -> None: + assert default_skill._cache is not None + default_skill._cache["https://example.com"] = "cached" # type: ignore[assignment] # placeholder value, only that it is cleared matters default_skill.cleanup() + assert default_skill._cache is not None assert len(default_skill._cache) == 0 - def test_cleanup_with_none_cache(self, custom_skill): + def test_cleanup_with_none_cache(self, custom_skill: "SpiderSkill") -> None: """custom_skill has cache_enabled=False so cache is None — cleanup must skip the .clear() call (calling .clear on None would crash) but still close the session.""" @@ -1129,32 +1134,34 @@ def test_cleanup_with_none_cache(self, custom_skill): assert custom_skill._cache is None custom_skill.cleanup() # Session was still closed even though cache branch was skipped. - custom_skill.session.close.assert_called_once() + custom_skill.session.close.assert_called_once() # type: ignore[attr-defined] # mock attr # Cache stays None — no surprise re-init. assert custom_skill._cache is None - def test_cleanup_without_session_attribute(self, default_skill): + def test_cleanup_without_session_attribute(self, default_skill: "SpiderSkill") -> None: """If `session` was never created, the hasattr guard must skip the close path. We verify cache.clear() still ran (the second guard is independent).""" del default_skill.session - default_skill._cache["https://example.com"] = "cached" + assert default_skill._cache is not None + default_skill._cache["https://example.com"] = "cached" # type: ignore[assignment] # placeholder value, only that it is cleared matters default_skill.cleanup() # Cache was cleared even though session attribute was missing. + assert default_skill._cache is not None assert len(default_skill._cache) == 0 # Session still missing — no re-creation. assert not hasattr(default_skill, "session") - def test_cleanup_without_cache_attribute(self, default_skill): + def test_cleanup_without_cache_attribute(self, default_skill: "SpiderSkill") -> None: """If `cache` was never created, the hasattr guard must skip the clear path while still closing the session.""" del default_skill._cache default_skill.cleanup() # Session was closed even though cache attribute was missing. - default_skill.session.close.assert_called_once() + default_skill.session.close.assert_called_once() # type: ignore[attr-defined] # mock attr assert not hasattr(default_skill, "cache") - def test_cleanup_logs_info(self, default_skill): + def test_cleanup_logs_info(self, default_skill: "SpiderSkill") -> None: with patch.object(default_skill.logger, "info") as mock_info: default_skill.cleanup() mock_info.assert_called_once() @@ -1167,7 +1174,7 @@ def test_cleanup_logs_info(self, default_skill): class TestEdgeCases: - def test_url_with_whitespace_stripped(self, default_skill): + def test_url_with_whitespace_stripped(self, default_skill: "SpiderSkill") -> None: """URLs with leading/trailing whitespace should be stripped.""" resp = _make_mock_response() with patch.object(default_skill, "_fetch_url", return_value=resp): @@ -1177,9 +1184,9 @@ def test_url_with_whitespace_stripped(self, default_skill): {"url": " https://example.com "}, {}) assert "content" in result.response - def test_cache_prevents_duplicate_fetches(self, default_skill): + def test_cache_prevents_duplicate_fetches(self, default_skill: "SpiderSkill") -> None: resp = _make_mock_response() - default_skill.session.get = Mock(return_value=resp) + default_skill.session.get = Mock(return_value=resp) # type: ignore[method-assign] # mock # Fetch twice result1 = default_skill._fetch_url("https://example.com") @@ -1189,11 +1196,11 @@ def test_cache_prevents_duplicate_fetches(self, default_skill): default_skill.session.get.assert_called_once() assert result1 is result2 - def test_scrape_handler_url_with_only_scheme(self, default_skill): + def test_scrape_handler_url_with_only_scheme(self, default_skill: "SpiderSkill") -> None: result = default_skill._scrape_url_handler({"url": "ftp://"}, {}) assert "Invalid URL" in result.response - def test_init_with_empty_params(self, mock_agent): + def test_init_with_empty_params(self, mock_agent: Mock) -> None: """Skill should initialize fine with no params at all.""" with patch("signalwire.skills.spider.skill.requests.Session") as MockSession: mock_session = Mock() @@ -1205,7 +1212,7 @@ def test_init_with_empty_params(self, mock_agent): assert skill.delay == 0.1 assert skill._cache == {} - def test_init_with_none_params(self, mock_agent): + def test_init_with_none_params(self, mock_agent: Mock) -> None: """Skill should handle None params gracefully (via SkillBase default).""" with patch("signalwire.skills.spider.skill.requests.Session") as MockSession: mock_session = Mock() @@ -1213,10 +1220,10 @@ def test_init_with_none_params(self, mock_agent): MockSession.return_value = mock_session from signalwire.skills.spider.skill import SpiderSkill - skill = SpiderSkill(mock_agent, None) + skill = SpiderSkill(mock_agent, None) # type: ignore[arg-type] # None params handled by SkillBase default assert skill.delay == 0.1 - def test_register_tools_no_prefix_when_tool_name_empty(self, mock_agent): + def test_register_tools_no_prefix_when_tool_name_empty(self, mock_agent: Mock) -> None: with patch("signalwire.skills.spider.skill.requests.Session") as MockSession: mock_session = Mock() mock_session.headers = {} @@ -1230,7 +1237,7 @@ def test_register_tools_no_prefix_when_tool_name_empty(self, mock_agent): # Empty tool_name should not add a prefix assert "scrape_url" in names - def test_fast_text_truncation_preserves_start_and_end(self, default_skill): + def test_fast_text_truncation_preserves_start_and_end(self, default_skill: "SpiderSkill") -> None: """Verify the smart truncation keeps 2/3 from start and 1/3 from end.""" default_skill.max_text_length = 300 body = "S" * 200 + "M" * 100 + "E" * 200 @@ -1241,7 +1248,7 @@ def test_fast_text_truncation_preserves_start_and_end(self, default_skill): assert text.endswith("E") assert "[...CONTENT TRUNCATED...]" in text - def test_structured_extract_css_selector(self, default_skill): + def test_structured_extract_css_selector(self, default_skill: "SpiderSkill") -> None: """CSS selectors (not starting with /) should be handled via CSSSelector.""" resp = _make_mock_response( content=b"

CSS content

") @@ -1272,7 +1279,7 @@ def test_structured_extract_css_selector(self, default_skill): else: sys.modules.pop("lxml.cssselect", None) - def test_crawl_link_extraction_error_handled(self, default_skill): + def test_crawl_link_extraction_error_handled(self, default_skill: "SpiderSkill") -> None: """Error during link extraction should not crash the crawl.""" default_skill.max_pages = 5 default_skill.max_depth = 1 diff --git a/tests/unit/skills/test_swml_transfer_skill.py b/tests/unit/skills/test_swml_transfer_skill.py index 7556d5d1..80b32ad7 100644 --- a/tests/unit/skills/test_swml_transfer_skill.py +++ b/tests/unit/skills/test_swml_transfer_skill.py @@ -5,20 +5,18 @@ Licensed under the MIT License. See LICENSE file in the project root for full license information. -""" -""" Unit tests for the SWMLTransfer skill module """ -import pytest -from unittest.mock import Mock, patch, MagicMock +from typing import Any + +from unittest.mock import Mock, patch from signalwire.skills.swml_transfer.skill import SWMLTransferSkill -from signalwire.core.function_result import FunctionResult -def _make_skill(params=None): +def _make_skill(params: dict[str, Any] | None = None) -> SWMLTransferSkill: """ Helper to create a SWMLTransferSkill with a mocked agent. Provides sensible defaults for all required parameters. @@ -58,22 +56,22 @@ def _make_skill(params=None): class TestSWMLTransferSkillClassAttributes: """Verify class-level constants and metadata.""" - def test_skill_name(self): + def test_skill_name(self) -> None: assert SWMLTransferSkill.SKILL_NAME == "swml_transfer" - def test_skill_description(self): + def test_skill_description(self) -> None: assert SWMLTransferSkill.SKILL_DESCRIPTION == "Transfer calls between agents based on pattern matching" - def test_skill_version(self): + def test_skill_version(self) -> None: assert SWMLTransferSkill.SKILL_VERSION == "1.0.0" - def test_required_packages(self): + def test_required_packages(self) -> None: assert SWMLTransferSkill.REQUIRED_PACKAGES == [] - def test_required_env_vars(self): + def test_required_env_vars(self) -> None: assert SWMLTransferSkill.REQUIRED_ENV_VARS == [] - def test_supports_multiple_instances(self): + def test_supports_multiple_instances(self) -> None: assert SWMLTransferSkill.SUPPORTS_MULTIPLE_INSTANCES is True @@ -84,26 +82,26 @@ def test_supports_multiple_instances(self): class TestSWMLTransferSkillInit: """Tests for __init__ (inherited from SkillBase).""" - def test_agent_is_stored(self): + def test_agent_is_stored(self) -> None: mock_agent = Mock() skill = SWMLTransferSkill(agent=mock_agent, params={"transfers": {}}) assert skill.agent is mock_agent - def test_params_stored(self): + def test_params_stored(self) -> None: params = {"transfers": {"/sales/": {"url": "http://x"}}} skill = SWMLTransferSkill(agent=Mock(), params=params) assert "/sales/" in skill.params["transfers"] - def test_params_default_to_empty_dict(self): + def test_params_default_to_empty_dict(self) -> None: skill = SWMLTransferSkill(agent=Mock()) assert skill.params == {} - def test_logger_created(self): + def test_logger_created(self) -> None: skill = SWMLTransferSkill(agent=Mock()) assert skill.logger is not None assert skill.logger.name == "signalwire.skills.swml_transfer" - def test_swaig_fields_extracted_from_params(self): + def test_swaig_fields_extracted_from_params(self) -> None: params = {"swaig_fields": {"meta_data": {"x": 1}}, "transfers": {}} skill = SWMLTransferSkill(agent=Mock(), params=params) assert skill.swaig_fields == {"meta_data": {"x": 1}} @@ -117,17 +115,17 @@ def test_swaig_fields_extracted_from_params(self): class TestGetInstanceKey: """Tests for get_instance_key method.""" - def test_default_instance_key(self): + def test_default_instance_key(self) -> None: skill = _make_skill() skill.setup() assert skill.get_instance_key() == "swml_transfer_transfer_call" - def test_custom_tool_name_instance_key(self): + def test_custom_tool_name_instance_key(self) -> None: skill = _make_skill({"tool_name": "route_call"}) skill.setup() assert skill.get_instance_key() == "swml_transfer_route_call" - def test_instance_key_before_setup_uses_params(self): + def test_instance_key_before_setup_uses_params(self) -> None: """get_instance_key reads from self.params, so it works before setup.""" skill = _make_skill({"tool_name": "early_key"}) assert skill.get_instance_key() == "swml_transfer_early_key" @@ -140,77 +138,77 @@ def test_instance_key_before_setup_uses_params(self): class TestSetup: """Tests for setup() validation and configuration.""" - def test_setup_returns_true_with_valid_params(self): + def test_setup_returns_true_with_valid_params(self) -> None: skill = _make_skill() assert skill.setup() is True - def test_setup_stores_tool_name_default(self): + def test_setup_stores_tool_name_default(self) -> None: skill = _make_skill() skill.setup() assert skill.tool_name == "transfer_call" - def test_setup_stores_custom_tool_name(self): + def test_setup_stores_custom_tool_name(self) -> None: skill = _make_skill({"tool_name": "my_transfer"}) skill.setup() assert skill.tool_name == "my_transfer" - def test_setup_stores_description_default(self): + def test_setup_stores_description_default(self) -> None: skill = _make_skill() skill.setup() assert skill.description == "Transfer call based on pattern matching" - def test_setup_stores_custom_description(self): + def test_setup_stores_custom_description(self) -> None: skill = _make_skill({"description": "Route the call"}) skill.setup() assert skill.description == "Route the call" - def test_setup_stores_parameter_name_default(self): + def test_setup_stores_parameter_name_default(self) -> None: skill = _make_skill() skill.setup() assert skill.parameter_name == "transfer_type" - def test_setup_stores_custom_parameter_name(self): + def test_setup_stores_custom_parameter_name(self) -> None: skill = _make_skill({"parameter_name": "dest"}) skill.setup() assert skill.parameter_name == "dest" - def test_setup_stores_parameter_description_default(self): + def test_setup_stores_parameter_description_default(self) -> None: skill = _make_skill() skill.setup() assert skill.parameter_description == "The type of transfer to perform" - def test_setup_stores_default_message(self): + def test_setup_stores_default_message(self) -> None: skill = _make_skill() skill.setup() assert skill.default_message == "Please specify a valid transfer type." - def test_setup_stores_custom_default_message(self): + def test_setup_stores_custom_default_message(self) -> None: skill = _make_skill({"default_message": "Unknown transfer."}) skill.setup() assert skill.default_message == "Unknown transfer." - def test_setup_stores_default_post_process(self): + def test_setup_stores_default_post_process(self) -> None: skill = _make_skill() skill.setup() assert skill.default_post_process is False - def test_setup_stores_custom_default_post_process(self): + def test_setup_stores_custom_default_post_process(self) -> None: skill = _make_skill({"default_post_process": True}) skill.setup() assert skill.default_post_process is True - def test_setup_stores_required_fields_default(self): + def test_setup_stores_required_fields_default(self) -> None: skill = _make_skill() skill.setup() assert skill.required_fields == {} - def test_setup_stores_custom_required_fields(self): + def test_setup_stores_custom_required_fields(self) -> None: fields = {"caller_name": "The caller's name"} skill = _make_skill({"required_fields": fields}) skill.setup() assert skill.required_fields == fields - def test_setup_sets_defaults_on_transfer_configs(self): + def test_setup_sets_defaults_on_transfer_configs(self) -> None: """Verify setup fills in defaults for optional config fields.""" transfers = { "/billing/": {"url": "https://example.com/billing"} @@ -225,28 +223,28 @@ def test_setup_sets_defaults_on_transfer_configs(self): # --- Failure cases --- - def test_setup_fails_missing_transfers(self): + def test_setup_fails_missing_transfers(self) -> None: skill = _make_skill({"transfers": None}) assert skill.setup() is False - def test_setup_fails_empty_transfers(self): + def test_setup_fails_empty_transfers(self) -> None: """Empty dict is falsy for 'not self.params.get(param)'.""" skill = _make_skill({"transfers": {}}) assert skill.setup() is False - def test_setup_fails_transfers_not_dict(self): + def test_setup_fails_transfers_not_dict(self) -> None: skill = _make_skill({"transfers": "not_a_dict"}) assert skill.setup() is False - def test_setup_fails_transfer_config_not_dict(self): + def test_setup_fails_transfer_config_not_dict(self) -> None: skill = _make_skill({"transfers": {"/bad/": "string_config"}}) assert skill.setup() is False - def test_setup_fails_transfer_missing_url_and_address(self): + def test_setup_fails_transfer_missing_url_and_address(self) -> None: skill = _make_skill({"transfers": {"/nowhere/": {"message": "hi"}}}) assert skill.setup() is False - def test_setup_fails_transfer_has_both_url_and_address(self): + def test_setup_fails_transfer_has_both_url_and_address(self) -> None: skill = _make_skill({ "transfers": { "/both/": { @@ -265,34 +263,34 @@ def test_setup_fails_transfer_has_both_url_and_address(self): class TestRegisterTools: """Tests for register_tools() method.""" - def test_register_calls_register_swaig_function(self): + def test_register_calls_register_swaig_function(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() skill.agent.register_swaig_function.assert_called_once() - def test_registered_function_name(self): + def test_registered_function_name(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() call_args = skill.agent.register_swaig_function.call_args[0][0] assert call_args["function"] == "transfer_call" - def test_registered_function_custom_name(self): + def test_registered_function_custom_name(self) -> None: skill = _make_skill({"tool_name": "route_it"}) skill.setup() skill.register_tools() call_args = skill.agent.register_swaig_function.call_args[0][0] assert call_args["function"] == "route_it" - def test_registered_function_description(self): + def test_registered_function_description(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() call_args = skill.agent.register_swaig_function.call_args[0][0] assert call_args["description"] == "Transfer call based on pattern matching" - def test_registered_function_has_transfer_type_param(self): + def test_registered_function_has_transfer_type_param(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() @@ -301,7 +299,7 @@ def test_registered_function_has_transfer_type_param(self): assert "transfer_type" in params["properties"] assert "transfer_type" in params.get("required", []) - def test_registered_function_custom_parameter_name(self): + def test_registered_function_custom_parameter_name(self) -> None: skill = _make_skill({"parameter_name": "dest_type"}) skill.setup() skill.register_tools() @@ -309,7 +307,7 @@ def test_registered_function_custom_parameter_name(self): params = call_args["parameters"] assert "dest_type" in params["properties"] - def test_registered_function_has_expressions(self): + def test_registered_function_has_expressions(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() @@ -318,7 +316,7 @@ def test_registered_function_has_expressions(self): # 2 patterns + 1 fallback = 3 expressions assert len(data_map["expressions"]) == 3 - def test_registered_function_fallback_expression(self): + def test_registered_function_fallback_expression(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() @@ -328,7 +326,7 @@ def test_registered_function_fallback_expression(self): assert fallback["pattern"] == r"/.*/" assert fallback["output"]["response"] == "Please specify a valid transfer type." - def test_url_transfer_uses_swml_transfer_action(self): + def test_url_transfer_uses_swml_transfer_action(self) -> None: """A 'url' config should produce an expression with SWML transfer action.""" skill = _make_skill() skill.setup() @@ -346,7 +344,7 @@ def test_url_transfer_uses_swml_transfer_action(self): main_section = swml_action["SWML"]["sections"]["main"] assert any("transfer" in step for step in main_section) - def test_address_transfer_uses_connect_action(self): + def test_address_transfer_uses_connect_action(self) -> None: """An 'address' config should produce an expression with connect action.""" skill = _make_skill() skill.setup() @@ -361,7 +359,7 @@ def test_address_transfer_uses_connect_action(self): connect_step = [s for s in main_section if "connect" in s][0] assert connect_step["connect"]["to"] == "+15551234567" - def test_address_transfer_with_from_addr(self): + def test_address_transfer_with_from_addr(self) -> None: """An address config with from_addr should include 'from' in the connect action.""" transfers = { "/vip/": { @@ -381,7 +379,7 @@ def test_address_transfer_with_from_addr(self): connect_step = [s for s in main_section if "connect" in s][0] assert connect_step["connect"]["from"] == "+15550001111" - def test_address_transfer_non_final(self): + def test_address_transfer_non_final(self) -> None: """An address config with final=False uses 'false' in SWML transfer key.""" skill = _make_skill() skill.setup() @@ -393,7 +391,7 @@ def test_address_transfer_non_final(self): swml_action = [a for a in actions if "SWML" in a][0] assert swml_action["transfer"] == "false" - def test_required_fields_added_as_parameters(self): + def test_required_fields_added_as_parameters(self) -> None: skill = _make_skill({"required_fields": {"caller_name": "Name of caller"}}) skill.setup() skill.register_tools() @@ -402,7 +400,7 @@ def test_required_fields_added_as_parameters(self): assert "caller_name" in params["properties"] assert "caller_name" in params.get("required", []) - def test_required_fields_saved_in_global_data(self): + def test_required_fields_saved_in_global_data(self) -> None: """When required_fields is set, expressions should contain set_global_data actions.""" skill = _make_skill({"required_fields": {"caller_name": "Name of caller"}}) skill.setup() @@ -418,7 +416,7 @@ def test_required_fields_saved_in_global_data(self): assert "call_data" in gd assert gd["call_data"]["caller_name"] == "${args.caller_name}" - def test_required_fields_in_fallback_expression(self): + def test_required_fields_in_fallback_expression(self) -> None: """The fallback expression should also save required fields.""" skill = _make_skill({"required_fields": {"caller_name": "Name of caller"}}) skill.setup() @@ -430,7 +428,7 @@ def test_required_fields_in_fallback_expression(self): global_data_actions = [a for a in actions if "set_global_data" in a] assert len(global_data_actions) >= 1 - def test_register_tools_fallback_without_register_swaig_function(self): + def test_register_tools_fallback_without_register_swaig_function(self) -> None: """When agent lacks register_swaig_function, the skill must take the else-branch and log an error — it must NOT silently do nothing.""" skill = _make_skill() @@ -452,13 +450,13 @@ def test_register_tools_fallback_without_register_swaig_function(self): class TestGetHints: """Tests for get_hints() method.""" - def test_get_hints_returns_list(self): + def test_get_hints_returns_list(self) -> None: skill = _make_skill() skill.setup() hints = skill.get_hints() assert isinstance(hints, list) - def test_get_hints_contains_common_words(self): + def test_get_hints_contains_common_words(self) -> None: skill = _make_skill() skill.setup() hints = skill.get_hints() @@ -467,14 +465,14 @@ def test_get_hints_contains_common_words(self): assert "speak to" in hints assert "talk to" in hints - def test_get_hints_extracts_pattern_names(self): + def test_get_hints_extracts_pattern_names(self) -> None: skill = _make_skill() skill.setup() hints = skill.get_hints() assert "sales" in hints assert "support" in hints - def test_get_hints_handles_pipe_separated_patterns(self): + def test_get_hints_handles_pipe_separated_patterns(self) -> None: transfers = { "/billing|accounts/i": {"url": "https://example.com/billing"} } @@ -484,7 +482,7 @@ def test_get_hints_handles_pipe_separated_patterns(self): assert "billing" in hints assert "accounts" in hints - def test_get_hints_skips_catch_all_patterns(self): + def test_get_hints_skips_catch_all_patterns(self) -> None: """Patterns starting with '.' (like '.*') should be skipped.""" transfers = { "/.*/": {"url": "https://example.com/fallback"} @@ -496,7 +494,7 @@ def test_get_hints_skips_catch_all_patterns(self): for h in hints: assert h != ".*" - def test_get_hints_strips_regex_delimiters(self): + def test_get_hints_strips_regex_delimiters(self) -> None: """Leading/trailing slashes should be stripped from patterns.""" transfers = { "/billing/": {"url": "https://example.com/billing"} @@ -506,7 +504,7 @@ def test_get_hints_strips_regex_delimiters(self): hints = skill.get_hints() assert "billing" in hints - def test_get_hints_strips_flags(self): + def test_get_hints_strips_flags(self) -> None: """Trailing flags like /i should be stripped.""" transfers = { "/technical/i": {"url": "https://example.com/tech"} @@ -524,32 +522,32 @@ def test_get_hints_strips_flags(self): class TestGetPromptSections: """Tests for get_prompt_sections() method.""" - def test_returns_list(self): + def test_returns_list(self) -> None: skill = _make_skill() skill.setup() sections = skill.get_prompt_sections() assert isinstance(sections, list) - def test_returns_two_sections(self): + def test_returns_two_sections(self) -> None: """Should have 'Transferring' and 'Transfer Instructions' sections.""" skill = _make_skill() skill.setup() sections = skill.get_prompt_sections() assert len(sections) == 2 - def test_transferring_section_title(self): + def test_transferring_section_title(self) -> None: skill = _make_skill() skill.setup() sections = skill.get_prompt_sections() assert sections[0]["title"] == "Transferring" - def test_transferring_section_body_includes_tool_name(self): + def test_transferring_section_body_includes_tool_name(self) -> None: skill = _make_skill() skill.setup() sections = skill.get_prompt_sections() assert "transfer_call" in sections[0]["body"] - def test_transferring_section_bullets(self): + def test_transferring_section_bullets(self) -> None: skill = _make_skill() skill.setup() sections = skill.get_prompt_sections() @@ -559,13 +557,13 @@ def test_transferring_section_bullets(self): combined = " ".join(bullets) assert "sales" in combined.lower() or "example.com" in combined - def test_transfer_instructions_section_title(self): + def test_transfer_instructions_section_title(self) -> None: skill = _make_skill() skill.setup() sections = skill.get_prompt_sections() assert sections[1]["title"] == "Transfer Instructions" - def test_transfer_instructions_bullets_mention_tool_name(self): + def test_transfer_instructions_bullets_mention_tool_name(self) -> None: skill = _make_skill() skill.setup() sections = skill.get_prompt_sections() @@ -573,7 +571,7 @@ def test_transfer_instructions_bullets_mention_tool_name(self): combined = " ".join(bullets) assert "transfer_call" in combined - def test_transfer_instructions_bullets_mention_param_name(self): + def test_transfer_instructions_bullets_mention_param_name(self) -> None: skill = _make_skill() skill.setup() sections = skill.get_prompt_sections() @@ -581,7 +579,7 @@ def test_transfer_instructions_bullets_mention_param_name(self): combined = " ".join(bullets) assert "transfer_type" in combined - def test_required_fields_appear_in_instructions(self): + def test_required_fields_appear_in_instructions(self) -> None: skill = _make_skill({"required_fields": {"caller_name": "The name of the caller"}}) skill.setup() sections = skill.get_prompt_sections() @@ -591,7 +589,7 @@ def test_required_fields_appear_in_instructions(self): assert "The name of the caller" in combined assert "call_data" in combined - def test_empty_transfers_returns_empty(self): + def test_empty_transfers_returns_empty(self) -> None: """If setup fails (empty transfers), prompt sections are not reachable. But if transfers was somehow set to empty dict post-setup, returns [].""" skill = _make_skill() @@ -600,7 +598,7 @@ def test_empty_transfers_returns_empty(self): sections = skill.get_prompt_sections() assert sections == [] - def test_catch_all_pattern_skipped_in_bullets(self): + def test_catch_all_pattern_skipped_in_bullets(self) -> None: """Patterns starting with '.' should not appear in transfer bullets.""" transfers = { "/.*/": {"url": "https://example.com/fallback"}, @@ -614,7 +612,7 @@ def test_catch_all_pattern_skipped_in_bullets(self): assert len(bullets) == 1 assert "sales" in bullets[0] - def test_address_destination_in_bullets(self): + def test_address_destination_in_bullets(self) -> None: """Address-based transfers should show the address in bullets.""" transfers = { "/helpdesk/": {"address": "+15559990000"} @@ -634,53 +632,53 @@ def test_address_destination_in_bullets(self): class TestGetParameterSchema: """Tests for get_parameter_schema() classmethod.""" - def test_returns_dict(self): + def test_returns_dict(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() assert isinstance(schema, dict) - def test_includes_transfers_key(self): + def test_includes_transfers_key(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() assert "transfers" in schema - def test_transfers_required(self): + def test_transfers_required(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() assert schema["transfers"]["required"] is True - def test_includes_description_key(self): + def test_includes_description_key(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() assert "description" in schema assert schema["description"]["required"] is False - def test_includes_parameter_name_key(self): + def test_includes_parameter_name_key(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() assert "parameter_name" in schema assert schema["parameter_name"]["default"] == "transfer_type" - def test_includes_parameter_description_key(self): + def test_includes_parameter_description_key(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() assert "parameter_description" in schema - def test_includes_default_message_key(self): + def test_includes_default_message_key(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() assert "default_message" in schema assert schema["default_message"]["default"] == "Please specify a valid transfer type." - def test_includes_default_post_process_key(self): + def test_includes_default_post_process_key(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() assert "default_post_process" in schema assert schema["default_post_process"]["default"] is False - def test_includes_required_fields_key(self): + def test_includes_required_fields_key(self) -> None: schema = SWMLTransferSkill.get_parameter_schema() assert "required_fields" in schema assert schema["required_fields"]["type"] == "object" - def test_inherits_swaig_fields(self): + def test_inherits_swaig_fields(self) -> None: """Should include base class swaig_fields parameter.""" schema = SWMLTransferSkill.get_parameter_schema() assert "swaig_fields" in schema - def test_inherits_tool_name_for_multi_instance(self): + def test_inherits_tool_name_for_multi_instance(self) -> None: """Since SUPPORTS_MULTIPLE_INSTANCES is True, tool_name should be present.""" schema = SWMLTransferSkill.get_parameter_schema() assert "tool_name" in schema @@ -693,7 +691,7 @@ def test_inherits_tool_name_for_multi_instance(self): class TestEdgeCases: """Edge-case and integration tests.""" - def test_single_url_transfer(self): + def test_single_url_transfer(self) -> None: """Skill with only one URL-based transfer.""" transfers = {"/only/": {"url": "https://example.com/only"}} skill = _make_skill({"transfers": transfers}) @@ -703,7 +701,7 @@ def test_single_url_transfer(self): # 1 pattern + 1 fallback assert len(call_args["data_map"]["expressions"]) == 2 - def test_single_address_transfer(self): + def test_single_address_transfer(self) -> None: """Skill with only one address-based transfer.""" transfers = {"/only/": {"address": "+15551112222"}} skill = _make_skill({"transfers": transfers}) @@ -712,7 +710,7 @@ def test_single_address_transfer(self): call_args = skill.agent.register_swaig_function.call_args[0][0] assert len(call_args["data_map"]["expressions"]) == 2 - def test_many_transfers(self): + def test_many_transfers(self) -> None: """Skill with many transfer patterns.""" transfers = {} for i in range(10): @@ -724,7 +722,7 @@ def test_many_transfers(self): # 10 patterns + 1 fallback assert len(call_args["data_map"]["expressions"]) == 11 - def test_transfer_with_no_optional_fields(self): + def test_transfer_with_no_optional_fields(self) -> None: """Transfer config with only 'url' should get all defaults filled by setup.""" transfers = {"/minimal/": {"url": "https://example.com/minimal"}} skill = _make_skill({"transfers": transfers}) @@ -735,7 +733,7 @@ def test_transfer_with_no_optional_fields(self): assert "post_process" in config assert "final" in config - def test_expression_string_references_parameter_name(self): + def test_expression_string_references_parameter_name(self) -> None: """Each expression's 'string' field should reference the parameter_name.""" skill = _make_skill({"parameter_name": "my_param"}) skill.setup() @@ -745,7 +743,7 @@ def test_expression_string_references_parameter_name(self): for expr in expressions: assert expr["string"] == "${args.my_param}" - def test_multiple_required_fields(self): + def test_multiple_required_fields(self) -> None: """Multiple required fields should all appear as parameters and in global data.""" fields = { "caller_name": "Name of the caller", diff --git a/tests/unit/skills/test_weather_api_skill.py b/tests/unit/skills/test_weather_api_skill.py index 7b31ccd5..19f718c8 100644 --- a/tests/unit/skills/test_weather_api_skill.py +++ b/tests/unit/skills/test_weather_api_skill.py @@ -5,20 +5,19 @@ Licensed under the MIT License. See LICENSE file in the project root for full license information. -""" -""" Unit tests for the Weather API skill module """ -import pytest +from typing import Any from unittest.mock import Mock +import pytest + from signalwire.skills.weather_api.skill import WeatherApiSkill -from signalwire.core.function_result import FunctionResult -def _make_skill(params=None): +def _make_skill(params: dict[str, Any] | None = None) -> WeatherApiSkill: """ Helper to create a WeatherApiSkill with a mocked agent. Provides sensible defaults for all required parameters. @@ -32,8 +31,7 @@ def _make_skill(params=None): mock_agent = Mock() mock_agent.define_tool = Mock() mock_agent.register_swaig_function = Mock() - skill = WeatherApiSkill(agent=mock_agent, params=default_params) - return skill + return WeatherApiSkill(agent=mock_agent, params=default_params) # --------------------------------------------------------------------------- @@ -43,22 +41,22 @@ def _make_skill(params=None): class TestWeatherApiSkillClassAttributes: """Verify class-level constants and metadata.""" - def test_skill_name(self): + def test_skill_name(self) -> None: assert WeatherApiSkill.SKILL_NAME == "weather_api" - def test_skill_description(self): + def test_skill_description(self) -> None: assert WeatherApiSkill.SKILL_DESCRIPTION == "Get current weather information from WeatherAPI.com" - def test_skill_version(self): + def test_skill_version(self) -> None: assert WeatherApiSkill.SKILL_VERSION == "1.0.0" - def test_required_packages(self): + def test_required_packages(self) -> None: assert WeatherApiSkill.REQUIRED_PACKAGES == [] - def test_required_env_vars(self): + def test_required_env_vars(self) -> None: assert WeatherApiSkill.REQUIRED_ENV_VARS == [] - def test_supports_multiple_instances(self): + def test_supports_multiple_instances(self) -> None: assert WeatherApiSkill.SUPPORTS_MULTIPLE_INSTANCES is False @@ -69,43 +67,43 @@ def test_supports_multiple_instances(self): class TestWeatherApiSkillInit: """Tests for __init__.""" - def test_agent_is_stored(self): + def test_agent_is_stored(self) -> None: mock_agent = Mock() skill = WeatherApiSkill(agent=mock_agent, params={"api_key": "key123"}) assert skill.agent is mock_agent - def test_default_tool_name(self): + def test_default_tool_name(self) -> None: skill = _make_skill() assert skill.tool_name == "get_weather" - def test_custom_tool_name(self): + def test_custom_tool_name(self) -> None: skill = _make_skill({"tool_name": "check_weather"}) assert skill.tool_name == "check_weather" - def test_default_temperature_unit(self): + def test_default_temperature_unit(self) -> None: skill = _make_skill() assert skill.temperature_unit == "fahrenheit" - def test_custom_temperature_unit_celsius(self): + def test_custom_temperature_unit_celsius(self) -> None: skill = _make_skill({"temperature_unit": "celsius"}) assert skill.temperature_unit == "celsius" - def test_api_key_stored(self): + def test_api_key_stored(self) -> None: skill = _make_skill({"api_key": "my-secret-key"}) assert skill.api_key == "my-secret-key" - def test_logger_created(self): + def test_logger_created(self) -> None: skill = _make_skill() assert skill.logger is not None assert skill.logger.name == "signalwire.skills.weather_api" - def test_swaig_fields_extracted_from_params(self): + def test_swaig_fields_extracted_from_params(self) -> None: params = {"swaig_fields": {"meta_data": {"x": 1}}, "api_key": "key"} skill = WeatherApiSkill(agent=Mock(), params=params) assert skill.swaig_fields == {"meta_data": {"x": 1}} assert "swaig_fields" not in skill.params - def test_swaig_fields_default_empty(self): + def test_swaig_fields_default_empty(self) -> None: skill = _make_skill() assert skill.swaig_fields == {} @@ -117,27 +115,27 @@ def test_swaig_fields_default_empty(self): class TestValidateConfig: """Tests for configuration validation.""" - def test_missing_api_key_raises(self): + def test_missing_api_key_raises(self) -> None: with pytest.raises(ValueError, match="api_key"): WeatherApiSkill(agent=Mock(), params={}) - def test_none_api_key_raises(self): + def test_none_api_key_raises(self) -> None: with pytest.raises(ValueError, match="api_key"): WeatherApiSkill(agent=Mock(), params={"api_key": None}) - def test_empty_string_api_key_raises(self): + def test_empty_string_api_key_raises(self) -> None: with pytest.raises(ValueError, match="api_key"): WeatherApiSkill(agent=Mock(), params={"api_key": ""}) - def test_non_string_api_key_raises(self): + def test_non_string_api_key_raises(self) -> None: with pytest.raises(ValueError, match="api_key"): WeatherApiSkill(agent=Mock(), params={"api_key": 12345}) - def test_invalid_temperature_unit_raises(self): + def test_invalid_temperature_unit_raises(self) -> None: with pytest.raises(ValueError, match="temperature_unit"): WeatherApiSkill(agent=Mock(), params={"api_key": "key", "temperature_unit": "kelvin"}) - def test_valid_config_does_not_raise(self): + def test_valid_config_does_not_raise(self) -> None: skill = _make_skill() # If we get here without error, validation passed assert skill.api_key == "test-api-key-123" @@ -150,11 +148,11 @@ def test_valid_config_does_not_raise(self): class TestSetup: """Tests for the setup method.""" - def test_setup_returns_true(self): + def test_setup_returns_true(self) -> None: skill = _make_skill() assert skill.setup() is True - def test_setup_returns_true_with_celsius(self): + def test_setup_returns_true_with_celsius(self) -> None: skill = _make_skill({"temperature_unit": "celsius"}) assert skill.setup() is True @@ -166,12 +164,12 @@ def test_setup_returns_true_with_celsius(self): class TestRegisterTools: """Tests for register_tools method.""" - def test_register_tools_calls_register_swaig_function(self): + def test_register_tools_calls_register_swaig_function(self) -> None: skill = _make_skill() skill.register_tools() skill.agent.register_swaig_function.assert_called_once() - def test_register_tools_passes_tool_dict(self): + def test_register_tools_passes_tool_dict(self) -> None: skill = _make_skill() skill.register_tools() call_args = skill.agent.register_swaig_function.call_args @@ -180,7 +178,7 @@ def test_register_tools_passes_tool_dict(self): assert "function" in tool assert "data_map" in tool - def test_register_tools_merges_swaig_fields(self): + def test_register_tools_merges_swaig_fields(self) -> None: """swaig_fields from params should be merged into the tool dict.""" params = { "swaig_fields": {"meta_data": {"key": "val"}}, @@ -203,29 +201,29 @@ def test_register_tools_merges_swaig_fields(self): class TestGetTools: """Tests for the get_tools method.""" - def test_returns_list_with_one_tool(self): + def test_returns_list_with_one_tool(self) -> None: skill = _make_skill() tools = skill.get_tools() assert isinstance(tools, list) assert len(tools) == 1 - def test_tool_function_name_default(self): + def test_tool_function_name_default(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] assert tool["function"] == "get_weather" - def test_tool_function_name_custom(self): + def test_tool_function_name_custom(self) -> None: skill = _make_skill({"tool_name": "weather_check"}) tool = skill.get_tools()[0] assert tool["function"] == "weather_check" - def test_tool_has_description(self): + def test_tool_has_description(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] assert "description" in tool assert "weather" in tool["description"].lower() - def test_tool_has_location_parameter(self): + def test_tool_has_location_parameter(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] params = tool["parameters"] @@ -234,49 +232,49 @@ def test_tool_has_location_parameter(self): assert params["properties"]["location"]["type"] == "string" assert "location" in params["required"] - def test_tool_has_data_map_with_webhooks(self): + def test_tool_has_data_map_with_webhooks(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] assert "data_map" in tool assert "webhooks" in tool["data_map"] assert len(tool["data_map"]["webhooks"]) == 1 - def test_webhook_url_contains_api_key(self): + def test_webhook_url_contains_api_key(self) -> None: skill = _make_skill({"api_key": "my-secret-key"}) tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] assert "my-secret-key" in webhook["url"] - def test_webhook_url_contains_location_placeholder(self): + def test_webhook_url_contains_location_placeholder(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] assert "${lc:enc:args.location}" in webhook["url"] - def test_webhook_method_is_get(self): + def test_webhook_method_is_get(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] assert webhook["method"] == "GET" - def test_webhook_output_is_dict(self): + def test_webhook_output_is_dict(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] assert isinstance(webhook["output"], dict) - def test_data_map_has_error_keys(self): + def test_data_map_has_error_keys(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] assert tool["data_map"]["error_keys"] == ["error"] - def test_data_map_has_fallback_output(self): + def test_data_map_has_fallback_output(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] assert "output" in tool["data_map"] assert isinstance(tool["data_map"]["output"], dict) - def test_fahrenheit_uses_temp_f(self): + def test_fahrenheit_uses_temp_f(self) -> None: skill = _make_skill({"temperature_unit": "fahrenheit"}) tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] @@ -286,7 +284,7 @@ def test_fahrenheit_uses_temp_f(self): assert "feelslike_f" in response_text assert "Fahrenheit" in response_text - def test_celsius_uses_temp_c(self): + def test_celsius_uses_temp_c(self) -> None: skill = _make_skill({"temperature_unit": "celsius"}) tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] @@ -296,14 +294,14 @@ def test_celsius_uses_temp_c(self): assert "feelslike_c" in response_text assert "Celsius" in response_text - def test_weather_template_contains_condition(self): + def test_weather_template_contains_condition(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] response_text = webhook["output"].get("response", "") assert "current.condition.text" in response_text - def test_weather_template_contains_wind_info(self): + def test_weather_template_contains_wind_info(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] @@ -311,14 +309,14 @@ def test_weather_template_contains_wind_info(self): assert "current.wind_dir" in response_text assert "current.wind_mph" in response_text - def test_weather_template_contains_cloud_info(self): + def test_weather_template_contains_cloud_info(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] response_text = webhook["output"].get("response", "") assert "current.cloud" in response_text - def test_fallback_output_contains_error_message(self): + def test_fallback_output_contains_error_message(self) -> None: skill = _make_skill() tool = skill.get_tools()[0] fallback = tool["data_map"]["output"] @@ -333,7 +331,7 @@ def test_fallback_output_contains_error_message(self): class TestGetHints: """Tests for the get_hints method.""" - def test_returns_empty_list(self): + def test_returns_empty_list(self) -> None: skill = _make_skill() assert skill.get_hints() == [] @@ -345,7 +343,7 @@ def test_returns_empty_list(self): class TestGetPromptSections: """Tests for the get_prompt_sections method.""" - def test_returns_empty_list(self): + def test_returns_empty_list(self) -> None: skill = _make_skill() assert skill.get_prompt_sections() == [] @@ -357,40 +355,40 @@ def test_returns_empty_list(self): class TestGetParameterSchema: """Tests for the class method get_parameter_schema.""" - def test_contains_api_key(self): + def test_contains_api_key(self) -> None: schema = WeatherApiSkill.get_parameter_schema() assert "api_key" in schema assert schema["api_key"]["required"] is True - def test_api_key_is_hidden(self): + def test_api_key_is_hidden(self) -> None: schema = WeatherApiSkill.get_parameter_schema() assert schema["api_key"].get("hidden") is True - def test_api_key_env_var(self): + def test_api_key_env_var(self) -> None: schema = WeatherApiSkill.get_parameter_schema() assert schema["api_key"].get("env_var") == "WEATHER_API_KEY" - def test_contains_tool_name(self): + def test_contains_tool_name(self) -> None: schema = WeatherApiSkill.get_parameter_schema() assert "tool_name" in schema assert schema["tool_name"]["required"] is False assert schema["tool_name"]["default"] == "get_weather" - def test_contains_temperature_unit(self): + def test_contains_temperature_unit(self) -> None: schema = WeatherApiSkill.get_parameter_schema() assert "temperature_unit" in schema assert schema["temperature_unit"]["required"] is False assert schema["temperature_unit"]["default"] == "fahrenheit" - def test_temperature_unit_enum(self): + def test_temperature_unit_enum(self) -> None: schema = WeatherApiSkill.get_parameter_schema() assert set(schema["temperature_unit"]["enum"]) == {"fahrenheit", "celsius"} - def test_includes_base_class_swaig_fields(self): + def test_includes_base_class_swaig_fields(self) -> None: schema = WeatherApiSkill.get_parameter_schema() assert "swaig_fields" in schema - def test_no_tool_name_from_base_because_single_instance(self): + def test_no_tool_name_from_base_because_single_instance(self) -> None: """Since SUPPORTS_MULTIPLE_INSTANCES is False, base class should NOT add tool_name. But the subclass adds its own tool_name, so it should still be there.""" schema = WeatherApiSkill.get_parameter_schema() @@ -407,7 +405,7 @@ def test_no_tool_name_from_base_because_single_instance(self): class TestGetInstanceKey: """Tests for get_instance_key.""" - def test_returns_skill_name_because_single_instance(self): + def test_returns_skill_name_because_single_instance(self) -> None: skill = _make_skill() assert skill.get_instance_key() == "weather_api" @@ -419,14 +417,14 @@ def test_returns_skill_name_because_single_instance(self): class TestEdgeCases: """Edge case tests.""" - def test_webhook_url_format(self): + def test_webhook_url_format(self) -> None: skill = _make_skill({"api_key": "abc123"}) tool = skill.get_tools()[0] webhook = tool["data_map"]["webhooks"][0] expected_prefix = "https://api.weatherapi.com/v1/current.json?key=abc123&q=${lc:enc:args.location}&aqi=no" assert webhook["url"] == expected_prefix - def test_tts_friendly_response_mentions_natural_language(self): + def test_tts_friendly_response_mentions_natural_language(self) -> None: """Response instruction should mention natural language for TTS.""" skill = _make_skill() tool = skill.get_tools()[0] diff --git a/tests/unit/skills/test_web_search_skill.py b/tests/unit/skills/test_web_search_skill.py index a4dd937d..f845cf55 100644 --- a/tests/unit/skills/test_web_search_skill.py +++ b/tests/unit/skills/test_web_search_skill.py @@ -5,15 +5,12 @@ Licensed under the MIT License. See LICENSE file in the project root for full license information. -""" -""" Unit tests for the WebSearch skill module """ -import json -import pytest -from unittest.mock import Mock, patch, MagicMock +from typing import Any +from unittest.mock import Mock, patch import requests @@ -21,12 +18,12 @@ from signalwire.core.function_result import FunctionResult -def _make_skill(params=None): +def _make_skill(params: dict[str, Any] | None = None) -> WebSearchSkill: """ Helper to create a WebSearchSkill with a mocked agent. Provides sensible defaults for all required parameters. """ - default_params = { + default_params: dict[str, Any] = { "api_key": "test-api-key", "search_engine_id": "test-engine-id", } @@ -46,22 +43,22 @@ def _make_skill(params=None): class TestWebSearchSkillClassAttributes: """Verify class-level constants and metadata.""" - def test_skill_name(self): + def test_skill_name(self) -> None: assert WebSearchSkill.SKILL_NAME == "web_search" - def test_skill_description(self): + def test_skill_description(self) -> None: assert WebSearchSkill.SKILL_DESCRIPTION == "Search the web for information using Google Custom Search API" - def test_skill_version(self): + def test_skill_version(self) -> None: assert WebSearchSkill.SKILL_VERSION == "2.0.0" - def test_required_packages(self): + def test_required_packages(self) -> None: assert WebSearchSkill.REQUIRED_PACKAGES == ["bs4", "requests"] - def test_required_env_vars(self): + def test_required_env_vars(self) -> None: assert WebSearchSkill.REQUIRED_ENV_VARS == [] - def test_supports_multiple_instances(self): + def test_supports_multiple_instances(self) -> None: assert WebSearchSkill.SUPPORTS_MULTIPLE_INSTANCES is True @@ -72,33 +69,33 @@ def test_supports_multiple_instances(self): class TestWebSearchSkillInit: """Tests for __init__ (inherited from SkillBase).""" - def test_agent_is_stored(self): + def test_agent_is_stored(self) -> None: mock_agent = Mock() skill = WebSearchSkill(agent=mock_agent, params={"api_key": "k"}) assert skill.agent is mock_agent - def test_params_stored(self): + def test_params_stored(self) -> None: params = {"api_key": "mykey", "search_engine_id": "myid"} skill = WebSearchSkill(agent=Mock(), params=params) assert skill.params["api_key"] == "mykey" assert skill.params["search_engine_id"] == "myid" - def test_params_default_to_empty_dict(self): + def test_params_default_to_empty_dict(self) -> None: skill = WebSearchSkill(agent=Mock()) assert skill.params == {} - def test_logger_created(self): + def test_logger_created(self) -> None: skill = WebSearchSkill(agent=Mock()) assert skill.logger is not None assert skill.logger.name == "signalwire.skills.web_search" - def test_swaig_fields_extracted_from_params(self): + def test_swaig_fields_extracted_from_params(self) -> None: params = {"swaig_fields": {"meta_data": {"x": 1}}, "api_key": "k"} skill = WebSearchSkill(agent=Mock(), params=params) assert skill.swaig_fields == {"meta_data": {"x": 1}} assert "swaig_fields" not in skill.params - def test_swaig_fields_default_empty(self): + def test_swaig_fields_default_empty(self) -> None: skill = WebSearchSkill(agent=Mock(), params={"api_key": "k"}) assert skill.swaig_fields == {} @@ -110,99 +107,99 @@ def test_swaig_fields_default_empty(self): class TestGetParameterSchema: """Tests for the class method get_parameter_schema.""" - def test_contains_required_params(self): + def test_contains_required_params(self) -> None: schema = WebSearchSkill.get_parameter_schema() for key in ("api_key", "search_engine_id"): assert key in schema, f"Missing required param: {key}" assert schema[key]["required"] is True - def test_contains_optional_params(self): + def test_contains_optional_params(self) -> None: schema = WebSearchSkill.get_parameter_schema() for key in ("num_results", "delay", "max_content_length", "oversample_factor", "min_quality_score", "no_results_message"): assert key in schema, f"Missing optional param: {key}" assert schema[key]["required"] is False - def test_api_key_is_hidden(self): + def test_api_key_is_hidden(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["api_key"].get("hidden") is True - def test_search_engine_id_is_hidden(self): + def test_search_engine_id_is_hidden(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["search_engine_id"].get("hidden") is True - def test_api_key_env_var(self): + def test_api_key_env_var(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["api_key"].get("env_var") == "GOOGLE_SEARCH_API_KEY" - def test_search_engine_id_env_var(self): + def test_search_engine_id_env_var(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["search_engine_id"].get("env_var") == "GOOGLE_SEARCH_ENGINE_ID" - def test_num_results_defaults(self): + def test_num_results_defaults(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["num_results"]["default"] == 3 assert schema["num_results"]["min"] == 1 assert schema["num_results"]["max"] == 10 - def test_delay_defaults(self): + def test_delay_defaults(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["delay"]["default"] == 0.5 assert schema["delay"]["min"] == 0 - def test_max_content_length_defaults(self): + def test_max_content_length_defaults(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["max_content_length"]["default"] == 32768 assert schema["max_content_length"]["min"] == 1000 - def test_oversample_factor_defaults(self): + def test_oversample_factor_defaults(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["oversample_factor"]["default"] == 2.5 assert schema["oversample_factor"]["min"] == 1.0 assert schema["oversample_factor"]["max"] == 3.5 - def test_min_quality_score_defaults(self): + def test_min_quality_score_defaults(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["min_quality_score"]["default"] == 0.3 assert schema["min_quality_score"]["min"] == 0.0 assert schema["min_quality_score"]["max"] == 1.0 - def test_includes_base_class_swaig_fields(self): + def test_includes_base_class_swaig_fields(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert "swaig_fields" in schema - def test_includes_tool_name_because_multi_instance(self): + def test_includes_tool_name_because_multi_instance(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert "tool_name" in schema - def test_response_prefix_postfix_advertised(self): + def test_response_prefix_postfix_advertised(self) -> None: schema = WebSearchSkill.get_parameter_schema() for key in ("response_prefix", "response_postfix"): assert key in schema, f"Missing param: {key}" assert schema[key]["required"] is False assert schema[key]["default"] == "" - def test_per_page_timeout_defaults(self): + def test_per_page_timeout_defaults(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["per_page_timeout"]["default"] == 2.0 assert schema["per_page_timeout"]["required"] is False - def test_overall_deadline_defaults(self): + def test_overall_deadline_defaults(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["overall_deadline"]["default"] == 10.0 assert schema["overall_deadline"]["required"] is False - def test_parallel_scrape_defaults(self): + def test_parallel_scrape_defaults(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["parallel_scrape"]["type"] == "boolean" assert schema["parallel_scrape"]["default"] is True - def test_snippets_only_defaults(self): + def test_snippets_only_defaults(self) -> None: schema = WebSearchSkill.get_parameter_schema() assert schema["snippets_only"]["type"] == "boolean" assert schema["snippets_only"]["default"] is False - def test_every_setup_param_is_advertised(self): + def test_every_setup_param_is_advertised(self) -> None: """Guard against the recurring 'committer added a self.params read but forgot the schema entry' drift. Every latency/response param read in setup() must appear in the advertised schema.""" @@ -219,24 +216,24 @@ def test_every_setup_param_is_advertised(self): class TestGetInstanceKey: """Tests for get_instance_key.""" - def test_default_instance_key(self): + def test_default_instance_key(self) -> None: skill = _make_skill() # Default: search_engine_id="test-engine-id", tool_name="web_search" key = skill.get_instance_key() assert "web_search" in key assert "test-engine-id" in key - def test_custom_tool_name_instance_key(self): + def test_custom_tool_name_instance_key(self) -> None: skill = _make_skill({"tool_name": "my_search"}) key = skill.get_instance_key() assert "my_search" in key - def test_custom_search_engine_id_in_instance_key(self): + def test_custom_search_engine_id_in_instance_key(self) -> None: skill = _make_skill({"search_engine_id": "custom-engine"}) key = skill.get_instance_key() assert "custom-engine" in key - def test_different_instances_have_different_keys(self): + def test_different_instances_have_different_keys(self) -> None: skill_a = _make_skill({"tool_name": "search_news"}) skill_b = _make_skill({"tool_name": "search_docs"}) assert skill_a.get_instance_key() != skill_b.get_instance_key() @@ -249,21 +246,21 @@ def test_different_instances_have_different_keys(self): class TestSetup: """Tests for the setup method.""" - def test_setup_success_all_required(self): + def test_setup_success_all_required(self) -> None: skill = _make_skill() result = skill.setup() assert result is True assert skill.api_key == "test-api-key" assert skill.search_engine_id == "test-engine-id" - def test_setup_creates_search_scraper(self): + def test_setup_creates_search_scraper(self) -> None: skill = _make_skill() skill.setup() assert isinstance(skill.search_scraper, GoogleSearchScraper) assert skill.search_scraper.api_key == "test-api-key" assert skill.search_scraper.search_engine_id == "test-engine-id" - def test_setup_optional_defaults(self): + def test_setup_optional_defaults(self) -> None: skill = _make_skill() skill.setup() assert skill.default_num_results == 3 @@ -274,7 +271,7 @@ def test_setup_optional_defaults(self): assert skill.tool_name == "web_search" assert "{query}" in skill.no_results_message - def test_setup_custom_optional_values(self): + def test_setup_custom_optional_values(self) -> None: skill = _make_skill({ "num_results": 5, "delay": 1.0, @@ -293,36 +290,36 @@ def test_setup_custom_optional_values(self): assert skill.tool_name == "my_search" assert skill.no_results_message == "Nothing found for '{query}'." - def test_setup_missing_api_key(self): + def test_setup_missing_api_key(self) -> None: skill = _make_skill({"api_key": ""}) result = skill.setup() assert result is False - def test_setup_missing_search_engine_id(self): + def test_setup_missing_search_engine_id(self) -> None: skill = _make_skill({"search_engine_id": ""}) result = skill.setup() assert result is False - def test_setup_missing_multiple_params(self): + def test_setup_missing_multiple_params(self) -> None: mock_agent = Mock() mock_agent.define_tool = Mock() skill = WebSearchSkill(agent=mock_agent, params={}) result = skill.setup() assert result is False - def test_setup_missing_param_none_value(self): + def test_setup_missing_param_none_value(self) -> None: skill = _make_skill({"api_key": None}) result = skill.setup() assert result is False - def test_setup_logs_error_on_missing_params(self): + def test_setup_logs_error_on_missing_params(self) -> None: skill = _make_skill({"api_key": ""}) with patch.object(skill.logger, "error") as mock_error: skill.setup() mock_error.assert_called_once() assert "api_key" in mock_error.call_args[0][0] - def test_setup_scraper_max_content_length_passed(self): + def test_setup_scraper_max_content_length_passed(self) -> None: skill = _make_skill({"max_content_length": 10000}) skill.setup() assert skill.search_scraper.max_content_length == 10000 @@ -335,27 +332,27 @@ def test_setup_scraper_max_content_length_passed(self): class TestRegisterTools: """Tests for register_tools method.""" - def test_register_tools_calls_define_tool(self): + def test_register_tools_calls_define_tool(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() skill.agent.define_tool.assert_called_once() - def test_register_tools_default_name(self): + def test_register_tools_default_name(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() _, kw = skill.agent.define_tool.call_args assert kw["name"] == "web_search" - def test_register_tools_custom_name(self): + def test_register_tools_custom_name(self) -> None: skill = _make_skill({"tool_name": "news_search"}) skill.setup() skill.register_tools() _, kw = skill.agent.define_tool.call_args assert kw["name"] == "news_search" - def test_register_tools_has_query_parameter(self): + def test_register_tools_has_query_parameter(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() @@ -363,14 +360,14 @@ def test_register_tools_has_query_parameter(self): assert "query" in kw["parameters"] assert kw["parameters"]["query"]["type"] == "string" - def test_register_tools_handler_is_callable(self): + def test_register_tools_handler_is_callable(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() _, kw = skill.agent.define_tool.call_args assert callable(kw["handler"]) - def test_register_tools_merges_swaig_fields(self): + def test_register_tools_merges_swaig_fields(self) -> None: """swaig_fields from params should be merged into define_tool call.""" params = { "swaig_fields": {"meta_data": {"key": "val"}}, @@ -385,7 +382,7 @@ def test_register_tools_merges_swaig_fields(self): _, kw = mock_agent.define_tool.call_args assert kw.get("meta_data") == {"key": "val"} - def test_register_tools_description_present(self): + def test_register_tools_description_present(self) -> None: skill = _make_skill() skill.setup() skill.register_tools() @@ -401,31 +398,31 @@ def test_register_tools_description_present(self): class TestWebSearchHandler: """Tests for the _web_search_handler method.""" - def _setup_skill(self, params=None): + def _setup_skill(self, params: dict[str, Any] | None = None) -> WebSearchSkill: """Helper that returns a skill ready for handler testing.""" skill = _make_skill(params) skill.setup() return skill - def test_empty_query_returns_error(self): + def test_empty_query_returns_error(self) -> None: skill = self._setup_skill() result = skill._web_search_handler({"query": ""}, {}) assert isinstance(result, FunctionResult) assert "provide a search query" in result.response.lower() - def test_whitespace_query_returns_error(self): + def test_whitespace_query_returns_error(self) -> None: skill = self._setup_skill() result = skill._web_search_handler({"query": " "}, {}) assert isinstance(result, FunctionResult) assert "provide a search query" in result.response.lower() - def test_missing_query_key_returns_error(self): + def test_missing_query_key_returns_error(self) -> None: skill = self._setup_skill() result = skill._web_search_handler({}, {}) assert isinstance(result, FunctionResult) assert "provide a search query" in result.response.lower() - def test_successful_search(self): + def test_successful_search(self) -> None: skill = self._setup_skill() mock_results = "Found 2 results meeting quality threshold from 8 searched.\nShowing top 2:\n\n=== RESULT 1 ===\nTitle: Test\nContent: Good content" with patch.object(skill.search_scraper, 'search_and_scrape_best', return_value=mock_results): @@ -434,7 +431,7 @@ def test_successful_search(self): assert "test query" in result.response assert "RESULT 1" in result.response - def test_no_search_results(self): + def test_no_search_results(self) -> None: skill = self._setup_skill() with patch.object(skill.search_scraper, 'search_and_scrape_best', return_value="No search results found for query: test"): @@ -443,20 +440,20 @@ def test_no_search_results(self): # Should trigger no_results_message assert "couldn't find" in result.response.lower() or "quality" in result.response.lower() - def test_no_quality_results(self): + def test_no_quality_results(self) -> None: skill = self._setup_skill() with patch.object(skill.search_scraper, 'search_and_scrape_best', return_value="No quality results found for query: test. All results were below quality threshold."): result = skill._web_search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) - def test_empty_search_results(self): + def test_empty_search_results(self) -> None: skill = self._setup_skill() with patch.object(skill.search_scraper, 'search_and_scrape_best', return_value=""): result = skill._web_search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) - def test_exception_during_search(self): + def test_exception_during_search(self) -> None: skill = self._setup_skill() with patch.object(skill.search_scraper, 'search_and_scrape_best', side_effect=RuntimeError("connection failed")): @@ -464,7 +461,7 @@ def test_exception_during_search(self): assert isinstance(result, FunctionResult) assert "error" in result.response.lower() - def test_no_results_custom_message_with_placeholder(self): + def test_no_results_custom_message_with_placeholder(self) -> None: skill = self._setup_skill( params={"no_results_message": "Sorry, '{query}' not found."} ) @@ -473,7 +470,7 @@ def test_no_results_custom_message_with_placeholder(self): result = skill._web_search_handler({"query": "widgets"}, {}) assert result.response == "Sorry, 'widgets' not found." - def test_no_results_custom_message_without_placeholder(self): + def test_no_results_custom_message_without_placeholder(self) -> None: skill = self._setup_skill( params={"no_results_message": "No data available."} ) @@ -482,7 +479,7 @@ def test_no_results_custom_message_without_placeholder(self): result = skill._web_search_handler({"query": "anything"}, {}) assert result.response == "No data available." - def test_handler_passes_correct_params_to_scraper(self): + def test_handler_passes_correct_params_to_scraper(self) -> None: skill = self._setup_skill(params={ "num_results": 5, "oversample_factor": 3.0, @@ -508,7 +505,7 @@ def test_handler_passes_correct_params_to_scraper(self): snippets_only=True, ) - def test_handler_strips_query_whitespace(self): + def test_handler_strips_query_whitespace(self) -> None: skill = self._setup_skill() with patch.object(skill.search_scraper, 'search_and_scrape_best', return_value="some results") as mock_search: @@ -516,7 +513,7 @@ def test_handler_strips_query_whitespace(self): mock_search.assert_called_once() assert mock_search.call_args[1]["query"] == "padded query" or mock_search.call_args.kwargs["query"] == "padded query" - def test_handler_logs_search_request(self): + def test_handler_logs_search_request(self) -> None: skill = self._setup_skill() with patch.object(skill.search_scraper, 'search_and_scrape_best', return_value="results"): with patch.object(skill.logger, "info") as mock_info: @@ -524,7 +521,7 @@ def test_handler_logs_search_request(self): mock_info.assert_called_once() assert "my search" in mock_info.call_args[0][0] - def test_handler_logs_error_on_exception(self): + def test_handler_logs_error_on_exception(self) -> None: skill = self._setup_skill() with patch.object(skill.search_scraper, 'search_and_scrape_best', side_effect=ValueError("bad")): @@ -540,27 +537,27 @@ def test_handler_logs_error_on_exception(self): class TestGoogleSearchScraper: """Tests for the GoogleSearchScraper class.""" - def test_init_stores_params(self): + def test_init_stores_params(self) -> None: scraper = GoogleSearchScraper("key", "engine_id", max_content_length=5000) assert scraper.api_key == "key" assert scraper.search_engine_id == "engine_id" assert scraper.max_content_length == 5000 - def test_init_default_max_content_length(self): + def test_init_default_max_content_length(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") assert scraper.max_content_length == 32768 - def test_init_session_created(self): + def test_init_session_created(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") assert scraper.session is not None - def test_is_reddit_url_true(self): + def test_is_reddit_url_true(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") assert scraper.is_reddit_url("https://www.reddit.com/r/test") is True assert scraper.is_reddit_url("https://old.reddit.com/r/test") is True assert scraper.is_reddit_url("https://redd.it/abc123") is True - def test_is_reddit_url_false(self): + def test_is_reddit_url_false(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") assert scraper.is_reddit_url("https://www.google.com") is False assert scraper.is_reddit_url("https://stackoverflow.com") is False @@ -570,7 +567,7 @@ def test_is_reddit_url_false(self): class TestSearchGoogle: """Tests for the search_google method.""" - def test_successful_search(self): + def test_successful_search(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() mock_response.json.return_value = { @@ -587,7 +584,7 @@ def test_successful_search(self): assert results[0]["url"] == "https://example.com/1" assert results[1]["snippet"] == "Snippet 2" - def test_search_no_items_key(self): + def test_search_no_items_key(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() mock_response.json.return_value = {"searchInformation": {"totalResults": "0"}} @@ -596,19 +593,19 @@ def test_search_no_items_key(self): results = scraper.search_google("test query") assert results == [] - def test_search_api_error(self): + def test_search_api_error(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") with patch.object(scraper.session, 'get', side_effect=requests.exceptions.HTTPError("403")): results = scraper.search_google("test query") assert results == [] - def test_search_network_error(self): + def test_search_network_error(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") with patch.object(scraper.session, 'get', side_effect=requests.exceptions.ConnectionError("failed")): results = scraper.search_google("test query") assert results == [] - def test_search_limits_num_results_to_10(self): + def test_search_limits_num_results_to_10(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() mock_response.json.return_value = {"items": []} @@ -618,7 +615,7 @@ def test_search_limits_num_results_to_10(self): call_kwargs = mock_get.call_args assert call_kwargs[1]["params"]["num"] == 10 - def test_search_missing_fields_defaults_empty(self): + def test_search_missing_fields_defaults_empty(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() mock_response.json.return_value = { @@ -635,14 +632,14 @@ def test_search_missing_fields_defaults_empty(self): class TestExtractTextFromUrl: """Tests for extract_text_from_url routing.""" - def test_routes_reddit_to_extract_reddit_content(self): + def test_routes_reddit_to_extract_reddit_content(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") with patch.object(scraper, 'extract_reddit_content', return_value=("reddit content", {})) as mock_reddit: text, _ = scraper.extract_text_from_url("https://www.reddit.com/r/test/comments/123") mock_reddit.assert_called_once() assert text == "reddit content" - def test_routes_non_reddit_to_extract_html_content(self): + def test_routes_non_reddit_to_extract_html_content(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") with patch.object(scraper, 'extract_html_content', return_value=("html content", {})) as mock_html: text, _ = scraper.extract_text_from_url("https://example.com/article") @@ -653,7 +650,7 @@ def test_routes_non_reddit_to_extract_html_content(self): class TestExtractHtmlContent: """Tests for extract_html_content.""" - def test_successful_extraction(self): + def test_successful_extraction(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() mock_response.content = b"
This is quality content with many sentences. " \ @@ -664,14 +661,14 @@ def test_successful_extraction(self): assert "quality content" in text assert "quality_score" in metrics - def test_extraction_error_returns_empty(self): + def test_extraction_error_returns_empty(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") with patch.object(scraper.session, 'get', side_effect=requests.exceptions.ConnectionError("failed")): text, metrics = scraper.extract_html_content("https://example.com") assert text == "" assert metrics["quality_score"] == 0 - def test_content_truncation(self): + def test_content_truncation(self) -> None: scraper = GoogleSearchScraper("key", "engine_id", max_content_length=50) mock_response = Mock() long_text = "A" * 200 @@ -681,7 +678,7 @@ def test_content_truncation(self): text, metrics = scraper.extract_html_content("https://example.com") assert len(text) <= 50 - def test_custom_content_limit(self): + def test_custom_content_limit(self) -> None: scraper = GoogleSearchScraper("key", "engine_id", max_content_length=100000) mock_response = Mock() long_text = "B" * 200 @@ -695,9 +692,10 @@ def test_custom_content_limit(self): class TestExtractRedditContent: """Tests for extract_reddit_content.""" - def _make_reddit_json(self, title="Test Post", author="testuser", score=100, - num_comments=50, selftext="Post body text", subreddit="test", - comments=None): + def _make_reddit_json(self, title: str = "Test Post", author: str = "testuser", + score: int = 100, num_comments: int = 50, + selftext: str = "Post body text", subreddit: str = "test", + comments: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]: """Helper to build Reddit JSON structure.""" post_data = { "data": { @@ -713,6 +711,7 @@ def _make_reddit_json(self, title="Test Post", author="testuser", score=100, }] } } + comments_data: dict[str, Any] if comments is None: comments_data = {"data": {"children": []}} else: @@ -720,7 +719,7 @@ def _make_reddit_json(self, title="Test Post", author="testuser", score=100, return [post_data, comments_data] @patch("signalwire.skills.web_search.skill.requests.get") - def test_successful_reddit_extraction(self, mock_get): + def test_successful_reddit_extraction(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() mock_response.json.return_value = self._make_reddit_json() @@ -733,7 +732,7 @@ def test_successful_reddit_extraction(self, mock_get): assert metrics["is_reddit"] is True @patch("signalwire.skills.web_search.skill.requests.get") - def test_reddit_with_comments(self, mock_get): + def test_reddit_with_comments(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") comments = [ { @@ -755,7 +754,7 @@ def test_reddit_with_comments(self, mock_get): assert "helpful" in text @patch("signalwire.skills.web_search.skill.requests.get") - def test_reddit_filters_short_comments(self, mock_get): + def test_reddit_filters_short_comments(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") comments = [ { @@ -773,7 +772,7 @@ def test_reddit_filters_short_comments(self, mock_get): assert "short_commenter" not in text @patch("signalwire.skills.web_search.skill.requests.get") - def test_reddit_filters_deleted_comments(self, mock_get): + def test_reddit_filters_deleted_comments(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") comments = [ { @@ -794,7 +793,7 @@ def test_reddit_filters_deleted_comments(self, mock_get): assert "deleted_user" not in text @patch("signalwire.skills.web_search.skill.requests.get") - def test_reddit_filters_removed_selftext(self, mock_get): + def test_reddit_filters_removed_selftext(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() mock_response.json.return_value = self._make_reddit_json(selftext="[removed]") @@ -805,7 +804,7 @@ def test_reddit_filters_removed_selftext(self, mock_get): assert "[removed]" not in text @patch("signalwire.skills.web_search.skill.requests.get") - def test_reddit_appends_json_suffix(self, mock_get): + def test_reddit_appends_json_suffix(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() mock_response.json.return_value = self._make_reddit_json() @@ -817,7 +816,7 @@ def test_reddit_appends_json_suffix(self, mock_get): assert called_url.endswith(".json") @patch("signalwire.skills.web_search.skill.requests.get") - def test_reddit_already_json_url(self, mock_get): + def test_reddit_already_json_url(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() mock_response.json.return_value = self._make_reddit_json() @@ -829,7 +828,7 @@ def test_reddit_already_json_url(self, mock_get): assert called_url == "https://reddit.com/r/test/comments/123.json" @patch("signalwire.skills.web_search.skill.requests.get") - def test_reddit_invalid_json_falls_back_to_html(self, mock_get): + def test_reddit_invalid_json_falls_back_to_html(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_get.side_effect = ValueError("Invalid JSON") @@ -839,7 +838,7 @@ def test_reddit_invalid_json_falls_back_to_html(self, mock_get): assert text == "fallback" @patch("signalwire.skills.web_search.skill.requests.get") - def test_reddit_content_limit(self, mock_get): + def test_reddit_content_limit(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id", max_content_length=100000) mock_response = Mock() mock_response.json.return_value = self._make_reddit_json(selftext="X" * 2000) @@ -850,7 +849,7 @@ def test_reddit_content_limit(self, mock_get): assert len(text) <= 50 @patch("signalwire.skills.web_search.skill.requests.get") - def test_reddit_quality_metrics(self, mock_get): + def test_reddit_quality_metrics(self, mock_get: Mock) -> None: scraper = GoogleSearchScraper("key", "engine_id") mock_response = Mock() mock_response.json.return_value = self._make_reddit_json(score=200, num_comments=100) @@ -867,30 +866,30 @@ def test_reddit_quality_metrics(self, mock_get): class TestCalculateContentQuality: """Tests for _calculate_content_quality.""" - def test_empty_text_zero_quality(self): + def test_empty_text_zero_quality(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") metrics = scraper._calculate_content_quality("", "https://example.com") assert metrics["quality_score"] == 0 - def test_short_text_low_quality(self): + def test_short_text_low_quality(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") metrics = scraper._calculate_content_quality("Short text", "https://example.com") assert metrics["quality_score"] < 0.5 - def test_quality_domain_bonus(self): + def test_quality_domain_bonus(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") text = "A " * 2000 # Enough text metrics_quality = scraper._calculate_content_quality(text, "https://wikipedia.org/wiki/test") metrics_generic = scraper._calculate_content_quality(text, "https://randomsite.com/page") assert metrics_quality["domain_score"] > metrics_generic["domain_score"] - def test_low_quality_domain_penalty(self): + def test_low_quality_domain_penalty(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") text = "A " * 2000 metrics = scraper._calculate_content_quality(text, "https://reddit.com/r/test") assert metrics["domain_score"] < 1.0 - def test_boilerplate_penalty(self): + def test_boilerplate_penalty(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") text_clean = "This is good content about programming. " * 100 text_boilerplate = "cookie privacy policy terms of service subscribe sign up " * 50 @@ -898,13 +897,13 @@ def test_boilerplate_penalty(self): metrics_boilerplate = scraper._calculate_content_quality(text_boilerplate, "https://example.com") assert metrics_clean["boilerplate_penalty"] > metrics_boilerplate["boilerplate_penalty"] - def test_query_relevance_scoring(self): + def test_query_relevance_scoring(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") text = "Python programming language is great for data science and machine learning tasks." metrics = scraper._calculate_content_quality(text, "https://example.com", query="Python programming") assert metrics["query_relevance"] > 0 - def test_no_query_neutral_relevance(self): + def test_no_query_neutral_relevance(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") text = "Some content here." metrics = scraper._calculate_content_quality(text, "https://example.com", query="") @@ -914,13 +913,13 @@ def test_no_query_neutral_relevance(self): class TestSearchAndScrapeBest: """Tests for search_and_scrape_best.""" - def test_no_search_results(self): + def test_no_search_results(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") with patch.object(scraper, 'search_google', return_value=[]): result = scraper.search_and_scrape_best("test query") assert "No search results found" in result - def test_all_results_below_threshold_falls_back_to_snippets(self): + def test_all_results_below_threshold_falls_back_to_snippets(self) -> None: # When every scraped page is below the quality threshold, the skill # now formats the CSE snippets it already has rather than returning # an empty "no results" message — so the kernel never sees a webhook @@ -938,7 +937,7 @@ def test_all_results_below_threshold_falls_back_to_snippets(self): assert "bad snippet text" in result assert "No quality results found" not in result - def test_snippets_only_skips_scraping(self): + def test_snippets_only_skips_scraping(self) -> None: # snippets_only short-circuits before any page fetch. scraper = GoogleSearchScraper("key", "engine_id") search_results = [ @@ -952,7 +951,7 @@ def test_snippets_only_skips_scraping(self): assert "Snippet-only results" in result assert "snip" in result - def test_successful_search_and_scrape(self): + def test_successful_search_and_scrape(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") search_results = [ {"title": "Good Result", "url": "https://example.com/good", "snippet": "A good result"} @@ -973,7 +972,7 @@ def test_successful_search_and_scrape(self): assert "RESULT 1" in result assert "Good Result" in result - def test_domain_diversity(self): + def test_domain_diversity(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") search_results = [ {"title": "Result A1", "url": "https://a.com/1", "snippet": "A1"}, @@ -985,12 +984,12 @@ def test_domain_diversity(self): metrics_b = {"quality_score": 0.7, "domain": "b.com", "text_length": 5000, "sentence_count": 10, "query_relevance": 0.8, "query_words_found": "1/1"} - def mock_extract(url, **kwargs): + def mock_extract(url: str, **kwargs: Any) -> tuple[str, dict[str, Any]]: if "a.com" in url: return ("Content A", metrics_a) return ("Content B", metrics_b) - def mock_quality(text, url, query=""): + def mock_quality(text: str, url: str, query: str = "") -> dict[str, Any]: if "a.com" in url: return metrics_a return metrics_b @@ -1003,7 +1002,7 @@ def mock_quality(text, url, query=""): assert "a.com" in result assert "b.com" in result - def test_backward_compatible_search_and_scrape(self): + def test_backward_compatible_search_and_scrape(self) -> None: scraper = GoogleSearchScraper("key", "engine_id") with patch.object(scraper, 'search_and_scrape_best', return_value="results") as mock_best: result = scraper.search_and_scrape("test query", num_results=2, delay=0.1) @@ -1023,7 +1022,7 @@ def test_backward_compatible_search_and_scrape(self): class TestGetHints: """Tests for the get_hints method.""" - def test_returns_empty_list(self): + def test_returns_empty_list(self) -> None: skill = _make_skill() assert skill.get_hints() == [] @@ -1035,7 +1034,7 @@ def test_returns_empty_list(self): class TestGetGlobalData: """Tests for the get_global_data method.""" - def test_returns_correct_keys(self): + def test_returns_correct_keys(self) -> None: skill = _make_skill() skill.setup() data = skill.get_global_data() @@ -1051,32 +1050,32 @@ def test_returns_correct_keys(self): class TestGetPromptSections: """Tests for the get_prompt_sections method.""" - def test_returns_one_section(self): + def test_returns_one_section(self) -> None: skill = _make_skill() skill.setup() sections = skill.get_prompt_sections() assert len(sections) == 1 - def test_section_title(self): + def test_section_title(self) -> None: skill = _make_skill() skill.setup() section = skill.get_prompt_sections()[0] assert section["title"] == "Web Search Capability (Quality Enhanced)" - def test_section_references_tool_name(self): + def test_section_references_tool_name(self) -> None: skill = _make_skill() skill.setup() section = skill.get_prompt_sections()[0] assert "web_search" in section["body"] - def test_section_references_custom_tool_name(self): + def test_section_references_custom_tool_name(self) -> None: skill = _make_skill({"tool_name": "news_search"}) skill.setup() section = skill.get_prompt_sections()[0] assert "news_search" in section["body"] assert any("news_search" in bullet for bullet in section["bullets"]) - def test_section_has_bullets(self): + def test_section_has_bullets(self) -> None: skill = _make_skill() skill.setup() section = skill.get_prompt_sections()[0] @@ -1091,7 +1090,7 @@ def test_section_has_bullets(self): class TestEdgeCases: """Edge case and integration-style tests.""" - def test_setup_then_register_then_handler_flow(self): + def test_setup_then_register_then_handler_flow(self) -> None: """Full lifecycle: setup -> register -> handle search.""" skill = _make_skill() assert skill.setup() is True @@ -1107,7 +1106,7 @@ def test_setup_then_register_then_handler_flow(self): assert isinstance(result, FunctionResult) assert "Lifecycle" in result.response - def test_setup_returns_false_for_each_missing_param(self): + def test_setup_returns_false_for_each_missing_param(self) -> None: """Verify setup returns False when each required param is empty.""" required = ["api_key", "search_engine_id"] for missing in required: @@ -1118,7 +1117,7 @@ def test_setup_returns_false_for_each_missing_param(self): skill = WebSearchSkill(agent=mock_agent, params=params) assert skill.setup() is False, f"Should fail when {missing} is empty" - def test_multiple_instances_different_tool_names(self): + def test_multiple_instances_different_tool_names(self) -> None: """Two instances with different tool_names should have different keys.""" skill_a = _make_skill({"tool_name": "search_news"}) skill_b = _make_skill({"tool_name": "search_docs"}) @@ -1126,7 +1125,7 @@ def test_multiple_instances_different_tool_names(self): assert "search_news" in skill_a.get_instance_key() assert "search_docs" in skill_b.get_instance_key() - def test_handler_with_none_return_from_scraper(self): + def test_handler_with_none_return_from_scraper(self) -> None: """If scraper returns None, handler should handle gracefully.""" skill = _make_skill() skill.setup() @@ -1134,14 +1133,14 @@ def test_handler_with_none_return_from_scraper(self): result = skill._web_search_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) - def test_content_quality_with_ideal_length_text(self): + def test_content_quality_with_ideal_length_text(self) -> None: """Text in the 2000-10000 char range should get max length score.""" scraper = GoogleSearchScraper("key", "engine_id") text = "This is a good sentence. " * 200 # ~5000 chars metrics = scraper._calculate_content_quality(text, "https://example.com") assert metrics["length_score"] == 1.0 - def test_content_quality_very_long_text(self): + def test_content_quality_very_long_text(self) -> None: """Very long text should still get decent but reduced length score.""" scraper = GoogleSearchScraper("key", "engine_id") text = "Word " * 20000 # ~100000 chars diff --git a/tests/unit/skills/test_wikipedia_search_skill.py b/tests/unit/skills/test_wikipedia_search_skill.py index 03116997..a784ba24 100644 --- a/tests/unit/skills/test_wikipedia_search_skill.py +++ b/tests/unit/skills/test_wikipedia_search_skill.py @@ -5,15 +5,14 @@ Licensed under the MIT License. See LICENSE file in the project root for full license information. -""" -""" Unit tests for WikipediaSearchSkill """ -import pytest +from typing import Any + import requests -from unittest.mock import Mock, patch, MagicMock, call +from unittest.mock import Mock, patch from signalwire.skills.wikipedia_search.skill import WikipediaSearchSkill @@ -22,15 +21,14 @@ # Helpers # --------------------------------------------------------------------------- -def _make_skill(params=None): +def _make_skill(params: dict[str, Any] | None = None) -> WikipediaSearchSkill: """Create a WikipediaSearchSkill instance with a mocked agent.""" mock_agent = Mock() mock_agent.define_tool = Mock() - skill = WikipediaSearchSkill(agent=mock_agent, params=params) - return skill + return WikipediaSearchSkill(agent=mock_agent, params=params) -def _setup_skill(params=None): +def _setup_skill(params: dict[str, Any] | None = None) -> WikipediaSearchSkill: """Create *and* setup a WikipediaSearchSkill instance.""" skill = _make_skill(params) with patch.object(skill, "validate_packages", return_value=True): @@ -39,7 +37,7 @@ def _setup_skill(params=None): return skill -def _mock_search_response(titles): +def _mock_search_response(titles: list[str]) -> dict[str, Any]: """Build a mock JSON response for Wikipedia search API.""" return { "query": { @@ -48,7 +46,7 @@ def _mock_search_response(titles): } -def _mock_extract_response(title, extract): +def _mock_extract_response(title: str, extract: str) -> dict[str, Any]: """Build a mock JSON response for Wikipedia extract API.""" return { "query": { @@ -62,7 +60,7 @@ def _mock_extract_response(title, extract): } -def _mock_extract_response_empty_pages(): +def _mock_extract_response_empty_pages() -> dict[str, Any]: """Build a mock JSON response with no pages.""" return { "query": { @@ -78,24 +76,24 @@ def _mock_extract_response_empty_pages(): class TestWikipediaSearchSkillMetadata: """Verify class-level attributes and metadata.""" - def test_skill_name(self): + def test_skill_name(self) -> None: assert WikipediaSearchSkill.SKILL_NAME == "wikipedia_search" - def test_skill_description(self): + def test_skill_description(self) -> None: assert WikipediaSearchSkill.SKILL_DESCRIPTION == ( "Search Wikipedia for information about a topic and get article summaries" ) - def test_skill_version(self): + def test_skill_version(self) -> None: assert WikipediaSearchSkill.SKILL_VERSION == "1.0.0" - def test_required_packages(self): + def test_required_packages(self) -> None: assert WikipediaSearchSkill.REQUIRED_PACKAGES == ["requests"] - def test_required_env_vars(self): + def test_required_env_vars(self) -> None: assert WikipediaSearchSkill.REQUIRED_ENV_VARS == [] - def test_supports_multiple_instances(self): + def test_supports_multiple_instances(self) -> None: assert WikipediaSearchSkill.SUPPORTS_MULTIPLE_INSTANCES is False @@ -106,29 +104,29 @@ def test_supports_multiple_instances(self): class TestWikipediaSearchSkillInit: """Test __init__ behaviour inherited from SkillBase.""" - def test_init_stores_agent(self): + def test_init_stores_agent(self) -> None: mock_agent = Mock() skill = WikipediaSearchSkill(agent=mock_agent, params=None) assert skill.agent is mock_agent - def test_init_default_params(self): + def test_init_default_params(self) -> None: skill = _make_skill() assert skill.params == {} - def test_init_custom_params(self): + def test_init_custom_params(self) -> None: params = {"num_results": 3, "no_results_message": "Nothing found."} skill = _make_skill(params) assert skill.params["num_results"] == 3 assert skill.params["no_results_message"] == "Nothing found." - def test_init_extracts_swaig_fields(self): + def test_init_extracts_swaig_fields(self) -> None: params = {"swaig_fields": {"fillers": {"en-US": ["hmm"]}}, "num_results": 2} skill = _make_skill(params) assert skill.swaig_fields == {"fillers": {"en-US": ["hmm"]}} # swaig_fields should be popped from params assert "swaig_fields" not in skill.params - def test_init_logger_name(self): + def test_init_logger_name(self) -> None: skill = _make_skill() assert skill.logger.name == "signalwire.skills.wikipedia_search" @@ -140,11 +138,11 @@ def test_init_logger_name(self): class TestParameterSchema: """Test the get_parameter_schema class method.""" - def test_returns_dict(self): + def test_returns_dict(self) -> None: schema = WikipediaSearchSkill.get_parameter_schema() assert isinstance(schema, dict) - def test_contains_num_results(self): + def test_contains_num_results(self) -> None: schema = WikipediaSearchSkill.get_parameter_schema() assert "num_results" in schema assert schema["num_results"]["type"] == "integer" @@ -152,17 +150,17 @@ def test_contains_num_results(self): assert schema["num_results"]["minimum"] == 1 assert schema["num_results"]["maximum"] == 5 - def test_contains_no_results_message(self): + def test_contains_no_results_message(self) -> None: schema = WikipediaSearchSkill.get_parameter_schema() assert "no_results_message" in schema assert schema["no_results_message"]["type"] == "string" assert "{query}" in schema["no_results_message"]["default"] - def test_contains_base_swaig_fields(self): + def test_contains_base_swaig_fields(self) -> None: schema = WikipediaSearchSkill.get_parameter_schema() assert "swaig_fields" in schema - def test_no_tool_name_for_single_instance_skill(self): + def test_no_tool_name_for_single_instance_skill(self) -> None: """Since SUPPORTS_MULTIPLE_INSTANCES is False, tool_name should not be present.""" schema = WikipediaSearchSkill.get_parameter_schema() assert "tool_name" not in schema @@ -175,7 +173,7 @@ def test_no_tool_name_for_single_instance_skill(self): class TestSetup: """Test the setup() method.""" - def test_setup_default_params(self): + def test_setup_default_params(self) -> None: skill = _make_skill() with patch.object(skill, "validate_packages", return_value=True): result = skill.setup() @@ -183,33 +181,33 @@ def test_setup_default_params(self): assert skill.num_results == 1 assert "{query}" in skill.no_results_message - def test_setup_custom_num_results(self): + def test_setup_custom_num_results(self) -> None: skill = _make_skill({"num_results": 4}) with patch.object(skill, "validate_packages", return_value=True): skill.setup() assert skill.num_results == 4 - def test_setup_num_results_clamped_to_minimum(self): + def test_setup_num_results_clamped_to_minimum(self) -> None: """num_results of 0 or negative should be clamped to 1.""" skill = _make_skill({"num_results": 0}) with patch.object(skill, "validate_packages", return_value=True): skill.setup() assert skill.num_results == 1 - def test_setup_num_results_negative_clamped(self): + def test_setup_num_results_negative_clamped(self) -> None: skill = _make_skill({"num_results": -5}) with patch.object(skill, "validate_packages", return_value=True): skill.setup() assert skill.num_results == 1 - def test_setup_custom_no_results_message(self): + def test_setup_custom_no_results_message(self) -> None: msg = "Sorry, no articles for '{query}'." skill = _make_skill({"no_results_message": msg}) with patch.object(skill, "validate_packages", return_value=True): skill.setup() assert skill.no_results_message == msg - def test_setup_empty_no_results_message_uses_default(self): + def test_setup_empty_no_results_message_uses_default(self) -> None: """An empty string for no_results_message should fall back to default.""" skill = _make_skill({"no_results_message": ""}) with patch.object(skill, "validate_packages", return_value=True): @@ -217,25 +215,25 @@ def test_setup_empty_no_results_message_uses_default(self): assert "{query}" in skill.no_results_message assert len(skill.no_results_message) > 0 - def test_setup_none_no_results_message_uses_default(self): + def test_setup_none_no_results_message_uses_default(self) -> None: skill = _make_skill({"no_results_message": None}) with patch.object(skill, "validate_packages", return_value=True): skill.setup() assert "{query}" in skill.no_results_message - def test_setup_returns_false_when_packages_missing(self): + def test_setup_returns_false_when_packages_missing(self) -> None: skill = _make_skill() with patch.object(skill, "validate_packages", return_value=False): result = skill.setup() assert result is False - def test_setup_logs_info(self): + def test_setup_logs_info(self) -> None: skill = _make_skill({"num_results": 3}) - with patch.object(skill, "validate_packages", return_value=True): - with patch.object(skill.logger, "info") as mock_info: - skill.setup() - mock_info.assert_called_once() - assert "3" in mock_info.call_args[0][0] + with patch.object(skill, "validate_packages", return_value=True), \ + patch.object(skill.logger, "info") as mock_info: + skill.setup() + mock_info.assert_called_once() + assert "3" in mock_info.call_args[0][0] # =========================================================================== @@ -245,18 +243,18 @@ def test_setup_logs_info(self): class TestRegisterTools: """Test the register_tools() method.""" - def test_register_tools_calls_define_tool(self): + def test_register_tools_calls_define_tool(self) -> None: skill = _setup_skill() skill.register_tools() skill.agent.define_tool.assert_called_once() - def test_register_tools_tool_name(self): + def test_register_tools_tool_name(self) -> None: skill = _setup_skill() skill.register_tools() kwargs = skill.agent.define_tool.call_args assert kwargs[1]["name"] == "search_wiki" or kwargs.kwargs["name"] == "search_wiki" - def test_register_tools_tool_has_query_parameter(self): + def test_register_tools_tool_has_query_parameter(self) -> None: skill = _setup_skill() skill.register_tools() call_kwargs = skill.agent.define_tool.call_args @@ -265,7 +263,7 @@ def test_register_tools_tool_has_query_parameter(self): assert "query" in params assert params["query"]["type"] == "string" - def test_register_tools_tool_has_handler(self): + def test_register_tools_tool_has_handler(self) -> None: skill = _setup_skill() skill.register_tools() call_kwargs = skill.agent.define_tool.call_args @@ -273,7 +271,7 @@ def test_register_tools_tool_has_handler(self): assert handler is not None assert callable(handler) - def test_register_tools_with_swaig_fields(self): + def test_register_tools_with_swaig_fields(self) -> None: """swaig_fields should be merged into the define_tool call.""" skill = _make_skill({"swaig_fields": {"meta_data": {"token": "abc"}}}) with patch.object(skill, "validate_packages", return_value=True): @@ -293,7 +291,7 @@ class TestSearchWikiHandler: """Test the _search_wiki_handler method.""" @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_handler_empty_query(self, mock_get): + def test_handler_empty_query(self, mock_get: Mock) -> None: skill = _setup_skill() result = skill._search_wiki_handler({"query": ""}, {}) # Should return a FunctionResult without calling the API @@ -301,35 +299,35 @@ def test_handler_empty_query(self, mock_get): assert result.response == "Please provide a search query for Wikipedia." @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_handler_whitespace_query(self, mock_get): + def test_handler_whitespace_query(self, mock_get: Mock) -> None: skill = _setup_skill() result = skill._search_wiki_handler({"query": " "}, {}) mock_get.assert_not_called() assert result.response == "Please provide a search query for Wikipedia." @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_handler_missing_query_key(self, mock_get): + def test_handler_missing_query_key(self, mock_get: Mock) -> None: skill = _setup_skill() result = skill._search_wiki_handler({}, {}) mock_get.assert_not_called() assert result.response == "Please provide a search query for Wikipedia." @patch.object(WikipediaSearchSkill, "search_wiki", return_value="Python is a language.") - def test_handler_delegates_to_search_wiki(self, mock_search): + def test_handler_delegates_to_search_wiki(self, mock_search: Mock) -> None: skill = _setup_skill() result = skill._search_wiki_handler({"query": "Python"}, {}) mock_search.assert_called_once_with("Python") assert result.response == "Python is a language." @patch.object(WikipediaSearchSkill, "search_wiki", return_value="Some content") - def test_handler_returns_swaig_function_result(self, mock_search): + def test_handler_returns_swaig_function_result(self, mock_search: Mock) -> None: from signalwire.core.function_result import FunctionResult skill = _setup_skill() result = skill._search_wiki_handler({"query": "test"}, {}) assert isinstance(result, FunctionResult) @patch.object(WikipediaSearchSkill, "search_wiki", return_value="Trimmed result") - def test_handler_strips_query(self, mock_search): + def test_handler_strips_query(self, mock_search: Mock) -> None: skill = _setup_skill() skill._search_wiki_handler({"query": " Python "}, {}) mock_search.assert_called_once_with("Python") @@ -343,7 +341,7 @@ class TestSearchWikiSingleResult: """Test search_wiki with a single result (default num_results=1).""" @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_single_result_success(self, mock_get): + def test_single_result_success(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -364,7 +362,7 @@ def test_single_result_success(self, mock_get): assert "Python is a high-level programming language." in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_search_url_contains_encoded_query(self, mock_get): + def test_search_url_contains_encoded_query(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -378,7 +376,7 @@ def test_search_url_contains_encoded_query(self, mock_get): assert "C%2B%2B" in called_url @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_search_url_contains_srlimit(self, mock_get): + def test_search_url_contains_srlimit(self, mock_get: Mock) -> None: skill = _setup_skill({"num_results": 3}) search_resp = Mock() @@ -391,7 +389,7 @@ def test_search_url_contains_srlimit(self, mock_get): assert "srlimit=3" in called_url @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_timeout_is_10_seconds(self, mock_get): + def test_timeout_is_10_seconds(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -411,7 +409,7 @@ class TestSearchWikiMultipleResults: """Test search_wiki when num_results > 1.""" @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_multiple_results_joined_with_separator(self, mock_get): + def test_multiple_results_joined_with_separator(self, mock_get: Mock) -> None: skill = _setup_skill({"num_results": 2}) search_resp = Mock() @@ -434,7 +432,7 @@ def test_multiple_results_joined_with_separator(self, mock_get): assert "=" * 50 in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_results_limited_to_num_results(self, mock_get): + def test_results_limited_to_num_results(self, mock_get: Mock) -> None: """Even if search returns more titles, only num_results extracts should be fetched.""" skill = _setup_skill({"num_results": 1}) @@ -462,7 +460,7 @@ class TestSearchWikiNoResults: """Test search_wiki edge cases for empty or missing data.""" @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_no_search_results(self, mock_get): + def test_no_search_results(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -475,7 +473,7 @@ def test_no_search_results(self, mock_get): assert "couldn't find" in result.lower() or "rephrasing" in result.lower() @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_no_search_key_in_response(self, mock_get): + def test_no_search_key_in_response(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -488,7 +486,7 @@ def test_no_search_key_in_response(self, mock_get): assert "broken" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_empty_extract_shows_no_summary(self, mock_get): + def test_empty_extract_shows_no_summary(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -506,7 +504,7 @@ def test_empty_extract_shows_no_summary(self, mock_get): assert "No summary available" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_whitespace_only_extract_shows_no_summary(self, mock_get): + def test_whitespace_only_extract_shows_no_summary(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -523,7 +521,7 @@ def test_whitespace_only_extract_shows_no_summary(self, mock_get): assert "No summary available" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_empty_pages_dict(self, mock_get): + def test_empty_pages_dict(self, mock_get: Mock) -> None: """If the extract API returns empty pages, no article is appended.""" skill = _setup_skill() @@ -542,7 +540,7 @@ def test_empty_pages_dict(self, mock_get): assert "ghost" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_custom_no_results_message_with_query(self, mock_get): + def test_custom_no_results_message_with_query(self, mock_get: Mock) -> None: msg = "Nothing for '{query}', sorry!" skill = _setup_skill({"no_results_message": msg}) @@ -563,7 +561,7 @@ class TestSearchWikiErrorHandling: """Test error handling in search_wiki.""" @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_request_exception_on_search(self, mock_get): + def test_request_exception_on_search(self, mock_get: Mock) -> None: skill = _setup_skill() mock_get.side_effect = requests.exceptions.ConnectionError("Connection refused") @@ -572,7 +570,7 @@ def test_request_exception_on_search(self, mock_get): assert "Connection refused" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_timeout_exception_on_search(self, mock_get): + def test_timeout_exception_on_search(self, mock_get: Mock) -> None: skill = _setup_skill() mock_get.side_effect = requests.exceptions.Timeout("Request timed out") @@ -580,7 +578,7 @@ def test_timeout_exception_on_search(self, mock_get): assert "Error accessing Wikipedia" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_http_error_on_search(self, mock_get): + def test_http_error_on_search(self, mock_get: Mock) -> None: skill = _setup_skill() resp = Mock() resp.raise_for_status.side_effect = requests.exceptions.HTTPError("500 Server Error") @@ -590,7 +588,7 @@ def test_http_error_on_search(self, mock_get): assert "Error accessing Wikipedia" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_request_exception_on_extract(self, mock_get): + def test_request_exception_on_extract(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -606,7 +604,7 @@ def test_request_exception_on_extract(self, mock_get): assert "Error accessing Wikipedia" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_generic_exception(self, mock_get): + def test_generic_exception(self, mock_get: Mock) -> None: skill = _setup_skill() mock_get.side_effect = ValueError("Unexpected error") @@ -615,7 +613,7 @@ def test_generic_exception(self, mock_get): assert "Unexpected error" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_json_decode_error_on_search(self, mock_get): + def test_json_decode_error_on_search(self, mock_get: Mock) -> None: skill = _setup_skill() resp = Mock() resp.raise_for_status = Mock() @@ -626,7 +624,7 @@ def test_json_decode_error_on_search(self, mock_get): assert "Error searching Wikipedia" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_json_decode_error_on_extract(self, mock_get): + def test_json_decode_error_on_extract(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -651,7 +649,7 @@ class TestSearchWikiResponseStructure: """Test subtle response structure edge cases.""" @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_single_article_no_separator(self, mock_get): + def test_single_article_no_separator(self, mock_get: Mock) -> None: """A single result should NOT contain the separator.""" skill = _setup_skill() @@ -670,7 +668,7 @@ def test_single_article_no_separator(self, mock_get): assert result == "**Only One**\n\nContent here." @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_extract_with_leading_trailing_whitespace_stripped(self, mock_get): + def test_extract_with_leading_trailing_whitespace_stripped(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -689,7 +687,7 @@ def test_extract_with_leading_trailing_whitespace_stripped(self, mock_get): assert "**Whitespace Test**\n\nSome content with whitespace" in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_multiple_results_some_empty_extracts(self, mock_get): + def test_multiple_results_some_empty_extracts(self, mock_get: Mock) -> None: """Articles with empty extracts should still appear with 'No summary' message.""" skill = _setup_skill({"num_results": 2}) @@ -715,7 +713,7 @@ def test_multiple_results_some_empty_extracts(self, mock_get): assert "=" * 50 in result @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_page_missing_extract_key(self, mock_get): + def test_page_missing_extract_key(self, mock_get: Mock) -> None: """If a page has no 'extract' key at all, it should show 'No summary available'.""" skill = _setup_skill() @@ -750,34 +748,34 @@ def test_page_missing_extract_key(self, mock_get): class TestGetPromptSections: """Test get_prompt_sections method.""" - def test_returns_list(self): + def test_returns_list(self) -> None: skill = _setup_skill() sections = skill.get_prompt_sections() assert isinstance(sections, list) - def test_returns_one_section(self): + def test_returns_one_section(self) -> None: skill = _setup_skill() sections = skill.get_prompt_sections() assert len(sections) == 1 - def test_section_has_title(self): + def test_section_has_title(self) -> None: skill = _setup_skill() section = skill.get_prompt_sections()[0] assert section["title"] == "Wikipedia Search" - def test_section_body_includes_num_results(self): + def test_section_body_includes_num_results(self) -> None: skill = _setup_skill({"num_results": 3}) section = skill.get_prompt_sections()[0] assert "3" in section["body"] assert "search_wiki" in section["body"] - def test_section_has_bullets(self): + def test_section_has_bullets(self) -> None: skill = _setup_skill() section = skill.get_prompt_sections()[0] assert isinstance(section["bullets"], list) assert len(section["bullets"]) == 3 - def test_section_bullets_mention_search_wiki(self): + def test_section_bullets_mention_search_wiki(self) -> None: skill = _setup_skill() section = skill.get_prompt_sections()[0] assert any("search_wiki" in b for b in section["bullets"]) @@ -790,12 +788,12 @@ def test_section_bullets_mention_search_wiki(self): class TestGetHints: """Test get_hints method.""" - def test_returns_empty_list(self): + def test_returns_empty_list(self) -> None: skill = _setup_skill() hints = skill.get_hints() assert hints == [] - def test_returns_list_type(self): + def test_returns_list_type(self) -> None: skill = _setup_skill() assert isinstance(skill.get_hints(), list) @@ -807,11 +805,11 @@ def test_returns_list_type(self): class TestGetInstanceKey: """Test instance key behaviour for single-instance skill.""" - def test_returns_skill_name(self): + def test_returns_skill_name(self) -> None: skill = _make_skill() assert skill.get_instance_key() == "wikipedia_search" - def test_ignores_tool_name_param(self): + def test_ignores_tool_name_param(self) -> None: """Since SUPPORTS_MULTIPLE_INSTANCES is False, tool_name should be ignored.""" skill = _make_skill({"tool_name": "custom_name"}) assert skill.get_instance_key() == "wikipedia_search" @@ -825,7 +823,7 @@ class TestHandlerToSearchIntegration: """Test the full handler -> search_wiki pipeline with mocked HTTP.""" @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_full_flow_success(self, mock_get): + def test_full_flow_success(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -846,7 +844,7 @@ def test_full_flow_success(self, mock_get): assert "theoretical physicist" in result.response @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_full_flow_no_results(self, mock_get): + def test_full_flow_no_results(self, mock_get: Mock) -> None: skill = _setup_skill() search_resp = Mock() @@ -858,7 +856,7 @@ def test_full_flow_no_results(self, mock_get): assert "zzznonexistenttopic" in result.response @patch("signalwire.skills.wikipedia_search.skill.requests.get") - def test_full_flow_api_error(self, mock_get): + def test_full_flow_api_error(self, mock_get: Mock) -> None: skill = _setup_skill() mock_get.side_effect = requests.exceptions.ConnectionError("Network down") diff --git a/tests/unit/utils/test_execution_mode.py b/tests/unit/utils/test_execution_mode.py index b1da744d..097c7810 100644 --- a/tests/unit/utils/test_execution_mode.py +++ b/tests/unit/utils/test_execution_mode.py @@ -19,7 +19,7 @@ class TestGetExecutionMode: - def test_default_is_server(self): + def test_default_is_server(self) -> None: # Clear all detected env vars; should default to "server". env_keys = [ "GATEWAY_INTERFACE", @@ -33,28 +33,28 @@ def test_default_is_server(self): os.environ.pop(k, None) assert get_execution_mode() == "server" - def test_cgi_detected_via_gateway_interface(self): + def test_cgi_detected_via_gateway_interface(self) -> None: with patch.dict(os.environ, {"GATEWAY_INTERFACE": "CGI/1.1"}, clear=False): assert get_execution_mode() == "cgi" - def test_lambda_detected_via_function_name(self): + def test_lambda_detected_via_function_name(self) -> None: with patch.dict(os.environ, {"AWS_LAMBDA_FUNCTION_NAME": "my-fn"}, clear=False): os.environ.pop("GATEWAY_INTERFACE", None) assert get_execution_mode() == "lambda" - def test_lambda_detected_via_task_root(self): + def test_lambda_detected_via_task_root(self) -> None: with patch.dict(os.environ, {"LAMBDA_TASK_ROOT": "/var/task"}, clear=False): os.environ.pop("GATEWAY_INTERFACE", None) os.environ.pop("AWS_LAMBDA_FUNCTION_NAME", None) assert get_execution_mode() == "lambda" - def test_google_cloud_function_detected(self): + def test_google_cloud_function_detected(self) -> None: with patch.dict(os.environ, {"FUNCTION_TARGET": "my_handler"}, clear=False): for k in ("GATEWAY_INTERFACE", "AWS_LAMBDA_FUNCTION_NAME", "LAMBDA_TASK_ROOT"): os.environ.pop(k, None) assert get_execution_mode() == "google_cloud_function" - def test_azure_function_detected(self): + def test_azure_function_detected(self) -> None: with patch.dict(os.environ, {"AZURE_FUNCTIONS_ENVIRONMENT": "Production"}, clear=False): for k in ( "GATEWAY_INTERFACE", "AWS_LAMBDA_FUNCTION_NAME", "LAMBDA_TASK_ROOT", @@ -65,7 +65,7 @@ def test_azure_function_detected(self): class TestIsServerlessMode: - def test_server_mode_is_not_serverless(self): + def test_server_mode_is_not_serverless(self) -> None: env_keys = [ "GATEWAY_INTERFACE", "AWS_LAMBDA_FUNCTION_NAME", "LAMBDA_TASK_ROOT", @@ -78,12 +78,12 @@ def test_server_mode_is_not_serverless(self): os.environ.pop(k, None) assert is_serverless_mode() is False - def test_lambda_is_serverless(self): + def test_lambda_is_serverless(self) -> None: with patch.dict(os.environ, {"AWS_LAMBDA_FUNCTION_NAME": "fn"}, clear=False): os.environ.pop("GATEWAY_INTERFACE", None) assert is_serverless_mode() is True - def test_cgi_is_serverless(self): + def test_cgi_is_serverless(self) -> None: # CGI is not a long-running server — counts as serverless. with patch.dict(os.environ, {"GATEWAY_INTERFACE": "CGI/1.1"}, clear=False): assert is_serverless_mode() is True diff --git a/tests/unit/utils/test_schema_utils.py b/tests/unit/utils/test_schema_utils.py index cfda7a4c..0aee1e0a 100644 --- a/tests/unit/utils/test_schema_utils.py +++ b/tests/unit/utils/test_schema_utils.py @@ -16,6 +16,7 @@ import os import tempfile from pathlib import Path +from importlib.resources.abc import Traversable from unittest.mock import Mock, patch, MagicMock, mock_open from typing import Dict, List, Any, Optional @@ -25,7 +26,7 @@ class TestSchemaUtils: """Test SchemaUtils functionality""" - def test_basic_initialization_with_schema_path(self): + def test_basic_initialization_with_schema_path(self) -> None: """Test basic SchemaUtils initialization with schema path""" with patch.object(SchemaUtils, 'load_schema', return_value={}): with patch.object(SchemaUtils, '_extract_verb_definitions', return_value={}): @@ -35,7 +36,7 @@ def test_basic_initialization_with_schema_path(self): assert utils.schema == {} assert utils.verbs == {} - def test_initialization_without_schema_path(self): + def test_initialization_without_schema_path(self) -> None: """Test initialization without schema path uses default""" with patch.object(SchemaUtils, '_get_default_schema_path', return_value="/default/schema.json"): with patch.object(SchemaUtils, 'load_schema', return_value={}): @@ -44,12 +45,12 @@ def test_initialization_without_schema_path(self): assert utils.schema_path == "/default/schema.json" - def test_get_default_schema_path_importlib_resources_new(self): + def test_get_default_schema_path_importlib_resources_new(self) -> None: """Test default schema path using importlib.resources (Python 3.13+)""" utils = SchemaUtils.__new__(SchemaUtils) # Create without calling __init__ mock_path = Mock() - mock_path.__str__ = Mock(return_value="/package/schema.json") + mock_path.__str__ = Mock(return_value="/package/schema.json") # type: ignore[method-assign] with patch('importlib.resources.files') as mock_files: mock_files.return_value.joinpath.return_value = mock_path @@ -59,7 +60,7 @@ def test_get_default_schema_path_importlib_resources_new(self): assert result == "/package/schema.json" mock_files.assert_called_once_with("signalwire") - def test_get_default_schema_path_importlib_resources_old(self): + def test_get_default_schema_path_importlib_resources_old(self) -> None: """Test default schema path using importlib.resources (Python 3.7-3.8)""" utils = SchemaUtils.__new__(SchemaUtils) @@ -74,7 +75,7 @@ def test_get_default_schema_path_importlib_resources_old(self): assert result == "/old/schema.json" - def test_get_default_schema_path_manual_search(self): + def test_get_default_schema_path_manual_search(self) -> None: """Test default schema path using manual file search when importlib.resources fails""" utils = SchemaUtils.__new__(SchemaUtils) utils.log = Mock() @@ -82,7 +83,7 @@ def test_get_default_schema_path_manual_search(self): import importlib.resources original_files = importlib.resources.files - def failing_files(package): + def failing_files(package: str) -> Traversable: if package == "signalwire": raise ImportError("mocked") return original_files(package) @@ -97,7 +98,7 @@ def failing_files(package): assert result == "/current/schema.json" - def test_get_default_schema_path_not_found(self): + def test_get_default_schema_path_not_found(self) -> None: """Test default schema path when file is not found anywhere""" utils = SchemaUtils.__new__(SchemaUtils) utils.log = Mock() @@ -105,7 +106,7 @@ def test_get_default_schema_path_not_found(self): import importlib.resources original_files = importlib.resources.files - def failing_files(package): + def failing_files(package: str) -> Traversable: if package == "signalwire": raise ImportError("mocked") return original_files(package) @@ -117,7 +118,7 @@ def failing_files(package): assert result is None - def test_load_schema_success(self): + def test_load_schema_success(self) -> None: """Test successful schema loading""" schema_data = { "$defs": { @@ -147,7 +148,7 @@ def test_load_schema_success(self): finally: os.unlink(schema_path) - def test_load_schema_file_not_found(self): + def test_load_schema_file_not_found(self) -> None: """Test schema loading when file doesn't exist""" utils = SchemaUtils.__new__(SchemaUtils) utils.schema_path = "/nonexistent/schema.json" @@ -158,7 +159,7 @@ def test_load_schema_file_not_found(self): assert result == {} utils.log.error.assert_called_once() - def test_load_schema_invalid_json(self): + def test_load_schema_invalid_json(self) -> None: """Test schema loading with invalid JSON""" with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: f.write("invalid json content") @@ -176,7 +177,7 @@ def test_load_schema_invalid_json(self): finally: os.unlink(schema_path) - def test_load_schema_no_path(self): + def test_load_schema_no_path(self) -> None: """Test schema loading when no path is provided""" utils = SchemaUtils.__new__(SchemaUtils) utils.schema_path = None @@ -191,7 +192,7 @@ def test_load_schema_no_path(self): class TestVerbExtraction: """Test verb extraction functionality""" - def test_extract_verb_definitions_success(self): + def test_extract_verb_definitions_success(self) -> None: """Test successful verb extraction""" schema = { "$defs": { @@ -237,9 +238,9 @@ def test_extract_verb_definitions_success(self): assert verbs["answer"]["name"] == "answer" assert verbs["answer"]["schema_name"] == "AnswerMethod" - def test_extract_verb_definitions_no_swml_method(self): + def test_extract_verb_definitions_no_swml_method(self) -> None: """Test verb extraction when SWMLMethod is missing""" - schema = {"$defs": {}} + schema: dict[str, Any] = {"$defs": {}} utils = SchemaUtils.__new__(SchemaUtils) utils.schema = schema @@ -250,9 +251,9 @@ def test_extract_verb_definitions_no_swml_method(self): assert verbs == {} utils.log.warning.assert_called_once() - def test_extract_verb_definitions_no_anyof(self): + def test_extract_verb_definitions_no_anyof(self) -> None: """Test verb extraction when anyOf is missing""" - schema = { + schema: dict[str, Any] = { "$defs": { "SWMLMethod": { "properties": {} @@ -272,7 +273,7 @@ def test_extract_verb_definitions_no_anyof(self): class TestVerbProperties: """Test verb property access methods""" - def setup_method(self): + def setup_method(self) -> None: """Set up test data""" self.utils = SchemaUtils.__new__(SchemaUtils) self.utils.verbs = { @@ -308,7 +309,7 @@ def setup_method(self): } } - def test_get_verb_properties_existing(self): + def test_get_verb_properties_existing(self) -> None: """Test getting properties for existing verb""" result = self.utils.get_verb_properties("ai") @@ -322,37 +323,37 @@ def test_get_verb_properties_existing(self): } assert result == expected - def test_get_verb_properties_nonexistent(self): + def test_get_verb_properties_nonexistent(self) -> None: """Test getting properties for nonexistent verb""" result = self.utils.get_verb_properties("nonexistent") assert result == {} - def test_get_verb_required_properties_existing(self): + def test_get_verb_required_properties_existing(self) -> None: """Test getting required properties for existing verb""" result = self.utils.get_verb_required_properties("ai") assert result == ["prompt"] - def test_get_verb_required_properties_no_required(self): + def test_get_verb_required_properties_no_required(self) -> None: """Test getting required properties when none are specified""" result = self.utils.get_verb_required_properties("answer") assert result == [] - def test_get_verb_required_properties_nonexistent(self): + def test_get_verb_required_properties_nonexistent(self) -> None: """Test getting required properties for nonexistent verb""" result = self.utils.get_verb_required_properties("nonexistent") assert result == [] - def test_get_all_verb_names(self): + def test_get_all_verb_names(self) -> None: """Test getting all verb names""" result = self.utils.get_all_verb_names() assert set(result) == {"ai", "answer"} - def test_get_verb_parameters_existing(self): + def test_get_verb_parameters_existing(self) -> None: """Test getting parameters for existing verb""" result = self.utils.get_verb_parameters("ai") @@ -362,7 +363,7 @@ def test_get_verb_parameters_existing(self): } assert result == expected - def test_get_verb_parameters_nonexistent(self): + def test_get_verb_parameters_nonexistent(self) -> None: """Test getting parameters for nonexistent verb""" result = self.utils.get_verb_parameters("nonexistent") @@ -372,7 +373,7 @@ def test_get_verb_parameters_nonexistent(self): class TestVerbValidation: """Test verb validation functionality""" - def setup_method(self): + def setup_method(self) -> None: """Set up test data""" self.utils = SchemaUtils.__new__(SchemaUtils) self.utils._validation_enabled = True @@ -397,7 +398,7 @@ def setup_method(self): } } - def test_validate_verb_valid_config(self): + def test_validate_verb_valid_config(self) -> None: """Test validation with valid configuration""" config = {"prompt": "You are helpful"} @@ -406,7 +407,7 @@ def test_validate_verb_valid_config(self): assert is_valid is True assert errors == [] - def test_validate_verb_missing_required(self): + def test_validate_verb_missing_required(self) -> None: """Test validation with missing required property""" config = {"temperature": 0.7} @@ -416,7 +417,7 @@ def test_validate_verb_missing_required(self): assert len(errors) == 1 assert "Missing required property 'prompt'" in errors[0] - def test_validate_verb_nonexistent_verb(self): + def test_validate_verb_nonexistent_verb(self) -> None: """Test validation with nonexistent verb""" config = {"some": "config"} @@ -426,7 +427,7 @@ def test_validate_verb_nonexistent_verb(self): assert len(errors) == 1 assert "Unknown verb: nonexistent" in errors[0] - def test_validate_verb_extra_properties_allowed(self): + def test_validate_verb_extra_properties_allowed(self) -> None: """Test validation allows extra properties""" config = {"prompt": "You are helpful", "extra_prop": "value"} @@ -439,7 +440,7 @@ def test_validate_verb_extra_properties_allowed(self): class TestCodeGeneration: """Test code generation functionality""" - def setup_method(self): + def setup_method(self) -> None: """Set up test data""" self.utils = SchemaUtils.__new__(SchemaUtils) self.utils.verbs = { @@ -467,7 +468,7 @@ def setup_method(self): } } - def test_generate_method_signature(self): + def test_generate_method_signature(self) -> None: """Test method signature generation""" result = self.utils.generate_method_signature("ai") @@ -476,7 +477,7 @@ def test_generate_method_signature(self): assert "prompt: The AI prompt text" in result assert "temperature: Temperature for AI generation" in result - def test_generate_method_body(self): + def test_generate_method_body(self) -> None: """Test method body generation""" result = self.utils.generate_method_body("ai") @@ -487,7 +488,7 @@ def test_generate_method_body(self): assert "config['temperature'] = temperature" in result assert "return self.add_verb('ai', config)" in result - def test_get_type_annotation_string(self): + def test_get_type_annotation_string(self) -> None: """Test type annotation for string""" param_def = {"type": "string"} @@ -495,7 +496,7 @@ def test_get_type_annotation_string(self): assert result == "str" - def test_get_type_annotation_integer(self): + def test_get_type_annotation_integer(self) -> None: """Test type annotation for integer""" param_def = {"type": "integer"} @@ -503,7 +504,7 @@ def test_get_type_annotation_integer(self): assert result == "int" - def test_get_type_annotation_number(self): + def test_get_type_annotation_number(self) -> None: """Test type annotation for number""" param_def = {"type": "number"} @@ -511,7 +512,7 @@ def test_get_type_annotation_number(self): assert result == "float" - def test_get_type_annotation_boolean(self): + def test_get_type_annotation_boolean(self) -> None: """Test type annotation for boolean""" param_def = {"type": "boolean"} @@ -519,7 +520,7 @@ def test_get_type_annotation_boolean(self): assert result == "bool" - def test_get_type_annotation_array(self): + def test_get_type_annotation_array(self) -> None: """Test type annotation for array""" param_def = { "type": "array", @@ -530,7 +531,7 @@ def test_get_type_annotation_array(self): assert result == "List[str]" - def test_get_type_annotation_array_no_items(self): + def test_get_type_annotation_array_no_items(self) -> None: """Test type annotation for array without items""" param_def = {"type": "array"} @@ -538,7 +539,7 @@ def test_get_type_annotation_array_no_items(self): assert result == "List[Any]" - def test_get_type_annotation_object(self): + def test_get_type_annotation_object(self) -> None: """Test type annotation for object""" param_def = {"type": "object"} @@ -546,7 +547,7 @@ def test_get_type_annotation_object(self): assert result == "Dict[str, Any]" - def test_get_type_annotation_anyof(self): + def test_get_type_annotation_anyof(self) -> None: """Test type annotation for anyOf""" param_def = { "anyOf": [ @@ -559,7 +560,7 @@ def test_get_type_annotation_anyof(self): assert result == "Any" - def test_get_type_annotation_ref(self): + def test_get_type_annotation_ref(self) -> None: """Test type annotation for $ref""" param_def = {"$ref": "#/$defs/SomeType"} @@ -567,7 +568,7 @@ def test_get_type_annotation_ref(self): assert result == "Any" - def test_get_type_annotation_unknown(self): + def test_get_type_annotation_unknown(self) -> None: """Test type annotation for unknown type""" param_def = {"type": "unknown"} @@ -579,7 +580,7 @@ def test_get_type_annotation_unknown(self): class TestSchemaUtilsIntegration: """Test integration scenarios""" - def test_complete_workflow(self): + def test_complete_workflow(self) -> None: """Test complete schema utils workflow""" schema_data = { "$defs": { @@ -642,7 +643,7 @@ def test_complete_workflow(self): is_valid, errors = utils.validate_verb("ai", valid_config) assert is_valid is True - invalid_config = {} + invalid_config: dict[str, Any] = {} is_valid, errors = utils.validate_verb("ai", invalid_config) assert is_valid is False @@ -656,7 +657,7 @@ def test_complete_workflow(self): finally: os.unlink(schema_path) - def test_error_recovery(self): + def test_error_recovery(self) -> None: """Test error recovery scenarios""" # Test with invalid schema path utils = SchemaUtils("/nonexistent/schema.json") @@ -669,7 +670,7 @@ def test_error_recovery(self): assert is_valid is False assert "Unknown verb" in errors[0] - def test_empty_schema_handling(self): + def test_empty_schema_handling(self) -> None: """Test handling of empty schema""" with patch.object(SchemaUtils, 'load_schema', return_value={}): utils = SchemaUtils("/path/to/schema.json") diff --git a/tests/unit/utils/test_url_validator.py b/tests/unit/utils/test_url_validator.py index afb676cc..af4f5fc6 100644 --- a/tests/unit/utils/test_url_validator.py +++ b/tests/unit/utils/test_url_validator.py @@ -17,72 +17,72 @@ class TestValidateUrlScheme: - def test_http_scheme_allowed(self): + def test_http_scheme_allowed(self) -> None: with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("1.2.3.4", 0))]): assert validate_url("http://example.com") is True - def test_https_scheme_allowed(self): + def test_https_scheme_allowed(self) -> None: with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("1.2.3.4", 0))]): assert validate_url("https://example.com") is True - def test_ftp_scheme_rejected(self): + def test_ftp_scheme_rejected(self) -> None: assert validate_url("ftp://example.com") is False - def test_file_scheme_rejected(self): + def test_file_scheme_rejected(self) -> None: assert validate_url("file:///etc/passwd") is False - def test_javascript_scheme_rejected(self): + def test_javascript_scheme_rejected(self) -> None: assert validate_url("javascript:alert(1)") is False class TestValidateUrlHostname: - def test_no_hostname_rejected(self): + def test_no_hostname_rejected(self) -> None: # Path-only URLs without a hostname are rejected assert validate_url("http://") is False - def test_hostname_unresolvable_rejected(self): + def test_hostname_unresolvable_rejected(self) -> None: import socket as _socket with patch("socket.getaddrinfo", side_effect=_socket.gaierror): assert validate_url("http://nonexistent.invalid") is False class TestValidateUrlBlockedRanges: - def test_loopback_ipv4_rejected(self): + def test_loopback_ipv4_rejected(self) -> None: with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("127.0.0.1", 0))]): assert validate_url("http://localhost") is False - def test_rfc1918_10_rejected(self): + def test_rfc1918_10_rejected(self) -> None: with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("10.0.0.5", 0))]): assert validate_url("http://internal") is False - def test_rfc1918_192_rejected(self): + def test_rfc1918_192_rejected(self) -> None: with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("192.168.1.1", 0))]): assert validate_url("http://router") is False - def test_link_local_metadata_rejected(self): + def test_link_local_metadata_rejected(self) -> None: # 169.254.169.254 is the AWS/GCP metadata endpoint with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("169.254.169.254", 0))]): assert validate_url("http://metadata") is False - def test_ipv6_loopback_rejected(self): + def test_ipv6_loopback_rejected(self) -> None: with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("::1", 0))]): assert validate_url("http://[::1]") is False - def test_public_ip_allowed(self): + def test_public_ip_allowed(self) -> None: with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("8.8.8.8", 0))]): assert validate_url("http://dns.google") is True class TestValidateUrlAllowPrivate: - def test_allow_private_param_bypasses_check(self): + def test_allow_private_param_bypasses_check(self) -> None: # No DNS call needed — allow_private short-circuits. assert validate_url("http://10.0.0.5", allow_private=True) is True - def test_env_var_bypasses_check(self): + def test_env_var_bypasses_check(self) -> None: with patch.dict(os.environ, {"SWML_ALLOW_PRIVATE_URLS": "true"}): assert validate_url("http://10.0.0.5") is True - def test_env_var_false_does_not_bypass(self): + def test_env_var_false_does_not_bypass(self) -> None: with patch.dict(os.environ, {"SWML_ALLOW_PRIVATE_URLS": "false"}, clear=False): with patch("socket.getaddrinfo", return_value=[(0, 0, 0, "", ("10.0.0.5", 0))]): assert validate_url("http://internal") is False diff --git a/tests/unit/web/test_web_service.py b/tests/unit/web/test_web_service.py index c451953a..187d7e43 100644 --- a/tests/unit/web/test_web_service.py +++ b/tests/unit/web/test_web_service.py @@ -23,9 +23,10 @@ """ import os -import secrets from pathlib import Path -from unittest.mock import Mock, MagicMock, patch, PropertyMock +from typing import Any +from collections.abc import Iterator +from unittest.mock import MagicMock, patch from html import escape import pytest @@ -35,7 +36,7 @@ # Helpers – build a minimally-patched WebService instance # --------------------------------------------------------------------------- -def _make_security_mock(): +def _make_security_mock() -> MagicMock: """Return a mock SecurityConfig that satisfies WebService.__init__.""" sec = MagicMock() sec.ssl_enabled = False @@ -57,19 +58,19 @@ def _make_security_mock(): def _make_web_service( - fastapi_available=True, - directories=None, - basic_auth=None, - enable_directory_browsing=False, - allowed_extensions=None, - blocked_extensions=None, - max_file_size=100 * 1024 * 1024, - enable_cors=True, -): + fastapi_available: bool = True, + directories: dict[str, str] | None = None, + basic_auth: tuple[str, str] | None = None, + enable_directory_browsing: bool = False, + allowed_extensions: list[str] | None = None, + blocked_extensions: list[str] | None = None, + max_file_size: int = 100 * 1024 * 1024, + enable_cors: bool = True, +) -> Any: """Construct a WebService with all heavy dependencies mocked out.""" security_mock = _make_security_mock() - patches = { + patches: dict[str, Any] = { "security_config": patch( "signalwire.web.web_service.SecurityConfig", return_value=security_mock, @@ -104,13 +105,13 @@ def _make_web_service( ) # Attach for later inspection / teardown - ws._test_patches = patches - ws._test_started = started - ws._test_security_mock = security_mock + ws._test_patches = patches # type: ignore[attr-defined] # test-only teardown attr + ws._test_started = started # type: ignore[attr-defined] # test-only + ws._test_security_mock = security_mock # type: ignore[attr-defined] # test-only return ws -def _stop_patches(ws): +def _stop_patches(ws: Any) -> None: for p in ws._test_patches.values(): p.stop() @@ -120,21 +121,21 @@ def _stop_patches(ws): # --------------------------------------------------------------------------- @pytest.fixture() -def web_service(): +def web_service() -> Iterator[Any]: ws = _make_web_service() yield ws _stop_patches(ws) @pytest.fixture() -def web_service_no_fastapi(): +def web_service_no_fastapi() -> Iterator[Any]: ws = _make_web_service(fastapi_available=False) yield ws _stop_patches(ws) @pytest.fixture() -def web_service_with_browsing(): +def web_service_with_browsing() -> Iterator[Any]: ws = _make_web_service(enable_directory_browsing=True) yield ws _stop_patches(ws) @@ -147,69 +148,69 @@ def web_service_with_browsing(): class TestWebServiceInit: """Tests for __init__ and _load_config.""" - def test_default_port(self, web_service): + def test_default_port(self, web_service: Any) -> None: assert web_service.port == 9999 - def test_default_directories_empty(self, web_service): + def test_default_directories_empty(self, web_service: Any) -> None: assert web_service.directories == {} - def test_custom_directories(self): + def test_custom_directories(self) -> None: ws = _make_web_service(directories={"/static": "/tmp"}) assert ws.directories == {"/static": "/tmp"} _stop_patches(ws) - def test_enable_directory_browsing_default_off(self, web_service): + def test_enable_directory_browsing_default_off(self, web_service: Any) -> None: assert web_service.enable_directory_browsing is False - def test_enable_directory_browsing_on(self, web_service_with_browsing): + def test_enable_directory_browsing_on(self, web_service_with_browsing: Any) -> None: assert web_service_with_browsing.enable_directory_browsing is True - def test_max_file_size_default(self, web_service): + def test_max_file_size_default(self, web_service: Any) -> None: assert web_service.max_file_size == 100 * 1024 * 1024 - def test_custom_max_file_size(self): + def test_custom_max_file_size(self) -> None: ws = _make_web_service(max_file_size=1024) assert ws.max_file_size == 1024 _stop_patches(ws) - def test_default_blocked_extensions(self, web_service): + def test_default_blocked_extensions(self, web_service: Any) -> None: for ext in [".env", ".git", ".gitignore", ".key", ".pem", ".crt", ".pyc", "__pycache__", ".DS_Store", ".swp"]: assert ext in web_service.blocked_extensions - def test_custom_blocked_extensions(self): + def test_custom_blocked_extensions(self) -> None: ws = _make_web_service(blocked_extensions=[".secret"]) assert ws.blocked_extensions == [".secret"] _stop_patches(ws) - def test_allowed_extensions_default_none(self, web_service): + def test_allowed_extensions_default_none(self, web_service: Any) -> None: assert web_service.allowed_extensions is None - def test_custom_allowed_extensions(self): + def test_custom_allowed_extensions(self) -> None: ws = _make_web_service(allowed_extensions=[".html", ".css"]) assert ws.allowed_extensions == [".html", ".css"] _stop_patches(ws) - def test_app_is_created_when_fastapi_available(self, web_service): + def test_app_is_created_when_fastapi_available(self, web_service: Any) -> None: # When FastAPI is importable the app attribute should not be None assert web_service.app is not None - def test_app_is_none_when_fastapi_unavailable(self, web_service_no_fastapi): + def test_app_is_none_when_fastapi_unavailable(self, web_service_no_fastapi: Any) -> None: assert web_service_no_fastapi.app is None - def test_basic_auth_from_constructor(self): + def test_basic_auth_from_constructor(self) -> None: ws = _make_web_service(basic_auth=("user1", "pass1")) assert ws._basic_auth == ("user1", "pass1") _stop_patches(ws) - def test_basic_auth_falls_back_to_security_config(self, web_service): + def test_basic_auth_falls_back_to_security_config(self, web_service: Any) -> None: # The security mock returns ("admin", "secret") assert web_service._basic_auth == ("admin", "secret") - def test_enable_cors_stored(self, web_service): + def test_enable_cors_stored(self, web_service: Any) -> None: assert web_service.enable_cors is True - def test_enable_cors_false(self): + def test_enable_cors_false(self) -> None: ws = _make_web_service(enable_cors=False) assert ws.enable_cors is False _stop_patches(ws) @@ -222,11 +223,11 @@ def test_enable_cors_false(self): class TestLoadConfig: """Tests for _load_config with mocked ConfigLoader.""" - def test_no_config_file_sets_defaults(self, web_service): + def test_no_config_file_sets_defaults(self, web_service: Any) -> None: # Already exercised in fixture – directories default to empty dict assert isinstance(web_service.directories, dict) - def test_config_with_service_section(self): + def test_config_with_service_section(self) -> None: """If a config file supplies a service section, values should be applied.""" service_section = { "port": 7777, @@ -271,37 +272,37 @@ def test_config_with_service_section(self): class TestIsFileAllowed: """Tests for the _is_file_allowed method.""" - def test_normal_html_file_allowed(self, web_service, tmp_path): + def test_normal_html_file_allowed(self, web_service: Any, tmp_path: Path) -> None: f = tmp_path / "index.html" f.write_text("") assert web_service._is_file_allowed(f) is True - def test_blocked_extension_env(self, web_service, tmp_path): + def test_blocked_extension_env(self, web_service: Any, tmp_path: Path) -> None: f = tmp_path / ".env" f.write_text("SECRET=x") assert web_service._is_file_allowed(f) is False - def test_blocked_extension_pem(self, web_service, tmp_path): + def test_blocked_extension_pem(self, web_service: Any, tmp_path: Path) -> None: f = tmp_path / "server.pem" f.write_text("-----BEGIN-----") assert web_service._is_file_allowed(f) is False - def test_blocked_extension_key(self, web_service, tmp_path): + def test_blocked_extension_key(self, web_service: Any, tmp_path: Path) -> None: f = tmp_path / "private.key" f.write_text("key data") assert web_service._is_file_allowed(f) is False - def test_blocked_extension_crt(self, web_service, tmp_path): + def test_blocked_extension_crt(self, web_service: Any, tmp_path: Path) -> None: f = tmp_path / "cert.crt" f.write_text("cert data") assert web_service._is_file_allowed(f) is False - def test_blocked_extension_pyc(self, web_service, tmp_path): + def test_blocked_extension_pyc(self, web_service: Any, tmp_path: Path) -> None: f = tmp_path / "module.pyc" f.write_bytes(b"\x00\x00") assert web_service._is_file_allowed(f) is False - def test_blocked_name_pycache(self, web_service, tmp_path): + def test_blocked_name_pycache(self, web_service: Any, tmp_path: Path) -> None: d = tmp_path / "__pycache__" d.mkdir() f = d / "module.cpython-310.pyc" @@ -309,48 +310,48 @@ def test_blocked_name_pycache(self, web_service, tmp_path): # __pycache__ appears in the path so it should be blocked assert web_service._is_file_allowed(f) is False - def test_blocked_name_ds_store(self, web_service, tmp_path): + def test_blocked_name_ds_store(self, web_service: Any, tmp_path: Path) -> None: f = tmp_path / ".DS_Store" f.write_bytes(b"\x00") assert web_service._is_file_allowed(f) is False - def test_blocked_extension_swp(self, web_service, tmp_path): + def test_blocked_extension_swp(self, web_service: Any, tmp_path: Path) -> None: f = tmp_path / ".file.swp" f.write_text("swap") assert web_service._is_file_allowed(f) is False - def test_blocked_gitignore(self, web_service, tmp_path): + def test_blocked_gitignore(self, web_service: Any, tmp_path: Path) -> None: f = tmp_path / ".gitignore" f.write_text("*.pyc") assert web_service._is_file_allowed(f) is False - def test_file_exceeds_max_size(self, tmp_path): + def test_file_exceeds_max_size(self, tmp_path: Path) -> None: ws = _make_web_service(max_file_size=10) f = tmp_path / "big.txt" f.write_text("A" * 100) assert ws._is_file_allowed(f) is False _stop_patches(ws) - def test_file_within_max_size(self, tmp_path): + def test_file_within_max_size(self, tmp_path: Path) -> None: ws = _make_web_service(max_file_size=1000) f = tmp_path / "small.txt" f.write_text("ok") assert ws._is_file_allowed(f) is True _stop_patches(ws) - def test_stat_oserror_returns_false(self, web_service): + def test_stat_oserror_returns_false(self, web_service: Any) -> None: """If stat() raises OSError the file should be denied.""" mock_path = MagicMock(spec=Path) mock_path.stat.side_effect = OSError("disk error") assert web_service._is_file_allowed(mock_path) is False - def test_stat_filenotfounderror_returns_false(self, web_service): + def test_stat_filenotfounderror_returns_false(self, web_service: Any) -> None: """If stat() raises FileNotFoundError the file should be denied.""" mock_path = MagicMock(spec=Path) mock_path.stat.side_effect = FileNotFoundError("gone") assert web_service._is_file_allowed(mock_path) is False - def test_allowed_extensions_filter(self, tmp_path): + def test_allowed_extensions_filter(self, tmp_path: Path) -> None: ws = _make_web_service(allowed_extensions=[".html"]) html = tmp_path / "page.html" html.write_text("

hi

") @@ -361,14 +362,14 @@ def test_allowed_extensions_filter(self, tmp_path): assert ws._is_file_allowed(css) is False _stop_patches(ws) - def test_allowed_extensions_blocks_unlisted(self, tmp_path): + def test_allowed_extensions_blocks_unlisted(self, tmp_path: Path) -> None: ws = _make_web_service(allowed_extensions=[".css", ".js"]) f = tmp_path / "data.json" f.write_text("{}") assert ws._is_file_allowed(f) is False _stop_patches(ws) - def test_custom_blocked_extensions(self, tmp_path): + def test_custom_blocked_extensions(self, tmp_path: Path) -> None: ws = _make_web_service(blocked_extensions=[".secret"]) f = tmp_path / "data.secret" f.write_text("hidden") @@ -391,7 +392,7 @@ class TestPathTraversalProtection: the configured directory. We test the underlying logic directly. """ - def test_traversal_dot_dot_blocked(self, tmp_path): + def test_traversal_dot_dot_blocked(self, tmp_path: Path) -> None: """../../../etc/passwd style traversal must be rejected.""" base_dir = tmp_path / "www" base_dir.mkdir() @@ -405,7 +406,7 @@ def test_traversal_dot_dot_blocked(self, tmp_path): within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path assert within is False, "Path traversal must be detected" - def test_valid_subpath_allowed(self, tmp_path): + def test_valid_subpath_allowed(self, tmp_path: Path) -> None: base_dir = tmp_path / "www" sub = base_dir / "css" sub.mkdir(parents=True) @@ -417,7 +418,7 @@ def test_valid_subpath_allowed(self, tmp_path): within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path assert within is True - def test_traversal_encoded_dots_blocked(self, tmp_path): + def test_traversal_encoded_dots_blocked(self, tmp_path: Path) -> None: """Paths that resolve outside the base should be caught.""" base_dir = tmp_path / "www" base_dir.mkdir() @@ -429,7 +430,7 @@ def test_traversal_encoded_dots_blocked(self, tmp_path): within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path assert within is False - def test_exact_base_dir_allowed(self, tmp_path): + def test_exact_base_dir_allowed(self, tmp_path: Path) -> None: """Requesting the base directory itself should be allowed (== check).""" base_dir = tmp_path / "www" base_dir.mkdir() @@ -440,7 +441,7 @@ def test_exact_base_dir_allowed(self, tmp_path): within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path assert within is True - def test_traversal_with_trailing_slash(self, tmp_path): + def test_traversal_with_trailing_slash(self, tmp_path: Path) -> None: base_dir = tmp_path / "www" base_dir.mkdir() @@ -453,7 +454,7 @@ def test_traversal_with_trailing_slash(self, tmp_path): # Unless base_dir IS tmp_path. Here base_dir is tmp_path/www so parent != www assert within is False - def test_sibling_directory_blocked(self, tmp_path): + def test_sibling_directory_blocked(self, tmp_path: Path) -> None: """Accessing a sibling of the base dir must be rejected.""" base_dir = tmp_path / "www" base_dir.mkdir() @@ -468,7 +469,7 @@ def test_sibling_directory_blocked(self, tmp_path): within = str(full_path).startswith(str(dir_path) + os.sep) or full_path == dir_path assert within is False - def test_null_byte_in_path(self, tmp_path): + def test_null_byte_in_path(self, tmp_path: Path) -> None: """Null bytes in a path component should not bypass checks.""" base_dir = tmp_path / "www" base_dir.mkdir() @@ -486,7 +487,7 @@ def test_null_byte_in_path(self, tmp_path): class TestXSSPrevention: """Verify that HTML-special characters in file/directory names are escaped.""" - def test_script_in_filename_escaped(self, web_service_with_browsing, tmp_path): + def test_script_in_filename_escaped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: malicious = tmp_path / '.txt' try: malicious.write_text("xss") @@ -497,7 +498,7 @@ def test_script_in_filename_escaped(self, web_service_with_browsing, tmp_path): assert "", quote=True) in html - def test_html_in_directory_name_escaped(self, web_service_with_browsing, tmp_path): + def test_html_in_directory_name_escaped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: malicious_dir = tmp_path / '' try: malicious_dir.mkdir() @@ -509,13 +510,13 @@ def test_html_in_directory_name_escaped(self, web_service_with_browsing, tmp_pat assert raw_name not in html assert escape(raw_name, quote=True) in html - def test_url_path_in_title_escaped(self, web_service_with_browsing, tmp_path): + def test_url_path_in_title_escaped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: """The url_path used in the / <h1> must be escaped.""" xss_path = '/<script>alert("xss")</script>' html = web_service_with_browsing._generate_directory_listing(tmp_path, xss_path) assert '<script>alert("xss")</script>' not in html - def test_ampersand_in_filename_escaped(self, web_service_with_browsing, tmp_path): + def test_ampersand_in_filename_escaped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: f = tmp_path / "a&b.txt" try: f.write_text("data") @@ -525,7 +526,7 @@ def test_ampersand_in_filename_escaped(self, web_service_with_browsing, tmp_path # The raw '&' in a non-entity context should be escaped to '&' assert "a&b.txt" in html - def test_quote_in_filename_escaped(self, web_service_with_browsing, tmp_path): + def test_quote_in_filename_escaped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: f = tmp_path / 'file"name.txt' try: f.write_text("data") @@ -543,34 +544,34 @@ def test_quote_in_filename_escaped(self, web_service_with_browsing, tmp_path): class TestDirectoryListing: """Non-security structural tests for _generate_directory_listing.""" - def test_root_path_no_parent_link(self, web_service_with_browsing, tmp_path): + def test_root_path_no_parent_link(self, web_service_with_browsing: Any, tmp_path: Path) -> None: html = web_service_with_browsing._generate_directory_listing(tmp_path, "/") assert "../" not in html - def test_non_root_path_has_parent_link(self, web_service_with_browsing, tmp_path): + def test_non_root_path_has_parent_link(self, web_service_with_browsing: Any, tmp_path: Path) -> None: html = web_service_with_browsing._generate_directory_listing(tmp_path, "/sub") assert "../" in html - def test_hidden_files_skipped(self, web_service_with_browsing, tmp_path): + def test_hidden_files_skipped(self, web_service_with_browsing: Any, tmp_path: Path) -> None: (tmp_path / ".hidden").write_text("hidden") (tmp_path / "visible.txt").write_text("visible") html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") assert ".hidden" not in html assert "visible.txt" in html - def test_file_size_bytes(self, web_service_with_browsing, tmp_path): + def test_file_size_bytes(self, web_service_with_browsing: Any, tmp_path: Path) -> None: f = tmp_path / "tiny.txt" f.write_text("ab") # 2 bytes html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") assert "B" in html - def test_file_size_kilobytes(self, web_service_with_browsing, tmp_path): + def test_file_size_kilobytes(self, web_service_with_browsing: Any, tmp_path: Path) -> None: f = tmp_path / "medium.txt" f.write_text("x" * 2048) html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") assert "KB" in html - def test_file_size_megabytes(self, web_service_with_browsing, tmp_path): + def test_file_size_megabytes(self, web_service_with_browsing: Any, tmp_path: Path) -> None: ws = _make_web_service( enable_directory_browsing=True, max_file_size=200 * 1024 * 1024, @@ -581,12 +582,12 @@ def test_file_size_megabytes(self, web_service_with_browsing, tmp_path): assert "MB" in html _stop_patches(ws) - def test_directories_listed(self, web_service_with_browsing, tmp_path): + def test_directories_listed(self, web_service_with_browsing: Any, tmp_path: Path) -> None: (tmp_path / "subdir").mkdir() html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") assert "subdir/" in html - def test_blocked_files_not_listed(self, web_service_with_browsing, tmp_path): + def test_blocked_files_not_listed(self, web_service_with_browsing: Any, tmp_path: Path) -> None: """Files that fail _is_file_allowed should not appear in listings.""" (tmp_path / "server.pem").write_text("cert") html = web_service_with_browsing._generate_directory_listing(tmp_path, "/files") @@ -600,11 +601,11 @@ def test_blocked_files_not_listed(self, web_service_with_browsing, tmp_path): class TestGetCurrentUsername: """Tests for _get_current_username.""" - def test_no_credentials_returns_none(self, web_service): + def test_no_credentials_returns_none(self, web_service: Any) -> None: result = web_service._get_current_username(None) assert result is None - def test_valid_credentials(self): + def test_valid_credentials(self) -> None: ws = _make_web_service(basic_auth=("myuser", "mypass")) creds = MagicMock() creds.username = "myuser" @@ -612,12 +613,12 @@ def test_valid_credentials(self): assert ws._get_current_username(creds) == "myuser" _stop_patches(ws) - def test_invalid_username_raises(self): + def test_invalid_username_raises(self) -> None: ws = _make_web_service(basic_auth=("admin", "secret")) creds = MagicMock() creds.username = "hacker" creds.password = "secret" - from signalwire.web.web_service import HTTPException + from fastapi import HTTPException if HTTPException is None: pytest.skip("HTTPException not available") @@ -625,12 +626,12 @@ def test_invalid_username_raises(self): ws._get_current_username(creds) _stop_patches(ws) - def test_invalid_password_raises(self): + def test_invalid_password_raises(self) -> None: ws = _make_web_service(basic_auth=("admin", "secret")) creds = MagicMock() creds.username = "admin" creds.password = "wrong" - from signalwire.web.web_service import HTTPException + from fastapi import HTTPException if HTTPException is None: pytest.skip("HTTPException not available") @@ -638,12 +639,12 @@ def test_invalid_password_raises(self): ws._get_current_username(creds) _stop_patches(ws) - def test_both_wrong_raises(self): + def test_both_wrong_raises(self) -> None: ws = _make_web_service(basic_auth=("admin", "secret")) creds = MagicMock() creds.username = "bad" creds.password = "bad" - from signalwire.web.web_service import HTTPException + from fastapi import HTTPException if HTTPException is None: pytest.skip("HTTPException not available") @@ -659,30 +660,30 @@ def test_both_wrong_raises(self): class TestAddRemoveDirectory: """Tests for add_directory and remove_directory helpers.""" - def test_add_directory_success(self, web_service, tmp_path): + def test_add_directory_success(self, web_service: Any, tmp_path: Path) -> None: d = tmp_path / "public" d.mkdir() web_service.add_directory("/pub", str(d)) assert "/pub" in web_service.directories assert web_service.directories["/pub"] == str(d) - def test_add_directory_auto_slash(self, web_service, tmp_path): + def test_add_directory_auto_slash(self, web_service: Any, tmp_path: Path) -> None: d = tmp_path / "assets" d.mkdir() web_service.add_directory("assets", str(d)) assert "/assets" in web_service.directories - def test_add_nonexistent_directory_raises(self, web_service): + def test_add_nonexistent_directory_raises(self, web_service: Any) -> None: with pytest.raises(ValueError, match="does not exist"): web_service.add_directory("/nope", "/nonexistent/path/xyz") - def test_add_file_as_directory_raises(self, web_service, tmp_path): + def test_add_file_as_directory_raises(self, web_service: Any, tmp_path: Path) -> None: f = tmp_path / "file.txt" f.write_text("hi") with pytest.raises(ValueError, match="not a directory"): web_service.add_directory("/f", str(f)) - def test_remove_directory(self, web_service, tmp_path): + def test_remove_directory(self, web_service: Any, tmp_path: Path) -> None: d = tmp_path / "removeme" d.mkdir() web_service.add_directory("/removeme", str(d)) @@ -690,14 +691,14 @@ def test_remove_directory(self, web_service, tmp_path): web_service.remove_directory("/removeme") assert "/removeme" not in web_service.directories - def test_remove_directory_auto_slash(self, web_service, tmp_path): + def test_remove_directory_auto_slash(self, web_service: Any, tmp_path: Path) -> None: d = tmp_path / "gone" d.mkdir() web_service.add_directory("/gone", str(d)) web_service.remove_directory("gone") # no leading slash assert "/gone" not in web_service.directories - def test_remove_nonexistent_is_noop(self, web_service): + def test_remove_nonexistent_is_noop(self, web_service: Any) -> None: """Removing an unknown route must be a silent no-op — and must not accidentally clobber the existing directories dict.""" # Seed an existing route so we can verify it survives. @@ -716,20 +717,20 @@ def test_remove_nonexistent_is_noop(self, web_service): class TestMountDirectories: """Tests for _mount_directories edge cases.""" - def test_skip_nonexistent_directory(self, web_service, tmp_path): + def test_skip_nonexistent_directory(self, web_service: Any, tmp_path: Path) -> None: """Directories that don't exist on disk should be skipped.""" web_service.directories = {"/missing": "/no/such/path"} # Should not raise web_service._mount_directories() - def test_skip_non_directory_path(self, web_service, tmp_path): + def test_skip_non_directory_path(self, web_service: Any, tmp_path: Path) -> None: """A path that is a file (not a dir) should be skipped.""" f = tmp_path / "afile.txt" f.write_text("content") web_service.directories = {"/afile": str(f)} web_service._mount_directories() - def test_route_gets_leading_slash(self, web_service, tmp_path): + def test_route_gets_leading_slash(self, web_service: Any, tmp_path: Path) -> None: d = tmp_path / "web" d.mkdir() web_service.directories = {"noslash": str(d)} @@ -744,11 +745,11 @@ def test_route_gets_leading_slash(self, web_service, tmp_path): class TestStartStop: """Tests for the start and stop lifecycle methods.""" - def test_start_without_fastapi_raises(self, web_service_no_fastapi): + def test_start_without_fastapi_raises(self, web_service_no_fastapi: Any) -> None: with pytest.raises(RuntimeError, match="FastAPI not available"): web_service_no_fastapi.start() - def test_start_calls_uvicorn(self, web_service): + def test_start_calls_uvicorn(self, web_service: Any) -> None: mock_uvicorn = MagicMock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): web_service._basic_auth = ("user", "pass") @@ -758,7 +759,7 @@ def test_start_calls_uvicorn(self, web_service): assert call_kwargs[1]["host"] == "127.0.0.1" assert call_kwargs[1]["port"] == 5555 - def test_start_uses_instance_port_as_default(self, web_service): + def test_start_uses_instance_port_as_default(self, web_service: Any) -> None: mock_uvicorn = MagicMock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): web_service._basic_auth = ("u", "p") @@ -766,7 +767,7 @@ def test_start_uses_instance_port_as_default(self, web_service): call_kwargs = mock_uvicorn.run.call_args assert call_kwargs[1]["port"] == 9999 - def test_start_with_ssl_params(self, web_service): + def test_start_with_ssl_params(self, web_service: Any) -> None: mock_uvicorn = MagicMock() with patch.dict("sys.modules", {"uvicorn": mock_uvicorn}): web_service._basic_auth = ("u", "p") @@ -775,13 +776,13 @@ def test_start_with_ssl_params(self, web_service): assert call_kwargs[1].get("ssl_certfile") == "/cert.pem" assert call_kwargs[1].get("ssl_keyfile") == "/key.pem" - def test_start_without_uvicorn_raises(self, web_service): + def test_start_without_uvicorn_raises(self, web_service: Any) -> None: web_service._basic_auth = ("u", "p") with patch.dict("sys.modules", {"uvicorn": None}): with pytest.raises((RuntimeError, ImportError)): web_service.start() - def test_stop_is_noop(self, web_service): + def test_stop_is_noop(self, web_service: Any) -> None: # stop() is a placeholder – should not raise web_service.stop() @@ -793,7 +794,7 @@ def test_stop_is_noop(self, web_service): class TestSetupSecurity: """Tests for the _setup_security method.""" - def test_no_app_returns_early(self): + def test_no_app_returns_early(self) -> None: """When fastapi is unavailable self.app is None; _setup_security must early-return without trying to call any method on the missing app. We confirm this by asserting that the WebService still has @@ -808,7 +809,7 @@ def test_no_app_returns_early(self): assert ws.app is None _stop_patches(ws) - def test_cors_added_when_enabled(self, web_service): + def test_cors_added_when_enabled(self, web_service: Any) -> None: """When enable_cors is True, add_middleware should have been called.""" # The middleware is added during __init__ via _setup_security. # We can verify it was called on the app mock. @@ -816,7 +817,7 @@ def test_cors_added_when_enabled(self, web_service): # If app is a real FastAPI or a mock, this simply confirms no crash pass # The fact that __init__ succeeded is the test - def test_cors_not_added_when_disabled(self): + def test_cors_not_added_when_disabled(self) -> None: ws = _make_web_service(enable_cors=False) # If app is a real FastAPI instance, we can't easily check middleware # was NOT added without inspecting internals. The key assertion is @@ -832,12 +833,12 @@ def test_cors_not_added_when_disabled(self): class TestSetupRoutes: """Tests for the _setup_routes method.""" - def test_no_app_returns_early(self): + def test_no_app_returns_early(self) -> None: ws = _make_web_service(fastapi_available=False) ws._setup_routes() # should not raise _stop_patches(ws) - def test_routes_registered(self, web_service): + def test_routes_registered(self, web_service: Any) -> None: """After init, routes should exist on the app.""" if hasattr(web_service.app, "routes"): route_paths = [ @@ -854,15 +855,15 @@ def test_routes_registered(self, web_service): class TestMimeTypes: """Test that custom MIME types are registered during init.""" - def test_js_mime_type(self, web_service): + def test_js_mime_type(self, web_service: Any) -> None: import mimetypes as mt assert mt.guess_type("script.js")[0] == "application/javascript" - def test_css_mime_type(self, web_service): + def test_css_mime_type(self, web_service: Any) -> None: import mimetypes as mt assert mt.guess_type("style.css")[0] == "text/css" - def test_json_mime_type(self, web_service): + def test_json_mime_type(self, web_service: Any) -> None: import mimetypes as mt assert mt.guess_type("data.json")[0] == "application/json" @@ -874,7 +875,7 @@ def test_json_mime_type(self, web_service): class TestBlockedExtensionEdgeCases: """Fine-grained tests for extension/name-based blocking logic.""" - def test_blocked_name_without_dot_prefix(self, tmp_path): + def test_blocked_name_without_dot_prefix(self, tmp_path: Path) -> None: """A blocked entry without a dot prefix (e.g. '__pycache__') is matched against both the name and as a substring of the full path.""" ws = _make_web_service(blocked_extensions=["secret_dir"]) @@ -885,14 +886,14 @@ def test_blocked_name_without_dot_prefix(self, tmp_path): assert ws._is_file_allowed(f) is False _stop_patches(ws) - def test_blocked_dot_entry_matches_extension(self, tmp_path): + def test_blocked_dot_entry_matches_extension(self, tmp_path: Path) -> None: ws = _make_web_service(blocked_extensions=[".bak"]) f = tmp_path / "db.bak" f.write_text("backup") assert ws._is_file_allowed(f) is False _stop_patches(ws) - def test_blocked_dot_entry_matches_full_name(self, tmp_path): + def test_blocked_dot_entry_matches_full_name(self, tmp_path: Path) -> None: """An entry like '.git' should block a file literally named '.git'.""" ws = _make_web_service(blocked_extensions=[".git"]) f = tmp_path / ".git" @@ -900,7 +901,7 @@ def test_blocked_dot_entry_matches_full_name(self, tmp_path): assert ws._is_file_allowed(f) is False _stop_patches(ws) - def test_unblocked_extension_allowed(self, tmp_path): + def test_unblocked_extension_allowed(self, tmp_path: Path) -> None: ws = _make_web_service(blocked_extensions=[".bak"]) f = tmp_path / "readme.md" f.write_text("# Title") @@ -915,7 +916,7 @@ def test_unblocked_extension_allowed(self, tmp_path): class TestDirectoryListingFiltering: """Verify the listing respects both hidden-file and extension filters.""" - def test_combined_filtering(self, tmp_path): + def test_combined_filtering(self, tmp_path: Path) -> None: ws = _make_web_service( enable_directory_browsing=True, blocked_extensions=[".secret"], @@ -938,7 +939,7 @@ def test_combined_filtering(self, tmp_path): class TestDirectoryListingStatErrors: """Ensure errors during directory iteration don't crash the listing.""" - def test_stat_error_on_file_skips_gracefully(self, web_service_with_browsing, tmp_path): + def test_stat_error_on_file_skips_gracefully(self, web_service_with_browsing: Any, tmp_path: Path) -> None: """If _is_file_allowed returns False (e.g. stat error), the file should simply be omitted from the listing.""" f = tmp_path / "broken.txt" @@ -957,7 +958,7 @@ def test_stat_error_on_file_skips_gracefully(self, web_service_with_browsing, tm class TestLoadConfigBranches: """Tests for _load_config to cover missing branches (lines 124, 129).""" - def test_load_config_find_returns_none(self): + def test_load_config_find_returns_none(self) -> None: """When find_config_file returns None and no config_file given, _load_config should return early (line 124).""" with patch( @@ -973,7 +974,7 @@ def test_load_config_find_returns_none(self): # for loading (only find_config_file was called) assert ws.directories == {} - def test_load_config_has_config_false(self): + def test_load_config_has_config_false(self) -> None: """When ConfigLoader.has_config() is False, _load_config should return early (line 129).""" mock_loader = MagicMock() @@ -992,7 +993,7 @@ def test_load_config_has_config_false(self): # get_section should never be called mock_loader.get_section.assert_not_called() - def test_load_config_service_section_none(self): + def test_load_config_service_section_none(self) -> None: """When get_section('service') returns None, no config is applied.""" mock_loader = MagicMock() mock_loader.has_config.return_value = True @@ -1010,7 +1011,7 @@ def test_load_config_service_section_none(self): ws = WebService(port=9999, directories={}) assert ws.directories == {} - def test_load_config_directories_not_dict_ignored(self): + def test_load_config_directories_not_dict_ignored(self) -> None: """When directories in service config is not a dict, it should be ignored.""" mock_loader = MagicMock() mock_loader.has_config.return_value = True @@ -1041,9 +1042,12 @@ class TestRouteHandlers: """Integration tests for the FastAPI route handlers using TestClient. Covers lines 313, 324-357 (root and health endpoints).""" - def _make_testable_service(self, directories=None, enable_directory_browsing=False, - basic_auth=None, max_file_size=100 * 1024 * 1024, - blocked_extensions=None, allowed_extensions=None): + def _make_testable_service(self, directories: dict[str, str] | None = None, + enable_directory_browsing: bool = False, + basic_auth: tuple[str, str] | None = None, + max_file_size: int = 100 * 1024 * 1024, + blocked_extensions: list[str] | None = None, + allowed_extensions: list[str] | None = None) -> Any: """Build a WebService with real FastAPI app for TestClient use.""" security_mock = _make_security_mock() @@ -1066,10 +1070,10 @@ def _make_testable_service(self, directories=None, enable_directory_browsing=Fal blocked_extensions=blocked_extensions, allowed_extensions=allowed_extensions, ) - ws._test_security_mock = security_mock + ws._test_security_mock = security_mock # type: ignore[attr-defined] # test-only return ws - def test_health_endpoint(self): + def test_health_endpoint(self) -> None: """GET /health should return status and configuration info.""" from starlette.testclient import TestClient ws = self._make_testable_service() @@ -1081,7 +1085,7 @@ def test_health_endpoint(self): assert "directories" in data assert "directory_browsing" in data - def test_root_endpoint_html(self): + def test_root_endpoint_html(self) -> None: """GET / should return HTML listing available directories.""" from starlette.testclient import TestClient ws = self._make_testable_service(directories={"/docs": "/tmp"}) @@ -1091,7 +1095,7 @@ def test_root_endpoint_html(self): assert "SignalWire Web Service" in resp.text assert "/docs" in resp.text - def test_root_endpoint_no_directories(self): + def test_root_endpoint_no_directories(self) -> None: """GET / with no directories should still return valid HTML.""" from starlette.testclient import TestClient ws = self._make_testable_service() @@ -1100,11 +1104,11 @@ def test_root_endpoint_no_directories(self): assert resp.status_code == 200 assert "Available Directories" in resp.text - def _auth(self): + def _auth(self) -> tuple[str, str]: """Return basic auth tuple for TestClient requests.""" return ("testuser", "testpass") - def test_serve_file_success(self, tmp_path): + def test_serve_file_success(self, tmp_path: Path) -> None: """Serving a valid file should return its contents.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1117,7 +1121,7 @@ def test_serve_file_success(self, tmp_path): assert resp.status_code == 200 assert resp.text == "hello world" - def test_serve_file_not_found(self, tmp_path): + def test_serve_file_not_found(self, tmp_path: Path) -> None: """Requesting a nonexistent file should return 404.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1128,7 +1132,7 @@ def test_serve_file_not_found(self, tmp_path): resp = client.get("/files/missing.txt", auth=self._auth()) assert resp.status_code == 404 - def test_serve_file_path_traversal_denied(self, tmp_path): + def test_serve_file_path_traversal_denied(self, tmp_path: Path) -> None: """Path traversal attempts should return 403.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1143,7 +1147,7 @@ def test_serve_file_path_traversal_denied(self, tmp_path): # either 403 or 404 assert resp.status_code in (403, 404) - def test_serve_file_blocked_extension(self, tmp_path): + def test_serve_file_blocked_extension(self, tmp_path: Path) -> None: """Files with blocked extensions should return 403.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1155,7 +1159,7 @@ def test_serve_file_blocked_extension(self, tmp_path): resp = client.get("/files/secrets.env", auth=self._auth()) assert resp.status_code == 403 - def test_serve_directory_browsing_disabled(self, tmp_path): + def test_serve_directory_browsing_disabled(self, tmp_path: Path) -> None: """When browsing is disabled and no index.html, return 403.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1170,7 +1174,7 @@ def test_serve_directory_browsing_disabled(self, tmp_path): resp = client.get("/files/subdir", auth=self._auth()) assert resp.status_code == 403 - def test_serve_directory_index_html_fallback(self, tmp_path): + def test_serve_directory_index_html_fallback(self, tmp_path: Path) -> None: """When browsing is disabled but index.html exists, serve it.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1187,7 +1191,7 @@ def test_serve_directory_index_html_fallback(self, tmp_path): assert resp.status_code == 200 assert "<h1>Index</h1>" in resp.text - def test_serve_directory_browsing_enabled(self, tmp_path): + def test_serve_directory_browsing_enabled(self, tmp_path: Path) -> None: """When directory browsing is enabled, return directory listing.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1205,7 +1209,7 @@ def test_serve_directory_browsing_enabled(self, tmp_path): assert "Directory listing" in resp.text assert "file.txt" in resp.text - def test_serve_file_mime_type_json(self, tmp_path): + def test_serve_file_mime_type_json(self, tmp_path: Path) -> None: """JSON files should be served with the correct MIME type.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1218,7 +1222,7 @@ def test_serve_file_mime_type_json(self, tmp_path): assert resp.status_code == 200 assert "application/json" in resp.headers.get("content-type", "") - def test_serve_file_cache_headers(self, tmp_path): + def test_serve_file_cache_headers(self, tmp_path: Path) -> None: """Served files should include Cache-Control and X-Content-Type-Options.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1231,7 +1235,7 @@ def test_serve_file_cache_headers(self, tmp_path): assert resp.status_code == 200 assert "nosniff" in resp.headers.get("X-Content-Type-Options", "") - def test_serve_file_too_large(self, tmp_path): + def test_serve_file_too_large(self, tmp_path: Path) -> None: """Files exceeding max_file_size should be denied.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1247,7 +1251,7 @@ def test_serve_file_too_large(self, tmp_path): resp = client.get("/files/big.bin", auth=self._auth()) assert resp.status_code == 403 - def test_serve_file_allowed_extension_filter(self, tmp_path): + def test_serve_file_allowed_extension_filter(self, tmp_path: Path) -> None: """When allowed_extensions is set, only those should be served.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1265,7 +1269,7 @@ def test_serve_file_allowed_extension_filter(self, tmp_path): assert resp_html.status_code == 200 assert resp_json.status_code == 403 - def test_serve_file_wrong_auth_rejected(self, tmp_path): + def test_serve_file_wrong_auth_rejected(self, tmp_path: Path) -> None: """Requests with wrong credentials should be rejected.""" from starlette.testclient import TestClient d = tmp_path / "www" @@ -1286,7 +1290,7 @@ class TestSecurityMiddleware: """Tests for security middleware (lines 169-182, 187-191). Uses TestClient to exercise the middleware in-process.""" - def _make_testable_service(self, **kwargs): + def _make_testable_service(self, **kwargs: Any) -> Any: security_mock = _make_security_mock() with patch( "signalwire.web.web_service.SecurityConfig", @@ -1304,10 +1308,10 @@ def _make_testable_service(self, **kwargs): basic_auth=("testuser", "testpass"), enable_directory_browsing=kwargs.get("enable_directory_browsing", False), ) - ws._test_security_mock = security_mock + ws._test_security_mock = security_mock # type: ignore[attr-defined] # test-only return ws - def test_security_headers_added_to_response(self): + def test_security_headers_added_to_response(self) -> None: """Security headers from SecurityConfig should be added to responses.""" from starlette.testclient import TestClient ws = self._make_testable_service() @@ -1318,7 +1322,7 @@ def test_security_headers_added_to_response(self): assert resp.headers.get("X-Content-Type-Options") == "nosniff" assert resp.headers.get("X-Frame-Options") == "DENY" - def test_host_validation_blocks_invalid_host(self): + def test_host_validation_blocks_invalid_host(self) -> None: """When should_allow_host returns False, the request should be rejected.""" from starlette.testclient import TestClient ws = self._make_testable_service() @@ -1329,7 +1333,7 @@ def test_host_validation_blocks_invalid_host(self): assert resp.status_code == 400 assert "Invalid host" in resp.text - def test_host_validation_allows_valid_host(self): + def test_host_validation_allows_valid_host(self) -> None: """When should_allow_host returns True, the request should proceed.""" from starlette.testclient import TestClient ws = self._make_testable_service() @@ -1338,7 +1342,7 @@ def test_host_validation_allows_valid_host(self): resp = client.get("/health", headers={"Host": "good.example.com"}) assert resp.status_code == 200 - def test_cache_headers_for_static_directory_paths(self, tmp_path): + def test_cache_headers_for_static_directory_paths(self, tmp_path: Path) -> None: """Requests to configured directory paths should get cache headers.""" from starlette.testclient import TestClient d = tmp_path / "static" @@ -1360,14 +1364,14 @@ def test_cache_headers_for_static_directory_paths(self, tmp_path): class TestMountDirectoriesEdgeCases: """Additional edge cases for _mount_directories (line 362).""" - def test_mount_no_app_returns_early(self): + def test_mount_no_app_returns_early(self) -> None: """When self.app is None, _mount_directories should return immediately.""" ws = _make_web_service(fastapi_available=False) ws.directories = {"/test": "/tmp"} ws._mount_directories() # should not raise _stop_patches(ws) - def test_mount_with_valid_directory(self, tmp_path): + def test_mount_with_valid_directory(self, tmp_path: Path) -> None: """Mounting a valid directory should register a route.""" ws = _make_web_service() d = tmp_path / "web" @@ -1385,7 +1389,7 @@ def test_mount_with_valid_directory(self, tmp_path): class TestStartEdgeCases: """Additional tests for start() covering SSL config paths.""" - def test_start_ssl_from_security_config(self): + def test_start_ssl_from_security_config(self) -> None: """When no ssl_cert/ssl_key params, use security config SSL settings.""" ws = _make_web_service(basic_auth=("u", "p")) ws._test_security_mock.get_ssl_context_kwargs.return_value = { @@ -1400,7 +1404,7 @@ def test_start_ssl_from_security_config(self): assert call_kwargs.get("ssl_keyfile") == "/path/key.pem" _stop_patches(ws) - def test_start_prints_ssl_enabled(self, capsys): + def test_start_prints_ssl_enabled(self, capsys: pytest.CaptureFixture[str]) -> None: """When SSL is configured, 'SSL: Enabled' should be printed.""" ws = _make_web_service(basic_auth=("u", "p")) ws._test_security_mock.get_ssl_context_kwargs.return_value = { @@ -1414,7 +1418,7 @@ def test_start_prints_ssl_enabled(self, capsys): assert "SSL: Enabled" in captured.out _stop_patches(ws) - def test_start_prints_directory_none_when_empty(self, capsys): + def test_start_prints_directory_none_when_empty(self, capsys: pytest.CaptureFixture[str]) -> None: """When no directories configured, should print 'None'.""" ws = _make_web_service(basic_auth=("u", "p")) ws.directories = {} @@ -1425,7 +1429,7 @@ def test_start_prints_directory_none_when_empty(self, capsys): assert "None" in captured.out _stop_patches(ws) - def test_start_https_scheme_in_output(self, capsys): + def test_start_https_scheme_in_output(self, capsys: pytest.CaptureFixture[str]) -> None: """When SSL params are given, scheme should be https.""" ws = _make_web_service(basic_auth=("u", "p")) mock_uvicorn = MagicMock() @@ -1435,7 +1439,7 @@ def test_start_https_scheme_in_output(self, capsys): assert "https://" in captured.out _stop_patches(ws) - def test_start_http_scheme_in_output(self, capsys): + def test_start_http_scheme_in_output(self, capsys: pytest.CaptureFixture[str]) -> None: """When no SSL, scheme should be http.""" ws = _make_web_service(basic_auth=("u", "p")) mock_uvicorn = MagicMock() @@ -1453,7 +1457,7 @@ def test_start_http_scheme_in_output(self, capsys): class TestAddDirectoryWithApp: """Tests for add_directory when app is already set.""" - def test_add_directory_triggers_mount(self, tmp_path): + def test_add_directory_triggers_mount(self, tmp_path: Path) -> None: """Adding a directory when app exists should call _mount_directories.""" ws = _make_web_service() d = tmp_path / "new_dir" @@ -1463,7 +1467,7 @@ def test_add_directory_triggers_mount(self, tmp_path): mock_mount.assert_called_once() _stop_patches(ws) - def test_add_directory_without_app_skips_mount(self, tmp_path): + def test_add_directory_without_app_skips_mount(self, tmp_path: Path) -> None: """Adding a directory when app is None should not call _mount_directories.""" ws = _make_web_service(fastapi_available=False) d = tmp_path / "new_dir"