Skip to content
9 changes: 7 additions & 2 deletions .github/workflows/doc-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,21 @@ jobs:
# deleted livewire member stops resolving instead of staying excused by a stale
# committed file. Mirrors run-ci.sh's SURFACE-NATIVE gate (DOC-AUDIT deps on it).
- name: Regenerate the native-only doc-audit sidecar
# `python` (the setup-python shim), not bare `python3` — see the PACKAGE-SMOKE
# note in multi-os.yml. This job is ubuntu-only, where setup-python front-loads
# its dir so both names currently resolve to the toolcache; using the shim keeps
# that true if this workflow ever gains a macOS runner, where bare `python3`
# resolves to the framework Python instead.
run: |
python3 signalwire-python/scripts/emit_surface_native.py \
python signalwire-python/scripts/emit_surface_native.py \
--out signalwire-python/port_surface_native.json

- name: Run audit_docs.py against the Python surface
# --native-names is load-bearing: signalwire.livewire.* is excluded from the
# surface oracle by design, but livewire/ docs are in this perimeter, so its
# real members only resolve via the sidecar. See scripts/emit_surface_native.py.
run: |
python3 porting-sdk/scripts/audit_docs.py \
python porting-sdk/scripts/audit_docs.py \
--root signalwire-python \
--surface porting-sdk/python_surface.json \
--ignore signalwire-python/DOC_AUDIT_IGNORE.md \
Expand Down
14 changes: 13 additions & 1 deletion .github/workflows/multi-os.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,16 @@ jobs:
- name: PACKAGE-SMOKE (build + install + import from the built artifact)
shell: bash
working-directory: signalwire-python
run: python3 ../porting-sdk/scripts/package_smoke.py --port python --repo .
# `python`, not `python3`: use the SAME name `pip` above pairs with, so this
# step provably runs the interpreter the deps were installed into. (On the
# macOS/arm64 runner BOTH names resolve to the pre-installed framework Python
# — setup-python does not win PATH there — and `pip` is that Python's pip, so
# the whole job is consistently one interpreter. Pinning the name keeps it that
# way if the image's precedence ever changes.)
#
# The nightly failure (run 30238061313, macos-latest) was
# "No module named build" — NOT an interpreter mismatch: `build` was never a
# declared dependency at all, so the gate silently relied on the runner image
# shipping it. Now declared in requirements-dev.txt, which the step above
# installs. package_smoke.py is not at fault; it correctly uses sys.executable.
run: python ../porting-sdk/scripts/package_smoke.py --port python --repo .
6 changes: 6 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ factory-boy>=3.3.0 # For test data factories
faker>=19.0.0 # For generating fake data
httpx>=0.24.0 # For async HTTP testing
aiofiles>=23.0.0 # For async file operations in tests
build>=1.0.0 # PACKAGE-SMOKE gate runs `python -m build --wheel` (see
# porting-sdk/scripts/package_smoke.py plan_python). It was
# never declared, so the gate depended on the runner image
# happening to ship it — and failed on macOS/Windows, where it
# does not (AGENT_RULES §7: a tool a gate needs is DECLARED,
# not assumed present).

# Optional-feature deps required by tests under tests/unit/search/.
# These mirror the [search-queryonly] extra in pyproject.toml so the
Expand Down
10 changes: 7 additions & 3 deletions signalwire/signalwire/cli/dokku.py
Original file line number Diff line number Diff line change
Expand Up @@ -1961,7 +1961,11 @@ def _write_file(self, path: str, content: str, executable: bool = False) -> None
"""Write a file to the project directory."""
file_path = self.project_dir / path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
# Always UTF-8, never the platform default. Several templates embed
# box-drawing characters (U+2500/U+2550) and arrows, which the Windows
# default codec (cp1252) cannot encode -- writing without an explicit
# encoding raises UnicodeEncodeError there.
file_path.write_text(content, encoding="utf-8")

