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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion signalwire/signalwire/rest/_pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,14 @@ def _fetch_next(self) -> None:

links = resp.get("links", {})
next_url = links.get("next")
if next_url and data:
# Termination is driven ONLY by the absence of a next link, NOT by an
# empty ``data`` array on this page. A page can legitimately carry a
# ``links.next`` (more pages exist) while returning zero items on THIS
# page — e.g. a filtered page that happens to match nothing here. The
# old ``next_url and data`` condition stopped on such a page and
# silently dropped every subsequent page; iterate while a next link
# exists, empty page or not.
if next_url:
# Parse cursor/page token from next URL if present
# Most SignalWire APIs use page_token or cursor param
from urllib.parse import urlparse, parse_qs
Expand Down
5 changes: 3 additions & 2 deletions signalwire/signalwire/skills/math/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ The calculate tool accepts one parameter:
- Uses regex pattern matching for character validation

### Safe Evaluation
- Uses Python's `eval()` with restricted builtins (empty `__builtins__`)
- No access to system functions or imports
- Parses the expression to an AST (`ast.parse(..., mode="eval")`) and walks it with a restricted evaluator that only permits numeric constants and a fixed set of arithmetic operators (`+`, `-`, `*`, `/`, `%`, `**`, unary `+`/`-`)
- Never calls Python's built-in `eval`, so there is no access to names, attributes, calls, system functions, or imports
- Caps the exponent to prevent resource exhaustion
- Cannot execute arbitrary code

