siphon-sip
Mock library and type stubs for SIPhon scripts — enables unit testing without the Rust binary and provides rich context for LLM-assisted script authoring.
Install
pip install siphon-sip
The PyPI distribution is
siphon-sip(matching thesiphon-sipcrate). The import package is stillsiphon_sdk—from siphon_sdk import ….
What is SIPhon?
SIPhon is a high-performance SIP proxy, B2BUA, and IMS platform written in Rust with Python scripting. Scripts use decorators to handle SIP events:
from siphon import proxy, registrar, auth, log
@proxy.on_request
def route(request):
if request.method == "REGISTER":
if not auth.require_digest(request, realm="example.com"):
return
registrar.save(request)
request.reply(200, "OK")
return
contacts = registrar.lookup(request.ruri)
if not contacts:
request.reply(404, "Not Found")
return
request.record_route()
request.fork([c.uri for c in contacts])
This SDK lets you test these scripts with pytest — no Rust binary needed.
Quick start
from siphon_sdk.testing import SipTestHarness
from siphon_sdk.types import Contact
harness = SipTestHarness(local_domains=["example.com"])
harness.load_script("scripts/proxy_default.py")
# Pre-populate the registrar
harness.registrar.add_contact(
"sip:alice@example.com",
Contact(uri="sip:alice@192.168.1.5:5060"),
)
# Test REGISTER challenge
result = harness.send_request("REGISTER", "sip:alice@example.com",
from_uri="sip:alice@example.com")
assert result.status_code == 401 # digest challenge
# Test INVITE routing
result = harness.send_request("INVITE", "sip:alice@example.com")
assert result.action == "fork"
assert "sip:alice@192.168.1.5:5060" in result.targets
Testing B2BUA scripts
harness = SipTestHarness()
harness.load_script("scripts/b2bua_default.py")
harness.registrar.add_contact(
"sip:bob@example.com",
Contact(uri="sip:bob@10.0.0.2:5060"),
)
result = harness.send_invite(ruri="sip:bob@example.com")
assert result.action == "fork"
assert result.targets == ["sip:bob@10.0.0.2:5060"]
# Test BYE handling
result = harness.send_bye(initiator_side="a")
assert result.was_terminated
Testing extension scripts (SMPP, HTTP)
The opt-in siphon extensions inject extra
namespaces (smpp, http) at runtime. The SDK mocks them too, with dedicated
harnesses, so extension scripts are testable from the same pip install siphon-sip — no running SMSC or HTTP listener required:
from siphon_sdk.smpp_testing import SmppTestHarness
harness = SmppTestHarness()
harness.load_script("scripts/gateway.py")
assert harness.bind("esme1", password="s3cret")
reply = harness.submit_sm(source_addr="15550100", destination_addr="15550101",
short_message=b"hi")
assert reply.ok
from siphon_sdk.http_testing import HttpTestHarness
from siphon_sdk.http import MockResponse
harness = HttpTestHarness()
harness.add_response(MockResponse(status=200, body=b'{"ok":true}'))
harness.load_script("scripts/api.py")
resp = harness.request("GET", "/users/42")
assert resp.status == 200
Inline scripts
Test scripts without separate files:
harness = SipTestHarness()
harness.load_source("""
from siphon import proxy
@proxy.on_request
def route(request):
if request.source_ip_in(["10.0.0.0/8"]):
request.relay()
else:
request.reply(403, "Forbidden")
""")
result = harness.send_request("INVITE", "sip:bob@host", source_ip="10.1.2.3")
assert result.was_relayed
result = harness.send_request("INVITE", "sip:bob@host", source_ip="8.8.8.8")
assert result.status_code == 403
Async handlers + RTPEngine
harness = SipTestHarness()
harness.load_source("""
from siphon import proxy, rtpengine
@proxy.on_request
async def route(request):
if request.method == "INVITE" and request.body:
await rtpengine.offer(request, profile="srtp_to_rtp")
request.relay()
""")
result = harness.send_request("INVITE", "sip:bob@host",
body=b"v=0\\r\\n...",
content_type="application/sdp")
assert result.was_relayed
assert harness.rtpengine.operations == [("offer", "srtp_to_rtp")]
Controlling mock behavior
# Auth: allow or deny all
harness.auth._allow = True # all auth checks pass
# Rate limiting
harness.proxy._utils._rate_limit_allow = False # simulate overload
# Cache: pre-populate
harness.cache.set_data("cnam", {"key": "value"})
# Registrar: add contacts directly
harness.registrar.add_contact("sip:alice@host", Contact(uri="sip:alice@1.2.3.4"))
# Log: inspect captured messages
assert any("error" in msg for level, msg in harness.log.messages)
# Reset between tests
harness.reset()
Result assertions
RequestResult provides convenient properties:
| Property | Description |
|---|---|
.action |
Primary action: "reply", "relay", "fork", "silent_drop" |
.status_code |
SIP status code (200, 401, 404, etc.) |
.reason |
Reason phrase |
.targets |
Fork targets list |
.strategy |
Fork strategy ("parallel" / "sequential") |
.was_relayed |
True if relay() was called |
.was_forked |
True if fork() was called |
.was_dropped |
True if handler returned without action (silent drop) |
.record_routed |
True if record_route() was called |
.request |
The mock Request object for header inspection |
API reference
Namespaces
| Import | Description |
|---|---|
proxy |
Stateful/stateless proxy decorators and utilities |
registrar |
Address-of-record contact store |
auth |
SIP digest authentication |
b2bua |
Back-to-back user agent call control |
log |
Structured logging |
cache |
Named cache (local LRU + Redis) |
rtpengine |
RTPEngine media proxy operations |
gateway |
Destination groups, load balancing, health probing |
cdr |
Call detail records |
diameter |
Diameter protocol (Cx, Ro, Rx, Rf, Sh) |
presence |
SUBSCRIBE/NOTIFY, PIDF presence |
li |
Lawful intercept (ETSI X1/X2/X3, SIPREC) |
registration |
Outbound REGISTER client (trunk registration) |
Request properties
| Property | Type | Description |
|---|---|---|
method |
str |
SIP method ("INVITE", "REGISTER", etc.) |
ruri |
SipUri |
Request-URI |
from_uri |
SipUri | None |
From header URI |
to_uri |
SipUri | None |
To header URI |
from_tag |
str | None |
From-tag |
to_tag |
str | None |
To-tag (None for initial requests) |
call_id |
str | None |
Call-ID |
cseq |
(int, str) | None |
CSeq tuple |
in_dialog |
bool |
Both tags present |
max_forwards |
int |
Max-Forwards value |
body |
bytes | None |
Message body |
content_type |
str | None |
Content-Type |
transport |
str |
"udp", "tcp", "tls", "ws", "wss" |
source_ip |
str |
Sender IP |
auth_user |
str | None |
Authenticated username |
event |
str | None |
Event header |
Request methods
| Method | Description |
|---|---|
reply(code, reason) |
Send SIP response |
relay(next_hop=None) |
Forward to destination |
fork(targets, strategy="parallel") |
Fork to multiple targets |
record_route() |
Insert Record-Route |
loose_route() -> bool |
RFC 3261 loose routing |
get_header(name) -> str | None |
Get header value |
set_header(name, value) |
Set header |
remove_header(name) |
Remove header |
has_header(name) -> bool |
Check header exists |
has_body(content_type) -> bool |
Check body type |
set_ruri_user(value) |
Set R-URI user part |
set_ruri_host(value) |
Set R-URI host |
source_ip_in(cidrs) -> bool |
CIDR membership check |
generate_icid() -> str |
Generate charging ID |
add_path(uri) |
Prepend Path header |
prepend_route(uri) |
Prepend Route header |
fix_nated_register() |
NAT fixup for REGISTER |
fix_nated_contact() |
NAT fixup for Contact |
Registrar
| Method | Description |
|---|---|
save(request, force=False) |
Save REGISTER bindings |
lookup(uri) -> list[Contact] |
Look up contacts (sorted by q-value) |
is_registered(uri) -> bool |
Check if URI has contacts |
service_route(uri) -> list[str] |
Get stored service routes (RFC 3608) |
set_service_routes(aor, routes) |
Store service routes for an AoR |
save_pending(request) |
IMS: save binding in pending state |
confirm_pending(uri) |
IMS: promote pending to active after SAR |
asserted_identity(uri) -> str | None |
IMS: stored P-Asserted-Identity |
reginfo_xml(aor, state, version) -> str |
Generate reginfo XML (RFC 3680) |
on_change |
Decorator: fires on registration state changes |
lookup() returns Contact objects:
| Field | Description |
|---|---|
uri |
Contact URI as advertised by the UE |
received |
Transport source of the REGISTER as a SIP URI (sip:<ip>:<port>;transport=<proto>), or None. Prefer it over uri when routing — the Contact URI may carry a private/NAT address (contact.received or contact.uri) |
q |
Quality value (0.0–1.0), higher wins |
expires |
Seconds remaining on the binding |
age_secs |
Seconds since the binding was created or refreshed |
path |
RFC 3327 Path headers stored with the binding |
instance_id / instance_epoch |
Which siphon instance/process accepted the REGISTER |
is_local |
True when this process accepted the binding |
flow_token / flow |
Captured inbound flow for RFC 5626 connection reuse |
params |
Contact-header params preserved from the REGISTER (RFC 3840 feature tags) |
kind |
"ue" (routable) or "as" (AS capability record) |
Auth
| Method | Description |
|---|---|
require_www_digest(request, realm) -> bool |
401 challenge |
require_proxy_digest(request, realm) -> bool |
407 challenge |
require_digest(request, realm) -> bool |
Alias for www_digest |
verify_digest(request, realm) -> bool |
Verify without challenge |
require_ims_digest(request, realm) -> bool |
IMS AKA via Diameter Cx MAR |
require_aka_digest(request, realm) -> bool |
Local Milenage AKA (no HSS) |
B2BUA call
Each B-leg gets a fresh Call-ID and From-tag by default, fully decoupling the two SIP dialogs. Use keep_call_id() to opt out of Call-ID regeneration.
| Property/Method | Description |
|---|---|
call.id |
UUID |
call.state |
"calling", "ringing", "answered", "terminated" |
call.from_uri |
A-leg From URI |
call.ruri |
A-leg Request-URI |
call.reject(code, reason) |
Reject call |
call.dial(uri, timeout=30) |
Dial single target |
call.fork(targets, strategy, timeout) |
Fork to multiple |
call.terminate() |
End call (BYE both legs) |
call.keep_call_id() |
Copy A-leg Call-ID to B-leg (From-tag always unique) |
call.set_credentials(user, pass) |
B-leg digest auth credentials (auto 401/407 retry) |
call.media.anchor(engine) |
Anchor media through RTPEngine |
call.media.release() |
Release media anchor |
call.session_timer(expires, min_se, refresher) |
Per-call RFC 4028 session timer |
call.record(srs_uri) |
Start SIPREC recording |
call.stop_recording() |
Stop SIPREC recording |
Provisioning outbound registrations
siphon_sdk.registrants is the typed mirror of the contract a
registrant.backend: http source answers with — the list of trunks siphon
should keep registered. A controller that owns trunks as data serves it and
siphon reconciles against it, so a trunk added, edited or deleted needs no
restart and no script:
from fastapi import FastAPI
from siphon_sdk.registrants import RegistrantListResponse, RegistrantRow
app = FastAPI()
@app.get("/registrants")
def registrants() -> dict:
return RegistrantListResponse(registrants=[
RegistrantRow(
aor="sip:trunk1@carrier.example",
registrar="sip:carrier.example:5060",
username="trunk1",
password="…",
),
]).to_dict()
The list is the complete desired state, not a delta: a trunk that is absent is
de-registered. Supply password or ha1, never both — an ha1 is not
reversible to the password but is still password-equivalent for its realm, and
is bound to the hash it was computed with.
Full contract, including the SQL column names the database source reads:
https://siphon-sip.org/reference/registrant-api/.
Provisioning gateways
siphon_sdk.gateways is the same idea for where calls egress to. One row is
one destination; rows are gathered into groups by group, which is the name
gateway.select() takes:
from siphon_sdk.gateways import GatewayListResponse, GatewayRow
@app.get("/gateways")
def gateways() -> dict:
return GatewayListResponse(gateways=[
GatewayRow(group="carriers", uri="sip:gw1.carrier.example:5060",
weight=3, registers="sip:trunk1@carrier.example"),
]).to_dict()
registers links a destination to an outbound registration: it answers
challenges with that registration's credential, so a trunk's secret is defined
once, and require_registration=True additionally keeps it out of selection
while that registration is down.
A destination whose definition has not changed keeps its health across a refresh, so a carrier that is down stays down rather than being marked healthy again every poll.
Full contract: https://siphon-sip.org/reference/gateway-api/.
Consuming CDRs
siphon_sdk.cdr.CallDetailRecord is the typed mirror of the record siphon
writes to its CDR sinks — one JSON object per HTTP POST, per line of the
JSON-lines file, per syslog message. Import it in your collector instead of
hand-parsing dicts:
from fastapi import FastAPI
from siphon_sdk.cdr import CallDetailRecord
app = FastAPI()
@app.post("/cdr")
async def collect(payload: dict) -> dict:
record = CallDetailRecord.from_dict(payload)
if record.is_media: # method == "MEDIA"
for leg in record.media_legs: # join to the call on call_id
print(leg.role, leg.codec, leg.packets_lost)
else:
print(record.call_id, record.duration_secs, record.reason_cause)
print(record.extra["billing_id"]) # cdr.write(extra={...})
return {"ok": True}
Take the body as dict and call from_dict. Custom fields are flattened into
the top level of the JSON, and a validating body model drops them before the
handler runs; from_dict keeps them in record.extra.
examples/cdr_collector.py in the SIPhon repo is a runnable version of this.
License
MIT
Release files for siphon-sip 1.9.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| siphon_sip-1.9.1.tar.gz | 278.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| siphon_sip-1.9.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 484.9 kB
Release files / siphon_sip-1.9.1.tar.gz
| Download URL | siphon_sip-1.9.1.tar.gz |
|---|---|
| Size | 278.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
eff01a332a810aef3d919507259a4f976afb6a572e1f9c09af1c379ab6c3246d
|
|
BLAKE2b-256 checksum How to use checksums |
6a50a77ebf665813eb7997b4b31937977c5fa9b57bfebf4cf0ad735c5e1939da
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency logRelease files / siphon_sip-1.9.1-py3-none-any.whl
| Download URL | siphon_sip-1.9.1-py3-none-any.whl |
|---|---|
| Size | 206.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
323332724a56dda5f1b505767ba0fea14c97fb85259f9a5995817827d7fadf9a
|
|
BLAKE2b-256 checksum How to use checksums |
f1b06c5dc4e030d14fa920fa2a5a6ab74b16944e5a9f82968f1dc910f53d2a32
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 21, 2026.
Transparency log