Skip to content

Script configuration (script_config)

A script holds dispatch logic. The tables that logic walks (routes, rule lists, policy) belong to whoever operates the node and change on a different schedule than the code. script_config: is where they live, and the config namespace is how a script reads them.

The script_config: key

The value is either the document itself or the path of a YAML file holding it.

# siphon.yaml, path form: the file is watched and reloaded
script_config: /etc/siphon/routes.yaml
# siphon.yaml, inline form: fixed for the life of the process
script_config:
  routes:
    default: gateway-a.example.com
    prefixes:
      - prefix: "+1555"
        gateway: gateway-b.example.com
  • The top level is a mapping. Below it, anything YAML can say with plain mappings, sequences and scalars; mapping keys are scalars, and YAML tags (!!binary, custom tags) are refused.
  • ${VAR} and ${VAR:-default} are expanded in both forms, with the rules siphon.yaml itself follows.
  • A relative path is resolved like script.path: against the directory holding siphon.yaml when the file is there, else against the working directory.
  • A file that is missing or does not parse at startup stops siphon, naming the file. Without the key the document is empty.
  • Quote anything that is a string but looks like a number. YAML reads an unquoted +1555 as the integer 1555 and 0031 as 31, so a dialling prefix or a number with a leading zero written bare is not the text you typed, as a value and as a mapping key alike.

Reloading

The path form follows script.reload. Under auto (the default) the file is watched and read again when it changes; under sighup it is not watched. In both modes SIGHUP reads it again, and so does POST /admin/script/reload, whose reply carries "script_config": "reloaded", "unchanged" or "failed" (with the reason in script_config_error and a 422).

A reload replaces the whole document at once: a handler sees the old document or the new one, never a mix. A file that cannot be read or does not parse is logged at error with the file and the reason, and the last good document keeps serving.

Reloading the document does not reload the script. Read values where they are used, inside the handler, rather than copying them into module-level variables when the script loads: those copies are taken once and never see a reload.

Reading it from a script

# /etc/siphon/routes.yaml
routes:
  default: gateway-a.example.com
  by_prefix:
    "+1555": gateway-b.example.com
    "+15550100": gateway-c.example.com
from siphon import b2bua, config

@b2bua.on_invite
def route(call):
    number = call.ruri.user
    gateway = config.require("routes.default")
    # Longest prefix first, one narrow lookup per length.
    for length in range(len(number), 0, -1):
        found = config.get("routes.by_prefix." + number[:length])
        if found is not None:
            gateway = found
            break
    call.dial(f"sip:{number}@{gateway}")

+15550100 goes to gateway-c.example.com, +15550199 to gateway-b.example.com, anything else to gateway-a.example.com. Edit the file and the next call routes by the new table.

The table is a mapping keyed by prefix, not a list to walk, for a reason: each config.get copies what it returns, so asking for the whole list on every call costs as much as the list is long, while a lookup by key costs the same for ten routes or a hundred thousand. Keep a list for what really is one (an ordered rule set of a handful of entries) and key anything that grows.

Keys are dotted paths. Each segment is a mapping key or a zero-based sequence index (routes.prefixes.0.gateway); a mapping key written as an integer in YAML (31: nl) is matched by the segment 31; "" is the whole document. A key that itself contains a dot cannot be named this way: fetch its parent and index that.

Every call returns plain data (dict, list, str, int, float, bool, None) as a new copy, so nothing a handler does to the value reaches another handler or the next call. The copy costs as much as the value is large: on a per-message path ask for the narrowest key you need, not the whole document.

config namespace

Mock config namespace: read access to the script_config: document.

script_config: in siphon.yaml holds the tables a script walks (routes, rule lists, policy), either inline or as the path of a YAML file that siphon watches and reloads. The script reads it at the moment it needs a value, so a reload is seen by the next call::

from siphon import b2bua, config

@b2bua.on_invite
def route(call):
    number = call.ruri.user
    gateway = config.require("routes.default")
    # Longest prefix first, one narrow lookup per length.
    for length in range(len(number), 0, -1):
        found = config.get("routes.by_prefix." + number[:length])
        if found is not None:
            gateway = found
            break
    call.dial(f"sip:{number}@{gateway}")

Every call returns a new copy: changing what you were handed changes nothing for the next caller. The copy costs as much as the value is large, so on a per-message path ask for the narrowest key rather than the whole document, and key a table that grows (by_prefix: {"+1555": ...}) instead of walking a list of its rows.

In a test, hand the mock the document the script should see::

mock_module.get_script_config().set({
    "routes": {"default": "gateway-a.example.com", "by_prefix": {}},
})

set

set(document: dict) -> None

Test helper: replace the document, as a reload of the file would.

Parameters:

Name Type Description Default
document dict

The new top-level mapping, as plain data.

required

Raises:

Type Description
TypeError

document is not a dict.

clear

clear() -> None

Test helper: back to an empty document.

get

get(key: str, default: Any = None) -> Any

Return the value at key, or default when it is not set.

Parameters:

Name Type Description Default
key str

A dotted path into the document, e.g. "routes.default". Each segment is a mapping key or a zero-based sequence index; a mapping key written as an integer in YAML (31: nl) is matched by the segment 31. "" is the whole document. A key that itself contains a dot cannot be named this way: fetch its parent and index that.

required
default Any

Returned when the path does not resolve.

None

Returns:

Type Description
Any

Plain data (dict, list, str, int, float,

Any

bool or None) as a new copy on every call. A key that is

Any

set to null returns None, not default.

Example::

gateway = config.get("routes.default.gateway", "carrier-a")

require

require(key: str) -> Any

Return the value at key, or raise when it is not set.

Parameters:

Name Type Description Default
key str

A dotted path into the document, as for :meth:get.

required

Returns:

Type Description
Any

The value, as a new copy on every call.

Raises:

Type Description
LookupError

The path does not resolve, or resolves to null. The message names the key and the segment it stopped at.

Example::

prefixes = config.require("routes.prefixes")