### Error Handling
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/rest/test_pagination_mock.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,64 @@ def test_next_raises_stop_iteration_when_done(self, signalwire_client: RestClien
stopped = False
assert stopped, "expected StopIteration on second __next__()"

def test_empty_page_with_next_continues(
self, signalwire_client: RestClient, mock: _MockHarness
) -> None:
"""An EMPTY page that still carries ``links.next`` must NOT stop iteration.

Regression for the empty-page-with-next bug (gate plan §3.3): a page can
legitimately return zero items while more pages exist (e.g. a filtered
page matching nothing on this cursor). The old termination condition
``next_url and data`` stopped on this empty page and silently dropped
every subsequent page. Correct behaviour: keep fetching while there is
a next link, so the item on page 2 is still yielded.

This test FAILS against the old ``if next_url and data:`` code (it
collects ``[]`` — page 1 is empty, and the ``and data`` clause marks the
iterator done before page 2 is ever fetched) and PASSES after the fix.
"""
# Page 1 — EMPTY data but a next cursor pointing at page 2.
_push_scenario(
mock, _FABRIC_ADDRESSES_ENDPOINT_ID,
status=200,
response={
"data": [],
"links": {"next": f"{_FABRIC_ADDRESSES_PATH}?cursor=page2"},
},
)
# Page 2 — the real item, terminal (no next).
_push_scenario(
mock, _FABRIC_ADDRESSES_ENDPOINT_ID,
status=200,
response={
"data": [{"id": "addr-late", "name": "found-after-empty-page"}],
"links": {},
},
)

it = PaginatedIterator(
signalwire_client._http,
_FABRIC_ADDRESSES_PATH,
data_key="data",
)
collected = list(it)
# The iterator did NOT stop on the empty page 1 — it fetched page 2 and
# yielded its item.
assert [item["id"] for item in collected] == ["addr-late"], (
"empty page 1 with links.next must not stop pagination; "
f"expected page-2 item, got {collected}"
)
# Two GETs went out: the empty page, then the page reached via the cursor.
gets = [e for e in mock.journal if e.path == _FABRIC_ADDRESSES_PATH]
assert len(gets) == 2, (
f"expected 2 paginated GETs across the empty page and the next, "
f"got {len(gets)}: {[(e.method, e.path, e.query_params) for e in gets]}"
)
assert gets[1].query_params.get("cursor") == ["page2"], (
f"second fetch missing cursor=page2 parsed from the empty page's "
f"links.next: {gets[1].query_params}"
)

def test_resource_paginate_walks_all_pages(
self, signalwire_client: RestClient, mock: _MockHarness
) -> None:
Expand Down
24 changes: 14 additions & 10 deletions tutorial/fred/tutorial/appendix-docker-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,25 +286,29 @@ ENTRYPOINT ["python", "fred.py"]

### 2. Secrets Management

Never hardcode credentials. Use Docker secrets or environment files:
Never hardcode credentials. Keep them out of the compose file by loading an
environment file that is not committed to source control:

```yaml
# docker-compose with secrets
# docker-compose using an env file for secrets
version: '3.8'

secrets:
fred_password:
file: ./secrets/fred_password.txt

services:
fred:
# ... other config ...
secrets:
- fred_password
environment:
- SWML_BASIC_AUTH_PASSWORD_FILE=/run/secrets/fred_password
env_file:
- ./secrets/fred.env # not committed; holds SWML_BASIC_AUTH_PASSWORD=...
```

```bash
# ./secrets/fred.env
SWML_BASIC_AUTH_USER=fred
SWML_BASIC_AUTH_PASSWORD=your-secure-password
```

The SDK reads `SWML_BASIC_AUTH_USER` / `SWML_BASIC_AUTH_PASSWORD` directly from
the process environment.

### 3. Reverse Proxy Setup

Add nginx for SSL termination:
Expand Down
4 changes: 2 additions & 2 deletions tutorial/multi_agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ python agent.py
| `SWML_SSL_CERT_PATH` | Path to SSL certificate | None |
| `SWML_SSL_KEY_PATH` | Path to SSL private key | None |
| `SWML_DOMAIN` | Domain for SSL | None |
| `SWML_AUTH_USER` | Basic auth username | Auto-generated |
| `SWML_AUTH_PASS` | Basic auth password | Auto-generated |
| `SWML_BASIC_AUTH_USER` | Basic auth username | Auto-generated |
| `SWML_BASIC_AUTH_PASSWORD` | Basic auth password | Auto-generated |
| `PYTORCH_DISABLE_AVX512` | Disable AVX512 for compatibility | `0` |

### Code Examples
Expand Down
4 changes: 2 additions & 2 deletions tutorial/multi_agents/lesson3_multi_agent_systems.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,8 +364,8 @@ Look for these in the logs:
**Authentication:**
```bash
# Set custom credentials
export SWML_AUTH_USER=myuser
export SWML_AUTH_PASS=mypassword
export SWML_BASIC_AUTH_USER=myuser
export SWML_BASIC_AUTH_PASSWORD=mypassword

# Or let the system generate them (check logs)
```
Expand Down
10 changes: 5 additions & 5 deletions tutorial/multi_agents/lesson4_advanced_features.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ class DebugAgent(AgentBase):
server = AgentServer(log_level="debug")

# Or via environment variable
export SWML_LOG_LEVEL=debug
export SIGNALWIRE_LOG_LEVEL=debug

# Available levels:
# - debug: Detailed information for debugging
Expand Down Expand Up @@ -373,9 +373,9 @@ def debug_state(self, args, raw_data):

```bash
# Core configuration
export SWML_AUTH_USER=produser
export SWML_AUTH_PASS=strongpassword
export SWML_LOG_LEVEL=info
export SWML_BASIC_AUTH_USER=produser
export SWML_BASIC_AUTH_PASSWORD=strongpassword
export SIGNALWIRE_LOG_LEVEL=info

# SSL configuration
export SWML_SSL_ENABLED=true
Expand Down Expand Up @@ -427,7 +427,7 @@ After=network.target
Type=simple
User=agent
WorkingDirectory=/opt/signalwire-agent
Environment="SWML_LOG_LEVEL=info"
Environment="SIGNALWIRE_LOG_LEVEL=info"
Environment="SWML_SSL_ENABLED=true"
ExecStart=/usr/bin/python3 /opt/signalwire-agent/agent.py
Restart=always
Expand Down