if executable:
file_path.chmod(0o755)
Expand Down Expand Up @@ -2101,7 +2105,7 @@ def cmd_deploy(args: argparse.Namespace) -> int:

try:
with open( # noqa: PTH123 # tests patch builtins.open while mocking Path; Path.open() would bypass the mock seam
"app.json"
"app.json", encoding="utf-8"
) as f:
app_json = json.load(f)
app_name = app_json.get("name")
Expand Down Expand Up @@ -2232,7 +2236,7 @@ def _get_app_name() -> str:

try:
with open( # noqa: PTH123 # tests patch builtins.open while mocking Path; Path.open() would bypass the mock seam
"app.json"
"app.json", encoding="utf-8"
) as f:
# json.load() is typed -> Any; the "name" field is a string
# (default "" when absent). Coerce to satisfy the str return.
Expand Down
82 changes: 53 additions & 29 deletions signalwire/signalwire/cli/init_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -1915,25 +1915,29 @@ def _generate_aws(self) -> bool:

# handler.py
handler_code = AWS_HANDLER_TEMPLATE.format(**template_vars)
(self.project_dir / "handler.py").write_text(handler_code)
(self.project_dir / "handler.py").write_text(handler_code, encoding="utf-8")
print_success("Created handler.py")

# requirements.txt
(self.project_dir / "requirements.txt").write_text(AWS_REQUIREMENTS_TEMPLATE)
(self.project_dir / "requirements.txt").write_text(
AWS_REQUIREMENTS_TEMPLATE, encoding="utf-8"
)
print_success("Created requirements.txt")

# deploy.sh
deploy_code = AWS_DEPLOY_TEMPLATE.format(**template_vars)
deploy_path = self.project_dir / "deploy.sh"
deploy_path.write_text(deploy_code)
deploy_path.write_text(deploy_code, encoding="utf-8")
deploy_path.chmod(0o755)
print_success("Created deploy.sh")

# .env.example
self._create_cloud_env_example("aws")

# .gitignore
(self.project_dir / ".gitignore").write_text(TEMPLATE_GITIGNORE)
(self.project_dir / ".gitignore").write_text(
TEMPLATE_GITIGNORE, encoding="utf-8"
)
print_success("Created .gitignore")

# README.md
Expand All @@ -1951,25 +1955,29 @@ def _generate_gcp(self) -> bool:

# main.py
main_code = GCP_MAIN_TEMPLATE.format(**template_vars)
(self.project_dir / "main.py").write_text(main_code)
(self.project_dir / "main.py").write_text(main_code, encoding="utf-8")
print_success("Created main.py")

# requirements.txt
(self.project_dir / "requirements.txt").write_text(GCP_REQUIREMENTS_TEMPLATE)
(self.project_dir / "requirements.txt").write_text(
GCP_REQUIREMENTS_TEMPLATE, encoding="utf-8"
)
print_success("Created requirements.txt")

# deploy.sh
deploy_code = GCP_DEPLOY_TEMPLATE.format(**template_vars)
deploy_path = self.project_dir / "deploy.sh"
deploy_path.write_text(deploy_code)
deploy_path.write_text(deploy_code, encoding="utf-8")
deploy_path.chmod(0o755)
print_success("Created deploy.sh")

# .env.example
self._create_cloud_env_example("gcp")

# .gitignore
(self.project_dir / ".gitignore").write_text(TEMPLATE_GITIGNORE)
(self.project_dir / ".gitignore").write_text(
TEMPLATE_GITIGNORE, encoding="utf-8"
)
print_success("Created .gitignore")

# README.md
Expand All @@ -1991,39 +1999,47 @@ def _generate_azure(self) -> bool:

# function_app/__init__.py
init_code = AZURE_INIT_TEMPLATE.format(**template_vars)
(function_dir / "__init__.py").write_text(init_code)
(function_dir / "__init__.py").write_text(init_code, encoding="utf-8")
print_success("Created function_app/__init__.py")

# function_app/function.json
(function_dir / "function.json").write_text(AZURE_FUNCTION_JSON_TEMPLATE)
(function_dir / "function.json").write_text(
AZURE_FUNCTION_JSON_TEMPLATE, encoding="utf-8"
)
print_success("Created function_app/function.json")

# host.json
(self.project_dir / "host.json").write_text(AZURE_HOST_JSON_TEMPLATE)
(self.project_dir / "host.json").write_text(
AZURE_HOST_JSON_TEMPLATE, encoding="utf-8"
)
print_success("Created host.json")

# local.settings.json
(self.project_dir / "local.settings.json").write_text(
AZURE_LOCAL_SETTINGS_TEMPLATE
AZURE_LOCAL_SETTINGS_TEMPLATE, encoding="utf-8"
)
print_success("Created local.settings.json")

# requirements.txt
(self.project_dir / "requirements.txt").write_text(AZURE_REQUIREMENTS_TEMPLATE)
(self.project_dir / "requirements.txt").write_text(
AZURE_REQUIREMENTS_TEMPLATE, encoding="utf-8"
)
print_success("Created requirements.txt")

# deploy.sh
deploy_code = AZURE_DEPLOY_TEMPLATE.format(**template_vars)
deploy_path = self.project_dir / "deploy.sh"
deploy_path.write_text(deploy_code)
deploy_path.write_text(deploy_code, encoding="utf-8")
deploy_path.chmod(0o755)
print_success("Created deploy.sh")

# .env.example
self._create_cloud_env_example("azure")

# .gitignore
(self.project_dir / ".gitignore").write_text(TEMPLATE_GITIGNORE)
(self.project_dir / ".gitignore").write_text(
TEMPLATE_GITIGNORE, encoding="utf-8"
)
print_success("Created .gitignore")

# README.md
Expand Down Expand Up @@ -2073,7 +2089,7 @@ def _create_cloud_env_example(self, platform: str) -> None:
SWML_BASIC_AUTH_USER=admin
SWML_BASIC_AUTH_PASSWORD=your-secure-password
"""
(self.project_dir / ".env.example").write_text(env_content)
(self.project_dir / ".env.example").write_text(env_content, encoding="utf-8")
print_success("Created .env.example")

def _create_cloud_readme(self, platform: str) -> None:
Expand Down Expand Up @@ -2269,7 +2285,7 @@ def _create_cloud_readme(self, platform: str) -> None:
Set your phone number's SWML URL to the endpoint URL shown after deployment.
"""

(self.project_dir / "README.md").write_text(readme)
(self.project_dir / "README.md").write_text(readme, encoding="utf-8")
print_success("Created README.md")

def _create_directories(self) -> None:
Expand All @@ -2291,24 +2307,26 @@ def _create_agent_files(self) -> None:
agents_dir = self.project_dir / "agents"

# __init__.py
(agents_dir / "__init__.py").write_text(TEMPLATE_AGENTS_INIT)
(agents_dir / "__init__.py").write_text(TEMPLATE_AGENTS_INIT, encoding="utf-8")
print_success("Created agents/__init__.py")

# main_agent.py
agent_code = get_agent_template(
self.config.get("agent_type", "basic"), self.features
)
(agents_dir / "main_agent.py").write_text(agent_code)
(agents_dir / "main_agent.py").write_text(agent_code, encoding="utf-8")
print_success("Created agents/main_agent.py")

# skills/__init__.py
(self.project_dir / "skills" / "__init__.py").write_text(TEMPLATE_SKILLS_INIT)
(self.project_dir / "skills" / "__init__.py").write_text(
TEMPLATE_SKILLS_INIT, encoding="utf-8"
)
print_success("Created skills/__init__.py")

def _create_app_file(self) -> None:
"""Create main app.py entry point."""
app_code = get_app_template(self.features)
(self.project_dir / "app.py").write_text(app_code)
(self.project_dir / "app.py").write_text(app_code, encoding="utf-8")
print_success("Created app.py")

def _create_config_files(self) -> None:
Expand Down Expand Up @@ -2342,43 +2360,49 @@ def _create_config_files(self) -> None:
DEBUG_WEBHOOK_LEVEL=1
"""

(self.project_dir / ".env").write_text(env_content)
(self.project_dir / ".env").write_text(env_content, encoding="utf-8")
print_success("Created .env")

# .env.example
(self.project_dir / ".env.example").write_text(TEMPLATE_ENV_EXAMPLE)
(self.project_dir / ".env.example").write_text(
TEMPLATE_ENV_EXAMPLE, encoding="utf-8"
)
print_success("Created .env.example")

# .gitignore
(self.project_dir / ".gitignore").write_text(TEMPLATE_GITIGNORE)
(self.project_dir / ".gitignore").write_text(
TEMPLATE_GITIGNORE, encoding="utf-8"
)
print_success("Created .gitignore")

# requirements.txt
(self.project_dir / "requirements.txt").write_text(TEMPLATE_REQUIREMENTS)
(self.project_dir / "requirements.txt").write_text(
TEMPLATE_REQUIREMENTS, encoding="utf-8"
)
print_success("Created requirements.txt")

def _create_test_files(self) -> None:
"""Create test files."""
tests_dir = self.project_dir / "tests"

(tests_dir / "__init__.py").write_text(TEMPLATE_TESTS_INIT)
(tests_dir / "__init__.py").write_text(TEMPLATE_TESTS_INIT, encoding="utf-8")
print_success("Created tests/__init__.py")

test_code = get_test_template(self.features.get("example_tool", True))
(tests_dir / "test_agent.py").write_text(test_code)
(tests_dir / "test_agent.py").write_text(test_code, encoding="utf-8")
print_success("Created tests/test_agent.py")

def _create_web_files(self) -> None:
"""Create web UI files."""
web_dir = self.project_dir / "web"

(web_dir / "index.html").write_text(get_web_index_template())
(web_dir / "index.html").write_text(get_web_index_template(), encoding="utf-8")
print_success("Created web/index.html")

def _create_readme(self) -> None:
"""Create README.md."""
readme = get_readme_template(self.project_name, self.features)
(self.project_dir / "README.md").write_text(readme)
(self.project_dir / "README.md").write_text(readme, encoding="utf-8")
print_success("Created README.md")

def _create_virtualenv(self) -> None:
Expand Down
25 changes: 21 additions & 4 deletions signalwire/signalwire/relay/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import json
import os
import re
import signal
import ssl as ssl_module
import uuid
from typing import Any, TYPE_CHECKING
Expand Down Expand Up @@ -680,11 +681,27 @@ async def _run_forever(self) -> None:
"""Connect and maintain the connection with auto-reconnect."""
# Register SIGINT handler so Ctrl+C triggers a clean shutdown
# instead of dumping a stack trace.
#
# loop.add_signal_handler() is a Unix-only asyncio capability: the
# Windows Proactor/Selector loops raise NotImplementedError
# unconditionally (CPython Lib/asyncio/events.py). Without this guard a
# bare `NotImplementedError` escaped _run_forever() on the very first
# statement, so RelayClient.run() could never connect on Windows at
# all. Degrade instead: on a platform with no loop-level signal
# handling, Ctrl+C still stops the client — asyncio.run() surfaces it
# as KeyboardInterrupt, which run() suppresses — we just lose the
# graceful _shutdown() handshake.
loop = asyncio.get_running_loop()
loop.add_signal_handler(
__import__("signal").SIGINT,
lambda: asyncio.ensure_future(self._shutdown()),
)
try:
loop.add_signal_handler(
signal.SIGINT,
lambda: asyncio.ensure_future(self._shutdown()),
)
except NotImplementedError:
logger.debug(
"Loop-level SIGINT handling unavailable on this platform; "
"falling back to KeyboardInterrupt-driven shutdown"
)

while not self._closing:
try:
Expand Down
Loading