diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..7d3433e --- /dev/null +++ b/.coveragerc @@ -0,0 +1,5 @@ +[run] +source = pystrix + +[report] +show_missing = True diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..1bea69e --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# Revisions listed here are skipped by `git blame` (git 2.23+) and GitHub. +# Configure locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs + +# Reformat with ruff format and sort imports (#52) +a494c88d84f14d26c9ce35dabafe15eb7124cb3e diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5ace460 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3f1689 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Install ruff + run: pip install ruff==0.15.19 + - name: Run ruff + run: ruff check . + - name: Check formatting + run: ruff format --check . + + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v7 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install package with test dependencies + run: pip install -e '.[test]' + - name: Run tests with coverage + run: pytest -q --cov --cov-report=term-missing + + docs: + name: Documentation build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Install dependencies + run: | + pip install -e . + pip install -r doc/requirements.txt + - name: Build docs with warnings as errors + run: sphinx-build -W --keep-going -b html doc doc/_build/html + + package: + name: Package build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Build sdist and wheel + run: | + python -m pip install build + python -m build + - name: Validate metadata and install the wheel + run: | + python -m pip install twine + twine check dist/* + python -m pip install dist/*.whl + python -c "import pystrix; print('installed', pystrix.VERSION)" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..581d274 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,68 @@ +name: Publish to PyPI + +# Publishes to PyPI when a GitHub release is published. Authentication uses +# PyPI Trusted Publishing (OIDC), so no API token is stored in the repo or CI. +# Build and publish are separate jobs: only the publish job holds the OIDC +# publishing identity, so the build backend never has access to it. +# +# One-time PyPI setup (maintainer): add a Trusted Publisher to the pystrix +# project with owner "IVRTech", repository "pystrix", workflow "publish.yml", +# and environment "pypi". +on: + release: + types: [published] + +permissions: + contents: read + +jobs: + build: + name: Build distribution + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Build sdist and wheel + run: | + python -m pip install build twine + python -m build + - name: Verify artifacts and version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + test -n "$(ls -A dist/)" || { echo "::error::dist/ is empty"; exit 1; } + python -m twine check dist/* + version="$(python -c 'import pystrix; print(pystrix.VERSION)')" + if [ "$RELEASE_TAG" != "v$version" ] && [ "$RELEASE_TAG" != "$version" ]; then + echo "::error::built version $version does not match release tag $RELEASE_TAG" + exit 1 + fi + - name: Upload distribution artifacts + uses: actions/upload-artifact@v7 # keep paired with download-artifact@v8 in the publish job + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/project/pystrix/ + permissions: + contents: read + id-token: write # required for Trusted Publishing (OIDC) + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@v8 # keep paired with upload-artifact@v7 in the build job + with: + name: dist + path: dist/ + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index cb80cc8..1cae4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -51,10 +51,23 @@ coverage.xml *.log # Sphinx documentation +doc/_build/ docs/_build/ # PyBuilder target/ # IDE stuff -.idea/ \ No newline at end of file +.idea/ +.vscode/ + +# Virtual environments +.venv/ +venv/ + +# Git worktrees +.worktrees/ + +# macOS +.DS_Store +._* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f06928e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +# Pre-commit hooks for pystrix. Run `pre-commit install` once to enable, then +# the hooks run on each commit. See https://pre-commit.com. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.19 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: check-added-large-files diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..a2c5aaf --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +# Read the Docs build configuration +# https://docs.readthedocs.io/en/stable/config-file/v2.html +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.9" + +sphinx: + configuration: doc/conf.py + +python: + install: + - requirements: doc/requirements.txt + - method: pip + path: . diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5a5e016 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md + +Guidance for AI agents working in the pystrix repository. Read this before making changes. + +## What pystrix is + +pystrix is a pure-Python client library for the Asterisk telephony server. It is a toolkit, not a framework, with no third-party runtime dependencies. It targets Python 3.9+ and Asterisk 1.10+. + +It covers three Asterisk protocols: + +- **AMI** (Asterisk Manager Interface): a persistent TCP socket on port 5038. You send actions and receive both request-responses and unsolicited server events on the same stream. Event-driven and concurrent. +- **AGI** (Asterisk Gateway Interface): per-call scripting over stdin and stdout. Blocking and synchronous. +- **FastAGI**: AGI served from a long-running threaded TCP server on port 4573. + +The core split to keep in mind: AGI is one blocking call per script; AMI is one socket multiplexing many concurrent request-responses and events, which is why the AMI code is built on threads and queues. + +## Repository layout + +``` +pystrix/ +├── __init__.py VERSION lives here (single source of truth) +├── ami/ +│ ├── ami.py Manager class + threading/socket core (the heart of AMI) +│ ├── core.py ~60 Action classes (Login, Originate, Hangup, Ping, ...) +│ ├── core_events.py ~50 Event classes + Aggregate classes +│ ├── dahdi.py DAHDI hardware actions +│ ├── dahdi_events.py DAHDI events +│ ├── app_confbridge.py ConfBridge app actions +│ ├── app_confbridge_events.py +│ ├── app_meetme.py MeetMe app actions +│ ├── app_meetme_events.py +│ └── generic_transforms.py type coercion + py2/3 bytes<->str helpers +└── agi/ + ├── agi_core.py _AGI base: command framing, response parsing, exceptions + ├── agi.py AGI class (stdin/stdout + SIGHUP handling) + ├── fastagi.py FastAGIServer (threaded TCP) + FastAGI handler + └── core.py ~45 Action classes (Answer, StreamFile, SayNumber, ...) + +doc/ Sphinx/reStructuredText docs + runnable examples in doc/examples/ +pyproject.toml Packaging (PEP 621) and ruff config; version read from pystrix.VERSION +``` + +## Architecture notes + +### AMI flow (`pystrix/ami/ami.py`) + +`Manager` is the entry point. On `connect()` it starts two daemon threads: + +1. `_MessageReader` (`ami.py:956`) reads framed messages off the socket and sorts each one. A message with an `Event:` header goes to the event queue. A message with an `ActionID` is a response to a request you sent, stored by ID. Anything else is an orphaned response. +2. `_event_dispatcher` (`ami.py:170`) pulls from those queues and fires registered callbacks. + +Three patterns build on the request/response basics: + +- **ActionID matching**: every request gets a host-qualified random ID (`_get_host_action_id`, `ami.py:315`) so responses pair with requests even across multiple AMI connections. +- **Synchronous requests**: some actions trigger a burst of events ending in a "complete" event. A synchronous `_Request` declares its unique, list, and finaliser event classes. `send_action` blocks until all finalisers arrive (`_add_outstanding_request`, `ami.py:578`). +- **Aggregates**: the async equivalent. `_Aggregate` classes in `core_events.py` gather a multi-event reply into one synthesized object with count validation. + +### The class-mutation trick + +Each raw message parses into a generic `_Message`. The reader then looks up the event name in `_EVENT_REGISTRY` and rebinds the object's `__class__` in place to the specific event subclass (`ami.py:1031`). This turns a socket string into a typed object whose `process()` method knows how to coerce its own fields, with no re-parsing. The registry is built in `pystrix/ami/__init__.py:55-63` by reflecting over the `*_events` modules at import time. + +### AGI flow (`pystrix/agi/agi_core.py`) + +`_AGI` is protocol-agnostic. It reads Asterisk's environment variables on startup, then `execute()` sends a command line and parses the numeric reply: 200 is success, 510 invalid command, 511 dead channel, 520 usage error. Hangups raise `AGIResultHangup` or `AGISIGHUPHangup`. + +`AGI` (`agi.py`) wires I/O to stdin/stdout and traps SIGHUP. `FastAGIServer` (`fastagi.py`) is a `ThreadingMixIn` TCP server that matches each request path against registered regexes to pick a handler. + +### FastAGI scaling + +This fork's main feature is FastAGI throughput. `_ThreadedTCPServer` (`fastagi.py`) sets `request_queue_size` to `_LISTEN_BACKLOG` (`INT_MAX`) instead of the small Python default, and sets `allow_reuse_address`. The listen backlog directly bounds how many simultaneous calls the server can accept under a surge. Rather than read the system limit, it passes the maximum and lets the OS cap the backlog. Unix-like kernels clamp it to the live `somaxconn` (`net.core.somaxconn` on Linux, `kern.ipc.somaxconn` on macOS/BSD), which tracks a tuned-up limit automatically; Windows applies the Winsock maximum (its `SOMAXCONN` is itself `INT_MAX`). This runs on every platform and avoids the old `sysctl` subprocess that raised on non-Linux/macOS hosts. See the `_LISTEN_BACKLOG` comment for the full rationale. + +## Conventions for extending the library + +- **Add an AMI action**: subclass `_Request` in the matching `ami/` module. Override `process_response()` for custom result handling. +- **Add an AMI event**: subclass `_Event` in a `*_events` module. The registry auto-discovers it at import, so no manual registration is needed. Match the class name to Asterisk's event name. +- **Add an AGI command**: subclass `_Action` in `agi/core.py`. Override `process_response()` to parse the reply. +- The `app_*` and `dahdi` modules are this same pattern applied to Asterisk add-on modules. Follow their structure for new modules. + +## Python version and Python 3 boundaries + +The project targets Python 3.9+ and the source is Python 3 only. The earlier Python 2 compatibility shims were removed (#47), so don't reintroduce patterns like `basestring` checks or `import Queue` fallbacks. + +Two bytes/string details at the I/O boundary are not Python 2 baggage and must stay: + +- Explicit bytes/string conversion via `string_to_bytes` and `bytes_to_string`. +- The AMI socket opens in binary mode (`makefile(mode="rb")` in `ami.py`) on purpose. Text mode silently drops carriage returns and breaks Asterisk's CRLF message framing. Do not change this. + +## Working in this repo + +- Run `pytest` for the unit suite, and `ruff check .` / `ruff format --check .` for lint and format. CI enforces all of them across Python 3.9 through 3.13. There is no live-Asterisk integration test, so socket-level changes are still worth checking against a real server. +- `VERSION` in `pystrix/__init__.py` is the single source of truth. `pyproject.toml` reads it dynamically through `[tool.setuptools.dynamic]`. Bump it for releases. +- Packaging is `pyproject.toml` (PEP 621, setuptools backend). Build with `python -m build`. +- Docs are Sphinx reStructuredText under `doc/`. Update the relevant `.rst` when adding public actions or events. +- Keep inline docstrings complete and in reStructuredText. The project relies on them for its documentation. diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index 42530cb..0000000 --- a/AUTHORS +++ /dev/null @@ -1,22 +0,0 @@ -[Ivrnet, inc.](http://www.ivrnet.com/) - * Initial development of pystrix was funded by Ivrnet - * Ivrnet is a software-as-a-service company that develops and operates intelligent software applications, delivered through traditional phone networks and over the Internet. These applications facilitate automated interaction, personalized communication between people, mass communication for disseminating information to thousands of people concurrently, and personalized communication between people and automated systems. Ivrnet's applications are accessible through nearly any form of communication technology, at any time, from anywhere in North America, via voice, phone, fax, email, texting, and the Internet. - -[Neil Tallim](http://uguu.ca/) - * Development lead - * Programming - - -Other contributions -------------------- - -[Marta Solano](marta.solano@ivrtechnology.com ) - * Bug solving - Programming - * Pip package maintenance - -[Eric Lee](eric@ivrtechnology.com) - * Python 2 to 3 migration - compatibility - * Programming - -[Karthic Raghupathi](karthicr@gmail.com) - * Bug Fixes / Programming diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..69138a2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +All notable changes to pystrix are recorded here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims +to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed +- Refreshed documentation copyright metadata and moved GitHub Actions workflow dependencies to current Node 24-backed major versions (#60). + +### Fixed +- `Manager.monitor_connection` no longer crashes its monitoring thread when the connection drops. Pinging a downed connection raised `ManagerSocketError` (broken socket) or `ManagerError` (the liveness check inside `send_action` failing when the connection dropped just after the loop's own check) inside the thread, which dumped a traceback to stderr and killed the monitor. The monitor now catches both and stops cleanly, logging the reason at debug level when a logger is set. The method also returns the monitoring thread so callers can join it (#3). +- `Manager.send_action` no longer raises a raw `AttributeError` when a concurrent `disconnect()` clears the connection between the liveness check and the send. It now drops the just-registered request and raises `ManagerSocketError` instead (#3). +- The FastAGI server no longer prints an unhandled traceback to stderr when a client disconnects during the AGI environment handshake. A caller hanging up, Asterisk aborting the leg, or a bare TCP probe raised `AGISIGPIPEHangup` from the handler and printed a full traceback for a routine event. The handler now ends the request quietly. Errors raised by the script handler itself still propagate (#49). +- The FastAGI server sizes its listen backlog without shelling out to `sysctl`. It now requests the maximum backlog and lets the operating system cap it: Unix-like kernels clamp it to the live `somaxconn`, which still tracks a tuned-up limit automatically, and Windows applies the Winsock maximum. This also lifts the platform restriction added in 1.3.0: the server previously raised `NotImplementedError` on any host that was neither Linux nor macOS, and now runs everywhere (#32). +- `Originate_Application` now treats a plain string `data` argument as one application argument instead of splitting it into comma-separated characters (#11). +- `Originate_Application` now rejects bytes-like `data` with `TypeError` instead of serializing byte values as comma-separated integers (#11). +- AMI request building now rejects header keys and values containing CR or LF, including `Action` and `ActionID`, by raising `ValueError`. + +## [1.3.0] - 2026-06-24 + +### Added +- `AGENTS.md` with an architecture overview and contributor guidance. +- `CLAUDE.md` pointing to `AGENTS.md`. +- A Contributing section and AGI, FastAGI, and AMI quick-start examples in the README. +- `.readthedocs.yaml` and `doc/requirements.txt` for reproducible documentation builds. +- A GitHub Actions CI workflow that runs the test suite with coverage across Python 3.9 through 3.13, plus a documentation build check. +- A `pytest` unit-test suite covering AMI message parsing and request building, AGI response parsing, and action and helper formatting, with `pytest` and `pytest-cov` in a `test` extra (`pip install -e '.[test]'`). +- Coverage measurement through `pytest-cov`, reported in the CI logs. No coverage data leaves CI. +- A CI status badge in the README. +- A curated `ruff` configuration, a `.pre-commit-config.yaml`, and a CI lint job. +- This changelog. + +### Changed +- Converted `README.rst` to `README.md` and corrected the version and install URL. +- Stated the license as the GNU LGPLv3 or later across the README and packaging metadata. pystrix is not dual-licensed. Both `COPYING` and `COPYING.LESSER` ship because the LGPL extends the GPL. +- Declared Python 3.9+ through `python_requires` and trove classifiers (3.9 through 3.13), and dropped Python 2 and the end-of-life 3.x entries. +- Narrowed the "any platform" claim. The FastAGI server runs on Linux and macOS only, because it reads `SOMAXCONN` with `sysctl`. +- Removed the `AUTHORS` file. Provenance now lives in the README, and the contributor list comes from git history and the GitHub contributors page. +- Modernized `doc/conf.py` (`exclude_patterns`, Read the Docs theme with a fallback, raw-string regex). +- Formatted the whole codebase with `ruff format` (line length 88) and enabled import sorting (ruff's `I` rule). Both are now enforced in pre-commit and CI. A `.git-blame-ignore-revs` file lets `git blame` skip the reformat commit. +- Migrated packaging from `setup.py` to `pyproject.toml` (PEP 621) with a PEP 639 SPDX license expression (`LGPL-3.0-or-later`). Moved the ruff config into `[tool.ruff]`, and removed `setup.py`, `build-release.py`, and `ruff.toml`. Build with `python -m build`; the version is read dynamically from `pystrix.VERSION`. + +### Removed +- The Python 2 compatibility shims: the `queue` and `socketserver` import fallbacks, the `basestring` branch in `generic_transforms`, and the explicit `(object)` base classes. The codebase is now Python 3 only. Dropping Python 2 is released as a pragmatic minor bump rather than a major one, since Python 2 support was already nominal and end-of-life. + +### Fixed +- Narrowed the bare `except` around `line.decode()` in `_AGI._read_line` to an `isinstance(line, bytes)` check. A real `UnicodeDecodeError` on malformed socket bytes now surfaces instead of being swallowed and leaving raw bytes in the line (#50). +- Applied safe lint fixes surfaced by ruff: `not x is`/`not x in` rewritten to `x is not`/`x not in`, removed unused imports, and turned two invalid escape sequences (`\d`, `\*`) into raw strings. Intentional re-exports are marked with targeted ignores. +- `_Request.build_request` now honors its documented ActionID precedence: an explicit argument wins, then a value already set on the request, then a generated one. Previously a pre-set ActionID was dropped when no argument was passed, and it overrode an explicit argument. The resolved ActionID is also coerced to a string so a pre-set non-string value matches Asterisk's responses (#43). +- Replaced the removed `cgi.urlparse.parse_qs` in `pystrix/agi/fastagi.py` with `urllib.parse.parse_qs`. This fixes FastAGI query-string parsing on all Python 3 and restores Python 3.13 support (#36). +- Corrected the `_Response.time` description in the AMI docs and fixed several typos. +- Stopped tracking `doc/_build/` output and macOS `._` metadata files, and fixed the `.gitignore` rule that let them in. + +## [1.2.0] + +### Added +- FastAGI server sizes its listen backlog from the system `SOMAXCONN`, raising the number of concurrent calls it can accept. + +### Changed +- FastAGI server enables `allow_reuse_address` on the socket. + +--- + +Releases before 1.2.0 are recorded in the git commit history. + +[Unreleased]: https://github.com/IVRTech/pystrix/compare/v1.3.0...HEAD +[1.3.0]: https://github.com/IVRTech/pystrix/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/IVRTech/pystrix/releases/tag/v1.2.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e82556 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# CLAUDE.md + +See [AGENTS.md](AGENTS.md) for architecture, conventions, and contributor guidance. It is the canonical guide for AI agents in this repository. diff --git a/MANIFEST.in b/MANIFEST.in index f5ef662..fc49293 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1 @@ -include README.md -include COPYING \ No newline at end of file +include CHANGELOG.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..877cd78 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# pystrix + +[![CI](https://github.com/IVRTech/pystrix/actions/workflows/ci.yml/badge.svg)](https://github.com/IVRTech/pystrix/actions/workflows/ci.yml) + +**pystrix** is a versatile [Asterisk](https://www.asterisk.org/) interface package for AMI and (Fast)AGI needs. [Ivrnet, inc.](https://www.ivrnet.com/) published it as open source under the LGPLv3, and contributions from all users are welcome. + +## Overview + +pystrix runs on Python 3.9+. It targets Asterisk 1.10+ and provides a rich, easy-to-extend set of bindings for three Asterisk integration protocols: + +- **AMI** (Asterisk Manager Interface) — a persistent TCP connection for controlling and monitoring the whole server. Originate calls, hang them up, read channel status, and react to live events. +- **AGI** (Asterisk Gateway Interface) — per-call scripting over stdin and stdout. Drive the dialplan of a single call: play audio, collect digits, set variables. +- **FastAGI** — the same call scripting as AGI, served from a long-running threaded TCP server instead of a process spawned per call. + +The package is a toolkit, not a framework. You can drop it into a larger project without adopting an async framework such as Twisted. + +This repository is version **1.3.0**. The canonical version lives in `pystrix/__init__.py`. New releases follow `..`, with a patch release cut for each bug fix. + +## Compatibility + +| pystrix | Python | +| --- | --- | +| 1.3.x | 3.9 – 3.13 | +| 1.2.0 and earlier | 2.7, 3.4+ (legacy, unmaintained) | + +From 1.3.0 on, the `requires-python` metadata makes pip resolve automatically: a project on an older Python receives 1.2.x. All versions target Asterisk 1.10+. + +## Installation + +From PyPI: + +```bash +pip install pystrix +``` + +From GitHub: + +```bash +pip install git+https://github.com/IVRTech/pystrix.git#egg=pystrix +``` + +## Quick start + +### AGI — script a single call + +```python +#!/usr/bin/env python +import pystrix + +if __name__ == '__main__': + agi = pystrix.agi.AGI() + + agi.execute(pystrix.agi.core.Answer()) # Answer the call + + # Play a file; DTMF '1' or '2' interrupts playback + response = agi.execute( + pystrix.agi.core.StreamFile('demo-thanks', escape_digits=('1', '2')) + ) + if response: # Playback was interrupted + (dtmf_character, offset) = response + + agi.execute(pystrix.agi.core.Hangup()) # Hang up +``` + +### FastAGI — serve many calls from one process + +```python +import re +import pystrix + +server = pystrix.agi.FastAGIServer() + +def demo_handler(agi, args, kwargs, match, path): + agi.execute(pystrix.agi.core.Answer()) + agi.execute(pystrix.agi.core.StreamFile('demo-thanks')) + agi.execute(pystrix.agi.core.Hangup()) + +server.register_script_handler(re.compile('demo'), demo_handler) +server.register_script_handler(None, demo_handler) # default handler +server.serve_forever() +``` + +The FastAGI server requests the largest listen backlog the socket layer accepts and lets the operating system cap it, so it absorbs large bursts of simultaneous calls. On Unix-like systems the kernel clamps it to the host's configured maximum (`net.core.somaxconn` on Linux, `kern.ipc.somaxconn` on macOS and the BSDs), so it automatically tracks a tuned-up limit; on Windows, Winsock applies its own maximum. This needs no configuration and runs on any platform. + +### AMI — control and monitor the server + +```python +import pystrix + +manager = pystrix.ami.Manager() +manager.connect('localhost') + +# Authenticate with an MD5 challenge to avoid sending the password in plain text +challenge = manager.send_action(pystrix.ami.core.Challenge()) +manager.send_action(pystrix.ami.core.Login( + 'username', 'password', challenge=challenge.result['Challenge'], +)) + +# React to events as Asterisk emits them +def on_hangup(event, manager): + print('Channel hung up:', event.get('Channel')) + +manager.register_callback(pystrix.ami.core_events.Hangup, on_hangup) +``` + +Full, commented examples for all three protocols live in `doc/examples/`. + +## Documentation + +Online documentation is available at . + +Inline documentation is complete and written in [reStructuredText](https://docutils.sourceforge.io/rst.html), so the source stays readable. + +For an architecture overview and contributor guidance, see [AGENTS.md](AGENTS.md). + +## Contributing + +pystrix has no third-party runtime dependencies, so setup is short. + +```bash +git clone https://github.com/IVRTech/pystrix.git +cd pystrix +python3 -m venv .venv && source .venv/bin/activate +pip install -e '.[test]' +``` + +The editable install (`-e`) means your local edits take effect without reinstalling. + +A few things to know before you send a change: + +- **Run the tests** with `pytest`. CI runs them across Python 3.9 through 3.13 on every pull request. There is no live-Asterisk integration test, so socket-level behavior is still worth checking against a real server (AMI on port 5038, FastAGI on port 4573). +- **Lint and format with ruff.** `ruff check .` and `ruff format --check .` must pass, and CI enforces both. Install the hooks to run them on each commit: `pip install pre-commit && pre-commit install`. +- **Target Python 3.9+.** The codebase is Python 3 only; the old Python 2 compatibility shims have been removed, so don't reintroduce them. +- **Build the docs** when you touch them: `pip install -r doc/requirements.txt`, then `cd doc && make html`. +- **Version bumps** go in `pystrix/__init__.py`. `pyproject.toml` reads `VERSION` from there dynamically. +- **Keep docstrings complete** and written in reStructuredText. The reference docs are generated from them. + +## License + +pystrix is licensed under the GNU Lesser General Public License v3 or later (`COPYING.LESSER`). The LGPL extends the GNU General Public License v3 (`COPYING`), so both license texts ship with the project. + +## Credits + +[Neil Tallim](http://uguu.ca/) created pystrix, and [Ivrnet, inc.](https://www.ivrnet.com/) funded its initial development. For the full list of contributors, see the [contributors page](https://github.com/IVRTech/pystrix/graphs/contributors) or the commit history. diff --git a/README.rst b/README.rst deleted file mode 100644 index 01db066..0000000 --- a/README.rst +++ /dev/null @@ -1,83 +0,0 @@ -**pystrix** is an attempt at creating a versatile `Asterisk `_-interface package for AMI and (Fast)AGI needs. It is published as an open-source library under the LGPLv3 by `Ivrnet, inc. `_, welcoming contributions from all users. - -**** - -======== -Overview -======== - -pystrix runs on python 2.7/python 3.4+ on any platform. It's targeted at Asterisk 1.10+ and provides a rich, easy-to-extend set of bindings for AGI, FastAGI, and AMI. - -================ -Release Schedule -================ - -The current code in the repository correspond to version **1.1.8** of the package. When a bug is found and fixed a new version of the package will be generated in order to keep it updated and as bug-free as possible. - -New releases will follow the format: .. according to the change made to the code. - -======= -History -======= - -After some research, we found that what was available was either incompatible with the architecture model we needed to work with `Twisted `_, (while excellent for a great many things, isn't always the right choice), was targeting an outdated version of Asterisk, or had a very rigid, monolithic design. Identifying the `pyst `_ and `py-asterisk `_ packages as being similar, but structurally incompatible, to what we wanted, pyst was chosen as the basis for this project, with a full rewrite of its AGI and AMI systems to provide a uniform-looking, highly modular design that incorporates logic and ideas from py-asterisk. The end result is a package that should satisfy anyone who was looking at either of its ancestors and that should be easier to extend as Asterisk continues to evolve. - -============ -Installation -============ - -* From pip - -.. code:: bash - - $ pip install pystrix - -* From github - -.. code:: bash - - $ pip install -e git://github.com/marsoguti/pystrix.git#egg=pystrix - -===== -Usage -===== - -Detailed usage information is provided in the documentation, along with simple examples that should help to get anyone started. - -============= -Documentation -============= - -Online documentation is available at http://pystrix.readthedocs.io/. - -Inline documentation is complete and made readable by `reStructuredText `_, so you'll never be completely lost. - -**** - -======= -Credits -======= - -`Ivrnet, inc. `_ - * Initial development of pystrix was funded by Ivrnet - * Ivrnet is a software-as-a-service company that develops and operates intelligent software applications, delivered through traditional phone networks and over the Internet. These applications facilitate automated interaction, personalized communication between people, mass communication for disseminating information to thousands of people concurrently, and personalized communication between people and automated systems. Ivrnet's applications are accessible through nearly any form of communication technology, at any time, from anywhere in North America, via voice, phone, fax, email, texting, and the Internet. - -`Neil Tallim `_ - * Development lead - * Programming - - -Other contributions and current package maintenance ---------------------------------------------------- - -`Marta Solano `_ - * Bug solving - Programming - * Pip package maintenance - -`Eric Lee `_ - * Python 2 to 3 migration - compatibility - * Programming - -`Karthic Raghupathi `_ - * Bug solving - Programming - * Pip package maintenance diff --git a/build-release.py b/build-release.py deleted file mode 100644 index 0c66f77..0000000 --- a/build-release.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python -""" -Release-building script for pystrix. -""" -import tarfile -import fileinput - -from pystrix import VERSION - -def _filter(tarinfo, acceptable_extensions): - if tarinfo.name.startswith('.'): #Ignore hidden files - return None - - if tarinfo.isdir(): #Directories are good - return tarinfo - - if tarinfo.name.endswith(acceptable_extensions): #It's a file we want - print("\tAdded " + tarinfo.name) - return tarinfo - - return None #DO NOT WANT - -def python_filter(tarinfo): - return _filter(tarinfo, ('.py',)) - -def doc_filter(tarinfo): - return _filter(tarinfo, ('.py','.rst','Makefile')) - -if __name__ == '__main__': - base_name = "pystrix-" + VERSION - archive_name = base_name + ".tar.bz2" - print("Assembling " + archive_name) - f = tarfile.open(name=archive_name, mode="w:bz2") - for line in fileinput.input("pystrix.spec", inplace=True, backup=False): - if line.startswith('%define initversion'): - line = "%%define initversion %s" % VERSION - print("%s" % (line.rstrip())) - f.add('pystrix.spec', arcname="%s/pystrix.spec" % base_name) - f.add('COPYING', arcname="%s/COPYING" % base_name) - f.add('COPYING.LESSER', arcname="%s/COPYING.LESSER" % base_name) - print("\tAdded license files") - f.add('doc',arcname="%s/doc" % base_name, filter=doc_filter) - f.add('build-release.py', arcname="%s/build-release.py" % base_name, filter=python_filter) - f.add('setup.py', arcname="%s/setup.py" % base_name, filter=python_filter) - f.add('pystrix', arcname="%s/pystrix" % base_name, filter=python_filter) - f.close() - diff --git a/doc/_build/._ b/doc/_build/._ deleted file mode 100644 index 6e15a0a..0000000 --- a/doc/_build/._ +++ /dev/null @@ -1 +0,0 @@ -mercurial placeholder file diff --git a/doc/_static/._ b/doc/_static/._ deleted file mode 100644 index 6e15a0a..0000000 --- a/doc/_static/._ +++ /dev/null @@ -1 +0,0 @@ -mercurial placeholder file diff --git a/doc/_static/.gitkeep b/doc/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/doc/_templates/._ b/doc/_templates/._ deleted file mode 100644 index 6e15a0a..0000000 --- a/doc/_templates/._ +++ /dev/null @@ -1 +0,0 @@ -mercurial placeholder file diff --git a/doc/_templates/.gitkeep b/doc/_templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/doc/ami/index.rst b/doc/ami/index.rst index fc490f9..2d3ead4 100644 --- a/doc/ami/index.rst +++ b/doc/ami/index.rst @@ -2,7 +2,7 @@ Asterisk Management Interface (AMI) =================================== The AMI interface consists primarily of a number of action classes that are sent to Asterisk to -ellicit responses. Additionally, a number of event classes are defined to provide convenience +elicit responses. A number of event classes are defined to provide convenience processing on the various messages Asterisk generates. .. toctree:: @@ -153,7 +153,7 @@ the system, with members that are worth knowing about. .. attribute:: events_timeout A boolean value indicating whether any events were still unreceived when the response was - returned. This is meaningful only if the reqyest had `synchronous` set. + returned. This is meaningful only if the request had `synchronous` set. .. attribute:: response @@ -163,7 +163,7 @@ the system, with members that are worth knowing about. .. attribute:: request The request object that led to this response. This will be `None` if the response is an - orhpan, which may happen when a request times out, but a response is generated anyway, + orphan, which may happen when a request times out, but a response is generated anyway, if multiple AMI clients are working with the same Asterisk instance (they won't know each other's action-IDs), or when working with buggy or experimental versions of Asterisk. @@ -179,7 +179,7 @@ the system, with members that are worth knowing about. .. attribute:: time - The amount of time, as a UNIX timestamp, that elapsed while waiting for a response. + The number of seconds, as a float, that elapsed while waiting for a response. Exceptions ++++++++++ diff --git a/doc/conf.py b/doc/conf.py index 548482f..85eaae1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,32 +1,48 @@ # -*- coding: utf-8 -*- -import sys, os, re +import os +import re +import sys -sys.path.append(os.path.abspath('..')) +sys.path.append(os.path.abspath("..")) import pystrix as module -sys.path.remove(os.path.abspath('..')) -sys.path.append(os.path.abspath('../pystrix')) -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage'] -templates_path = ['_templates'] -source_suffix = '.rst' -master_doc = 'index' +sys.path.remove(os.path.abspath("..")) +sys.path.append(os.path.abspath("../pystrix")) -project = u'pystrix' +extensions = ["sphinx.ext.autodoc", "sphinx.ext.todo", "sphinx.ext.coverage"] +templates_path = ["_templates"] +source_suffix = ".rst" +master_doc = "index" + +project = "pystrix" copyright = module.COPYRIGHT -version = re.match('^(\d+\.\d+)', module.VERSION).group(1) +copyright_holder = module.COPYRIGHT.split(", ", 1)[1] +version = re.match(r"^(\d+\.\d+)", module.VERSION).group(1) release = module.VERSION -exclude_trees = ['_build'] +exclude_patterns = ["_build"] + +pygments_style = "sphinx" -pygments_style = 'sphinx' +# Prefer the Read the Docs theme when it is installed; fall back to the +# bundled 'alabaster' theme so local builds work without extra packages. +try: + import sphinx_rtd_theme # noqa: F401 -html_theme = 'default' -html_static_path = ['_static'] + html_theme = "sphinx_rtd_theme" +except ImportError: + html_theme = "alabaster" +html_static_path = ["_static"] html_show_sourcelink = False -htmlhelp_basename = 'pystrixdoc' +htmlhelp_basename = "pystrixdoc" latex_documents = [ - ('index', 'pystrix.tex', u'pystrix Documentation', - re.search(', (.*?) <', module.COPYRIGHT).group(1), 'manual'), + ( + "index", + "pystrix.tex", + "pystrix Documentation", + copyright_holder, + "manual", + ), ] diff --git a/doc/examples/fastagi.rst b/doc/examples/fastagi.rst index c6fa0fb..4a05d25 100644 --- a/doc/examples/fastagi.rst +++ b/doc/examples/fastagi.rst @@ -70,3 +70,31 @@ up:: time.sleep(1) fastagi_core.kill() + +Hangup signaling +---------------- + +Asterisk sends FastAGI hangup notifications over the same network connection used for command +responses. If ``HANGUP`` arrives while pystrix is waiting for a command response, pystrix consumes +that notification and continues reading the actual response from Asterisk. + +If a channel hangs up while a long-running application such as ``Dial`` is active, Asterisk may also +send a final ``HANGUP`` after the FastAGI handler has already returned and the socket has been +closed. On some Asterisk versions this can appear in the Asterisk console as an +``ast_carefulwrite`` ``Broken pipe`` error even though the FastAGI handler completed normally. + +Set ``AGISIGHUP`` before entering FastAGI if Asterisk should not send those hangup notifications:: + + exten => 97153654,1,Progress() + same => n,Set(AGISIGHUP=no) + same => n,AGI(agi://127.0.0.1/router) + +If Asterisk should instead stop AGI processing as soon as it detects the hangup, set +``AGIEXITONHANGUP`` before entering FastAGI; Asterisk closes the AGI connection itself in this +case:: + + exten => 97153654,1,Progress() + same => n,Set(AGIEXITONHANGUP=yes) + same => n,AGI(agi://127.0.0.1/router) + +A closed FastAGI connection is reported to pystrix as :class:`agi.AGISIGPIPEHangup`. diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 0000000..c611803 --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,3 @@ +# Documentation build dependencies +sphinx>=5.0 +sphinx-rtd-theme>=1.0 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..115d467 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[build-system] +# setuptools >= 77 is required for the PEP 639 SPDX license expression below. +# Build isolation supplies it regardless of the locally installed version. +requires = ["setuptools>=77"] +build-backend = "setuptools.build_meta" + +[project] +name = "pystrix" +description = "Python bindings for Asterisk Manager Interface and Asterisk Gateway Interface" +readme = "README.md" +requires-python = ">=3.9" +license = "LGPL-3.0-or-later" +license-files = ["COPYING", "COPYING.LESSER"] +authors = [{ name = "Neil Tallim" }] +maintainers = [{ name = "IVR Technology Group" }] +keywords = ["asterisk", "ami", "agi", "fastagi", "telephony"] +classifiers = [ + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Communications :: Telephony", +] +dynamic = ["version"] + +[project.optional-dependencies] +test = ["pytest", "pytest-cov"] + +[project.urls] +Homepage = "https://github.com/IVRTech/pystrix" +Documentation = "https://pystrix.readthedocs.io/" +Repository = "https://github.com/IVRTech/pystrix" +Changelog = "https://github.com/IVRTech/pystrix/blob/master/CHANGELOG.md" + +[tool.setuptools] +packages = ["pystrix", "pystrix.agi", "pystrix.ami"] + +[tool.setuptools.dynamic] +version = { attr = "pystrix.VERSION" } + +# Lint and format configuration (moved from ruff.toml). +[tool.ruff] +target-version = "py39" + +[tool.ruff.lint] +# pycodestyle errors, pyflakes, import sorting (isort), and invalid escape +# sequences. Whitespace, indentation, and line length are owned by ruff format. +select = ["E4", "E7", "E9", "F", "I", "W605"] + +[tool.ruff.lint.per-file-ignores] +# The __init__ modules deliberately re-export their public API. +"pystrix/__init__.py" = ["F401"] +"pystrix/agi/__init__.py" = ["F401"] +"pystrix/ami/__init__.py" = ["F401"] +# The AGI wrapper modules intentionally star-import the shared core. +"pystrix/agi/agi.py" = ["F403", "F405"] +"pystrix/agi/fastagi.py" = ["F403", "F405"] diff --git a/pystrix/__init__.py b/pystrix/__init__.py index 1a1d497..72a4960 100644 --- a/pystrix/__init__.py +++ b/pystrix/__init__.py @@ -14,7 +14,7 @@ Importing this package, or the 'agi' or 'ami' sub-packages is recommended over importing individual modules. - + Legal ----- @@ -32,15 +32,16 @@ You should have received a copy of the GNU General Public License and GNU Lesser General Public License along with this program. If not, see . - + (C) Ivrnet, inc., 2011 Authors: - Neil Tallim """ + import pystrix.agi import pystrix.ami -VERSION = '1.1.8' -COPYRIGHT = '2013, Neil Tallim ' +VERSION = "1.3.0" +COPYRIGHT = "2026, IVR Technology Group" diff --git a/pystrix/agi/__init__.py b/pystrix/agi/__init__.py index 17694e2..725d308 100644 --- a/pystrix/agi/__init__.py +++ b/pystrix/agi/__init__.py @@ -4,7 +4,7 @@ Provides a library suitable for interacting with an Asterisk server using the Asterisk Gateway Interface (AGI) protocol. - + Usage ----- @@ -27,27 +27,33 @@ You should have received a copy of the GNU General Public License and GNU Lesser General Public License along with this program. If not, see . - + (C) Ivrnet, inc., 2011 Authors: - Neil Tallim """ -from pystrix.agi.agi_core import ( - AGIException, AGIError, AGINoResultError, AGIUnknownError, AGIAppError, - AGIHangup, AGISIGPIPEHangup, AGIResultHangup, - AGIDeadChannelError, AGIUsageError, AGIInvalidCommandError, -) +from pystrix.agi import core from pystrix.agi.agi import ( - AGI, - AGISIGHUPHangup, + AGI, + AGISIGHUPHangup, +) +from pystrix.agi.agi_core import ( + AGIAppError, + AGIDeadChannelError, + AGIError, + AGIException, + AGIHangup, + AGIInvalidCommandError, + AGINoResultError, + AGIResultHangup, + AGISIGPIPEHangup, + AGIUnknownError, + AGIUsageError, ) - from pystrix.agi.fastagi import ( - FastAGIServer, FastAGI, + FastAGI, + FastAGIServer, ) - -from pystrix.agi import core - diff --git a/pystrix/agi/agi.py b/pystrix/agi/agi.py index 86ca8b3..2634e1b 100644 --- a/pystrix/agi/agi.py +++ b/pystrix/agi/agi.py @@ -4,7 +4,7 @@ Provides a class that exposes methods for communicating with Asterisk from an AGI (script) context. - + Legal ----- @@ -22,26 +22,29 @@ You should have received a copy of the GNU General Public License and GNU Lesser General Public License along with this program. If not, see . - + (C) Ivrnet, inc., 2011 Authors: - Neil Tallim """ + import signal import sys from pystrix.agi.agi_core import * from pystrix.agi.agi_core import _AGI + class AGI(_AGI): """ An interface to Asterisk, exposing request-response functions for synchronous management of the call associated with this channel. """ - _got_sighup = False #True when a hangup signal has been received. - + + _got_sighup = False # True when a hangup signal has been received. + def __init__(self, debug=False): """ Binds the SIGHUP signal and associates I/O with stdin/stdout. @@ -51,32 +54,32 @@ def __init__(self, debug=False): signal.signal(signal.SIGHUP, self._handle_sighup) self._rfile = sys.stdin self._wfile = sys.stdout - + _AGI.__init__(self, debug) - + def _handle_sighup(self, signum, frame): """ Sets the has-hungup flag to trigger an exception when the next command is received. """ self._got_sighup = True - + def _test_hangup(self): """ If SIGHUP has been received, or another hangup flag has been set, an exception is raised; if not, this function is a no-op. - + Raises `AGISIGHUPHangup` if SIGHUP has been recieved, or any other exceptions normally raised by `_AGI`'s `_test_hangup()`. """ if self._got_sighup: raise AGISIGHUPHangup("Received SIGHUP from Asterisk") - + _AGI._test_hangup(self) - + + class AGISIGHUPHangup(AGIHangup): """ Indicates that the script's process received the SIGHUP signal, implying Asterisk has hung up the call. Specific to script-based instances. """ - diff --git a/pystrix/agi/agi_core.py b/pystrix/agi/agi_core.py index ce04b6d..8070b09 100644 --- a/pystrix/agi/agi_core.py +++ b/pystrix/agi/agi_core.py @@ -34,22 +34,23 @@ - Neil Tallim """ + import collections import re -import time - import sys -_Response = collections.namedtuple('Response', ('items', 'code', 'raw')) -_ValueData = collections.namedtuple('ValueData', ('value', 'data')) +_Response = collections.namedtuple("Response", ("items", "code", "raw")) +_ValueData = collections.namedtuple("ValueData", ("value", "data")) -_RE_CODE = re.compile(r'(^\d+)\s*(.+)') #Matches Asterisk's response-code lines -_RE_KV = re.compile(r'(?P\w+)=(?P[^\s]+)?(?:\s+\((?P.*)\))?') #Matches Asterisk's key-value response-pairs +_RE_CODE = re.compile(r"(^\d+)\s*(.+)") # Matches Asterisk's response-code lines +_RE_KV = re.compile( + r"(?P\w+)=(?P[^\s]+)?(?:\s+\((?P.*)\))?" +) # Matches Asterisk's key-value response-pairs -_RESULT_KEY = 'result' +_RESULT_KEY = "result" -#Functions +# Functions ############################################################################### def quote(value): """ @@ -57,23 +58,26 @@ def quote(value): necessary. """ return '"%(value)s"' % { - 'value': str(value), + "value": str(value), } -#Classes +# Classes ############################################################################### -class _AGI(object): +class _AGI: """ This class encapsulates communication between Asterisk an a python script. It handles encoding commands to Asterisk and parsing responses from - Asterisk. + Asterisk. """ - _environment = None #The environment variables received from Asterisk for this channel - _debug = False #If True, development information is printed to console - _rfile = None #The input file-like-object - _wfile = None #The output file-like-object - + + _environment = ( + None # The environment variables received from Asterisk for this channel + ) + _debug = False # If True, development information is printed to console + _rfile = None # The input file-like-object + _wfile = None # The output file-like-object + def __init__(self, debug=False): """ Sets up variables required to process an AGI session. @@ -81,7 +85,7 @@ def __init__(self, debug=False): `debug` should only be turned on for library development. """ self._debug = debug - + self._environment = {} self._parse_agi_environment() @@ -96,14 +100,14 @@ def execute(self, action): still connected. An instance of `AGIHangup` is raised if it is not. """ self._test_hangup() - + self._send_command(action.command) return action.process_response(self._get_result(action.check_hangup)) def get_environment(self): """ Returns Asterisk's initial environment variables as a dictionary. - + Note that this function returns a copy of the values, so repeated calls are less favourable than storing the returned value locally and dissecting it there. @@ -128,35 +132,41 @@ def _get_result(self, check_hangup=True): `AGIInvalidCommandError` is raised if the given command is unrecognised, either because the requested function isn't implemented in the current version of Asterisk or because the `execute()` function was invoked incorrectly. - + `AGIUsageError` is emitted if the arguments provided for a command are invalid. - + `AGIDeadChannelError` occurs if a command is attempted on a dead channel. `AGIUnknownError` covers any unrecognised Asterisk response code. """ code = 0 response = {} - + line = self._read_line() m = _RE_CODE.search(line) if m: code = int(m.group(1)) - + if code == 200: - raw = m.group(2) #The entire line, excluding the code - for (key, value, data) in _RE_KV.findall(m.group(2)): - response[key] = _ValueData(value or '', data) - - if not _RESULT_KEY in response: #Must always be present. - raise AGINoResultError("Asterisk did not provide a '%(result-key)s' key-value pair" % { - 'result-key': _RESULT_KEY, - }, response) + raw = m.group(2) # The entire line, excluding the code + for key, value, data in _RE_KV.findall(m.group(2)): + response[key] = _ValueData(value or "", data) + + if _RESULT_KEY not in response: # Must always be present. + raise AGINoResultError( + "Asterisk did not provide a '%(result-key)s' key-value pair" + % { + "result-key": _RESULT_KEY, + }, + response, + ) result = response.get(_RESULT_KEY) - if check_hangup and result.data == 'hangup': #A 'hangup' response usually indicates that the channel was hungup, but it is a legal variable value + if ( + check_hangup and result.data == "hangup" + ): # A 'hangup' response usually indicates that the channel was hungup, but it is a legal variable value raise AGIResultHangup("User hung up during execution", response) - + return _Response(response, code, raw) elif code == 0: # No code was returned by Asterisk. @@ -172,63 +182,71 @@ def _get_result(self, check_hangup=True): while True: line = self._read_line() usage.append(line) - if '520 End of proper usage.' in line: + if "520 End of proper usage." in line: break - raise AGIUsageError('\n'.join(usage + [''])) + raise AGIUsageError("\n".join(usage + [""])) else: - raise AGIUnknownError("Unhandled code or undefined response: %(code)i : %(line)s" % { - 'code': code, - 'line': repr(line), - }) - + raise AGIUnknownError( + "Unhandled code or undefined response: %(code)i : %(line)s" + % { + "code": code, + "line": repr(line), + } + ) + def _parse_agi_environment(self): """ Reads all of Asterisk's environment variables and stores them in memory. """ while True: line = self._read_line() - if line == '': #Blank line signals end + if line == "": # Blank line signals end break - - if ':' in line: - (key, data) = line.split(':', 1) + + if ":" in line: + (key, data) = line.split(":", 1) key = key.strip() data = data.strip() if key: self._environment[key] = data - + def _read_line(self, should_strip=True): """ Reads and returns a line from the Asterisk pipe, blocking until a complete line is assembled. - + If the pipe is closed before this happens, `AGISIGPIPEHangup` is raised. """ try: line = self._rfile.readline() - try: - line = line.decode() # decode line if it comes in bytes, example if it comes from a socket - except: - pass # line it's a string, so nothing to change - it's string if it's using stdin as _rfile for example + if isinstance( + line, bytes + ): # bytes from a FastAGI socket; already str from stdin (AGI) + line = line.decode() # Check to see if we received a HANGUP because AGISIGHUP was not set explicitly or is no # and then handle the HANGUP which is being returned because the AGI script can still interact with # Asterisk after the call was hungup in DeadAGI mode (which Asterisk converts the channel to automatically) # All commands won't work in DeadAGI but that is not our concern here because if such a command is issued # which indeed requires channel interaction, Asterisk will respond with a 511 code. - if 'no' == self._environment.get('AGISIGHUP', 'no') and 'HANGUP\n' == line: - line = self._read_line(should_strip=False) # read from pipe again to get response for the given command - if not line: #EOF encountered + if "no" == self._environment.get("AGISIGHUP", "no") and "HANGUP\n" == line: + line = self._read_line( + should_strip=False + ) # read from pipe again to get response for the given command + if not line: # EOF encountered raise AGISIGPIPEHangup("Process input pipe closed") - elif not line.endswith('\n'): #Fragment encountered - #Recursively append to the current fragment until the line is - #complete or the socket dies. + elif not line.endswith("\n"): # Fragment encountered + # Recursively append to the current fragment until the line is + # complete or the socket dies. line += self._read_line() return line.strip() if should_strip else line except IOError as e: - raise AGISIGPIPEHangup("Process input pipe broken: %(error)s" % { - 'error': str(e), - }) - + raise AGISIGPIPEHangup( + "Process input pipe broken: %(error)s" + % { + "error": str(e), + } + ) + def _send_command(self, command, *args): """ Formats a `command` and sends it to Asterisk. @@ -239,43 +257,55 @@ def _send_command(self, command, *args): If the connection to Asterisk is broken, `AGISIGPIPEHangup` is raised. """ try: - if self._wfile is not sys.stdout: # stdout handles str instead of bytes, so we don't encode + if ( + self._wfile is not sys.stdout + ): # stdout handles str instead of bytes, so we don't encode command = command.encode() self._wfile.write(command) self._wfile.flush() except Exception as e: - raise AGISIGPIPEHangup("Socket link broken: %(error)s" % { - 'error': str(e), - }) - + raise AGISIGPIPEHangup( + "Socket link broken: %(error)s" + % { + "error": str(e), + } + ) + def _test_hangup(self): """ Tests to see if the channel has been hung up. - + At present, this is a no-op because no generic hang-up conditions are known, but subclasses may have specific scenarios to test for. """ return -class _Action(object): + +class _Action: """ Provides the basis for assembling and issuing an action via AGI. """ - _command = None #The command that drives this action - _arguments = None #A tuple of arguments to qualify the command - check_hangup = True #True if the output of this action is sure to be hangup-detection-safe - + + _command = None # The command that drives this action + _arguments = None # A tuple of arguments to qualify the command + check_hangup = ( + True # True if the output of this action is sure to be hangup-detection-safe + ) + def __init__(self, command, *arguments): self._command = command self._arguments = arguments @property def command(self): - command = ' '.join([self._command.strip()] + [str(arg) for arg in self._arguments if not arg is None]).strip() - if not command.endswith('\n'): - command += '\n' + command = " ".join( + [self._command.strip()] + + [str(arg) for arg in self._arguments if arg is not None] + ).strip() + if not command.endswith("\n"): + command += "\n" return command - + def process_response(self, response): """ Just returns the `response` from Asterisk verbatim. May be overridden to allow for @@ -284,68 +314,78 @@ def process_response(self, response): return response -#Exceptions +# Exceptions ############################################################################### class AGIException(Exception): """ The base exception from which all exceptions native to this module inherit. """ - items = None #Any items received from Asterisk, as a dictionary. + + items = None # Any items received from Asterisk, as a dictionary. def __init__(self, message, items=None): Exception.__init__(self, message) self.items = items if items else {} - + + class AGIError(AGIException): """ The base error from which all errors native to this module inherit. """ - + + class AGINoResultError(AGIException): """ Indicates that Asterisk did not return a 'result' parameter in a 200 response. """ - + + class AGIUnknownError(AGIError): """ An error raised when an unknown response is received from Asterisk. """ - + + class AGIAppError(AGIError): """ An error raised when an attempt to make use of an Asterisk application fails. """ - + + class AGIDeadChannelError(AGIError): """ Indicates that a command was issued on a channel that can no longer process it. """ - + + class AGIInvalidCommandError(AGIError): """ Indicates that a request made to Asterisk was not understood. """ - + + class AGIUsageError(AGIError): """ Indicates that a request made to Asterisk was sent with invalid syntax. """ - + + class AGIHangup(AGIException): """ The base exception used to indicate that the call has been completed or abandoned. """ - + + class AGISIGPIPEHangup(AGIHangup): """ Indicates that the communications pipe to Asterisk has been severed. """ - + + class AGIResultHangup(AGIHangup): """ Indicates that Asterisk received a clean hangup event. """ - diff --git a/pystrix/agi/core.py b/pystrix/agi/core.py index 77839d1..a1345dd 100644 --- a/pystrix/agi/core.py +++ b/pystrix/agi/core.py @@ -6,7 +6,7 @@ `execute()` function of an AGI interface. Also includes constants to make programmatic interaction cleaner. - + Legal ----- @@ -24,20 +24,21 @@ You should have received a copy of the GNU General Public License and GNU Lesser General Public License along with this program. If not, see . - + (C) Ivrnet, inc., 2011 Authors: - Neil Tallim """ + import time from pystrix.agi.agi_core import ( - _Action, - quote, _RESULT_KEY, AGIAppError, + _Action, + quote, ) CHANNEL_DOWN_AVAILABLE = 0 # Channel is down and available @@ -49,14 +50,14 @@ CHANNEL_UP = 6 # The channel is connected CHANNEL_BUSY = 7 # The channel is in a busy, non-conductive state -FORMAT_SLN = 'sln' -FORMAT_G723 = 'g723' -FORMAT_G729 = 'g729' -FORMAT_GSM = 'gsm' -FORMAT_ALAW = 'alaw' -FORMAT_ULAW = 'ulaw' -FORMAT_VOX = 'vox' -FORMAT_WAV = 'wav' +FORMAT_SLN = "sln" +FORMAT_G723 = "g723" +FORMAT_G729 = "g729" +FORMAT_GSM = "gsm" +FORMAT_ALAW = "alaw" +FORMAT_ULAW = "ulaw" +FORMAT_VOX = "vox" +FORMAT_WAV = "wav" LOG_DEBUG = 0 LOG_INFO = 1 @@ -64,9 +65,9 @@ LOG_ERROR = 3 LOG_CRITICAL = 4 -TDD_ON = 'on' -TDD_OFF = 'off' -TDD_MATE = 'mate' +TDD_ON = "on" +TDD_OFF = "off" +TDD_MATE = "mate" # Functions @@ -79,9 +80,13 @@ def _convert_to_char(value, items): try: return chr(int(value)) except ValueError: - raise AGIAppError("Unable to convert Asterisk result to DTMF character: %(value)r" % { - 'value': value, - }, items) + raise AGIAppError( + "Unable to convert Asterisk result to DTMF character: %(value)r" + % { + "value": value, + }, + items, + ) def _convert_to_int(items): @@ -89,7 +94,7 @@ def _convert_to_int(items): Extracts the offset-value from Asterisk's response, `items`, or returns -1 if the value can't be parsed. """ - offset = items.get('endpos') + offset = items.get("endpos") if offset and offset.data.isdigit(): return int(offset.data) else: @@ -101,7 +106,7 @@ def _process_digit_list(digits): Ensures that digit-lists are processed uniformly. """ if type(digits) in (list, tuple, set, frozenset): - digits = ''.join([str(d) for d in digits]) + digits = "".join([str(d) for d in digits]) return quote(digits) @@ -110,7 +115,7 @@ def _process_digit_list(digits): class Answer(_Action): """ Answers the call on the channel. - + If the channel has already been answered, this is a no-op. `AGIAppError` is raised on failure, most commonly because the connection @@ -118,16 +123,16 @@ class Answer(_Action): """ def __init__(self): - _Action.__init__(self, 'ANSWER') + _Action.__init__(self, "ANSWER") class ChannelStatus(_Action): """ Provides the current state of this channel or, if `channel` is set, that of the named channel. - + Returns one of the channel-state constants listed below: - + - CHANNEL_DOWN_AVAILABLE: Channel is down and available - CHANNEL_DOWN_RESERVED: Channel is down and reserved - CHANNEL_OFFHOOK: Channel is off-hook @@ -148,7 +153,7 @@ class ChannelStatus(_Action): """ def __init__(self, channel=None): - _Action.__init__(self, 'CHANNEL STATUS', (channel and quote(channel)) or None) + _Action.__init__(self, "CHANNEL STATUS", (channel and quote(channel)) or None) def process_response(self, response): result = response.items.get(_RESULT_KEY) @@ -156,24 +161,27 @@ def process_response(self, response): return int(result.value) except ValueError: raise AGIAppError( - "'%(result-key)s' key-value pair received from Asterisk contained a non-numeric value: %(value)r" % { - 'result-key': _RESULT_KEY, - 'value': result.value, - }, response.items) + "'%(result-key)s' key-value pair received from Asterisk contained a non-numeric value: %(value)r" + % { + "result-key": _RESULT_KEY, + "value": result.value, + }, + response.items, + ) class ControlStreamFile(_Action): """ See also `GetData`, `GetOption`, `StreamFile`. - + Plays back the specified file, which is the `filename` of the file to be played, either in an Asterisk-searched directory or as an absolute path, without extension. ('myfile.wav' would be specified as 'myfile', to allow Asterisk to choose the most efficient encoding, based on extension, for the channel) - + `escape_digits` may optionally be a list of DTMF digits, specified as a string or a sequence of possibly mixed ints and strings. Playback ends immediately when one is received. - + `sample_offset` may be used to jump an arbitrary number of milliseconds into the audio data. If specified, `forward`, `rewind`, and `pause` are DTMF characters that will seek forwards @@ -182,21 +190,35 @@ class ControlStreamFile(_Action): If a DTMF key is received, it is returned as a string. If nothing is received or the file could not be played back (see Asterisk logs), None is returned. - + `AGIAppError` is raised on failure, most commonly because the channel was hung-up. """ - def __init__(self, filename, escape_digits='', sample_offset=0, forward='', rewind='', pause=''): + def __init__( + self, + filename, + escape_digits="", + sample_offset=0, + forward="", + rewind="", + pause="", + ): escape_digits = _process_digit_list(escape_digits) - _Action.__init__(self, 'CONTROL STREAM FILE', quote(filename), - quote(escape_digits), quote(sample_offset), - quote(forward), quote(rewind), quote(pause) - ) + _Action.__init__( + self, + "CONTROL STREAM FILE", + quote(filename), + quote(escape_digits), + quote(sample_offset), + quote(forward), + quote(rewind), + quote(pause), + ) def process_response(self, response): result = response.items.get(_RESULT_KEY) - if not result.value == '0': + if not result.value == "0": return _convert_to_char(result.value, response.items) return None @@ -204,25 +226,29 @@ def process_response(self, response): class DatabaseDel(_Action): """ Deletes the specified family/key entry from Asterisk's database. - + `AGIAppError` is raised on failure. - + `AGIDBError` is raised if the key could not be removed, which usually indicates that it didn't exist in the first place. """ def __init__(self, family, key): - _Action.__init__(self, 'DATABASE DEL', quote(family), quote(key)) + _Action.__init__(self, "DATABASE DEL", quote(family), quote(key)) self.family = family self.key = key def process_response(self, response): result = response.items.get(_RESULT_KEY) - if result.value == '0': - raise AGIDBError("Unable to delete from database: family=%(family)r, key=%(key)r" % { - 'family': self.family, - 'key': self.key, - }, response.items) + if result.value == "0": + raise AGIDBError( + "Unable to delete from database: family=%(family)r, key=%(key)r" + % { + "family": self.family, + "key": self.key, + }, + response.items, + ) class DatabaseDeltree(_Action): @@ -230,84 +256,104 @@ class DatabaseDeltree(_Action): Deletes the specificed family (and optionally keytree) from Asterisk's database. `AGIAppError` is raised on failure. - + `AGIDBError` is raised if the family (or keytree) could not be removed, which usually indicates that it didn't exist in the first place. """ def __init__(self, family, keytree=None): - _Action.__init__(self, - 'DATABASE DELTREE', quote(family), (keytree and quote(keytree) or None) - ) + _Action.__init__( + self, + "DATABASE DELTREE", + quote(family), + (keytree and quote(keytree) or None), + ) self.family = family self.keytree = keytree def process_response(self, response): result = response.items.get(_RESULT_KEY) - if result.value == '0': - raise AGIDBError("Unable to delete family from database: family=%(family)r, keytree=%(keytree)r" % { - 'family': self.family, - 'keytree': self.keytree or '', - }, response.items) + if result.value == "0": + raise AGIDBError( + "Unable to delete family from database: family=%(family)r, keytree=%(keytree)r" + % { + "family": self.family, + "keytree": self.keytree or "", + }, + response.items, + ) class DatabaseGet(_Action): """ Retrieves the value of the specified family/key entry from Asterisk's database. - + `AGIAppError` is raised on failure. - + `AGIDBError` is raised if the key could not be found or if some other database problem occurs. """ + check_hangup = False def __init__(self, family, key): - _Action.__init__(self, 'DATABASE GET', quote(family), quote(key)) + _Action.__init__(self, "DATABASE GET", quote(family), quote(key)) self.family = family self.key = key def process_response(self, response): result = response.items.get(_RESULT_KEY) - if result.value == '0': - raise AGIDBError("Key not found in database: family=%(family)r, key=%(key)r" % { - 'family': self.family, - 'key': self.key, - }, response.items) - elif result.value == '1': + if result.value == "0": + raise AGIDBError( + "Key not found in database: family=%(family)r, key=%(key)r" + % { + "family": self.family, + "key": self.key, + }, + response.items, + ) + elif result.value == "1": return result.data - raise AGIDBError("Unable to query database: family=%(family)r, key=%(key)r, result=%(result)r" % { - 'family': self.family, - 'key': self.key, - 'result': result.value, - }, response.items) + raise AGIDBError( + "Unable to query database: family=%(family)r, key=%(key)r, result=%(result)r" + % { + "family": self.family, + "key": self.key, + "result": result.value, + }, + response.items, + ) class DatabasePut(_Action): """ Inserts or updates value of the specified family/key entry in Asterisk's database. - + `AGIAppError` is raised on failure. - + `AGIDBError` is raised if the key could not be inserted or if some other database problem occurs. """ def __init__(self, family, key, value): - _Action.__init__(self, 'DATABASE PUT', quote(family), quote(key), quote(value)) + _Action.__init__(self, "DATABASE PUT", quote(family), quote(key), quote(value)) self.family = family self.key = key self.value = value def process_response(self, response): result = response.items.get(_RESULT_KEY) - if result.value == '0': - raise AGIDBError("Unable to store value in database: family=%(family)r, key=%(key)r, value=%(value)r" % { - 'family': self.family, - 'key': self.key, - 'value': self.value, - }, response.items) + if result.value == "0": + raise AGIDBError( + "Unable to store value in database: family=%(family)r, key=%(key)r, value=%(value)r" + % { + "family": self.family, + "key": self.key, + "value": self.value, + }, + response.items, + ) class Exec(_Action): @@ -320,31 +366,38 @@ class Exec(_Action): `AGIAppError` is raised if the application could not be executed. """ + check_hangup = False def __init__(self, application, options=()): self._application = application - options = ','.join((str(o or '') for o in options)) - _Action.__init__(self, 'EXEC', self._application, (options and quote(options)) or '') + options = ",".join((str(o or "") for o in options)) + _Action.__init__( + self, "EXEC", self._application, (options and quote(options)) or "" + ) def process_response(self, response): result = response.items.get(_RESULT_KEY) - if result.value == '-2': - raise AGIAppError("Unable to execute application '%(application)r'" % { - 'application': self._application, - }, response.items) + if result.value == "-2": + raise AGIAppError( + "Unable to execute application '%(application)r'" + % { + "application": self._application, + }, + response.items, + ) return response.raw[7:] # Everything after 'result=' class GetData(_Action): """ See also `ControlStreamFile`, `GetOption`, `StreamFile`. - + Plays back the specified file, which is the `filename` of the file to be played, either in an Asterisk-searched directory or as an absolute path, without extension. ('myfile.wav' would be specified as 'myfile', to allow Asterisk to choose the most efficient encoding, based on extension, for the channel) - + `timeout` is the number of milliseconds to wait between DTMF presses or following the end of playback if no keys were pressed to interrupt playback prior to that point. It defaults to 2000. @@ -353,38 +406,39 @@ class GetData(_Action): The value returned is a tuple consisting of (dtmf_keys:str, timeout:bool). '#' is always interpreted as an end-of-event character and will never be present in the output. - + `AGIAppError` is raised on failure, most commonly because no keys, aside from '#', were entered. """ def __init__(self, filename, timeout=2000, max_digits=255): - _Action.__init__(self, - 'GET DATA', quote(filename), quote(timeout), quote(max_digits) - ) + _Action.__init__( + self, "GET DATA", quote(filename), quote(timeout), quote(max_digits) + ) def process_response(self, response): result = response.items.get(_RESULT_KEY) - return (result.value, result.data == 'timeout') + return (result.value, result.data == "timeout") class GetFullVariable(_Action): """ Returns a `variable` associated with this channel, with full expression-processing. - + The value of the requested variable is returned as a string. If the variable is undefined, `None` is returned. `AGIAppError` is raised on failure. """ + check_hangup = False def __init__(self, variable): - _Action.__init__(self, 'GET FULL VARIABLE', quote(variable)) + _Action.__init__(self, "GET FULL VARIABLE", quote(variable)) def process_response(self, response): result = response.items.get(_RESULT_KEY) - if result.value == '1': + if result.value == "1": return result.data return None @@ -397,31 +451,30 @@ class GetOption(_Action): an Asterisk-searched directory or as an absolute path, without extension. ('myfile.wav' would be specified as 'myfile', to allow Asterisk to choose the most efficient encoding, based on extension, for the channel) - + `escape_digits` may optionally be a list of DTMF digits, specified as a string or a sequence of possibly mixed ints and strings. Playback ends immediately when one is received. - + `timeout` is the number of milliseconds to wait following the end of playback if no keys were pressed to interrupt playback prior to that point. It defaults to 2000. - + The value returned is a tuple consisting of (dtmf_key:str, offset:int), where the offset is the number of milliseconds that elapsed since the start of playback, or None if playback completed successfully or the sample could not be opened. - + `AGIAppError` is raised on failure, most commonly because the channel was hung-up. """ - def __init__(self, filename, escape_digits='', timeout=2000): + def __init__(self, filename, escape_digits="", timeout=2000): escape_digits = _process_digit_list(escape_digits) - _Action.__init__(self, - 'GET OPTION', quote(filename), - quote(escape_digits), quote(timeout) - ) + _Action.__init__( + self, "GET OPTION", quote(filename), quote(escape_digits), quote(timeout) + ) def process_response(self, response): result = response.items.get(_RESULT_KEY) - if not result.value == '0': + if not result.value == "0": dtmf_character = _convert_to_char(result.value, response.items) offset = _convert_to_int(response.items) return (dtmf_character, offset) @@ -431,20 +484,21 @@ def process_response(self, response): class GetVariable(_Action): """ Returns a `variable` associated with this channel. - + The value of the requested variable is returned as a string. If the variable is undefined, `None` is returned. `AGIAppError` is raised on failure. """ + check_hangup = False def __init__(self, variable): - _Action.__init__(self, 'GET VARIABLE', quote(variable)) + _Action.__init__(self, "GET VARIABLE", quote(variable)) def process_response(self, response): result = response.items.get(_RESULT_KEY) - if result.value == '1': + if result.value == "1": return result.data return None @@ -457,22 +511,22 @@ class Hangup(_Action): """ def __init__(self, channel=None): - _Action.__init__(self, 'HANGUP', (channel and quote(channel) or None)) + _Action.__init__(self, "HANGUP", (channel and quote(channel) or None)) class Noop(_Action): """ Does nothing. - + Good for testing the connection to the Asterisk server, like a ping, but not useful for much else. If you wish to log information through Asterisk, use the `verbose` method instead. - + `AGIAppError` is raised on failure. """ def __init__(self): - _Action.__init__(self, 'NOOP') + _Action.__init__(self, "NOOP") class ReceiveChar(_Action): @@ -491,12 +545,15 @@ class ReceiveChar(_Action): """ def __init__(self, timeout=0): - _Action.__init__(self, 'RECEIVE CHAR', quote(timeout)) + _Action.__init__(self, "RECEIVE CHAR", quote(timeout)) def process_response(self, response): result = response.items.get(_RESULT_KEY) - if not result.value == '0': - return (_convert_to_char(result.value, response.items), result.data == 'timeout') + if not result.value == "0": + return ( + _convert_to_char(result.value, response.items), + result.data == "timeout", + ) return None @@ -513,7 +570,7 @@ class ReceiveText(_Action): """ def __init__(self, timeout=0): - _Action.__init__(self, 'RECEIVE TEXT', quote(timeout)) + _Action.__init__(self, "RECEIVE TEXT", quote(timeout)) def process_response(self, response): result = response.items.get(_RESULT_KEY) @@ -526,7 +583,7 @@ class RecordFile(_Action): defaulting to Asterisk's sounds path or an absolute path, without extension. ('myfile.wav' would be specified as 'myfile') `format` is one of the following, which sets the extension and encoding, with WAV being the default: - + - FORMAT_SLN - FORMAT_G723 - FORMAT_G729 @@ -539,10 +596,10 @@ class RecordFile(_Action): The filename may also contain the special string '%d', which Asterisk will replace with an auto-incrementing number, with the resulting filename appearing in the 'RECORDED_FILE' channel variable. - + `escape_digits` may optionally be a list of DTMF digits, specified as a string or a sequence of possibly mixed ints and strings. Playback ends immediately when one is received. - + `timeout` is the number of milliseconds to wait following the end of playback if no keys were pressed to end recording prior to that point. By default, it waits forever. @@ -552,49 +609,69 @@ class RecordFile(_Action): `silence`, if given, is the number of seconds of silence to allow before terminating recording early. - + The value returned is a tuple consisting of (dtmf_key:str, offset:int, timeout:bool), where the offset is the number of milliseconds that elapsed since the start of playback dtmf_key may be the empty string if no key was pressed, and timeout is `False` if recording ended due to another condition (DTMF or silence). - + The raising of `AGIResultHangup` is another condition that signals a successful recording, though it also means the user hung up. - + `AGIAppError` is raised on failure, most commonly because the destination file isn't writable. """ - def __init__(self, filename, format=FORMAT_WAV, escape_digits='', timeout=-1, sample_offset=0, beep=True, - silence=None): + def __init__( + self, + filename, + format=FORMAT_WAV, + escape_digits="", + timeout=-1, + sample_offset=0, + beep=True, + silence=None, + ): escape_digits = _process_digit_list(escape_digits) - _Action.__init__(self, - 'RECORD FILE', quote(filename), quote(format), - quote(escape_digits), quote(timeout), quote(sample_offset), - (beep and quote('beep') or None), - (silence and quote('s=' + str(silence)) or None) - ) + _Action.__init__( + self, + "RECORD FILE", + quote(filename), + quote(format), + quote(escape_digits), + quote(timeout), + quote(sample_offset), + (beep and quote("beep") or None), + (silence and quote("s=" + str(silence)) or None), + ) def process_response(self, response): result = response.items.get(_RESULT_KEY) offset = _convert_to_int(response.items) - if result.data == 'randomerror': - raise AGIAppError("Unknown error occurred %(ms)i into recording: %(error)s" % { - 'ms': offset, - 'error': result.value, - }) - elif result.data == 'timeout': - return ('', offset, True) - elif result.data == 'dtmf': + if result.data == "randomerror": + raise AGIAppError( + "Unknown error occurred %(ms)i into recording: %(error)s" + % { + "ms": offset, + "error": result.value, + } + ) + elif result.data == "timeout": + return ("", offset, True) + elif result.data == "dtmf": return (_convert_to_char(result.value, response.items), offset, False) - return ('', offset, True) # Assume a timeout if any other result data is received. + return ( + "", + offset, + True, + ) # Assume a timeout if any other result data is received. class _SayAction(_Action): """ A specialised subclass of `_Action` that provides behaviour common to several children. - + Synthesises speech on a channel. This abstracts the commonalities between the "SAY ?" subclasses. @@ -611,14 +688,14 @@ class _SayAction(_Action): def __init__(self, say_type, argument, escape_digits, *args): escape_digits = _process_digit_list(escape_digits) - _Action.__init__(self, - 'SAY ' + say_type, quote(argument), quote(escape_digits), *args - ) + _Action.__init__( + self, "SAY " + say_type, quote(argument), quote(escape_digits), *args + ) def process_response(self, response): result = response.items.get(_RESULT_KEY) - if not result.value == '0': + if not result.value == "0": return _convert_to_char(result.value, response.items) return None @@ -635,16 +712,16 @@ class SayAlpha(_SayAction): hung-up. """ - def __init__(self, characters, escape_digits=''): + def __init__(self, characters, escape_digits=""): characters = _process_digit_list(characters) - _SayAction.__init__(self, 'ALPHA', characters, escape_digits) + _SayAction.__init__(self, "ALPHA", characters, escape_digits) class SayDate(_SayAction): """ Reads the date associated with `seconds` since the UNIX Epoch. If not given, the local time is used. - + `escape_digits` may optionally be a list of DTMF digits, specified as a string or a sequence of possibly mixed ints and strings. Playback ends immediately when one is received and it is returned. If nothing is recieved, `None` is returned. @@ -653,24 +730,24 @@ class SayDate(_SayAction): hung-up. """ - def __init__(self, seconds=None, escape_digits=''): + def __init__(self, seconds=None, escape_digits=""): if seconds is None: seconds = int(time.time()) - _SayAction.__init__(self, 'DATE', seconds, escape_digits) + _SayAction.__init__(self, "DATE", seconds, escape_digits) class SayDatetime(_SayAction): """ Reads the datetime associated with `seconds` since the UNIX Epoch. If not given, the local time is used. - + `escape_digits` may optionally be a list of DTMF digits, specified as a string or a sequence of possibly mixed ints and strings. Playback ends immediately when one is received and it is returned. If nothing is recieved, `None` is returned. `format` defaults to `"ABdY 'digits/at' IMp"`, but may be a string with any of the following meta-characters (or single-quote-escaped sound-file references): - + - A: Day of the week - B: Month (Full Text) - m: Month (Numeric) @@ -687,20 +764,24 @@ class SayDatetime(_SayAction): `timezone` may be a string in standard UNIX form, like 'America/Edmonton'. If `format` is undefined, `timezone` is ignored and left to default to the system's local value. - + `AGIAppError` is raised on failure, most commonly because the channel was hung-up. """ - def __init__(self, seconds=None, escape_digits='', format=None, timezone=None): + def __init__(self, seconds=None, escape_digits="", format=None, timezone=None): if seconds is None: seconds = int(time.time()) if not format: timezone = None - _SayAction.__init__(self, - 'DATETIME', seconds, escape_digits, - (format and quote(format) or None), (timezone and quote(timezone) or None) - ) + _SayAction.__init__( + self, + "DATETIME", + seconds, + escape_digits, + (format and quote(format) or None), + (timezone and quote(timezone) or None), + ) class SayDigits(_SayAction): @@ -715,9 +796,9 @@ class SayDigits(_SayAction): hung-up. """ - def __init__(self, digits, escape_digits=''): + def __init__(self, digits, escape_digits=""): digits = _process_digit_list(digits) - _SayAction.__init__(self, 'DIGITS', digits, escape_digits) + _SayAction.__init__(self, "DIGITS", digits, escape_digits) class SayNumber(_SayAction): @@ -732,9 +813,9 @@ class SayNumber(_SayAction): hung-up. """ - def __init__(self, number, escape_digits=''): + def __init__(self, number, escape_digits=""): number = _process_digit_list(number) - _SayAction.__init__(self, 'NUMBER', number, escape_digits) + _SayAction.__init__(self, "NUMBER", number, escape_digits) class SayPhonetic(_SayAction): @@ -749,16 +830,16 @@ class SayPhonetic(_SayAction): hung-up. """ - def __init__(self, characters, escape_digits=''): + def __init__(self, characters, escape_digits=""): characters = _process_digit_list(characters) - _SayAction.__init__(self, 'PHONETIC', characters, escape_digits) + _SayAction.__init__(self, "PHONETIC", characters, escape_digits) class SayTime(_SayAction): """ Reads the time associated with `seconds` since the UNIX Epoch. If not given, the local time is used. - + `escape_digits` may optionally be a list of DTMF digits, specified as a string or a sequence of possibly mixed ints and strings. Playback ends immediately when one is received and it is returned. If nothing is received, `None` is returned. @@ -767,10 +848,10 @@ class SayTime(_SayAction): hung-up. """ - def __init__(self, seconds=None, escape_digits=''): + def __init__(self, seconds=None, escape_digits=""): if seconds is None: seconds = int(time.time()) - _SayAction.__init__(self, 'TIME', seconds, escape_digits) + _SayAction.__init__(self, "TIME", seconds, escape_digits) class SendImage(_Action): @@ -784,7 +865,7 @@ class SendImage(_Action): """ def __init__(self, filename): - _Action.__init__(self, 'SEND FILE', quote(filename)) + _Action.__init__(self, "SEND FILE", quote(filename)) class SendText(_Action): @@ -795,7 +876,7 @@ class SendText(_Action): """ def __init__(self, text): - _Action.__init__(self, 'SEND TEXT', quote(text)) + _Action.__init__(self, "SEND TEXT", quote(text)) class SetAutohangup(_Action): @@ -808,7 +889,7 @@ class SetAutohangup(_Action): """ def __init__(self, seconds=0): - _Action.__init__(self, 'SET AUTOHANGUP', quote(seconds)) + _Action.__init__(self, "SET AUTOHANGUP", quote(seconds)) class SetCallerid(_Action): @@ -821,43 +902,43 @@ class SetCallerid(_Action): def __init__(self, number, name=None): if name: # Escape it name = '\\"%(name)s\\"' % { - 'name': name, + "name": name, } else: # Make sure it's the empty string - name = '' + name = "" number = "%(name)s<%(number)s>" % { - 'name': name, - 'number': number, + "name": name, + "number": number, } - _Action.__init__(self, 'SET CALLERID', quote(number)) + _Action.__init__(self, "SET CALLERID", quote(number)) class SetContext(_Action): """ Sets the context for Asterisk to use upon completion of this AGI instance. - + No context-validation is performed; specifying an invalid context will cause the call to terminate unexpectedly. - + `AGIAppError` is raised on failure. """ def __init__(self, context): - _Action.__init__(self, 'SET CONTEXT', quote(context)) + _Action.__init__(self, "SET CONTEXT", quote(context)) class SetExtension(_Action): """ Sets the extension for Asterisk to use upon completion of this AGI instance. - + No extension-validation is performed; specifying an invalid extension will cause the call to terminate unexpectedly. - + `AGIAppError` is raised on failure. """ def __init__(self, extension): - _Action.__init__(self, 'SET EXTENSION', quote(extension)) + _Action.__init__(self, "SET EXTENSION", quote(extension)) class SetMusic(_Action): @@ -865,29 +946,31 @@ class SetMusic(_Action): Enables or disables music-on-hold for this channel, per the state of the `on` argument. If specified, `moh_class` identifies the music-on-hold class to be used. - + `AGIAppError` is raised on failure. """ def __init__(self, on, moh_class=None): - _Action.__init__(self, - 'SET MUSIC', quote(on and 'on' or 'off'), - (moh_class and quote(moh_class) or None) - ) + _Action.__init__( + self, + "SET MUSIC", + quote(on and "on" or "off"), + (moh_class and quote(moh_class) or None), + ) class SetPriority(_Action): """ Sets the priority for Asterisk to use upon completion of this AGI instance. - + No priority-validation is performed; specifying an invalid priority will cause the call to terminate unexpectedly. - + `AGIAppError` is raised on failure. """ def __init__(self, priority): - _Action.__init__(self, 'SET PRIORITY', quote(priority)) + _Action.__init__(self, "SET PRIORITY", quote(priority)) class SetVariable(_Action): @@ -898,7 +981,7 @@ class SetVariable(_Action): """ def __init__(self, name, value): - _Action.__init__(self, 'SET VARIABLE', quote(name), quote(value)) + _Action.__init__(self, "SET VARIABLE", quote(name), quote(value)) class StreamFile(_Action): @@ -909,30 +992,33 @@ class StreamFile(_Action): an Asterisk-searched directory or as an absolute path, without extension. ('myfile.wav' would be specified as 'myfile', to allow Asterisk to choose the most efficient encoding, based on extension, for the channel) - + `escape_digits` may optionally be a list of DTMF digits, specified as a string or a sequence of possibly mixed ints and strings. Playback ends immediately when one is received. - + `sample_offset` may be used to jump an arbitrary number of milliseconds into the audio data. - + The value returned is a tuple consisting of (dtmf_key:str, offset:int), where the offset is the number of milliseconds that elapsed since the start of playback, or None if playback completed successfully or the sample could not be opened. - + `AGIAppError` is raised on failure, most commonly because the channel was hung-up. """ - def __init__(self, filename, escape_digits='', sample_offset=0): + def __init__(self, filename, escape_digits="", sample_offset=0): escape_digits = _process_digit_list(escape_digits) - _Action.__init__(self, - 'STREAM FILE', quote(filename), - quote(escape_digits), quote(sample_offset) - ) + _Action.__init__( + self, + "STREAM FILE", + quote(filename), + quote(escape_digits), + quote(sample_offset), + ) def process_response(self, response): result = response.items.get(_RESULT_KEY) - if not result.value == '0': + if not result.value == "0": dtmf_character = _convert_to_char(result.value, response.items) offset = _convert_to_int(response.items) return (dtmf_character, offset) @@ -942,22 +1028,22 @@ def process_response(self, response): class TDDMode(_Action): """ Sets the TDD transmission `mode` on supporting channels, one of the following: - + - TDD_ON - TDD_OFF - TDD_MATE - + `True` is returned if the mode is set, `False` if the channel isn't capable, and `AGIAppError` is raised if a problem occurs. According to documentation from 2006, all non-capable channels will cause an exception to occur. """ def __init__(self, mode): - _Action.__init__(self, 'TDD MODE', mode) + _Action.__init__(self, "TDD MODE", mode) def process_response(self, response): result = response.items.get(_RESULT_KEY) - return result.value == '1' + return result.value == "1" class Verbose(_Action): @@ -965,20 +1051,20 @@ class Verbose(_Action): Causes Asterisk to process `message`, logging it to console or disk, depending on whether `level` is greater-than-or-equal-to Asterisk's corresponding verbosity threshold. - + `level` is one of the following, defaulting to LOG_INFO: - + - LOG_DEBUG - LOG_INFO - LOG_WARN - LOG_ERROR - LOG_CRITICAL - + `AGIAppError` is raised on failure. """ def __init__(self, message, level=LOG_INFO): - _Action.__init__(self, 'VERBOSE', quote(message), quote(level)) + _Action.__init__(self, "VERBOSE", quote(message), quote(level)) class WaitForDigit(_Action): @@ -987,17 +1073,17 @@ class WaitForDigit(_Action): value. By default, this function blocks indefinitely. If no DTMF key is pressed, `None` is returned. - + `AGIAppError` is raised on failure, most commonly because the channel was hung-up. """ def __init__(self, timeout=-1): - _Action.__init__(self, 'WAIT FOR DIGIT', quote(timeout)) + _Action.__init__(self, "WAIT FOR DIGIT", quote(timeout)) def process_response(self, response): result = response.items.get(_RESULT_KEY) - if not result.value == '0': + if not result.value == "0": return _convert_to_char(result.value, response.items) return None diff --git a/pystrix/agi/fastagi.py b/pystrix/agi/fastagi.py index 55d0799..45870ef 100644 --- a/pystrix/agi/fastagi.py +++ b/pystrix/agi/fastagi.py @@ -28,27 +28,50 @@ You should have received a copy of the GNU General Public License and GNU Lesser General Public License along with this program. If not, see . - + (C) Ivrnet, inc., 2011 Authors: - Neil Tallim """ -import cgi -import re -import socket + +import socketserver import threading -import types +from urllib.parse import parse_qs + from pystrix.agi.agi_core import * from pystrix.agi.agi_core import _AGI -try: - import socketserver -except: - import SocketServer as socketserver - - +# The listen backlog bounds how many connections may queue while the server is +# busy, which directly limits the call surge a FastAGI server can absorb. We +# want the largest backlog the kernel will allow on the host. +# +# We do NOT read that limit ourselves. The kernel already enforces it: listen() +# silently caps the backlog to the live system maximum -- net.core.somaxconn on +# Linux, kern.ipc.somaxconn on macOS and the BSDs -- so passing a value at or +# above that maximum yields exactly the system maximum. Passing the largest +# value the call accepts therefore tracks a tuned-up maximum automatically, with +# no per-call lookup. +# +# This is why we do not use socket.SOMAXCONN (a small compile-time constant, +# historically 128 and 4096 since Linux 5.4) and do not hardcode a ceiling like +# 65535: on modern kernels somaxconn is a 32-bit value that an administrator can +# tune above 65535, so any fixed ceiling could undershoot. INT_MAX cannot, since +# the kernel's own limit is then the only cap. It also replaces the previous +# approach of shelling out to `sysctl`, which ran a subprocess, parsed +# OS-specific output, and raised on any system that was neither Linux nor macOS. +# +# The value must be exactly INT_MAX (2**31 - 1), not larger: CPython parses the +# listen() backlog into a C int and raises OverflowError above INT_MAX, which +# would crash server startup. Do not "round up" this constant. +# +# Windows works too, but by a different route: it does not clamp to a system +# somaxconn. Winsock's own SOMAXCONN constant is 0x7fffffff (= INT_MAX), and +# Winsock treats that exact value as a sentinel meaning "use a maximum +# reasonable backlog". So INT_MAX is the right value on every platform, though +# Windows honors it as that sentinel rather than as a tuned registry limit. +_LISTEN_BACKLOG = 2**31 - 1 # INT_MAX; Unix kernels cap this to the live somaxconn class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): @@ -56,24 +79,40 @@ class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): Provides a variant of the TCPServer that spawns a new thread to handle each request. """ - def server_bind(self): - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - socketserver.TCPServer.server_bind(self) - + + def __init__(self, *args, **kwargs): + # Request the maximum backlog and let the kernel cap it to the live + # system somaxconn; see _LISTEN_BACKLOG for the full rationale. + self.request_queue_size = _LISTEN_BACKLOG + self.allow_reuse_address = True + super().__init__(*args, **kwargs) + + class _AGIClientHandler(socketserver.StreamRequestHandler): """ Handles TCP connections. """ + def handle(self): """ Creates an instance of an AGI-interface object and passes it to a pre-specified callable, selected by matching the request parameters against a series of regular expressions. """ - agi_instance = FastAGI(self.rfile, self.wfile, debug=self.server.debug) + try: + agi_instance = FastAGI(self.rfile, self.wfile, debug=self.server.debug) + except AGISIGPIPEHangup: + # The client's pipe closed before the full AGI environment arrived: + # the caller hung up as the call reached the AGI step, Asterisk + # aborted the leg, or a bare TCP probe connected and closed. No call + # is in flight, so end the request quietly instead of letting the + # hangup propagate into a socketserver stderr traceback. This catches + # only the handshake-disconnect signal; errors from the handler + # itself are raised below, outside this guard, so they still surface. + return (path, kwargs) = self._extract_query_elements(agi_instance) args = self._extract_positional_args(agi_instance) - + (handler, match) = self.server.get_script_handler(path) if handler: handler(agi_instance, args, kwargs, match, path) @@ -84,8 +123,8 @@ def _extract_positional_args(self, agi_instance): the specification by which they're supplied may change in the future. """ env = agi_instance.get_environment() - keys = sorted((int(key[8:]) for key in env if key.startswith('agi_arg_'))) - return tuple((env['agi_arg_%i' % key] for key in keys)) + keys = sorted((int(key[8:]) for key in env if key.startswith("agi_arg_"))) + return tuple((env["agi_arg_%i" % key] for key in keys)) def _extract_query_elements(self, agi_instance): """ @@ -93,29 +132,37 @@ def _extract_query_elements(self, agi_instance): request. Arguments are supplied as a list, since the same parameter may be specified multiple times. """ - tokens = (agi_instance.get_environment().get('agi_network_script') or '/').split('?', 1) + tokens = ( + agi_instance.get_environment().get("agi_network_script") or "/" + ).split("?", 1) path = tokens[0] if len(tokens) == 1: return (path, {}) - return (path, cgi.urlparse.parse_qs(tokens[1])) + return (path, parse_qs(tokens[1])) + class FastAGIServer(_ThreadedTCPServer): """ Provides a FastAGI TCP server to handle requests from Asterisk servers. """ - debug = False #Used to enable various printouts for library development - _default_script_handler = None #A script-handler to use if nothing else matched - _script_handlers = None #A list of regex/callable pairs to use when determining how to handle an AGI request - _script_handlers_lock = None #A lock used to prevent race conditions on the handlers list - - def __init__(self, interface='127.0.0.1', port=4573, daemon_threads=True, debug=False): + + debug = False # Used to enable various printouts for library development + _default_script_handler = None # A script-handler to use if nothing else matched + _script_handlers = None # A list of regex/callable pairs to use when determining how to handle an AGI request + _script_handlers_lock = ( + None # A lock used to prevent race conditions on the handlers list + ) + + def __init__( + self, interface="127.0.0.1", port=4573, daemon_threads=True, debug=False + ): """ Creates the server and binds the client-handler callable. - + `interface` is the address of the interface on which to listen; defaults to localhost, but may be any interface on the host or `'0.0.0.0'` for all. `port` is the TCP port on which to listen. - + `daemon_threads` indicates whether any threads spawned to handle requests should be killed if the main thread dies. (Generally a good idea to avoid hung calls keeping the process alive forever) @@ -144,7 +191,7 @@ def get_script_handler(self, script_path): `script_path` is the path received from Asterisk. """ with self._script_handlers_lock: - for (regex, handler) in self._script_handlers: + for regex, handler in self._script_handlers: match = None if isinstance(regex, str): match = re.match(regex, script_path) @@ -152,7 +199,7 @@ def get_script_handler(self, script_path): match = regex.match(script_path) if match: return (handler, match) - + return (self._default_script_handler, None) def register_script_handler(self, regex, handler): @@ -175,12 +222,12 @@ def register_script_handler(self, regex, handler): self._default_script_handler = handler return - #Ensure that the regex hasn't been registered before - for (old_regex, old_handler) in self._script_handlers: + # Ensure that the regex hasn't been registered before + for old_regex, old_handler in self._script_handlers: if old_regex == regex: return - #Add the handler to the end of the list + # Add the handler to the end of the list self._script_handlers.append((regex, handler)) def unregister_script_handler(self, regex): @@ -193,16 +240,18 @@ def unregister_script_handler(self, regex): handlers in the desired order. """ with self._script_handlers_lock: - for (i, (old_regex, old_handler)) in enumerate(self._script_handlers): + for i, (old_regex, old_handler) in enumerate(self._script_handlers): if old_regex == regex: self._script_handlers.pop(i) break + class FastAGI(_AGI): """ An interface to Asterisk, exposing request-response functions for synchronous management of the call associated with this channel. """ + def __init__(self, rfile, wfile, debug=False): """ Associates I/O with `rfile` and `wfile`. @@ -211,6 +260,5 @@ def __init__(self, rfile, wfile, debug=False): """ self._rfile = rfile self._wfile = wfile - + _AGI.__init__(self, debug) - diff --git a/pystrix/ami/__init__.py b/pystrix/ami/__init__.py index 3e44669..f17b585 100644 --- a/pystrix/ami/__init__.py +++ b/pystrix/ami/__init__.py @@ -34,33 +34,42 @@ - Neil Tallim """ -from pystrix.ami.ami import ( - RESPONSE_GENERIC, EVENT_GENERIC, - KEY_ACTION, KEY_ACTIONID, KEY_EVENT, KEY_RESPONSE, - Manager, - Error, ManagerError, ManagerSocketError, -) - -from pystrix.ami import core -from pystrix.ami import dahdi -from pystrix.ami import app_confbridge -from pystrix.ami import app_meetme # Register events -from pystrix.ami import core_events -from pystrix.ami import dahdi_events -from pystrix.ami import app_confbridge_events -from pystrix.ami import app_meetme_events +from pystrix.ami import ( + app_confbridge, + app_confbridge_events, + app_meetme, + app_meetme_events, + core, + core_events, + dahdi, + dahdi_events, +) +from pystrix.ami.ami import ( + _EVENT_REGISTRY, + _EVENT_REGISTRY_REV, + EVENT_GENERIC, + KEY_ACTION, + KEY_ACTIONID, + KEY_EVENT, + KEY_RESPONSE, + RESPONSE_GENERIC, + Error, + Manager, + ManagerError, + ManagerSocketError, +) -from pystrix.ami.ami import (_EVENT_REGISTRY, _EVENT_REGISTRY_REV) for module in ( - core_events, dahdi_events, - app_confbridge_events, app_meetme_events, + core_events, + dahdi_events, + app_confbridge_events, + app_meetme_events, ): - for event in (e for e in dir(module) if not e.startswith('_')): + for event in (e for e in dir(module) if not e.startswith("_")): class_object = getattr(module, event) _EVENT_REGISTRY[event] = class_object _EVENT_REGISTRY_REV[class_object] = event del _EVENT_REGISTRY del _EVENT_REGISTRY_REV - diff --git a/pystrix/ami/ami.py b/pystrix/ami/ami.py index ce34531..017fcc9 100644 --- a/pystrix/ami/ami.py +++ b/pystrix/ami/ami.py @@ -32,8 +32,10 @@ - Neil Tallim """ + import abc import collections +import queue import random import re import socket @@ -42,76 +44,100 @@ import traceback import warnings -try: - import queue -except: - import Queue as queue - from pystrix.ami import generic_transforms -_EVENT_REGISTRY = {} #Meant to be internally managed only, this provides mappings from event-class-names to the classes, to enable type-mutation -_EVENT_REGISTRY_REV = {} #Provides the friendly names of events as strings, keyed by class object - -_EOC = '--END COMMAND--' #A string used by Asterisk to mark the end of some of its responses. -_EOL = '\r\n' #Asterisk uses CRLF linebreaks to mark the ends of its lines. -_EOL_FAKE = ('\n\r\n', '\r\r\n') #End-of-line patterns that indicate data, not headers. - -_EOC_INDICATOR = re.compile(r'Response:\s*Follows\s*$') #A regular expression that matches response headers that indicate the payload is attached - -_Response = collections.namedtuple('Response', [ - 'result', 'response', 'request', 'action_id', 'success', 'time', 'events', 'events_timeout', -]) #A container for responses to requests. - -RESPONSE_GENERIC = 'Generic Response' #A header-value provided as a surrogate for unidentifiable responses -EVENT_GENERIC = 'Generic Event' #A header-value provided as a surrogate for unidentifiable unsolicited events +_EVENT_REGISTRY = {} # Meant to be internally managed only, this provides mappings from event-class-names to the classes, to enable type-mutation +_EVENT_REGISTRY_REV = {} # Provides the friendly names of events as strings, keyed by class object + +_EOC = "--END COMMAND--" # A string used by Asterisk to mark the end of some of its responses. +_EOL = "\r\n" # Asterisk uses CRLF linebreaks to mark the ends of its lines. +_EOL_FAKE = ( + "\n\r\n", + "\r\r\n", +) # End-of-line patterns that indicate data, not headers. + +_EOC_INDICATOR = re.compile( + r"Response:\s*Follows\s*$" +) # A regular expression that matches response headers that indicate the payload is attached + +_Response = collections.namedtuple( + "Response", + [ + "result", + "response", + "request", + "action_id", + "success", + "time", + "events", + "events_timeout", + ], +) # A container for responses to requests. + +RESPONSE_GENERIC = "Generic Response" # A header-value provided as a surrogate for unidentifiable responses +EVENT_GENERIC = "Generic Event" # A header-value provided as a surrogate for unidentifiable unsolicited events + +KEY_ACTION = "Action" # The key used to identify an action being requested of Asterisk +KEY_ACTIONID = "ActionID" # The key used to hold the ActionID of a request, for matching with responses +KEY_EVENT = "Event" # The key used to hold the event-name of a response +KEY_RESPONSE = "Response" # The key used to hold the event-name of a request + +_CALLBACK_TYPE_REFERENCE = 1 # Identifies a callback-definition as an event-reference +_CALLBACK_TYPE_UNIVERSAL = 2 # Identifies a callback-definition as universal +_CALLBACK_TYPE_ORPHANED = 3 # Identifies a callback-definition for orphaned responses -KEY_ACTION = 'Action' #The key used to identify an action being requested of Asterisk -KEY_ACTIONID = 'ActionID' #The key used to hold the ActionID of a request, for matching with responses -KEY_EVENT = 'Event' #The key used to hold the event-name of a response -KEY_RESPONSE = 'Response' #The key used to hold the event-name of a request - -_CALLBACK_TYPE_REFERENCE = 1 #Identifies a callback-definition as an event-reference -_CALLBACK_TYPE_UNIVERSAL = 2 #Identifies a callback-definition as universal -_CALLBACK_TYPE_ORPHANED = 3 #Identifies a callback-definition for orphaned responses def _format_socket_error(exception): """ Ensures that, regardless of the form that a `socket.error` takes, it is formatted into a readable string. - + @param str exception: The `socket.error` to be formatted. @return str: A nicely formatted summary of the exception. """ try: (errno, message) = exception return "[%(errno)i] %(error)s" % { - 'errno': errno, - 'error': message, + "errno": errno, + "error": message, } except Exception: return str(exception) - -class Manager(object): - _alive = True #False when this manager object is ready to be disposed of - _action_id = None #The ActionID last sent to Asterisk - _action_id_random_token = None #A randomly generated token, used to help avoid conflicts when multiple AMI connections are in use - _action_id_lock = None #A lock used to prevent race conditions on ActionIDs - _connection = None #A connection to the Asterisk manager, realised as a `_SynchronisedSocket` - _connection_lock = None #A means of preventing race conditions on the connection - _debug = False #If True, development information is emitted along the normal logging stream - _event_aggregates = None #A list of aggregates awaiting fulfillment - _event_aggregates_lock = None #A lock used to prevent race conditions on event aggregation - _event_aggregates_timeout = None #The amount of time to wait before considering an aggregate timed-out - _event_callbacks = None #A list of tuples of type-identifiers, match-criteria, and callback functions - _event_callbacks_lock = None #A lock used to prevent race conditions on event callbacks - _event_callbacks_thread = None #A thread used to process event callbacks - _hostname = socket.gethostname() #The hostname of this system, used to prevent repeated calls through the C layer - _message_reader = None #A thread that continuously collects messages from the Asterisk server - _orphaned_response_timeout = None #The number of seconds to hold on to request-responses before considering them to be timed-out - _outstanding_requests = None #A dictionary of ActionIDs sent to Asterisk, currently awaiting responses; values are a tuple of (events, pending_finalisers), if synchronous, and None otherwise - _logger = None #A logger that may be used to record warnings - - def __init__(self, debug=False, logger=None, aggregate_timeout=5, orphaned_response_timeout=5): + + +class Manager: + _alive = True # False when this manager object is ready to be disposed of + _action_id = None # The ActionID last sent to Asterisk + _action_id_random_token = None # A randomly generated token, used to help avoid conflicts when multiple AMI connections are in use + _action_id_lock = None # A lock used to prevent race conditions on ActionIDs + _connection = None # A connection to the Asterisk manager, realised as a `_SynchronisedSocket` + _connection_lock = None # A means of preventing race conditions on the connection + _debug = False # If True, development information is emitted along the normal logging stream + _event_aggregates = None # A list of aggregates awaiting fulfillment + _event_aggregates_lock = ( + None # A lock used to prevent race conditions on event aggregation + ) + _event_aggregates_timeout = ( + None # The amount of time to wait before considering an aggregate timed-out + ) + _event_callbacks = None # A list of tuples of type-identifiers, match-criteria, and callback functions + _event_callbacks_lock = ( + None # A lock used to prevent race conditions on event callbacks + ) + _event_callbacks_thread = None # A thread used to process event callbacks + _hostname = ( + socket.gethostname() + ) # The hostname of this system, used to prevent repeated calls through the C layer + _message_reader = ( + None # A thread that continuously collects messages from the Asterisk server + ) + _orphaned_response_timeout = None # The number of seconds to hold on to request-responses before considering them to be timed-out + _outstanding_requests = None # A dictionary of ActionIDs sent to Asterisk, currently awaiting responses; values are a tuple of (events, pending_finalisers), if synchronous, and None otherwise + _logger = None # A logger that may be used to record warnings + + def __init__( + self, debug=False, logger=None, aggregate_timeout=5, orphaned_response_timeout=5 + ): """ Sets up an environment for interacting with an Asterisk Management Interface. @@ -120,10 +146,10 @@ def __init__(self, debug=False, logger=None, aggregate_timeout=5, orphaned_respo `logger` may be a logging.Logger object to use for logging problems in AMI threads. If not provided, problems will be emitted through the Python warnings interface. - + `aggregate_timeout` is the number of seconds to wait for aggregates to be fully assembled before considering them timed-out. - + `orphaned_response_timeout` is the number of seconds to wait for responses to requests to be collected before considering them timed out. (This should never happen, but a guarantee must be made that the buffer can stay clean) @@ -132,18 +158,18 @@ def __init__(self, debug=False, logger=None, aggregate_timeout=5, orphaned_respo """ self._debug = debug self._logger = logger - + self._action_id = 0 action_id_random_token = [] for i in range(5): - if random.random() < 0.25: #Append a digit + if random.random() < 0.25: # Append a digit action_id_random_token.append(chr(random.randint(48, 57))) else: - if random.random() < 0.5: #Append a upper-case letter + if random.random() < 0.5: # Append a upper-case letter action_id_random_token.append(chr(random.randint(65, 90))) - else: #Append a lower-case letter + else: # Append a lower-case letter action_id_random_token.append(chr(random.randint(97, 122))) - self._action_id_random_token = ''.join(action_id_random_token) + self._action_id_random_token = "".join(action_id_random_token) self._action_id_lock = threading.Lock() self._connection_lock = threading.Lock() @@ -160,7 +186,7 @@ def __init__(self, debug=False, logger=None, aggregate_timeout=5, orphaned_respo self._event_callbacks_thread = threading.Thread(target=self._event_dispatcher) self._event_callbacks_thread.daemon = True self._event_callbacks_thread.start() - + def __del__(self): """ Ensure that all resources are freed quickly upon garbage-collection. @@ -177,43 +203,50 @@ def _event_dispatcher(self): event_aggregates_complete = collections.deque() event_aggregate_cycle = 0 while self._alive: - #Determine whether there's actually anything to read from + # Determine whether there's actually anything to read from message_reader = None with self._connection_lock: message_reader = self._message_reader if not message_reader: time.sleep(0.02) continue - - #Emit events, sleeping if nothing was sent during this cycle - sleep = not self._event_dispatcher_events(message_reader, event_aggregates_complete) - sleep = not self._event_dispatcher_orphaned_responses(message_reader) and sleep + + # Emit events, sleeping if nothing was sent during this cycle + sleep = not self._event_dispatcher_events( + message_reader, event_aggregates_complete + ) + sleep = ( + not self._event_dispatcher_orphaned_responses(message_reader) and sleep + ) if sleep: time.sleep(0.02) - #Clean up old aggregates about once every second + # Clean up old aggregates about once every second if event_aggregate_cycle == 0: event_aggregate_cycle = 50 current_time = time.time() with self._event_aggregates_lock: - for (i, aggregate) in enumerate(self._event_aggregates): - if aggregate[0] <= current_time: #Expired + for i, aggregate in enumerate(self._event_aggregates): + if aggregate[0] <= current_time: # Expired del self._event_aggregates[i] - (self._logger and self._logger.warn or warnings.warn)("Aggregate '%(name)s' for action-ID '%(action-id)s' timed out before all events were gathered" % { - 'name': aggregate[1].name, - 'action-id': aggregate[1].action_id, - }) + (self._logger and self._logger.warn or warnings.warn)( + "Aggregate '%(name)s' for action-ID '%(action-id)s' timed out before all events were gathered" + % { + "name": aggregate[1].name, + "action-id": aggregate[1].action_id, + } + ) else: event_aggregate_cycle -= 1 - + def _event_dispatcher_events(self, message_reader, event_aggregates_complete): """ Pulls events from the message-reader, then sends them to all registered callbacks. The returned value indicates whether anything was done during the current cycle. - + If aggregates are in use, they are assembled and broadcast here, as well. """ event = None - if event_aggregates_complete: #Check for completed aggregates first + if event_aggregates_complete: # Check for completed aggregates first event = event_aggregates_complete.popleft() else: try: @@ -221,50 +254,61 @@ def _event_dispatcher_events(self, message_reader, event_aggregates_complete): except queue.Empty: pass else: - #Bind it to a request, if appropriate + # Bind it to a request, if appropriate if self._process_outstanding_request_event(event): - return #Synchronous requests do not generate asynchronous events - - #Evaluate the new event against all pending aggregates + return # Synchronous requests do not generate asynchronous events + + # Evaluate the new event against all pending aggregates with self._event_aggregates_lock: - for (i, (_, aggregate)) in enumerate(self._event_aggregates): + for i, (_, aggregate) in enumerate(self._event_aggregates): aggregation_result = aggregate.evaluate_event(event) - if aggregation_result is None: #Not relevant + if aggregation_result is None: # Not relevant continue else: - if aggregation_result: #Finalised + if aggregation_result: # Finalised event_aggregates_complete.append(aggregate) del self._event_aggregates[i] break - + if event: event_name = event.name callbacks = None global _CALLBACK_TYPE_REFERENCE global _CALLBACK_TYPE_UNIVERSAL with self._event_callbacks_lock: - callbacks = [c for (t, e, c) in self._event_callbacks if (t == _CALLBACK_TYPE_REFERENCE and event_name == e) or (t == _CALLBACK_TYPE_UNIVERSAL)] - + callbacks = [ + c + for (t, e, c) in self._event_callbacks + if (t == _CALLBACK_TYPE_REFERENCE and event_name == e) + or (t == _CALLBACK_TYPE_UNIVERSAL) + ] + if self._logger: - self._logger.debug("Received event '%(name)s' with %(callbacks)i callbacks" % { - 'name': event_name, - 'callbacks': len(callbacks), - }) - + self._logger.debug( + "Received event '%(name)s' with %(callbacks)i callbacks" + % { + "name": event_name, + "callbacks": len(callbacks), + } + ) + for callback in callbacks: try: callback(event, self) except Exception as e: - (self._logger and self._logger.error or warnings.warn)("Exception occurred while processing event callback: event='%(event)r'; handler='%(function)s' exception: %(error)s; trace:\n%(trace)s" % { - 'event': event, - 'function': str(callback), - 'error': str(e), - 'trace': traceback.format_exc(), - }) - + (self._logger and self._logger.error or warnings.warn)( + "Exception occurred while processing event callback: event='%(event)r'; handler='%(function)s' exception: %(error)s; trace:\n%(trace)s" + % { + "event": event, + "function": str(callback), + "error": str(e), + "trace": traceback.format_exc(), + } + ) + return True return False - + def _event_dispatcher_orphaned_responses(self, message_reader): """ Pulls orphaned responses from the message-reader, then sends them to orhpan-handler @@ -275,33 +319,43 @@ def _event_dispatcher_orphaned_responses(self, message_reader): response = message_reader.response_queue.get_nowait() except queue.Empty: pass - + if response: callbacks = None global _CALLBACK_TYPE_ORPHANED with self._event_callbacks_lock: - callbacks = [c for (t, e, c) in self._event_callbacks if t == _CALLBACK_TYPE_ORPHANED] - + callbacks = [ + c + for (t, e, c) in self._event_callbacks + if t == _CALLBACK_TYPE_ORPHANED + ] + if self._logger: - self._logger.debug("Received orphaned response '%(name)s' with %(callbacks)i callbacks" % { - 'name': response.name, - 'callbacks': len(callbacks), - }) - + self._logger.debug( + "Received orphaned response '%(name)s' with %(callbacks)i callbacks" + % { + "name": response.name, + "callbacks": len(callbacks), + } + ) + for callback in callbacks: try: callback(response, self) except Exception as e: - (self._logger and self._logger.error or warnings.warn)("Exception occurred while processing orphaned response handler: response=%(response)r; handler='%(function)s'; exception: %(error)s; trace:\n%(trace)s" % { - 'response': response, - 'function': str(callback), - 'error': str(e), - 'trace': traceback.format_exc(), - }) - + (self._logger and self._logger.error or warnings.warn)( + "Exception occurred while processing orphaned response handler: response=%(response)r; handler='%(function)s'; exception: %(error)s; trace:\n%(trace)s" + % { + "response": response, + "function": str(callback), + "error": str(e), + "trace": traceback.format_exc(), + } + ) + return True return False - + def _get_action_id(self): """ Produces a session-unique int, suitable for passing to Asterisk as an ActionID. @@ -317,23 +371,23 @@ def _get_host_action_id(self): Generates a host-qualified, random-token-augmented ActionID as a string, for greater identifiability. """ - return '%(hostname)s-%(random)s-%(id)08x' % { - 'hostname': self._hostname, - 'random': self._action_id_random_token, - 'id': self._get_action_id(), + return "%(hostname)s-%(random)s-%(id)08x" % { + "hostname": self._hostname, + "random": self._action_id_random_token, + "id": self._get_action_id(), } def close(self): """ Release all resources associated with this manager and ensure that all threads have stopped. - + This function is automatically invoked when garbage-collected. """ self.disconnect() - + self._alive = False self._event_callbacks_thread.join() - + def connect(self, host, port=5038, timeout=5): """ Establishes a connection to the specified Asterisk manager, closing any existing connection @@ -342,29 +396,33 @@ def connect(self, host, port=5038, timeout=5): `timeout` specifies the number of seconds to allow Asterisk to go between producing lines of a response; it differs from the timeout that may be set on individual requests and exists primarily to avoid having a thread stay active forever, to allow for clean shutdowns. - + If the connection fails, `ManagerSocketError` is raised. """ self.disconnect() with self._connection_lock: - self._connection = _SynchronisedSocket(host=host, port=port, timeout=timeout) - + self._connection = _SynchronisedSocket( + host=host, port=port, timeout=timeout + ) + self._message_reader = _MessageReader(self, self._orphaned_response_timeout) self._message_reader.start() - + def disconnect(self): """ Gracefully closes a connection to the Asterisk manager. - + If not connected, this is a no-op. """ with self._connection_lock: - if self._connection: #Close the old connection, if any. + if self._connection: # Close the old connection, if any. self._connection.close() self._connection = None self._outstanding_requests.clear() - - if self._message_reader: #Kill, but don't drop, the reader, since it may have unprocessed data + + if ( + self._message_reader + ): # Kill, but don't drop, the reader, since it may have unprocessed data self._message_reader.kill() def get_asterisk_info(self): @@ -380,12 +438,12 @@ def get_connection(self): """ Returns the current `_SynchronisedSocket` in use by the active connection, or `None` if no manager is attached. - + This function is exposed for debugging purposes and should not be used by normal applications that do not have very special reasons for interacting with Asterisk directly. """ return self._connection - + def is_connected(self): """ Indicates whether the manager is connected. @@ -397,21 +455,41 @@ def monitor_connection(self, interval=2.5): """ Spawns a thread that watches the AMI connection to indicate a disruption when the connection goes down. - + `interval` is the number of seconds to wait between automated Pings to see if Asterisk is still alive; defaults to 2.5. + + Returns the monitoring `threading.Thread`, which a caller may join. The thread stops on + its own when the connection drops: pinging a downed connection raises `ManagerSocketError` + (broken socket) or `ManagerError` (the liveness re-check inside `send_action` fails when + the connection dropped just after the loop's own check), and the monitor catches both to + exit cleanly rather than dying with an unhandled traceback. """ + def _monitor_connection(): from pystrix.ami import core while self.is_connected(): - self.send_action(core.Ping()) + try: + self.send_action(core.Ping()) + except (ManagerError, ManagerSocketError) as exc: + # The connection dropped between the loop's check and the ping: + # either the socket broke (ManagerSocketError) or send_action's + # own liveness check failed (ManagerError, e.g. Asterisk stopped). + # Nothing can be reported, so the monitor stops cleanly instead of + # crashing the thread. Record why it left, when a logger is set. + if self._logger: + self._logger.debug("AMI connection monitor stopping: %s" % exc) + break time.sleep(interval) - - monitor = threading.Thread(target=_monitor_connection, name='pystrix-ami-monitor') + + monitor = threading.Thread( + target=_monitor_connection, name="pystrix-ami-monitor" + ) monitor.daemon = True monitor.start() - + return monitor + def _compile_callback_definition(self, event, function): """ Provides a triple of type, match-criteria, and callback for the given event-identifier and @@ -427,9 +505,11 @@ def _compile_callback_definition(self, event, function): return (_CALLBACK_TYPE_REFERENCE, event_name, function) elif event is None: return (_CALLBACK_TYPE_ORPHANED, None, function) - - raise ValueError("Attempted to build callback definition using an unsupported identifier") - + + raise ValueError( + "Attempted to build callback definition using an unsupported identifier" + ) + def register_callback(self, event, function): """ Registers a callback for an Asterisk event identified by `event`, which may be a string for @@ -438,7 +518,7 @@ def register_callback(self, event, function): `function` is the callable to be invoked whenever a matching `_Event` is emitted; it must accept the positional arguments "event" and "manager", with "event" being the `_Event` object and "manager" being a reference to generating instance of this class. - + Registering the same function twice for the same event will unset the first callback, placing the new one at the end of the list. @@ -455,42 +535,42 @@ def register_callback(self, event, function): self._unregister_callback(callback_definition) with self._event_callbacks_lock: self._event_callbacks.append(callback_definition) - + def _unregister_callback(self, definition): """ Removes the indicated callback from the list of those registered, if a match is found. - + The value returned indicates whether anything was removed. """ with self._event_callbacks_lock: - for (i, d) in enumerate(self._event_callbacks): + for i, d in enumerate(self._event_callbacks): if definition == d: del self._event_callbacks[i] return True return False - + def unregister_callback(self, event, function): """ Unregisters a previously bound callback. - + A boolean value is returned, indicating whether anything was removed. """ callback_definition = self._compile_callback_definition(event, function) return self._unregister_callback(callback_definition) - + def send_action(self, request, action_id=None, **kwargs): """ Sends a command, contained in `request`, a `_Request`, to the Asterisk manager, referred to interchangeably as "actions". Any additional keyword arguments are added directly into the request command as though they were native headers, though the original object is unaffected. - + `action_id` is an optional Asterisk ActionID to use; if unspecified, whatever is in the request, keyed at 'ActionID', is used with the output of `id_generator` being a fallback. - + Asterisk's response is returned as a named tuple of the following form, or `None` if the request timed out: - + - result: The processed response from Asterisk, nominally the same as `response`; see the specific `_Request` subclass for details in case it provides additional processing - response: The formatted, but unprocessed, response from Asterisk @@ -501,36 +581,68 @@ def send_action(self, request, action_id=None, **kwargs): - time: The number of seconds, as a float, that the request took to be serviced - events: A dictionary containing related events if the request is synchronous or None otherwise - events_timeout: Whether the request timed out while waiting for events - + For forward-compatibility reasons, elements of the tuple should be accessed by name, rather than by index. - + Raises `ManagerError` if the manager is not connected. - Raises `ManagerSocketError` if the socket is broken during transmission. + Raises `ManagerSocketError` if the socket is broken during transmission, including when a + concurrent `disconnect()` closes the connection before the request is sent. + + Raises `ValueError` if any header key or value contains CR or LF. This function is thread-safe. """ if not self.is_connected(): raise ManagerError("Not connected to an Asterisk manager") - - (command, action_id) = request.build_request(action_id and str(action_id), self._get_host_action_id, **kwargs) + + (command, action_id) = request.build_request( + None if action_id is None else str(action_id), + self._get_host_action_id, + **kwargs, + ) events = self._add_outstanding_request(action_id, request) with self._connection_lock: - self._connection.send_message(command) - - if request.aggregate and not request.synchronous: #Set up aggregate-event generation + if self._connection is None: + # A concurrent disconnect() cleared the connection after the + # liveness check above. Drop the request we just registered and + # raise the documented exception instead of dereferencing None. + self._outstanding_requests.pop(action_id, None) + raise ManagerSocketError( + "Connection to Asterisk manager closed before the request could be sent" + ) + try: + self._connection.send_message(command) + except ManagerSocketError: + # The socket broke mid-send, before the normal cleanup below can + # run. Drop the request we just registered so it does not linger + # in _outstanding_requests, then surface the error. + self._outstanding_requests.pop(action_id, None) + raise + + if ( + request.aggregate and not request.synchronous + ): # Set up aggregate-event generation with self._event_aggregates_lock: for aggregate_class in request.get_aggregate_classes(): - self._event_aggregates.append((time.time() + self._event_aggregates_timeout, aggregate_class(action_id))) + self._event_aggregates.append( + ( + time.time() + self._event_aggregates_timeout, + aggregate_class(action_id), + ) + ) if self._debug: - (self._logger and self._logger.debug or warnings.warn)("Started building aggregate-event '%(event)s' for action-ID '%(action-id)s'" % { - 'event': _EVENT_REGISTRY_REV.get(aggregate_class), - 'action-id': action_id, - }) + (self._logger and self._logger.debug or warnings.warn)( + "Started building aggregate-event '%(event)s' for action-ID '%(action-id)s'" + % { + "event": _EVENT_REGISTRY_REV.get(aggregate_class), + "action-id": action_id, + } + ) start_time = time.time() - if request['Action'] == 'Originate': + if request["Action"] == "Originate": # timeout is in millisecs timeout = start_time + (request.timeout / 1000) else: @@ -538,26 +650,36 @@ def send_action(self, request, action_id=None, **kwargs): response = processed_response = success = None events_timeout = False while time.time() < timeout: - if not response: #If blocking for event synchronisation, don't bother polling for the already-received response + if not response: # If blocking for event synchronisation, don't bother polling for the already-received response with self._connection_lock: response = self._message_reader.get_response(action_id) if response: processed_response = request.process_response(response) - success = hasattr(processed_response, 'success') and processed_response.success + success = ( + hasattr(processed_response, "success") + and processed_response.success + ) if not request.synchronous or not success: - break #No events to watch for - else: #Synchronous processing - if self._check_outstanding_request_complete(action_id): #Not waiting for any more events + break # No events to watch for + else: # Synchronous processing + if self._check_outstanding_request_complete( + action_id + ): # Not waiting for any more events break time.sleep(0.05) - else: #Timed out + else: # Timed out if request.synchronous: events_timeout = True - (self._logger and self._logger.warn or warnings.warn)("Timed out while collecting events for synchronised action-ID '%(action-id)s'" % { - 'action-id': action_id, - }) - - self._serve_outstanding_request(action_id) #Get the ActionID out of circulation + (self._logger and self._logger.warn or warnings.warn)( + "Timed out while collecting events for synchronised action-ID '%(action-id)s'" + % { + "action-id": action_id, + } + ) + + self._serve_outstanding_request( + action_id + ) # Get the ActionID out of circulation if response: return _Response( processed_response, @@ -567,20 +689,23 @@ def send_action(self, request, action_id=None, **kwargs): success, time.time() - start_time, events, - events_timeout + events_timeout, ) else: - (self._logger and self._logger.warn or warnings.warn)("Timed out while waiting for response for action-ID '%(action-id)s'" % { - 'action-id': action_id, - }) + (self._logger and self._logger.warn or warnings.warn)( + "Timed out while waiting for response for action-ID '%(action-id)s'" + % { + "action-id": action_id, + } + ) return None def _add_outstanding_request(self, action_id, request): """ Beings tracking the given `action_id` to synchronise communication with Asterisk. - + If full event-synchronisation is requested, that's set up here, too. - + The value returned is the events-map, if one was set up. """ with self._connection_lock: @@ -594,45 +719,51 @@ def _add_outstanding_request(self, action_id, request): events[c] = events[_EVENT_REGISTRY_REV.get(c)] = [] for c in finalisers: events[c] = events[_EVENT_REGISTRY_REV.get(c)] = None - + self._outstanding_requests[action_id] = (events, set(finalisers)) return events else: self._outstanding_requests[action_id] = None return None - + def _check_outstanding_request_complete(self, action_id): """ Yields a boolean value that indicates whether the indicated request has been fully served, in terms of having received all expected requests if synchronised. - + If not synchronised or if the request isn't tracked, the value returned is True. """ with self._connection_lock: status = self._outstanding_requests.get(action_id) - if not status: #Undefined or not synchronous + if not status: # Undefined or not synchronous return True - return not status[1] #True if all finalisers have been received - + return not status[1] # True if all finalisers have been received + def _process_outstanding_request_event(self, event): """ Checks the event against pending requests and adds it to the appropriate event-list, if one exists, updating pending finalisers as needed. - + The value returned indicates whether the event was bound to an action. """ with self._connection_lock: status = self._outstanding_requests.get(event.action_id) - if status: #It's being tracked + if status: # It's being tracked event_type = type(event) - - status[1].discard(event_type) #Mark it as received if it's a finaliser - + + status[1].discard(event_type) # Mark it as received if it's a finaliser + value = status[0].get(event_type) - if type(value) is list: #If it's part of a list-type, add it to the collection - value.append(event) #No need to add it to both the named and class-type value, since they share the same list - else: #Set it as the relevant entry, for both the class-def and named keys - status[0][event_type] = status[0][_EVENT_REGISTRY_REV.get(event_type)] = event + if ( + type(value) is list + ): # If it's part of a list-type, add it to the collection + value.append( + event + ) # No need to add it to both the named and class-type value, since they share the same list + else: # Set it as the relevant entry, for both the class-def and named keys + status[0][event_type] = status[0][ + _EVENT_REGISTRY_REV.get(event_type) + ] = event return True return False @@ -647,12 +778,14 @@ def _serve_outstanding_request(self, action_id): del self._outstanding_requests[action_id] return served -class _MessageTemplate(object): + +class _MessageTemplate: """ An abstract base-class for all message-types, including aggregates. """ + __meta__ = abc.ABCMeta - + def __eq__(self, o): """ A convenience qualifier for decision-blocks to allow the message to be compared to strings for @@ -661,71 +794,75 @@ def __eq__(self, o): if isinstance(o, str): return self.name == o return dict.__eq__(self, o) - + @property def action_id(self): """ Provides the action-ID associated with the message, if any. """ raise NotImplementedError("Action-IDs must be implemented by subclasses") - + @property def name(self): """ Provides the name of the message. """ raise NotImplementedError("Names must be implemented by subclasses") - + + class _Aggregate(_MessageTemplate, dict): """ Provides, as a dictionary, access to all events that make up the aggregation, keyed by event-class. Repeatable event-types are exposed as lists, while others are direct references to the event itself. """ - _name = None #The name of the aggregate-type - _action_id = None #The action-ID associated with the aggregate, if any - _valid = True #Indicates whether the aggregate's contents are consistent with Asterisk's protocol - _error_message = None #A string that explains why validation failed, if it failed - - _aggregation_members = () #A tuple containing all classes that can be members of this aggregation - _aggregation_finalisers = () #A tuplecontaining all class that must be received for the aggregation to be complete - _pending_finalisers = None #All finalisers yet to be received - + + _name = None # The name of the aggregate-type + _action_id = None # The action-ID associated with the aggregate, if any + _valid = True # Indicates whether the aggregate's contents are consistent with Asterisk's protocol + _error_message = None # A string that explains why validation failed, if it failed + + _aggregation_members = () # A tuple containing all classes that can be members of this aggregation + _aggregation_finalisers = () # A tuplecontaining all class that must be received for the aggregation to be complete + _pending_finalisers = None # All finalisers yet to be received + def __init__(self, action_id): """ Associates the aggregate with an action-ID. """ self._action_id = action_id self._pending_finalisers = set(self._aggregation_finalisers) - + global _EVENT_REGISTRY_REV for c in self._aggregation_members: self[c] = self[_EVENT_REGISTRY_REV.get(c)] = [] for c in self._aggregation_finalisers: self[c] = self[_EVENT_REGISTRY_REV.get(c)] = None - + def _evaluate_action_id(self, event): """ Indicates whether the aggregate's action-ID matches that of the event. """ return self._action_id == event.action_id - + def _aggregate(self, event): """ Adds the `event` to this aggregate, if appropriate, inheriting properties as necessary. - + The value returned indicates whether `event` was added. """ if self._evaluate_action_id(event): - self[type(event)].append(event) #Lists are shared between class-object and string elements + self[type(event)].append( + event + ) # Lists are shared between class-object and string elements return True return False - + def _finalise(self, event): """ Finalises this aggregate, if appropriate, performing any additional checks as needed, based on the properties of the `event`. - + The value returned indicates whether finalisation succeeded. """ if self._evaluate_action_id(event): @@ -734,30 +871,37 @@ def _finalise(self, event): self._pending_finalisers.discard(event_type) return len(self._pending_finalisers) == 0 return False - + def _check_list_items_count(self, event, count_header): """ Most finalisers have a count-property, so check it to assert validity. - + `count_header` identifies the header-item to check to validate the number of received entries. """ event = event.process()[0] list_items_count = event.get(count_header) if list_items_count is not None: - items_count = sum(len(v) for (k, v) in self.items() if type(v) is list and isinstance(k, str)) + items_count = sum( + len(v) + for (k, v) in self.items() + if type(v) is list and isinstance(k, str) + ) self._valid = list_items_count == items_count if not self._valid: - self._error_message = "Expected %(event)i list-items; received %(count)i" % { - 'event': list_items_count, - 'count': items_count, - } - + self._error_message = ( + "Expected %(event)i list-items; received %(count)i" + % { + "event": list_items_count, + "count": items_count, + } + ) + def evaluate_event(self, event): """ Folds the event into the aggregation, if it's associated with the same action-ID and is of a relevant type. - + `True` is returned if the aggregate is finalised after this event, `False` if it was simply merged, or `None` if the aggregate was unrelated. """ @@ -768,35 +912,36 @@ def evaluate_event(self, event): elif event_type in self._aggregation_finalisers: return self._finalise(event) return None - + @property def action_id(self): """ Provides the action-ID associated with the message, if any. """ return self._action_id - + @property def name(self): """ Provides the name of the event or response. """ return self._name - + @property def valid(self): """ Indicates whether the aggregate is consistent with Asterisk's protocol. """ return self._valid - + @property def error_message(self): """ If `valid` is `False`, this will offer a string explaining why validation failed. """ return self._error_message - + + class _Message(_MessageTemplate, dict): """ The common base-class for both replies and events, this is any structured response received @@ -804,29 +949,32 @@ class _Message(_MessageTemplate, dict): All message headers are exposed as dictionary items on this object directly. """ - data = None #The payload received from Asterisk - headers = None #A reference to this object, which is a dictionary with header responses from Asterisk - raw = None #The raw response received from Asterisk - + + data = None # The payload received from Asterisk + headers = None # A reference to this object, which is a dictionary with header responses from Asterisk + raw = None # The raw response received from Asterisk + def __init__(self, response): """ Parses the `response` received from Asterisk and assembles a structured event object suitable for processing by application logic. """ self.data = [] - self.headers = self #For simplicity's sake, the 'headers' property is a recursive reference to this object + self.headers = self # For simplicity's sake, the 'headers' property is a recursive reference to this object self.raw = response self._parse(response) - #Apply some tests to see if Asterisk sent back a malformed response and try to make it - #salvagable. This typically only happens with specific events, so applications can deal - #with it more effectively than the processing core. + # Apply some tests to see if Asterisk sent back a malformed response and try to make it + # salvagable. This typically only happens with specific events, so applications can deal + # with it more effectively than the processing core. if KEY_EVENT not in self and KEY_RESPONSE not in self: - if KEY_ACTIONID in self: #If 'ActionID' is present, it's a response to an action. + if ( + KEY_ACTIONID in self + ): # If 'ActionID' is present, it's a response to an action. self.headers[KEY_RESPONSE] = RESPONSE_GENERIC - else: #It's an unsolicited event + else: # It's an unsolicited event self.headers[KEY_EVENT] = EVENT_GENERIC - + def _parse(self, response): """ Parses the response from Asterisk. @@ -835,10 +983,12 @@ def _parse(self, response): """ while response: line = response[0] - if line.endswith(_EOL_FAKE) or not line.endswith(_EOL) or not ':' in line: #All lines from this point forth are data - self.data.extend((l.strip() for l in response)) + if ( + line.endswith(_EOL_FAKE) or not line.endswith(_EOL) or ":" not in line + ): # All lines from this point forth are data + self.data.extend((entry.strip() for entry in response)) break - (key, value) = response.pop(0).split(':', 1) + (key, value) = response.pop(0).split(":", 1) self[key.strip()] = value.strip() @property @@ -847,7 +997,7 @@ def action_id(self): Provides the action-ID associated with the message, if any. """ return self.get(KEY_ACTIONID) - + @property def name(self): """ @@ -855,11 +1005,13 @@ def name(self): """ return self.get(KEY_EVENT) or self.get(KEY_RESPONSE) + class _Event(_Message): """ The base-class of any event received from Asterisk, either unsolicited or as part of an extended response-chain. """ + def process(self): """ Provides a tuple containing a copy of all headers as a dictionary and a copy of all response @@ -867,106 +1019,143 @@ def process(self): replacing the values of headers with Python types or making the data easier to work with. """ return (self.copy(), self.data[:]) - + + class _Request(dict): """ Provides a generic container for assembling AMI requests, the basis of all actions. - + Subclasses may override `__init__` and define any additional behaviours they may need, as well as override `process_response()` to specially format the data to be returned after a request has been served. """ - aggregate = False #Only has an effect on certain types of requests; will result in an aggregate-event being generated after a list of independent events - synchronous = False #If True, requests will block until all response events have been collected; these events will appear in a `response` dictionary-attribute - timeout = 5 #The number of seconds to wait before considering this request timed out; may be a float - - _aggregates = () #A tuple containing all aggregate-types associated with this request - _synchronous_events_unique = () #A tuple containing all unique events associatable with this request - _synchronous_events_list = () #A tuple containing all list-type events associatable with this request - _synchronous_events_finalising = () #A tuple containing all events that must be received to consider this request complete - + + aggregate = False # Only has an effect on certain types of requests; will result in an aggregate-event being generated after a list of independent events + synchronous = False # If True, requests will block until all response events have been collected; these events will appear in a `response` dictionary-attribute + timeout = 5 # The number of seconds to wait before considering this request timed out; may be a float + + _aggregates = () # A tuple containing all aggregate-types associated with this request + _synchronous_events_unique = () # A tuple containing all unique events associatable with this request + _synchronous_events_list = () # A tuple containing all list-type events associatable with this request + _synchronous_events_finalising = () # A tuple containing all events that must be received to consider this request complete + def __init__(self, action): """ `action` is the type of action being requested of the Asterisk server. """ - self['Action'] = action - + self["Action"] = action + def build_request(self, action_id, id_generator, **kwargs): """ Returns a string formatted for transmission to Asterisk to place a request and the action ID associated with the request. - + `action_id` is the Asterisk ActionID to use, or None to use whatever is in the request, if anything, or the output of `id_generator` if not. - + `id_generator` is a function that generates an Asterisk ActionID. - + `**kwargs` is a dictionary of additional arguments that may be passed along with the request at submission time. - + + Raises `ValueError` if any header key or value contains CR or LF. + The 'Action' line is always first. """ - items = [(KEY_ACTION, self[KEY_ACTION])] - for (key, value) in [(k, v) for (k, v) in self.items() if not k in (KEY_ACTION, KEY_ACTIONID)] + list(kwargs.items()): + + def validate_header(key, value): + # AMI v2 uses '\r\n' as the field separator and a bare '\r\n' (blank + # line) as the packet terminator. The protocol defines no escaping + # mechanism, so a literal CR or LF inside a key or value is + # indistinguishable from a field boundary or end-of-packet marker. + # See: https://docs.asterisk.org/Configuration/Interfaces/Asterisk-Manager-Interface-AMI/AMI-v2-Specification/ + if "\r" in key or "\n" in key: + raise ValueError("AMI header keys must not contain CR or LF") + if "\r" in value or "\n" in value: + raise ValueError("AMI header values must not contain CR or LF") + return (key, value) + + items = [validate_header(KEY_ACTION, str(self[KEY_ACTION]))] + for key, value in [ + (k, v) for (k, v) in self.items() if k not in (KEY_ACTION, KEY_ACTIONID) + ] + list(kwargs.items()): key = str(key) if type(value) in (tuple, list, set, frozenset): for val in value: - items.append((key, str(val))) + items.append(validate_header(key, str(val))) else: - items.append((key, str(value))) - - if action_id or not KEY_ACTIONID in self: #Replace or add an ActionID, if necessary - if not action_id: - action_id = str(id_generator()) - elif KEY_ACTIONID in self: - action_id = self[KEY_ACTIONID] - items.append((KEY_ACTIONID, action_id)) - + items.append(validate_header(key, str(value))) + + # Resolve the ActionID using the precedence documented above: an explicit + # argument wins, then any value already set on the request, then a generated + # one. Fall back only when the argument is None, not merely falsy, and coerce + # to a string so it matches the string-keyed responses Asterisk sends back. + if action_id is None: + action_id = self[KEY_ACTIONID] if KEY_ACTIONID in self else id_generator() + action_id = str(action_id) + items.append(validate_header(KEY_ACTIONID, action_id)) + return ( - _EOL.join(['%(key)s: %(value)s' % { - 'key': key, - 'value': value, - } for (key, value) in items] + [_EOL]), - action_id, + _EOL.join( + [ + "%(key)s: %(value)s" + % { + "key": key, + "value": value, + } + for (key, value) in items + ] + + [_EOL] + ), + action_id, ) def process_response(self, response): """ Provides an opportunity to parse, filter, or react to a response from Asterisk. - + This implementation just passes the response back to the caller as received, adding a new 'success' attribute on the response object with a boolean value. """ - response.success = response.get('Response') in ('Success', 'Follows') + response.success = response.get("Response") in ("Success", "Follows") return response - + def get_aggregate_classes(self): """ Provides a tuple of all aggregate event-classes associated with this request. """ return self._aggregates - + def get_synchronous_classes(self): """ Provides a triple of (unique, list, finalising) tuples of sychronous event-classes associated with this request. """ - return (self._synchronous_events_unique, self._synchronous_events_list, self._synchronous_events_finalising) - + return ( + self._synchronous_events_unique, + self._synchronous_events_list, + self._synchronous_events_finalising, + ) + + class _MessageReader(threading.Thread): - event_queue = None #A queue containing unsolicited events received from Asterisk - response_queue = None #A queue containing orphaned or unparented responses from Asterisk - _alive = True #False when this thread has been killed - _manager = None #A reference to the manager instance that serves as the parent of this thread - _orphaned_response_timeout = None #The number of seconds to hold on to request-responses before considering them to be timed-out - _served_requests = None #A dictionary of responses from Asterisk, keyed by ActionID - _served_requests_lock = None #A means of preventing race conditions from affecting the served-request set + event_queue = None # A queue containing unsolicited events received from Asterisk + response_queue = ( + None # A queue containing orphaned or unparented responses from Asterisk + ) + _alive = True # False when this thread has been killed + _manager = None # A reference to the manager instance that serves as the parent of this thread + _orphaned_response_timeout = None # The number of seconds to hold on to request-responses before considering them to be timed-out + _served_requests = ( + None # A dictionary of responses from Asterisk, keyed by ActionID + ) + _served_requests_lock = None # A means of preventing race conditions from affecting the served-request set def __init__(self, manager, orphaned_response_timeout): threading.Thread.__init__(self) self.daemon = True - self.name = 'pystrix-ami-message-reader' - + self.name = "pystrix-ami-message-reader" + self._manager = manager self._orphaned_response_timeout = orphaned_response_timeout @@ -974,7 +1163,7 @@ def __init__(self, manager, orphaned_response_timeout): self.response_queue = queue.Queue() self._served_requests = {} self._served_requests_lock = threading.Lock() - + def _clean_orphaned_responses(self): """ Ensures that old responses are moved to the orphaned queue, even though they should never @@ -983,14 +1172,14 @@ def _clean_orphaned_responses(self): current_time = time.time() with self._served_requests_lock: expired_action_ids = [] - for (action_id, (response, timeout)) in self._served_requests.items(): + for action_id, (response, timeout) in self._served_requests.items(): if timeout <= current_time: expired_action_ids.append(action_id) - self.response_queue.put(response) #Move it to the queue - + self.response_queue.put(response) # Move it to the queue + for action_id in expired_action_ids: del self._served_requests[action_id] - + def kill(self): self._alive = False @@ -1004,7 +1193,7 @@ def get_response(self, action_id): del self._served_requests[action_id] return response[0] return None - + def run(self): """ Continuously reads messages from the Asterisk server, placing them in the appropriate queue. @@ -1014,50 +1203,65 @@ def run(self): global _EVENT_REGISTRY global KEY_ACTIONID socket = self._manager.get_connection() - + while self._alive: try: message = socket.read_message() if not message: continue except ManagerSocketError: - break #Nothing can be reported, but the socket died, so there's no point in running + break # Nothing can be reported, but the socket died, so there's no point in running else: action_id = message.get(KEY_ACTIONID) if KEY_EVENT in message: - #See if the event has a corresponding subclass and mutate it if it does + # See if the event has a corresponding subclass and mutate it if it does event_class = _EVENT_REGISTRY.get(message.name) if event_class: message.__class__ = event_class else: message.__class__ = _Event if self._manager._debug: - (self._manager._logger and self._manager._logger.warn or warnings.warn)("Unknown event received: " + repr(message)) - + ( + self._manager._logger + and self._manager._logger.warn + or warnings.warn + )("Unknown event received: " + repr(message)) + self.event_queue.put(message) elif action_id is not None: self._clean_orphaned_responses() with self._served_requests_lock: if action_id not in self._served_requests: - self._served_requests[action_id] = (message, time.time() + self._orphaned_response_timeout) - else: #If there's already an associated response, treat this one as orphaned to avoid data-loss + self._served_requests[action_id] = ( + message, + time.time() + self._orphaned_response_timeout, + ) + else: # If there's already an associated response, treat this one as orphaned to avoid data-loss self.response_queue.put(message) - else: #It's an orphaned response + else: # It's an orphaned response self.response_queue.put(message) - -class _SynchronisedSocket(object): + + +class _SynchronisedSocket: """ Provides a threadsafe conduit for communication with an Asterisk manager interface. """ - _asterisk_name = '' #The name of the Asterisk server to which the socket is connected - _asterisk_version = '' # The version of the Asterisk server to which the socket is connected - _connected = False #True while a connection is active - _socket = None #The socket used to communicate with the Asterisk server - _socket_file = None #The socket exposed as a file-like object - _socket_read_lock = None #A lock used to prevent race conditions from affecting the Asterisk link - _socket_write_lock = None #A lock used to prevent race conditions from affecting the Asterisk link - _timeout = None #The number of seconds to wait before considering communications with the Asterisk server timed out - + + _asterisk_name = ( + "" # The name of the Asterisk server to which the socket is connected + ) + _asterisk_version = "" # The version of the Asterisk server to which the socket is connected + _connected = False # True while a connection is active + _socket = None # The socket used to communicate with the Asterisk server + _socket_file = None # The socket exposed as a file-like object + _socket_read_lock = ( + None # A lock used to prevent race conditions from affecting the Asterisk link + ) + _socket_write_lock = ( + None # A lock used to prevent race conditions from affecting the Asterisk link + ) + _timeout = None # The number of seconds to wait before considering communications with the Asterisk server timed out + def __init__(self, host, port=5038, timeout=5): """ Establishes a connection to the specified Asterisk manager, setting session variables as @@ -1065,27 +1269,30 @@ def __init__(self, host, port=5038, timeout=5): `timeout` is the number of seconds to wait before considering the Asterisk server unresponsive. - + If the connection fails, `ManagerSocketError` is raised. """ self._timeout = timeout self._connect(host, port) self._socket_read_lock = threading.Lock() self._socket_write_lock = threading.Lock() - + def __del__(self): """ Ensure the resources are freed. """ self.close() - + def close(self): """ Release resources associated with this connection. """ - with self._socket_write_lock as write_lock: + if self._socket_write_lock is None: + self._close() + return + with self._socket_write_lock: self._close() - + def _close(self): """ Performs the actual closing; needed to avoid a deadlock. @@ -1095,14 +1302,14 @@ def _close(self): try: closable.close() except Exception: - pass #Can't do anything about it - + pass # Can't do anything about it + def get_asterisk_info(self): """ Provides the name and version of Asterisk as a tuple of strings. """ return (self._asterisk_name, self._asterisk_version) - + def is_connected(self): """ Indicates whether the socket is connected. @@ -1116,48 +1323,57 @@ def read_message(self): The message read is returned as a `_Message` after being parsed. Or `None` if a timeout occurred while waiting for a full message. - + `ManagerSocketError` is raised if the connection is broken. """ if not self.is_connected(): raise ManagerSocketError("Not connected to Asterisk server") - - #Bring some often-referenced values into the local namespace + + # Bring some often-referenced values into the local namespace global _EOC global _EOC_INDICATOR global _EOL - - wait_for_marker = False #When true, the special string '--END COMMAND--' is used to end a message, rather than a CRLF - response_lines = [] #Lines collected from Asterisk - while True: #Keep reading lines until a full message has been collected + + wait_for_marker = False # When true, the special string '--END COMMAND--' is used to end a message, rather than a CRLF + response_lines = [] # Lines collected from Asterisk + while True: # Keep reading lines until a full message has been collected line = None with self._socket_read_lock: try: - line = generic_transforms.bytes_to_string(self._socket_file.readline()) + line = generic_transforms.bytes_to_string( + self._socket_file.readline() + ) except socket.timeout: return None except socket.error as e: self._close() - raise ManagerSocketError("Connection to Asterisk manager broken while reading data: %(error)s" % { - 'error': _format_socket_error(e), - }) + raise ManagerSocketError( + "Connection to Asterisk manager broken while reading data: %(error)s" + % { + "error": _format_socket_error(e), + } + ) except AttributeError: - raise ManagerSocketError("Local socket no longer defined, caused by system shutdown and blocking I/O") + raise ManagerSocketError( + "Local socket no longer defined, caused by system shutdown and blocking I/O" + ) if line == _EOL and not wait_for_marker: - if response_lines: #A full response has been collected + if response_lines: # A full response has been collected return _Message(response_lines) - continue #Asterisk is allowed to send empty lines before and after real data, so ignore them + continue # Asterisk is allowed to send empty lines before and after real data, so ignore them - #Test to see if this line is related to the requirements for an explicit end to the message + # Test to see if this line is related to the requirements for an explicit end to the message if wait_for_marker: - if line.startswith(_EOC): #The message is complete + if line.startswith(_EOC): # The message is complete return _Message(response_lines) - elif _EOC_INDICATOR.match(line): #Data that may contain the _EOL pattern is now legal + elif _EOC_INDICATOR.match( + line + ): # Data that may contain the _EOL pattern is now legal wait_for_marker = True - - response_lines.append(line) #Add the line to the response - + + response_lines.append(line) # Add the line to the response + def send_message(self, message): """ Locks the socket and writes the entire `message` into the stream. @@ -1166,16 +1382,19 @@ def send_message(self, message): """ if not self.is_connected(): raise ManagerSocketError("Not connected to Asterisk server") - + with self._socket_write_lock: try: self._socket.sendall(generic_transforms.string_to_bytes(message)) except socket.error as e: self._close() - raise ManagerSocketError("Connection to Asterisk manager broken while writing data: %(error)s" % { - 'error': _format_socket_error(e), - }) - + raise ManagerSocketError( + "Connection to Asterisk manager broken while writing data: %(error)s" + % { + "error": _format_socket_error(e), + } + ) + def _connect(self, host, port): """ Establishes a connection to the specified Asterisk manager, then dissects its greeting. @@ -1192,36 +1411,46 @@ def _connect(self, host, port): self._socket_file = self._socket.makefile(mode="rb") except socket.error as e: self._socket.close() - raise ManagerSocketError("Connection to Asterisk manager could not be established: %(error)s" % { - 'error': _format_socket_error(e), - }) + raise ManagerSocketError( + "Connection to Asterisk manager could not be established: %(error)s" + % { + "error": _format_socket_error(e), + } + ) self._connected = True - #Pop the greeting off the head of the pipe and set the version information + # Pop the greeting off the head of the pipe and set the version information try: line = generic_transforms.bytes_to_string(self._socket_file.readline()) except socket.error as e: self._socket.close() - raise ManagerSocketError("Connection to Asterisk manager broken while reading greeting: %(error)s" % { - 'error': _format_socket_error(e), - }) + raise ManagerSocketError( + "Connection to Asterisk manager broken while reading greeting: %(error)s" + % { + "error": _format_socket_error(e), + } + ) else: - if '/' in line: - (self._asterisk_name, self._asterisk_version) = (token.strip() for token in line.split('/', 1)) - - -#Exceptions + if "/" in line: + (self._asterisk_name, self._asterisk_version) = ( + token.strip() for token in line.split("/", 1) + ) + + +# Exceptions ############################################################################### class Error(Exception): """ The base exception from which all errors native to this module inherit. """ - + + class ManagerError(Error): """ Represents a generic error involving the Asterisk manager. """ - + + class ManagerSocketError(Error): """ Represents a generic error involving the Asterisk connection. diff --git a/pystrix/ami/app_confbridge.py b/pystrix/ami/app_confbridge.py index d80b4ff..c79d194 100644 --- a/pystrix/ami/app_confbridge.py +++ b/pystrix/ami/app_confbridge.py @@ -6,7 +6,7 @@ Specifically, this module provides implementations for features specific to the ConfBridge application. - + Legal ----- @@ -34,39 +34,43 @@ The requests and events implemented by this module follow the definitions provided by https://wiki.asterisk.org/ """ -from pystrix.ami.ami import (_Request, ManagerError) -from pystrix.ami import core_events -from pystrix.ami import app_confbridge_events -from pystrix.ami import generic_transforms + +from pystrix.ami import app_confbridge_events, core_events +from pystrix.ami.ami import _Request + class ConfbridgeKick(_Request): """ Kicks a participant from a ConfBridge room. """ + def __init__(self, conference, channel): """ `channel` is the channel to be kicked from `conference`. """ - _Request.__init__(self, 'ConfbridgeKick') - self['Conference'] = conference - self['Channel'] = channel - + _Request.__init__(self, "ConfbridgeKick") + self["Conference"] = conference + self["Channel"] = channel + + class ConfbridgeList(_Request): """ Lists all participants in a ConfBridge room. A series of 'ConfbridgeList' events follow, with one 'ConfbridgeListComplete' event at the end. """ + _aggregates = (app_confbridge_events.ConfbridgeList_Aggregate,) _synchronous_events_list = (app_confbridge_events.ConfbridgeList,) _synchronous_events_finalising = (app_confbridge_events.ConfbridgeListComplete,) - + def __init__(self, conference): """ `conference` is the identifier of the bridge. """ - _Request.__init__(self, 'ConfbridgeList') - self['Conference'] = conference + _Request.__init__(self, "ConfbridgeList") + self["Conference"] = conference + class ConfbridgeListRooms(_Request): """ @@ -75,109 +79,127 @@ class ConfbridgeListRooms(_Request): A series of 'ConfbridgeListRooms' events follow, with one 'ConfbridgeListRoomsComplete' event at the end. """ + _aggregates = (app_confbridge_events.ConfbridgeListRooms_Aggregate,) _synchronous_events_list = (app_confbridge_events.ConfbridgeListRooms,) - _synchronous_events_finalising = (app_confbridge_events.ConfbridgeListRoomsComplete,) - + _synchronous_events_finalising = ( + app_confbridge_events.ConfbridgeListRoomsComplete, + ) + def __init__(self): - _Request.__init__(self, 'ConfbridgeListRooms') + _Request.__init__(self, "ConfbridgeListRooms") + class ConfbridgeLock(_Request): """ Locks a ConfBridge room, disallowing access to non-administrators. """ + def __init__(self, conference): """ `conference` is the identifier of the bridge. """ - _Request.__init__(self, 'ConfbridgeLock') - self['Conference'] = conference + _Request.__init__(self, "ConfbridgeLock") + self["Conference"] = conference + class ConfbridgeUnlock(_Request): """ Unlocks a ConfBridge room, allowing access to non-administrators. """ + def __init__(self, conference): """ `conference` is the identifier of the bridge. """ - _Request.__init__(self, 'ConfbridgeUnlock') - self['Conference'] = conference + _Request.__init__(self, "ConfbridgeUnlock") + self["Conference"] = conference + class ConfbridgeMoHOn(_Request): """ Forces MoH to a participant in a ConfBridge room. - + This action does not mute audio coming from the participant. - + Depends on . """ + def __init__(self, conference, channel): """ `channel` is the channel to which MoH should be started in `conference`. """ - _Request.__init__(self, 'ConfbridgeMoHOn') - self['Conference'] = conference - self['Channel'] = channel + _Request.__init__(self, "ConfbridgeMoHOn") + self["Conference"] = conference + self["Channel"] = channel + class ConfbridgeMoHOff(_Request): """ Stops forcing MoH to a participant in a ConfBridge room. - + This action does not unmute audio coming from the participant. - + Depends on . """ + def __init__(self, conference, channel): """ `channel` is the channel to which MoH should be stopped in `conference`. """ - _Request.__init__(self, 'ConfbridgeMoHOff') - self['Conference'] = conference - self['Channel'] = channel + _Request.__init__(self, "ConfbridgeMoHOff") + self["Conference"] = conference + self["Channel"] = channel + class ConfbridgeMute(_Request): """ Mutes a participant in a ConfBridge room. """ + def __init__(self, conference, channel): """ `channel` is the channel to be muted in `conference`. """ - _Request.__init__(self, 'ConfbridgeMute') - self['Conference'] = conference - self['Channel'] = channel + _Request.__init__(self, "ConfbridgeMute") + self["Conference"] = conference + self["Channel"] = channel + class ConfbridgeUnmute(_Request): """ Unmutes a participant in a ConfBridge room. """ + def __init__(self, conference, channel): """ `channel` is the channel to be unmuted in `conference`. """ - _Request.__init__(self, 'ConfbridgeUnmute') - self['Conference'] = conference - self['Channel'] = channel + _Request.__init__(self, "ConfbridgeUnmute") + self["Conference"] = conference + self["Channel"] = channel + class ConfbridgePlayFile(_Request): """ Plays a file to individuals or an entire conference. - + Note: This implementation is built upon the not-yet-accepted patch under https://issues.asterisk.org/jira/browse/ASTERISK-19571 """ + def __init__(self, file, conference, channel=None): """ `file`, resolved like other Asterisk media, is played to `conference` or, if specified, a specific `channel` therein. """ - _Request.__init__(self, 'ConfbridgePlayFile') - self['Conference'] = conference + _Request.__init__(self, "ConfbridgePlayFile") + self["Conference"] = conference if channel: - self['Channel'] = channel - self['File'] = file - + self["Channel"] = channel + self["File"] = file + + class ConfbridgeStartRecord(_Request): """ Starts recording a ConfBridge conference. @@ -187,17 +209,19 @@ class ConfbridgeStartRecord(_Request): Asterisk-generated identification data that can be discarded and "?" is the room ID. The 'Variable' key must be "MIXMONITOR_FILENAME", with the 'Value' key holding the file's path. """ + _synchronous_events_finalising = (core_events.VarSet,) - + def __init__(self, conference, filename=None): """ `conference` is the room to be recorded, and `filename`, optional, is the path, Asterisk-resolved or absolute, of the file to write. """ - _Request.__init__(self, 'ConfbridgeStartRecord') - self['Conference'] = conference + _Request.__init__(self, "ConfbridgeStartRecord") + self["Conference"] = conference if filename: - self['RecordFile'] = filename + self["RecordFile"] = filename + class ConfbridgeStopRecord(_Request): """ @@ -207,24 +231,26 @@ class ConfbridgeStopRecord(_Request): it, match its 'Channel' key against "ConfBridgeRecorder/conf-?-...", where "..." is Asterisk-generated identification data that can be discarded and "?" is the room ID. """ + _synchronous_events_finalising = (core_events.Hangup,) - + def __init__(self, conference): """ `conference` is the room being recorded. """ - _Request.__init__(self, 'ConfbridgeStopRecord') - self['Conference'] = conference + _Request.__init__(self, "ConfbridgeStopRecord") + self["Conference"] = conference + class ConfbridgeSetSingleVideoSrc(_Request): """ Sets the video source for the conference to a single channel's stream. """ + def __init__(self, conference, channel): """ `channel` is the video source in `conference`. """ - _Request.__init__(self, 'ConfbridgeSetSingleVideoSource') - self['Conference'] = conference - self['Channel'] = channel - + _Request.__init__(self, "ConfbridgeSetSingleVideoSource") + self["Conference"] = conference + self["Channel"] = channel diff --git a/pystrix/ami/app_confbridge_events.py b/pystrix/ami/app_confbridge_events.py index 369021e..aaa5a74 100644 --- a/pystrix/ami/app_confbridge_events.py +++ b/pystrix/ami/app_confbridge_events.py @@ -32,22 +32,25 @@ The events implemented by this module follow the definitions provided by http://www.asteriskdocs.org/ and https://wiki.asterisk.org/ """ -from pystrix.ami.ami import (_Aggregate, _Event) + from pystrix.ami import generic_transforms - +from pystrix.ami.ami import _Aggregate, _Event + + class ConfbridgeEnd(_Event): """ Indicates that a ConfBridge has ended. - + - 'Conference' : The room's identifier """ - + + class ConfbridgeJoin(_Event): """ Indicates that a participant has joined a ConfBridge room. - + `NameRecordingPath` blocks on - + - 'CallerIDname' (optional) : The name, on supporting channels, of the participant - 'CallerIDnum' : The (often) numeric address of the participant - 'Channel' : The channel that joined @@ -56,21 +59,23 @@ class ConfbridgeJoin(_Event): - 'Uniqueid' : An Asterisk unique value """ + class ConfbridgeLeave(_Event): """ Indicates that a participant has left a ConfBridge room. - + - 'CallerIDname' (optional) : The name, on supporting channels, of the participant - 'CallerIDnum' : The (often) numeric address of the participant - 'Channel' : The channel that left - 'Conference' : The identifier of the room that was left - 'Uniqueid' : An Asterisk unique value """ - + + class ConfbridgeList(_Event): """ Describes a participant in a ConfBridge room. - + - 'Admin' : 'Yes' or 'No' - 'CallerIDNum' : The (often) numeric address of the participant - 'CallerIDName' (optional) : The name of the participant on supporting channels @@ -79,137 +84,164 @@ class ConfbridgeList(_Event): - 'MarkedUser' : 'Yes' or 'No' - 'NameRecordingPath' (optional) : The path at which the user's name-recording is kept """ + def process(self): """ Translates the 'Admin' and 'MarkedUser' headers' values into bools. """ (headers, data) = _Event.process(self) - - generic_transforms.to_bool(headers, ('Admin', 'MarkedUser',), truth_value='Yes') - + + generic_transforms.to_bool( + headers, + ( + "Admin", + "MarkedUser", + ), + truth_value="Yes", + ) + return (headers, data) + class ConfbridgeListComplete(_Event): """ Indicates that all participants in a ConfBridge room have been enumerated. - + - 'ListItems' : The number of items returned prior to this event """ + def process(self): """ Translates the 'ListItems' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - - generic_transforms.to_int(headers, ('ListItems',), -1) - + + generic_transforms.to_int(headers, ("ListItems",), -1) + return (headers, data) - + + class ConfbridgeListRooms(_Event): """ Describes a ConfBridge room. - + And, yes, it's plural in Asterisk, too. - + - 'Conference' : The room's identifier - 'Locked' : 'Yes' or 'No' - 'Marked' : The number of marked users - 'Parties' : The number of participants """ + def process(self): """ Translates the 'Marked' and 'Parties' headers' values into ints, or -1 on failure. - + Translates the 'Locked' header's value into a bool. """ (headers, data) = _Event.process(self) - - generic_transforms.to_bool(headers, ('Locked',), truth_value='Yes') - generic_transforms.to_int(headers, ('Marked', 'Parties',), -1) - + + generic_transforms.to_bool(headers, ("Locked",), truth_value="Yes") + generic_transforms.to_int( + headers, + ( + "Marked", + "Parties", + ), + -1, + ) + return (headers, data) + class ConfbridgeListRoomsComplete(_Event): """ Indicates that all ConfBridge rooms have been enumerated. - + - 'ListItems' : The number of items returned prior to this event """ + def process(self): """ Translates the 'ListItems' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - - generic_transforms.to_int(headers, ('ListItems',), -1) - + + generic_transforms.to_int(headers, ("ListItems",), -1) + return (headers, data) - + + class ConfbridgeStart(_Event): """ Indicates that a ConfBridge has started. - + - 'Conference' : The room's identifier """ + class ConfbridgeTalking(_Event): """ Indicates that a participant has started or stopped talking. - + - 'Channel' : The Asterisk channel in use by the participant - 'Conference' : The room's identifier - 'TalkingStatus' : 'on' or 'off' - 'Uniqueid' : An Asterisk unique value """ + def process(self): """ Translates the 'TalkingStatus' header's value into a bool. """ (headers, data) = _Event.process(self) - - generic_transforms.to_bool(headers, ('TalkingStatus',), truth_value='on') - + + generic_transforms.to_bool(headers, ("TalkingStatus",), truth_value="on") + return (headers, data) - - -#List-aggregation events + + +# List-aggregation events #################################################################################################### -#These define non-Asterisk-native event-types that collect multiple events (cases where multiple -#events are generated in response to a single action) and emit the bundle as a single message. +# These define non-Asterisk-native event-types that collect multiple events (cases where multiple +# events are generated in response to a single action) and emit the bundle as a single message. + class ConfbridgeList_Aggregate(_Aggregate): """ Emitted after all conference participants have been received in response to a ConfbridgeList request. - + Its members consist of ConfbridgeList events. - + It is finalised by ConfbridgeListComplete. """ + _name = "ConfbridgeList_Aggregate" - + _aggregation_members = (ConfbridgeList,) _aggregation_finalisers = (ConfbridgeListComplete,) - + def _finalise(self, event): - self._check_list_items_count(event, 'ListItems') + self._check_list_items_count(event, "ListItems") return _Aggregate._finalise(self, event) - + + class ConfbridgeListRooms_Aggregate(_Aggregate): """ Emitted after all conference rooms have been received in response to a ConfbridgeListRooms request. - + Its members consist of ConfbridgeListRooms events. - + It is finalised by ConfbridgeListRoomsComplete. """ + _name = "ConfbridgeListRooms_Aggregate" - + _aggregation_members = (ConfbridgeListRooms,) _aggregation_finalisers = (ConfbridgeListRoomsComplete,) - + def _finalise(self, event): - self._check_list_items_count(event, 'ListItems') + self._check_list_items_count(event, "ListItems") return _Aggregate._finalise(self, event) - diff --git a/pystrix/ami/app_meetme.py b/pystrix/ami/app_meetme.py index b285058..52249ab 100644 --- a/pystrix/ami/app_meetme.py +++ b/pystrix/ami/app_meetme.py @@ -34,9 +34,10 @@ The requests and events implemented by this module follow the definitions provided by http://www.asteriskdocs.org/ and https://wiki.asterisk.org/ """ -from pystrix.ami.ami import (_Request, ManagerError) + from pystrix.ami import app_meetme_events -from pystrix.ami import generic_transforms +from pystrix.ami.ami import _Request + class MeetmeList(_Request): """ @@ -47,18 +48,20 @@ class MeetmeList(_Request): Note that if no conferences are active, the response will indicate that it was not successful, per https://issues.asterisk.org/jira/browse/ASTERISK-16812 """ + _aggregates = (app_meetme_events.MeetmeList_Aggregate,) _synchronous_events_list = (app_meetme_events.MeetmeList,) _synchronous_events_finalising = (app_meetme_events.MeetmeListComplete,) - + def __init__(self, conference=None): """ `conference` is the optional identifier of the bridge. """ - _Request.__init__(self, 'MeetmeList') - if not conference is None: - self['Conference'] = conference - + _Request.__init__(self, "MeetmeList") + if conference is not None: + self["Conference"] = conference + + class MeetmeListRooms(_Request): """ Lists all conferences. @@ -66,42 +69,46 @@ class MeetmeListRooms(_Request): A series of 'MeetmeListRooms' events follow, with one 'MeetmeListRoomsComplete' event at the end. """ + _aggregates = (app_meetme_events.MeetmeListRooms_Aggregate,) _synchronous_events_list = (app_meetme_events.MeetmeListRooms,) _synchronous_events_finalising = (app_meetme_events.MeetmeListRoomsComplete,) - + def __init__(self): - _Request.__init__(self, 'MeetmeListRooms') - + _Request.__init__(self, "MeetmeListRooms") + + class MeetmeMute(_Request): """ Mutes a participant in a Meetme bridge. - + Requires call """ + def __init__(self, meetme, usernum): """ `meetme` is the identifier of the bridge and `usernum` is the participant ID of the user to be muted, which is associated with a channel by the 'MeetmeJoin' event. If successful, this request will trigger a 'MeetmeMute' event. """ - _Request.__init__(self, 'MeetmeMute') - self['Meetme'] = meetme - self['Usernum'] = usernum + _Request.__init__(self, "MeetmeMute") + self["Meetme"] = meetme + self["Usernum"] = usernum + class MeetmeUnmute(_Request): """ Unmutes a participant in a Meetme bridge. - + Requires call """ + def __init__(self, meetme, usernum): """ `meetme` is the identifier of the bridge and `usernum` is the participant ID of the user to be unmuted, which is associated with a channel by the 'MeetmeJoin' event. If successful, this request will trigger a 'MeetmeMute' event. """ - _Request.__init__(self, 'MeetmeUnmute') - self['Meetme'] = meetme - self['Usernum'] = usernum - + _Request.__init__(self, "MeetmeUnmute") + self["Meetme"] = meetme + self["Usernum"] = usernum diff --git a/pystrix/ami/app_meetme_events.py b/pystrix/ami/app_meetme_events.py index e2f96fa..dba9488 100644 --- a/pystrix/ami/app_meetme_events.py +++ b/pystrix/ami/app_meetme_events.py @@ -31,23 +31,26 @@ The events implemented by this module follow the definitions provided by http://www.asteriskdocs.org/ and https://wiki.asterisk.org/ """ -from pystrix.ami.ami import (_Aggregate, _Event) + from pystrix.ami import generic_transforms +from pystrix.ami.ami import _Aggregate, _Event + class MeetmeJoin(_Event): """ Indicates that a user has joined a Meetme bridge. - + - 'Channel' : The channel that was bridged - 'Meetme' : The ID of the Meetme bridge, typically a number formatted as a string - 'Uniqueid' : An Asterisk unique value - 'Usernum' : The bridge-specific participant ID assigned to the channel """ + class MeetmeList(_Event): """ Describes a participant in a Meetme room. - + - 'Admin' : 'Yes' or 'No' - 'CallerIDNum' : The (often) numeric address of the participant - 'CallerIDName' (optional) : The name of the participant on supporting channels @@ -61,51 +64,62 @@ class MeetmeList(_Event): - 'Talking' : 'Yes', 'No', or 'Not monitored' - 'UserNumber' : The ID of the participant in the conference """ + def process(self): """ Translates the 'Admin' and 'MarkedUser' headers' values into bools. - + Translates the 'Talking' header's value into a bool, or `None` if not monitored. - + Translates the 'UserNumber' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - - talking = headers.get('Talking') - if talking == 'Yes': - headers['Talking'] = True - elif talking == 'No': - headers['Talking'] = False + + talking = headers.get("Talking") + if talking == "Yes": + headers["Talking"] = True + elif talking == "No": + headers["Talking"] = False else: - headers['Talking'] = None - - generic_transforms.to_bool(headers, ('Admin', 'MarkedUser',), truth_value='Yes') - generic_transforms.to_int(headers, ('UserNumber',), -1) - + headers["Talking"] = None + + generic_transforms.to_bool( + headers, + ( + "Admin", + "MarkedUser", + ), + truth_value="Yes", + ) + generic_transforms.to_int(headers, ("UserNumber",), -1) + return (headers, data) + class MeetmeListComplete(_Event): """ Indicates that all participants in a Meetme query have been enumerated. - + - 'ListItems' : The number of items returned prior to this event """ + def process(self): """ Translates the 'ListItems' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - - generic_transforms.to_int(headers, ('ListItems',), -1) - + + generic_transforms.to_int(headers, ("ListItems",), -1) + return (headers, data) + class MeetmeListRooms(_Event): """ Describes a Meetme room. - + And, yes, it's plural in Asterisk, too. - + - 'Activity' : The duration of the conference - 'Conference' : The room's identifier - 'Creation' : 'Dynamic' or 'Static' @@ -113,92 +127,100 @@ class MeetmeListRooms(_Event): - 'Marked' : The number of marked users, but not as an integer: 'N/A' or %.4d - 'Parties' : The number of participants """ + def process(self): """ Translates the 'Parties' header's value into an int, or -1 on failure. - + Translates the 'Locked' header's value into a bool. """ (headers, data) = _Event.process(self) - - generic_transforms.to_bool(headers, ('Locked',), truth_value='Yes') - generic_transforms.to_int(headers, ('Parties',), -1) - + + generic_transforms.to_bool(headers, ("Locked",), truth_value="Yes") + generic_transforms.to_int(headers, ("Parties",), -1) + return (headers, data) + class MeetmeListRoomsComplete(_Event): """ Indicates that all Meetme rooms have been enumerated. - + - 'ListItems' : The number of items returned prior to this event """ + def process(self): """ Translates the 'ListItems' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - - generic_transforms.to_int(headers, ('ListItems',), -1) - + + generic_transforms.to_int(headers, ("ListItems",), -1) + return (headers, data) + class MeetmeMute(_Event): """ Indicates that a user has been muted in a Meetme bridge. - + - 'Channel' : The channel that was muted - 'Meetme' : The ID of the Meetme bridge, typically a number formatted as a string - 'Status' : 'on' or 'off', depending on whether the user was muted or unmuted - 'Uniqueid' : An Asterisk unique value - 'Usernum' : The participant ID of the user that was affected """ + def process(self): """ Translates the 'Status' header's value into a bool. """ (headers, data) = _Event.process(self) - - generic_transforms.to_bool(headers, ('Status',), truth_value='on') - + + generic_transforms.to_bool(headers, ("Status",), truth_value="on") + return (headers, data) - - -#List-aggregation events + + +# List-aggregation events #################################################################################################### -#These define non-Asterisk-native event-types that collect multiple events (cases where multiple -#events are generated in response to a single action) and emit the bundle as a single message. +# These define non-Asterisk-native event-types that collect multiple events (cases where multiple +# events are generated in response to a single action) and emit the bundle as a single message. + class MeetmeList_Aggregate(_Aggregate): """ Emitted after all participants have been received in response to a MeetmeList request. - + Its members consist of MeetmeList events. - + It is finalised by MeetmeListComplete. """ + _name = "MeetmeList_Aggregate" - + _aggregation_members = (MeetmeList,) _aggregation_finalisers = (MeetmeListComplete,) - + def _finalise(self, event): - self._check_list_items_count(event, 'ListItems') + self._check_list_items_count(event, "ListItems") return _Aggregate._finalise(self, event) - + + class MeetmeListRooms_Aggregate(_Aggregate): """ Emitted after all participants have been received in response to a MeetmeListRooms request. - + Its members consist of MeetmeListRooms events. - + It is finalised by MeetmeListRoomsComplete. """ + _name = "MeetmeListRooms_Aggregate" - + _aggregation_members = (MeetmeListRooms,) _aggregation_finalisers = (MeetmeListRoomsComplete,) - + def _finalise(self, event): - self._check_list_items_count(event, 'ListItems') + self._check_list_items_count(event, "ListItems") return _Aggregate._finalise(self, event) - diff --git a/pystrix/ami/core.py b/pystrix/ami/core.py index b9dc9a8..9224473 100644 --- a/pystrix/ami/core.py +++ b/pystrix/ami/core.py @@ -3,13 +3,13 @@ ================ Provides classes meant to be fed to a `Manager` instance's `send_action()` function. - + Notes ----- pystrix.ami.core_events contains event-definitions and processing rules for events raised by actions in this module (and some others, since extensions can use built-in features). - + Legal ----- @@ -37,32 +37,31 @@ The requests implemented by this module follow the definitions provided by http://www.asteriskdocs.org/ and https://wiki.asterisk.org/ """ + import hashlib import time -import types -from pystrix.ami.ami import (_Request, ManagerError) -from pystrix.ami import core_events -from pystrix.ami import generic_transforms +from pystrix.ami import core_events, generic_transforms +from pystrix.ami.ami import ManagerError, _Request -AUTHTYPE_MD5 = 'MD5' # Uses MD5 authentication when logging into AMI +AUTHTYPE_MD5 = "MD5" # Uses MD5 authentication when logging into AMI # Constants for use with the `Events` action -EVENTMASK_ALL = 'on' -EVENTMASK_NONE = 'off' -EVENTMASK_CALL = 'call' -EVENTMASK_LOG = 'log' -EVENTMASK_SYSTEM = 'system' +EVENTMASK_ALL = "on" +EVENTMASK_NONE = "off" +EVENTMASK_CALL = "call" +EVENTMASK_LOG = "log" +EVENTMASK_SYSTEM = "system" # Audio format constants -FORMAT_SLN = 'sln' -FORMAT_G723 = 'g723' -FORMAT_G729 = 'g729' -FORMAT_GSM = 'gsm' -FORMAT_ALAW = 'alaw' -FORMAT_ULAW = 'ulaw' -FORMAT_VOX = 'vox' -FORMAT_WAV = 'wav' +FORMAT_SLN = "sln" +FORMAT_G723 = "g723" +FORMAT_G729 = "g729" +FORMAT_GSM = "gsm" +FORMAT_ALAW = "alaw" +FORMAT_ULAW = "ulaw" +FORMAT_VOX = "vox" +FORMAT_WAV = "wav" # Originate result constants # https://github.com/asterisk/asterisk/blob/56028426de0692e8e36167251053c91b96e97c41/include/asterisk/frame.h#L277 @@ -76,44 +75,47 @@ # Reason 0 is not documented in source # https://github.com/asterisk/asterisk/blob/56028426de0692e8e36167251053c91b96e97c41/main/manager.c#L5446 # External reference https://www.voip-info.org/asterisk-manager-api-action-originate/ -ORIGINATE_RESULT_BAD_NUMBER = 0 #No such number/extension or bad technology +ORIGINATE_RESULT_BAD_NUMBER = 0 # No such number/extension or bad technology ORIGINATE_RESULT_MAP = { - ORIGINATE_RESULT_BAD_NUMBER: 'BAD_NUMBER', - ORIGINATE_RESULT_REJECT: 'REJECTED', - ORIGINATE_RESULT_RING_LOCAL: 'RINGING', - ORIGINATE_RESULT_RING_REMOTE: 'RINGING', - ORIGINATE_RESULT_ANSWERED: 'ANSWERED', - ORIGINATE_RESULT_BUSY: 'BUSY', - ORIGINATE_RESULT_CONGESTION: 'CONGESTION', - ORIGINATE_RESULT_INCOMPLETE: 'INCOMPLETE' + ORIGINATE_RESULT_BAD_NUMBER: "BAD_NUMBER", + ORIGINATE_RESULT_REJECT: "REJECTED", + ORIGINATE_RESULT_RING_LOCAL: "RINGING", + ORIGINATE_RESULT_RING_REMOTE: "RINGING", + ORIGINATE_RESULT_ANSWERED: "ANSWERED", + ORIGINATE_RESULT_BUSY: "BUSY", + ORIGINATE_RESULT_CONGESTION: "CONGESTION", + ORIGINATE_RESULT_INCOMPLETE: "INCOMPLETE", } class AbsoluteTimeout(_Request): """ Causes Asterisk to hang up a channel after a given number of seconds. - + Requires call """ + def __init__(self, channel, seconds=0): """ Causes the call on `channel` to be hung up after `seconds` have elapsed, defaulting to disabling auto-hangup. """ - _Request.__init__(self, 'AbsoluteTimeout') - self['Channel'] = channel - self['Timeout'] = str(int(seconds)) + _Request.__init__(self, "AbsoluteTimeout") + self["Channel"] = channel + self["Timeout"] = str(int(seconds)) + class AGI(_Request): """ Causes Asterisk to execute an arbitrary AGI application in a call. Upon successful execution, an 'AsyncAGI' event is generated. - + Requires call """ + _synchronous_events_finalising = (core_events.AsyncAGI,) - + def __init__(self, channel, command, command_id=None): """ `channel` is the call in which to execute `command`, the value passed to the AGI dialplan @@ -121,11 +123,12 @@ def __init__(self, channel, command, command_id=None): and can reasonably be set to a sequential digit or UUID in your application for tracking purposes. """ - _Request.__init__(self, 'AGI') - self['Channel'] = channel - self['Command'] = command - if not command_id is None: - self['CommandID'] = str(command_id) + _Request.__init__(self, "AGI") + self["Channel"] = channel + self["Command"] = command + if command_id is not None: + self["CommandID"] = str(command_id) + class Bridge(_Request): """ @@ -133,280 +136,319 @@ class Bridge(_Request): Requires call """ + def __init__(self, channel_1, channel_2, tone=False): """ `channel_1` is the channel to which `channel_2` will be connected. `tone`, if `True`, will cause a sound to be played on `channel_2`. """ _Request.__init__(self, "Bridge") - self['Channel1'] = channel_1 - self['Channel2'] = channel_2 - self['Tone'] = tone and 'yes' or 'no' - + self["Channel1"] = channel_1 + self["Channel2"] = channel_2 + self["Tone"] = tone and "yes" or "no" + + class Challenge(_Request): """ Asks the AMI server for a challenge token to be used to hash the login secret. - + The value provided under the returned response's 'Challenge' key must be passed as the 'challenge' parameter of the `Login` object's constructor:: login = Login(username='me', secret='password', challenge=response.get('Challenge')) """ + def __init__(self, authtype=AUTHTYPE_MD5): """ `authtype` is used to specify the authentication type to be used. """ - _Request.__init__(self, 'Challenge') - self['AuthType'] = authtype - + _Request.__init__(self, "Challenge") + self["AuthType"] = authtype + + class ChangeMonitor(_Request): """ Changes the filename associated with the recording of a monitored channel. The channel must have previously been selected by the `Monitor` action. - + Requires call """ + def __init__(self, channel, filename): """ `channel` is the channel to be affected and `filename` is the new target filename, without extension, as either an auto-resolved or absolute path. """ - _Request.__init__(self, 'ChangeMonitor') - self['Channel'] = channel - self['File'] = filename - + _Request.__init__(self, "ChangeMonitor") + self["Channel"] = channel + self["File"] = filename + + class Command(_Request): """ Sends an arbitrary shell command to Asterisk, returning its response as a series of lines in the 'data' attribute. - + Requires command """ + def __init__(self, command): """ `command` is the command to be executed. """ - _Request.__init__(self, 'Command') - self['Command'] = command + _Request.__init__(self, "Command") + self["Command"] = command + class CoreShowChannels(_Request): """ Asks Asterisk to list all active channels. - + Any number of 'CoreShowChannel' events may be generated in response to this request, followed by one 'CoreShowChannelsComplete'. Requires system """ + _aggregates = (core_events.CoreShowChannels_Aggregate,) _synchronous_events_list = (core_events.CoreShowChannel,) _synchronous_events_finalising = (core_events.CoreShowChannelsComplete,) - + def __init__(self): _Request.__init__(self, "CoreShowChannels") - + + class CreateConfig(_Request): """ Creates an empty configuration file, intended for use before `UpdateConfig()`. Requires config """ + def __init__(self, filename): """ `filename` is the name of the file, with extension, to be created. """ _Request.__init__(self, "CreateConfig") - self['Filename'] = filename + self["Filename"] = filename + class DBDel(_Request): """ Deletes a database value from Asterisk. - + Requires system """ + def __init__(self, family, key): """ `family` and `key` are specifiers to select the value to remove. """ - _Request.__init__(self, 'DBDel') - self['Family'] = family - self['Key'] = key + _Request.__init__(self, "DBDel") + self["Family"] = family + self["Key"] = key + class DBDelTree(_Request): """ Deletes a database tree from Asterisk. - + Requires system """ + def __init__(self, family, key=None): """ `family` and `key` (optional) are specifiers to select the values to remove. """ - _Request.__init__(self, 'DBDelTree') - self['Family'] = family - if not key is None: - self['Key'] = key - + _Request.__init__(self, "DBDelTree") + self["Family"] = family + if key is not None: + self["Key"] = key + + class DBGet(_Request): """ Requests a database value from Asterisk. - + A 'DBGetResponse' event will be generated upon success. - + Requires system """ + def __init__(self, family, key): """ `family` and `key` are specifiers to select the value to retrieve. """ - _Request.__init__(self, 'DBGet') - self['Family'] = family - self['Key'] = key - + _Request.__init__(self, "DBGet") + self["Family"] = family + self["Key"] = key + + class DBPut(_Request): """ Stores a database value in Asterisk. - + Requires system """ + def __init__(self, family, key, value): """ `family` and `key` are specifiers for where to place `value`. """ - _Request.__init__(self, 'DBPut') - self['Family'] = family - self['Key'] = key - self['Val'] = value - + _Request.__init__(self, "DBPut") + self["Family"] = family + self["Key"] = key + self["Val"] = value + + class Events(_Request): """ Changes the types of unsolicited events Asterisk sends to this manager connection. """ + def __init__(self, mask): """ `Mask` is one of the following... - + * EVENTMASK_ALL * EVENTMASK_NONE - + ...or an iterable, like a tuple, with any combination of the following... - + * EVENTMASK_CALL * EVENTMASK_LOG * EVENTMASK_SYSTEM - + If an empty value is provided, EVENTMASK_NONE is assumed. """ - _Request.__init__(self, 'Events') + _Request.__init__(self, "Events") if isinstance(mask, str): - self['EventMask'] = mask + self["EventMask"] = mask else: if EVENTMASK_ALL in mask: - self['EventMask'] = EVENTMASK_ALL + self["EventMask"] = EVENTMASK_ALL else: - self['EventMask'] = ','.join((m for m in mask if not m == EVENTMASK_NONE)) or EVENTMASK_NONE - + self["EventMask"] = ( + ",".join((m for m in mask if not m == EVENTMASK_NONE)) + or EVENTMASK_NONE + ) + def process_response(self, response): """ Indicates success if the response matches one of the valid patterns. """ response = _Request.process_response(self, response) - response.success = response.get('Response') in ('Events On', 'Events Off') + response.success = response.get("Response") in ("Events On", "Events Off") return response - + + class ExtensionState(_Request): """ Provides the state of an extension. - + If successful, a 'Status' key will be present, with one of the following values as a string: - + * -2: Extension removed * -1: Extension hint not found * 0: Idle * 1: In use * 2: Busy - + If non-negative, a 'Hint' key will be present, too, containing string data that can be helpful in discerning the current activity of the device. - + Requires call """ + def __init__(self, extension, context): """ `extension` is the extension to be checked and `context` is the container in which it resides. """ - _Request.__init__(self, 'ExtensionState') - self['Exten'] = extension - self['Context'] = context - + _Request.__init__(self, "ExtensionState") + self["Exten"] = extension + self["Context"] = context + + class GetConfig(_Request): """ Gets the contents of an Asterisk configuration file. - + The result is recturned as a series of 'Line-XXXXXX-XXXXXX' keys that increment from 0 sequentially, starting with 'Line-000000-000000'. - + A sequential generator is provided by the 'get_lines()' function on the response. - + Requires config """ + def __init__(self, filename): """ `filename` is the name of the config file to be read, including extension. """ - _Request.__init__(self, 'GetConfig') - self['Filename'] = filename - + _Request.__init__(self, "GetConfig") + self["Filename"] = filename + def process_response(self, response): """ Adds a 'get_lines' function that returns a generator that yields every line in order. """ response = _Request.process_response(self, response) - response.get_lines = lambda : (value for (key, value) in sorted(response.items()) if key.startswith('Line-')) + response.get_lines = lambda: ( + value + for (key, value) in sorted(response.items()) + if key.startswith("Line-") + ) return response - + + class Getvar(_Request): """ Gets the value of a channel or global variable from Asterisk, returning the result under the 'Value' key. - + Requires call """ + def __init__(self, variable, channel=None): """ `variable` is the name of the variable to retrieve. `channel` is optional; if not specified, a global variable is retrieved. """ - _Request.__init__(self, 'Getvar') - self['Variable'] = variable - if not channel is None: - self['Channel'] = channel - + _Request.__init__(self, "Getvar") + self["Variable"] = variable + if channel is not None: + self["Channel"] = channel + + class Hangup(_Request): """ Hangs up a channel. - + On success, a 'Hangup' event is generated. - + Requires call """ + _synchronous_events_finalising = (core_events.Hangup,) - + def __init__(self, channel): """ `channel` is the ID of the channel to be hung up. """ - _Request.__init__(self, 'Hangup') - self['Channel'] = channel - + _Request.__init__(self, "Hangup") + self["Channel"] = channel + + class ListCommands(_Request): """ Provides a list of every command exposed by the Asterisk Management Interface, with synopsis, as a series of lines in the response's 'data' attribute. """ + def __init__(self): - _Request.__init__(self, 'ListCommands') + _Request.__init__(self, "ListCommands") + class ListCategories(_Request): """ @@ -415,13 +457,14 @@ class ListCategories(_Request): Requires config """ + def __init__(self, filename): """ `filename` is the name of the file, with extension, to be read. """ - _Request.__init__(self, 'ListCategories') - self['Filename'] = filename - + _Request.__init__(self, "ListCategories") + self["Filename"] = filename + class LocalOptimizeAway(_Request): """ @@ -430,124 +473,148 @@ class LocalOptimizeAway(_Request): Requires call """ + def __init__(self, channel): """ `channel` is the channel to be optimised. """ - _Request.__init__(self, 'LocalOptimizeAway') - self['Channel'] = channel - + _Request.__init__(self, "LocalOptimizeAway") + self["Channel"] = channel + + class Login(_Request): """ Authenticates to the AMI server. """ - def __init__(self, username, secret, events=True, challenge=None, authtype=AUTHTYPE_MD5): + + def __init__( + self, username, secret, events=True, challenge=None, authtype=AUTHTYPE_MD5 + ): """ `username` and `secret` are the credentials used to authenticate. - + `events` may be set to `False` to prevent unsolicited events from being received. This is normally not desireable, so leaving it `True` is usually a good idea. - + If given, `challenge` is a challenge string provided by Asterisk after sending a `Challenge` action, used with `authtype` to determine how to authenticate. `authtype` is ignored if the `challenge` parameter is unset. """ - _Request.__init__(self, 'Login') - self['Username'] = username - - if not challenge is None and authtype: - self['AuthType'] = authtype + _Request.__init__(self, "Login") + self["Username"] = username + + if challenge is not None and authtype: + self["AuthType"] = authtype if authtype == AUTHTYPE_MD5: - self['Key'] = hashlib.md5( + self["Key"] = hashlib.md5( generic_transforms.string_to_bytes(challenge + secret) ).hexdigest() else: - raise ManagerAuthError("Invalid AuthType specified: %(authtype)s" % { - 'authtype': authtype, - }) + raise ManagerAuthError( + "Invalid AuthType specified: %(authtype)s" + % { + "authtype": authtype, + } + ) else: - self['Secret'] = secret - + self["Secret"] = secret + if not events: - self['Events'] = 'off' - + self["Events"] = "off" + def process_response(self, response): """ Raises `ManagerAuthError` if an error is received while attempting to authenticate. """ - if response.get('Response') == 'Error': - raise ManagerAuthError(response.get('Message')) + if response.get("Response") == "Error": + raise ManagerAuthError(response.get("Message")) return _Request.process_response(self, response) - + + class Logoff(_Request): """ Logs out of the current manager session, permitting reauthentication. """ + def __init__(self): - _Request.__init__(self, 'Logoff') - + _Request.__init__(self, "Logoff") + + class MailboxCount(_Request): """ Provides the number of new and old messages in the specified mailbox, keyed under 'NewMessages' and 'OldMessages', which contain ints; -1 indicates a failure while parsing the value. """ + def __init__(self, mailbox): """ `mailbox` is the mailbox to check. """ - _Request.__init__(self, 'MailboxCount') - self['Mailbox'] = mailbox - + _Request.__init__(self, "MailboxCount") + self["Mailbox"] = mailbox + def process_response(self, response): """ Converts the message-counts into integers. """ response = _Request.process_response(self, response) - generic_transforms.to_int(response, ('NewMessages', 'OldMessages',), -1) + generic_transforms.to_int( + response, + ( + "NewMessages", + "OldMessages", + ), + -1, + ) return response - + + class MailboxStatus(_Request): """ Provides the number of waiting messages in the specified mailbox, keyed under 'Waiting', which contains an int; -1 indicates a failure while parsing the value. """ + def __init__(self, mailbox): """ `mailbox` is the mailbox to check. """ - _Request.__init__(self, 'MailboxStatus') - self['Mailbox'] = mailbox + _Request.__init__(self, "MailboxStatus") + self["Mailbox"] = mailbox def process_response(self, response): """ Converts the waiting-message-count into an integer. """ response = _Request.process_response(self, response) - generic_transforms.to_int(response, ('Waiting',), -1) + generic_transforms.to_int(response, ("Waiting",), -1) return response + class MixMonitorMute(_Request): """ Mutes or unmutes one or both channels being monitored (recorded). Requires call """ + def __init__(self, channel, direction, mute): """ `channel` is the channel to operate on. `direction` is one of the following: - + * 'read': voice originating on the `channel` * 'write': voice delivered to the `channel` * 'both': all audio on the `channel` `mute` is `True` to muste the audio. """ - _Request.__init__(self, 'MixMonitorMute') - self['Channel'] = channel - self['Direction'] = direction - self['State'] = mute and '1' or '0' + _Request.__init__(self, "MixMonitorMute") + self["Channel"] = channel + self["Direction"] = direction + self["State"] = mute and "1" or "0" + class ModuleCheck(_Request): """ @@ -555,12 +622,14 @@ class ModuleCheck(_Request): If the module was loaded, its version is present in the response. """ + def __init__(self, module): """ `module` is the name of the module, without extension. """ - _Request.__init__(self, 'ModuleCheck') - self['Module'] = module + _Request.__init__(self, "ModuleCheck") + self["Module"] = module + class ModuleLoad(_Request): """ @@ -568,17 +637,18 @@ class ModuleLoad(_Request): Requires system """ + def __init__(self, load_type, module=None): """ `load_type` is one of the following: - + * 'load' * 'unload' * 'reload': if `module` is undefined, all modules are reloaded - + `module` is optionally the name of the module, with extension, or one of the following for a built-in subsystem: - + * 'cdr' * 'dnsmgr' * 'enum' @@ -587,24 +657,26 @@ def __init__(self, load_type, module=None): * 'manager' * 'rtp' """ - _Request.__init__(self, 'ModuleLoad') - self['LoadType'] = load_type - if not module is None: - self['Module'] = module - + _Request.__init__(self, "ModuleLoad") + self["LoadType"] = load_type + if module is not None: + self["Module"] = module + + class Monitor(_Request): """ Starts monitoring (recording) a channel. - + Requires call """ - def __init__(self, channel, filename, format='wav', mix=True): + + def __init__(self, channel, filename, format="wav", mix=True): """ `channel` is the channel to be affected and `filename` is the new target filename, without extension, as either an auto-resolved or absolute path. `format` may be any format Asterisk understands, defaulting to FORMAT_WAV: - + * FORMAT_SLN * FORMAT_G723 * FORMAT_G729 @@ -617,45 +689,59 @@ def __init__(self, channel, filename, format='wav', mix=True): `mix`, defaulting to `True`, muxes both audio streams associated with the channel after recording is complete, with the alternative leaving the two streams separate. """ - _Request.__init__(self, 'Monitor') - self['Channel'] = channel - self['File'] = filename - self['Format'] = format - self['Mix'] = mix and 'true' or 'false' + _Request.__init__(self, "Monitor") + self["Channel"] = channel + self["File"] = filename + self["Format"] = format + self["Mix"] = mix and "true" or "false" + class MuteAudio(_Request): """ Starts or stops muting audio on a channel. Either (or both) directions can be silenced. - + Requires system """ + def __init__(self, channel, input=False, output=False, muted=False): """ `channel` is the channel to be affected and `muted` indicates whether audio is being turned on or off. `input` (from the channel) and `output` (to the channel) indicate the subchannels to be adjusted. """ - _Request.__init__(self, 'MuteAudio') - self['Channel'] = channel + _Request.__init__(self, "MuteAudio") + self["Channel"] = channel if input and output: - self['Direction'] = 'all' + self["Direction"] = "all" elif input: - self['Direction'] = 'in' + self["Direction"] = "in" elif output: - self['Direction'] = 'out' + self["Direction"] = "out" else: - raise ValueError("Unable to construct request that affects no audio subchannels") - self['State'] = muted and 'on' or 'off' + raise ValueError( + "Unable to construct request that affects no audio subchannels" + ) + self["State"] = muted and "on" or "off" + class _Originate(_Request): """ Provides the common base for originated calls. - + Requires call """ - def __init__(self, channel, timeout=None, callerid=None, variables={}, account=None, async_=True): + + def __init__( + self, + channel, + timeout=None, + callerid=None, + variables={}, + account=None, + async_=True, + ): """ Sets common parameters for originated calls. @@ -680,38 +766,60 @@ def __init__(self, channel, timeout=None, callerid=None, variables={}, account=N `async_` should always be `True`. If not, only one unanswered call can be active at a time. """ _Request.__init__(self, "Originate") - self['Channel'] = channel - self['Async'] = async_ and 'true' or 'false' - + self["Channel"] = channel + self["Async"] = async_ and "true" or "false" + if timeout and timeout > 0: - self['Timeout'] = str(timeout) - self.timeout = timeout + 2000 #Timeout + 2s + self["Timeout"] = str(timeout) + self.timeout = timeout + 2000 # Timeout + 2s else: - self.timeout = 10 * 60 * 1000 #Ten minutes + self.timeout = 10 * 60 * 1000 # Ten minutes if callerid: - self['CallerID'] = callerid + self["CallerID"] = callerid if variables: - self['Variable'] = tuple(['%(key)s=%(value)s' % {'key': key, 'value': value,} for (key, value) in variables.items()]) + self["Variable"] = tuple( + [ + "%(key)s=%(value)s" + % { + "key": key, + "value": value, + } + for (key, value) in variables.items() + ] + ) if account: - self['Account'] = account - + self["Account"] = account + + class Originate_Application(_Originate): """ Initiates a call that answers, executes an arbitrary dialplan application, and hangs up. - + Requires call """ - def __init__(self, channel, application, data=(), timeout=None, callerid=None, variables={}, account=None, async_=True): + + def __init__( + self, + channel, + application, + data=(), + timeout=None, + callerid=None, + variables={}, + account=None, + async_=True, + ): """ `channel` is the destination to be called, expressed as a fully qualified Asterisk channel, like "SIP/test-account@example.org". - `application` is the name of the application to be executed, and `data` is optionally any - parameters to pass to the application, as an ordered sequence (list or tuple) of strings, - escaped as necessary (the ',' character is special). + `application` is the name of the application to be executed, and `data` is optionally a + single string argument or an ordered sequence (list or tuple) of strings to be joined with + commas. The ',' character is special and should be escaped as needed by the caller. + Bytes-like objects passed as `data` raise `TypeError`. `timeout`, if given, is the number of milliseconds to wait before dropping an unanwsered call. If set, the request's timeout value will be set to this number + 2 seconds, removing @@ -730,18 +838,48 @@ def __init__(self, channel, application, data=(), timeout=None, callerid=None, v `async_` should always be `True`. If not, only one unanswered call can be active at a time. """ - _Originate.__init__(self, channel, timeout, callerid, variables, account, async_) - self['Application'] = application + _Originate.__init__( + self, channel, timeout, callerid, variables, account, async_ + ) + self["Application"] = application + if not isinstance(data, str): + try: + data_view = memoryview(data) + except TypeError: + # Non-buffer sequences fall through to normal Data joining below. + pass + else: + data_view.release() + raise TypeError( + "data must be a string or sequence of strings, " + "not a bytes-like object" + ) if data: - self['Data'] = ','.join((str(d) for d in data)) - + # Plain strings skip the buffer probe and need wrapping for join(). + if isinstance(data, str): + data = (data,) + self["Data"] = ",".join((str(d) for d in data)) + + class Originate_Context(_Originate): """ Initiates a call with instructions derived from an arbitrary context/extension/priority. - + Requires call """ - def __init__(self, channel, context, extension, priority, timeout=None, callerid=None, variables={}, account=None, async_=True): + + def __init__( + self, + channel, + context, + extension, + priority, + timeout=None, + callerid=None, + variables={}, + account=None, + async_=True, + ): """ `channel` is the destination to be called, expressed as a fully qualified Asterisk channel, like "SIP/test-account@example.org". @@ -767,17 +905,21 @@ def __init__(self, channel, context, extension, priority, timeout=None, callerid `async_` should always be `True`. If not, only one unanswered call can be active at a time. """ - _Originate.__init__(self, channel, timeout, callerid, variables, account, async_) - self['Context'] = context - self['Exten'] = extension - self['Priority'] = priority - + _Originate.__init__( + self, channel, timeout, callerid, variables, account, async_ + ) + self["Context"] = context + self["Exten"] = extension + self["Priority"] = priority + + class Park(_Request): """ Parks a call for later retrieval. Requires call """ + def __init__(self, channel, channel_callback, timeout=None): """ `channel` is the channel to be parked and `channel_callback` is the channel to which parking @@ -787,10 +929,11 @@ def __init__(self, channel, channel_callback, timeout=None): if the call was not previously retrieved. """ _Request.__init__(self, "Park") - self['Channel'] = channel - self['Channel2'] = channel_callback + self["Channel"] = channel + self["Channel2"] = channel_callback if timeout: - self['Timeout'] = str(timeout) + self["Timeout"] = str(timeout) + class ParkedCalls(_Request): """ @@ -799,37 +942,42 @@ class ParkedCalls(_Request): Any number of 'ParkedCall' events may be generated in response to this request, followed by one 'ParkedCallsComplete'. """ + _aggregates = (core_events.ParkedCalls_Aggregate,) _synchronous_events_list = (core_events.ParkedCall,) _synchronous_events_finalising = (core_events.ParkedCallsComplete,) - + def __init__(self): _Request.__init__(self, "ParkedCalls") + class PauseMonitor(_Request): """ Pauses the recording of a monitored channel. The channel must have previously been selected by the `Monitor` action. - + Requires call """ + def __init__(self, channel): """ `channel` is the channel to be affected. """ - _Request.__init__(self, 'PauseMonitor') - self['Channel'] = channel - + _Request.__init__(self, "PauseMonitor") + self["Channel"] = channel + + class Ping(_Request): """ Pings the AMI server. The response value has a 'RTT' attribute, which is the number of seconds the trip took, as a floating-point number, or -1 in case of failure. """ - _start_time = None #The time at which the ping message was built - + + _start_time = None # The time at which the ping message was built + def __init__(self): - _Request.__init__(self, 'Ping') - + _Request.__init__(self, "Ping") + def build_request(self, action_id, id_generator, **kwargs): """ Records the time at which the request was assembled, to provide a latency value. @@ -837,32 +985,35 @@ def build_request(self, action_id, id_generator, **kwargs): request = _Request.build_request(self, action_id, id_generator, **kwargs) self._start_time = time.time() return request - + def process_response(self, response): """ Adds the number of seconds elapsed since the message was prepared for transmission under the 'RTT' key or sets it to -1 in case the server didn't respond as expected. """ response = _Request.process_response(self, response) - if response.get('Response') == 'Pong': - response['RTT'] = time.time() - self._start_time + if response.get("Response") == "Pong": + response["RTT"] = time.time() - self._start_time else: - response['RTT'] = -1 + response["RTT"] = -1 return response - + + class PlayDTMF(_Request): """ Plays a DTMF tone on a channel. - + Requires call """ + def __init__(self, channel, digit): """ `channel` is the channel to be affected, and `digit` is the tone to play. """ - _Request.__init__(self, 'PlayDTMF') - self['Channel'] = channel - self['Digit'] = str(digit) + _Request.__init__(self, "PlayDTMF") + self["Channel"] = channel + self["Digit"] = str(digit) + class QueueAdd(_Request): """ @@ -872,8 +1023,9 @@ class QueueAdd(_Request): Requires agent """ + _synchronous_events_finalising = (core_events.QueueMemberAdded,) - + def __init__(self, interface, queue, membername=None, penalty=0, paused=False): """ Adds the device identified by `interface` to the given `queue`. @@ -883,12 +1035,13 @@ def __init__(self, interface, queue, membername=None, penalty=0, paused=False): `paused` optinally allows the interface to start in a disabled state. """ _Request.__init__(self, "QueueAdd") - self['Queue'] = queue - self['Interface'] = interface - self['Penalty'] = str(penalty) - self['Paused'] = paused and 'yes' or 'no' + self["Queue"] = queue + self["Interface"] = interface + self["Penalty"] = str(penalty) + self["Paused"] = paused and "yes" or "no" if membername: - self['MemberName'] = membername + self["MemberName"] = membername + class QueueLog(_Request): """ @@ -896,6 +1049,7 @@ class QueueLog(_Request): Requires agent """ + def __init__(self, queue, event, interface=None, uniqueid=None, message=None): """ `queue` is the queue to which the `event` is to be attached. @@ -907,14 +1061,15 @@ def __init__(self, queue, event, interface=None, uniqueid=None, message=None): `message`'s purpose is presently unknown. """ _Request.__init__(self, "QueueLog") - self['Queue'] = queue - self['Event'] = event - if not uniqueid is None: - self['Uniqueid'] = uniqueid - if not interface is None: - self['Interface'] = interface - if not message is None: - self['Message'] = message + self["Queue"] = queue + self["Event"] = event + if uniqueid is not None: + self["Uniqueid"] = uniqueid + if interface is not None: + self["Interface"] = interface + if message is not None: + self["Message"] = message + class QueuePause(_Request): """ @@ -924,16 +1079,18 @@ class QueuePause(_Request): Requires agent """ + def __init__(self, interface, paused, queue=None): """ `interface` is the device to be affected, and `queue` optionally limits the scope to a single queue. `paused` must be `True` or `False`, to control the action being taken. """ _Request.__init__(self, "QueuePause") - self['Interface'] = interface - self['Paused'] = paused and 'true' or 'false' - if not queue is None: - self['Queue'] = queue + self["Interface"] = interface + self["Paused"] = paused and "true" or "false" + if queue is not None: + self["Queue"] = queue + class QueuePenalty(_Request): """ @@ -941,16 +1098,18 @@ class QueuePenalty(_Request): Requires agent """ + def __init__(self, interface, penalty, queue=None): """ Changes the `penalty` value associated with `interface` in all queues, unless `queue` is defined, limiting it to one. """ _Request.__init__(self, "QueuePenalty") - self['Interface'] = interface - self['Penalty'] = str(penalty) - if not queue is None: - self['Queue'] = queue + self["Interface"] = interface + self["Penalty"] = str(penalty) + if queue is not None: + self["Queue"] = queue + class QueueReload(_Request): """ @@ -958,24 +1117,26 @@ class QueueReload(_Request): Requires agent """ - def __init__(self, queue=None, members='yes', rules='yes', parameters='yes'): + + def __init__(self, queue=None, members="yes", rules="yes", parameters="yes"): """ Reloads parameters for all queues, unless `queue` is defined, limiting it to one. `members` is 'yes' (default) or 'no', indicating whether the member-list should be reloaded. - + `rules` is 'yes' (default) or 'no', indicating whether the rule-list should be reloaded. `parameters` is 'yes' (default) or 'no', indicating whether the parameter-list should be reloaded. """ _Request.__init__(self, "QueueReload") - self['Members'] = members - self['Rules'] = rules - self['Parameters'] = parameters - if not queue is None: - self['Queue'] = queue - + self["Members"] = members + self["Rules"] = rules + self["Parameters"] = parameters + if queue is not None: + self["Queue"] = queue + + class QueueRemove(_Request): """ Removes a member from a queue. @@ -984,15 +1145,17 @@ class QueueRemove(_Request): Requires agent """ + _synchronous_events_finalising = (core_events.QueueMemberRemoved,) - + def __init__(self, interface, queue): """ Removes the device identified by `interface` from the given `queue`. """ _Request.__init__(self, "QueueRemove") - self['Queue'] = queue - self['Interface'] = interface + self["Queue"] = queue + self["Interface"] = interface + class QueueStatus(_Request): """ @@ -1001,19 +1164,24 @@ class QueueStatus(_Request): Upon success, 'QueueParams', 'QueueMember', and 'QueueEntry' events will be generated, ending with 'QueueStatusComplete'. """ + _aggregates = (core_events.QueueStatus_Aggregate,) - _synchronous_events_list = (core_events.QueueParams, core_events.QueueMember, core_events.QueueEntry,) + _synchronous_events_list = ( + core_events.QueueParams, + core_events.QueueMember, + core_events.QueueEntry, + ) _synchronous_events_finalising = (core_events.QueueStatusComplete,) - + def __init__(self, queue=None): """ Describes all queues in the system, unless `queue` is given, which limits the scope to one. """ _Request.__init__(self, "QueueStatus") - if not queue is None: - self['Queue'] = queue + if queue is not None: + self["Queue"] = queue + - class QueueSummary(_Request): """ Describes the Summary of one (or all) queues. @@ -1021,6 +1189,7 @@ class QueueSummary(_Request): Upon success, 'QueueSummary' event will be generated, ending with 'QueueSummaryComplete'. """ + _aggregates = (core_events.QueueSummary_Aggregate,) _synchronous_events_list = (core_events.QueueSummary,) _synchronous_events_finalising = (core_events.QueueSummaryComplete,) @@ -1030,16 +1199,17 @@ def __init__(self, queue=None): Describes all queues in the system, unless `queue` is given, which limits the scope to one. """ _Request.__init__(self, "QueueSummary") - if not queue is None: - self['Queue'] = queue - - + if queue is not None: + self["Queue"] = queue + + class Redirect(_Request): """ Redirects a call to an arbitrary context/extension/priority. - + Requires call """ + def __init__(self, channel, context, extension, priority): """ `channel` is the destination to be redirected. @@ -1049,71 +1219,80 @@ def __init__(self, channel, context, extension, priority): immediately. """ _Request.__init__(self, "Redirect") - self['Channel'] = channel - self['Context'] = context - self['Exten'] = extension - self['Priority'] = priority + self["Channel"] = channel + self["Context"] = context + self["Exten"] = extension + self["Priority"] = priority + class Reload(_Request): """ Reloads Asterisk's configuration globally or for a specific module. - + Requires call """ + def __init__(self, module=None): """ If given, `module` limits the scope of the reload to a specific module, named without extension. """ _Request.__init__(self, "Reload") - if not module is None: - self['Module'] = module + if module is not None: + self["Module"] = module + class SendText(_Request): """ Sends text along a supporting channel. - + Requires call """ + def __init__(self, channel, message): """ `channel` is the channel along which to send `message`. """ _Request.__init__(self, "SendText") - self['Channel'] = channel - self['Message'] = message - + self["Channel"] = channel + self["Message"] = message + + class SetCDRUserField(_Request): """ Sets the user-field attribute for the CDR associated with a channel. - + Requires call """ + def __init__(self, channel, user_field): """ `channel` is the channel to be affected, and `user_field` is the value to set. """ - _Request.__init__(self, 'SetCDRUserField') - self['Channel'] = channel - self['UserField'] = user_field - + _Request.__init__(self, "SetCDRUserField") + self["Channel"] = channel + self["UserField"] = user_field + + class Setvar(_Request): """ Sets a channel-level or global variable. - + Requires call """ + def __init__(self, variable, value, channel=None): """ `value` is the value to be set under `variable`. - + `channel` is the channel to be affected, or `None`, the default, if the variable is global. """ - _Request.__init__(self, 'Setvar') + _Request.__init__(self, "Setvar") if channel: - self['Channel'] = channel - self['Variable'] = variable - self['Value'] = value + self["Channel"] = channel + self["Variable"] = variable + self["Value"] = value + class SIPnotify(_Request): """ @@ -1121,6 +1300,7 @@ class SIPnotify(_Request): Requires call """ + def __init__(self, channel, headers={}): """ `channel` is the channel along which to send the NOTIFY. @@ -1128,9 +1308,19 @@ def __init__(self, channel, headers={}): `headers` is a dictionary of key-value pairs to be inserted as SIP headers. """ _Request.__init__(self, "SIPnotify") - self['Channel'] = channel + self["Channel"] = channel if headers: - self['Variable'] = tuple(['%(key)s=%(value)s' % {'key': key, 'value': value,} for (key, value) in headers.items()]) + self["Variable"] = tuple( + [ + "%(key)s=%(value)s" + % { + "key": key, + "value": value, + } + for (key, value) in headers.items() + ] + ) + class SIPpeers(_Request): """ @@ -1141,13 +1331,15 @@ class SIPpeers(_Request): Requires system """ + _aggregates = (core_events.SIPpeers_Aggregate,) _synchronous_events_list = (core_events.PeerEntry,) _synchronous_events_finalising = (core_events.PeerlistComplete,) - + def __init__(self): _Request.__init__(self, "SIPpeers") + class SIPqualify(_Request): """ Sends a SIP OPTIONS to the specified peer, mostly to ensure its presence. @@ -1156,19 +1348,21 @@ class SIPqualify(_Request): Requires system """ + def __init__(self, peer): """ `peer` is the peer to ping. """ _Request.__init__(self, "SIPqualify") - self['Peer'] = peer + self["Peer"] = peer + class SIPshowpeer(_Request): - """ + r""" Provides detailed information about a SIP peer. The response has the following key-value pairs: - + * 'ACL': True or False * 'Address-IP': The IP of the peer * 'Address-Port': The port of the peer, as an integer @@ -1180,10 +1374,10 @@ class SIPshowpeer(_Request): * 'ChanObjectType': "peer" * 'CID-CallingPres': ? * 'Context': The context associated with the peer - + * 'CodecOrder': The order in which codecs are tried * 'Codecs': A list of supported codecs - + * 'Default-addr-IP': ? * 'Default-addr-port': ? * 'Default-Username': ? @@ -1214,37 +1408,54 @@ class SIPshowpeer(_Request): * 'ToHost': ? * 'TransferMode': "open" * 'VoiceMailbox': The mailbox associated with the peer; may be null - + Requires system """ + def __init__(self, peer): """ `peer` is the identifier of the peer for which information is to be retrieved. """ _Request.__init__(self, "SIPshowpeer") - self['Peer'] = peer - + self["Peer"] = peer + def process_response(self, response): """ Sets the 'ACL', 'Dynamic', 'MD5SecretExist', 'SecretExist', 'SIP-CanReinvite', 'SIP-PromiscRedir', 'SIP-UserPhone', 'SIP-VideoSupport', and 'SIP-AuthInsecure' headers' values to booleans. - + Sets the 'Address-Port', 'MaxCallBR', and 'RegExpire' headers' values to ints, with -1 indicating failure. """ response = _Request.process_response(self, response) - - generic_transforms.to_bool(response, ( - 'ACL', 'Dynamic', 'MD5SecretExist', 'SecretExist', 'SIP-CanReinvite', 'SIP-PromiscRedir', - 'SIP-UserPhone', 'SIP-VideoSupport', - ), truth_value='Y') - generic_transforms.to_bool(response, ('SIP-AuthInsecure',), truth_value='yes') - generic_transforms.to_int(response, ('Address-Port',), -1) - generic_transforms.to_int(response, ('MaxCallBR', 'RegExpire'), -1, preprocess=(lambda x:x.split()[0])) - + + generic_transforms.to_bool( + response, + ( + "ACL", + "Dynamic", + "MD5SecretExist", + "SecretExist", + "SIP-CanReinvite", + "SIP-PromiscRedir", + "SIP-UserPhone", + "SIP-VideoSupport", + ), + truth_value="Y", + ) + generic_transforms.to_bool(response, ("SIP-AuthInsecure",), truth_value="yes") + generic_transforms.to_int(response, ("Address-Port",), -1) + generic_transforms.to_int( + response, + ("MaxCallBR", "RegExpire"), + -1, + preprocess=(lambda x: x.split()[0]), + ) + return response - + + class SIPshowregistry(_Request): """ Lists all SIP registrations. @@ -1254,13 +1465,15 @@ class SIPshowregistry(_Request): Requires system """ + _aggregates = (core_events.SIPshowregistry_Aggregate,) _synchronous_events_list = (core_events.RegistryEntry,) _synchronous_events_finalising = (core_events.RegistrationsComplete,) - + def __init__(self): _Request.__init__(self, "SIPshowregistry") - + + class Status(_Request): """ Lists the status of an active channel. @@ -1269,44 +1482,50 @@ class Status(_Request): Requires call """ + _aggregates = (core_events.Status_Aggregate,) _synchronous_events_list = (core_events.Status,) _synchronous_events_finalising = (core_events.StatusComplete,) - + def __init__(self, channel): """ `channel` is the channel for which status information is to be retrieved. """ _Request.__init__(self, "Status") - self['channel'] = channel + self["channel"] = channel + class StopMonitor(_Request): """ Stops recording a monitored channel. The channel must have previously been selected by the `Monitor` action. - + Requires call """ + def __init__(self, channel): """ `channel` is the channel to be affected. """ - _Request.__init__(self, 'StopMonitor') - self['Channel'] = channel + _Request.__init__(self, "StopMonitor") + self["Channel"] = channel + class UnpauseMonitor(_Request): """ Unpauses recording on a monitored channel. The channel must have previously been selected by the `Monitor` action. - + Requires call """ + def __init__(self, channel): """ `channel` is the channel to be affected. """ - _Request.__init__(self, 'UnpauseMonitor') - self['Channel'] = channel + _Request.__init__(self, "UnpauseMonitor") + self["Channel"] = channel + class UpdateConfig(_Request): """ @@ -1314,6 +1533,7 @@ class UpdateConfig(_Request): Requires config """ + def __init__(self, src_filename, dst_filename, changes, reload=True): """ Reads from `src_filename`, performing all `changes`, and writing to `dst_filename`. @@ -1322,76 +1542,84 @@ def __init__(self, src_filename, dst_filename, changes, reload=True): module, that module is reloaded. `changes` may be any iterable object countaining quintuples with the following items: - + #. One of the following: - + * 'NewCat': creates a new category * 'RenameCat': renames a category * 'DelCat': deletes a category * 'Update': changes a value * 'Delete': removes a value * 'Append': adds a value - + 2. The name of the category to operate on #. `None` or the name of the variable to operate on #. `None` or the value to be set/added (has no effect with 'Delete') #. `None` or a string that needs to be matched in the line to serve as a qualifier """ _Request.__init__(self, "UpdateConfig") - self['SrcFilename'] = src_filename - self['DstFilename'] = dst_filename - self['Reload'] = type(reload) == bool and (reload and 'true' or 'false') or reload - - for (i, (action, category, variable, value, match)) in enumerate(changes): - index = '%(index)06i' % { - 'index': i, + self["SrcFilename"] = src_filename + self["DstFilename"] = dst_filename + self["Reload"] = ( + type(reload) == bool # noqa: E721 + and (reload and "true" or "false") + or reload + ) + + for i, (action, category, variable, value, match) in enumerate(changes): + index = "%(index)06i" % { + "index": i, } - self['Action-' + index] = action - self['Cat-' + index] = category - if not variable is None: - self['Var-' + index] = variable - if not value is None: - self['Value-' + index] = value - if not match is None: - self['Match-' + index] = match + self["Action-" + index] = action + self["Cat-" + index] = category + if variable is not None: + self["Var-" + index] = variable + if value is not None: + self["Value-" + index] = value + if match is not None: + self["Match-" + index] = match + class UserEvent(_Request): """ Causes a 'UserEvent' event to be generated. - + Requires user """ + _synchronous_events_finalising = (core_events.UserEvent,) - + def __init__(self, **kwargs): """ Any keyword-arguments passed will be present in the generated event, making this usable as a crude form of message-passing between AMI clients. """ - _Request.__init__(self, 'UserEvent') - for (key, value) in kwargs.items(): + _Request.__init__(self, "UserEvent") + for key, value in kwargs.items(): self[key] = value + class VoicemailUsersList(_Request): """ Lists all voicemail information. - + Any number of 'VoicemailUserEntry' events may be generated in response to this request, followed by one 'VoicemailUserEntryComplete'. - + Requires system (probably) """ + _aggregates = (core_events.VoicemailUsersList_Aggregate,) _synchronous_events_list = (core_events.VoicemailUserEntry,) _synchronous_events_finalising = (core_events.VoicemailUserEntryComplete,) - + def __init__(self): - _Request.__init__(self, 'VoicemailUsersList') + _Request.__init__(self, "VoicemailUsersList") -#Exceptions +# Exceptions ############################################################################### class ManagerAuthError(ManagerError): """ - Indicates that a problem occurred while authenticating + Indicates that a problem occurred while authenticating """ diff --git a/pystrix/ami/core_events.py b/pystrix/ami/core_events.py index 3e0f02c..810bf97 100644 --- a/pystrix/ami/core_events.py +++ b/pystrix/ami/core_events.py @@ -31,16 +31,17 @@ The events implemented by this module follow the definitions provided by http://www.asteriskdocs.org/ and https://wiki.asterisk.org/ """ + import re -from pystrix.ami.ami import (_Aggregate, _Event) from pystrix.ami import generic_transforms +from pystrix.ami.ami import _Aggregate, _Event class AGIExec(_Event): """ Generated when an AGI script executes an arbitrary application. - + - 'Channel': The channel in use - 'Command': The command that was issued - 'CommandId': The command's identifier, used to track events from start to finish @@ -48,50 +49,54 @@ class AGIExec(_Event): - 'Result': Only present when 'SubEvent' is "End": "Success" (and "Failure"?) - 'ResultCode': Only present when 'SubEvent' is "End": the result-code from Asterisk """ + def process(self): """ Translates the 'Result' header's value into a bool. - + Translates the 'ResultCode' header's value into an int, setting it to `-1` if coercion fails. """ (headers, data) = _Event.process(self) - - generic_transforms.to_bool(headers, ('Result',), truth_value='Success') - generic_transforms.to_int(headers, ('ResultCode',), -1) - + + generic_transforms.to_bool(headers, ("Result",), truth_value="Success") + generic_transforms.to_int(headers, ("ResultCode",), -1) + return (headers, data) - + + class AsyncAGI(_Event): """ Generated when an AGI request is processed. - + - All fields currently unknown """ - + + class ChannelUpdate(_Event): """ Describes a change in a channel. - + Some fields are type-dependent and will appear as children of that type in the list. - + - 'Channel': The channel being described - 'Channeltype': One of the following types - + - 'SIP': SIP channels have the following fields - + - 'SIPcallid': 'DB45B1B5-1EAD11E1-B979D0B6-32548E42@10.13.38.201', the CallID negotiated with the endpoint; this should be present in any CDRs generated - 'SIPfullcontact': 'sip:flan@uguu.ca', the address of the SIP contact field, if any (observed during a REFER) - + - 'UniqueID': An Asterisk-unique value """ - + + class CoreShowChannel(_Event): """ Provides the definition of an active Asterisk channel. - + - 'AccountCode': The account code associated with the channel - 'Application': The application currently being executed by the channel - 'ApplicationData': The arguments provided to the application @@ -105,7 +110,7 @@ class CoreShowChannel(_Event): - '0': Not connected - '4': Alerting - '6': Connected - + - 'ChannelStateDesc': A lexical description of the channel's current state - 'ConnectedLineNum': The (often) numeric address of the called party (may be nil) - 'ConnectedLineName': The (optional, media-specific) name of the called party (may be nil) @@ -115,51 +120,56 @@ class CoreShowChannel(_Event): - 'Priority': The dialplan priority in which the channel is executing - 'UniqueID': An Asterisk-unique value (the timestamp at which the channel was connected?) """ + def process(self): """ Translates the 'ChannelState' header's value into an int, setting it to `None` if coercion fails. - + Replaces the 'Duration' header's value with the number of seconds, as an int, or -1 if conversion fails. """ (headers, data) = _Event.process(self) - + try: - (h, m, s) = (int(v) for v in headers['Duration'].split(':')) - headers['Duration'] = s + m * 60 + h * 60 * 60 + (h, m, s) = (int(v) for v in headers["Duration"].split(":")) + headers["Duration"] = s + m * 60 + h * 60 * 60 except Exception: - headers['Duration'] = -1 - - generic_transforms.to_int(headers, ('ChannelState',), None) - + headers["Duration"] = -1 + + generic_transforms.to_int(headers, ("ChannelState",), None) + return (headers, data) - + + class CoreShowChannelsComplete(_Event): """ Indicates that all Asterisk channels have been listed. - + - 'ListItems' : The number of items returned prior to this event """ + def process(self): """ Translates the 'ListItems' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - - generic_transforms.to_int(headers, ('ListItems',), -1) - + + generic_transforms.to_int(headers, ("ListItems",), -1) + return (headers, data) - + + class DBGetResponse(_Event): """ Provides the value requested from the database. - + - 'Family': The family of the value being provided - 'Key': The key of the value being provided - 'Val': The value being provided, represented as a string """ + class DTMF(_Event): """ - 'Begin': 'Yes' or 'No', indicating whether this started or ended the DTMF press @@ -170,45 +180,57 @@ class DTMF(_Event): `Begin`, though both may be `Yes` if the event has no duration) - 'UniqueID': An Asterisk-unique value """ + def process(self): """ Translates 'Begin' and 'End' into booleans, and adds a 'Received':bool header. """ (headers, data) = _Event.process(self) - - headers['Received'] = headers.get('Direction') == 'Received' - generic_transforms.to_bool(headers, ('Begin', 'End',), truth_value='Yes') - + + headers["Received"] = headers.get("Direction") == "Received" + generic_transforms.to_bool( + headers, + ( + "Begin", + "End", + ), + truth_value="Yes", + ) + return (headers, data) - + + class FullyBooted(_Event): """ Indicates that Asterisk is online. - + - 'Status': "Fully Booted" """ - + + class Hangup(_Event): """ Indicates that a channel has been hung up. - + - 'Cause': One of the following numeric values, as a string: - + - '0': Hung up - '16': Normal clearing - + - 'Cause-txt': Additional information related to the hangup - 'Channel': The channel hung-up - 'Uniqueid': An Asterisk unique value """ + def process(self): """ Translates the 'Cause' header's value into an int, setting it to `None` if coercion fails. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('Cause',), None) + generic_transforms.to_int(headers, ("Cause",), None) return (headers, data) + class HangupRequest(_Event): """ Emitted when a request to terminate the call is received. @@ -217,31 +239,35 @@ class HangupRequest(_Event): - 'Uniqueid': An Asterisk unique value """ + class MonitorStart(_Event): """ Indicates that monitoring has begun. - + - 'Channel': The channel being monitored - 'Uniqueid': An Asterisk unique value """ + class MonitorStop(_Event): """ Indicates that monitoring has ceased. - + - 'Channel': The channel that was monitored - 'Uniqueid': An Asterisk unique value """ + class NewAccountCode(_Event): """ Indicates that the account-code associated with a channel has changed. - + - 'AccountCode': The new account code - 'Channel': The channel that was affected. - 'OldAccountCode': The old account code """ + class Newchannel(_Event): """ Indicates that a new channel has been created. @@ -261,15 +287,17 @@ class Newchannel(_Event): - 'Exten': The extension the channel is currently operating in - 'Uniqueid': An Asterisk unique value """ + def process(self): """ Translates the 'ChannelState' header's value into an int, setting it to `None` if coercion fails. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('ChannelState',), None) + generic_transforms.to_int(headers, ("ChannelState",), None) return (headers, data) + class Newexten(_Event): """ Emitted when a channel switches executing extensions. @@ -283,6 +311,7 @@ class Newexten(_Event): - 'Uniqueid': An Asterisk unique value """ + class Newstate(_Event): """ Indicates that a channel's state has changed. @@ -301,19 +330,21 @@ class Newstate(_Event): - 'ConnectedLineName': ? - 'Uniqueid': An Asterisk unique value """ + def process(self): """ Translates the 'ChannelState' header's value into an int, setting it to `None` if coercion fails. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('ChannelState',), None) + generic_transforms.to_int(headers, ("ChannelState",), None) return (headers, data) - + + class OriginateResponse(_Event): """ Describes the result of an Originate request. - + * 'CallerIDName': The supplied source name * 'CallerIDNum': The supplied source address * 'Channel': The Asterisk channel used for the call @@ -321,21 +352,26 @@ class OriginateResponse(_Event): * 'Exten': The dialplan extension into which the call was placed, as a string; unused for applications * 'Reason': An integer as a string, ostensibly one of the `ORIGINATE_RESULT` constants; undefined integers may exist """ + def process(self): """ Sets the 'Reason' values to an int, one of the `ORIGINATE_RESULT` constants, with -1 indicating failure. """ - from pystrix.ami.core import ORIGINATE_RESULT_MAP # import here to prevent circular imports + from pystrix.ami.core import ( + ORIGINATE_RESULT_MAP, # import here to prevent circular imports + ) + (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('Reason',), -1) - generic_transforms.add_result(headers, 'Reason', ORIGINATE_RESULT_MAP) + generic_transforms.to_int(headers, ("Reason",), -1) + generic_transforms.add_result(headers, "Reason", ORIGINATE_RESULT_MAP) return (headers, data) - + + class ParkedCall(_Event): """ Describes a parked call. - + - 'CallerID': The ID of the caller, ".+?" <.+?> - 'CallerIDName' (optional): The name of the caller, on supporting channels - 'Channel': The channel of the parked call @@ -344,43 +380,47 @@ class ParkedCall(_Event): - 'Timeout' (optional): The time remaining before the call is reconnected with the callback channel """ + def process(self): """ Translates the 'Timeout' header's value into an int, setting it to `None` if coercion fails, and leaving it absent if it wasn't present in the original response. """ (headers, data) = _Event.process(self) - if 'Timeout' in headers: - generic_transforms.to_int(headers, ('Timeout',), None) + if "Timeout" in headers: + generic_transforms.to_int(headers, ("Timeout",), None) return (headers, data) - + + class ParkedCallsComplete(_Event): """ Indicates that all parked calls have been listed. - + - 'Total' : The number of items returned prior to this event """ + def process(self): """ Translates the 'Total' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('Total',), -1) + generic_transforms.to_int(headers, ("Total",), -1) return (headers, data) + class PeerEntry(_Event): """ Describes a peer. - + - 'ChannelType': The type of channel being described. - + - 'SIP' - + - 'ObjectName': The internal name by which this peer is known - 'ChanObjectType': The type of object - + - 'peer' - + - 'IPaddress' (optional): The IP of the peer - 'IPport' (optional): The port of the peer - 'Dynamic': 'yes' or 'no', depending on whether the peer is resolved by IP or authentication @@ -391,52 +431,62 @@ class PeerEntry(_Event): - 'Status': 'Unmonitored', 'OK (\\d+ ms)' - 'RealtimeDevice': 'yes' or 'no' """ + def process(self): """ Translates the 'Port' header's value into an int, setting it to `None` if coercion fails, and leaving it absent if it wasn't present in the original response. - + Translates the 'Dynamic', 'Natsupport', 'VideoSupport', 'ACL', and 'RealtimeDevice' headers' values into bools. - + Translates 'Status' into the number of milliseconds since the peer was last seen or -2 if unmonitored. -1 if parsing failed. """ (headers, data) = _Event.process(self) - + try: - if headers['Status'] == 'Unmonitored': - headers['Status'] = -2 + if headers["Status"] == "Unmonitored": + headers["Status"] = -2 else: - headers['Status'] = int(re.match(r'OK \((\d+) ms\)', headers['Status']).group(1)) + headers["Status"] = int( + re.match(r"OK \((\d+) ms\)", headers["Status"]).group(1) + ) except Exception: - headers['Status'] = -1 - - if 'IPport' in headers: - generic_transforms.to_int(headers, ('IPPort',), None) - - generic_transforms.to_bool(headers, ('Dynamic', 'Natsupport', 'VideoSupport', 'ACL', 'RealtimeDevice'), truth_value='yes') - + headers["Status"] = -1 + + if "IPport" in headers: + generic_transforms.to_int(headers, ("IPPort",), None) + + generic_transforms.to_bool( + headers, + ("Dynamic", "Natsupport", "VideoSupport", "ACL", "RealtimeDevice"), + truth_value="yes", + ) + return (headers, data) + class PeerlistComplete(_Event): """ Indicates that all peers have been listed. - + - 'ListItems' : The number of items returned prior to this event """ + def process(self): """ Translates the 'ListItems' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('ListItems',), -1) + generic_transforms.to_int(headers, ("ListItems",), -1) return (headers, data) + class QueueEntry(_Event): """ Indicates that a call is waiting to be answered. - + - 'Channel': The channel of the inbound call - 'CallerID': The (often) numeric ID of the caller - 'CallerIDName' (optional): The friendly name of the caller on supporting channels @@ -444,18 +494,27 @@ class QueueEntry(_Event): - 'Queue': The queue in which the caller is waiting - 'Wait': The number of seconds the caller has been waiting """ + def process(self): """ Translates the 'Position' and 'Wait' headers' values into ints, setting them to -1 on error. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('Position', 'Wait',), -1) + generic_transforms.to_int( + headers, + ( + "Position", + "Wait", + ), + -1, + ) return (headers, data) + class QueueMember(_Event): """ Describes a member of a queue. - + - 'CallsTaken': The number of calls received by this member - 'LastCall': The UNIX timestamp of the last call taken, or 0 if none - 'Location': The interface in the queue @@ -465,27 +524,38 @@ class QueueMember(_Event): - 'Penalty': The selection penalty to apply to this member (higher numbers mean later selection) - 'Queue': The queue to which the member belongs - 'Status': One of the following, as a string: - + - '0': Idle - '1': In use - '2': Busy """ + def process(self): """ Translates the 'CallsTaken', 'LastCall', 'Penalty', and 'Status' headers' values into ints, setting them to -1 on error. - + 'Paused' is set to a bool. """ (headers, data) = _Event.process(self) - generic_transforms.to_bool(headers, ('Paused',), truth_value='1') - generic_transforms.to_int(headers, ('CallsTaken', 'LastCall', 'Penalty', 'Status',), -1) + generic_transforms.to_bool(headers, ("Paused",), truth_value="1") + generic_transforms.to_int( + headers, + ( + "CallsTaken", + "LastCall", + "Penalty", + "Status", + ), + -1, + ) return (headers, data) - + + class QueueMemberAdded(_Event): """ Indicates that a member was added to a queue. - + - 'CallsTaken': The number of calls received by this member - 'LastCall': The UNIX timestamp of the last call taken, or 0 if none - 'Location': The interface added to the queue @@ -495,53 +565,67 @@ class QueueMemberAdded(_Event): - 'Penalty': The selection penalty to apply to this member (higher numbers mean later selection) - 'Queue': The queue to which the member was added - 'Status': One of the following, as a string: - + - '0': Idle - '1': In use - '2': Busy """ + def process(self): """ Translates the 'CallsTaken', 'LastCall', 'Penalty', and 'Status' headers' values into ints, setting them to -1 on error. - + 'Paused' is set to a bool. """ (headers, data) = _Event.process(self) - generic_transforms.to_bool(headers, ('Paused',), truth_value='1') - generic_transforms.to_int(headers, ('CallsTaken', 'LastCall', 'Penalty', 'Status',), -1) + generic_transforms.to_bool(headers, ("Paused",), truth_value="1") + generic_transforms.to_int( + headers, + ( + "CallsTaken", + "LastCall", + "Penalty", + "Status", + ), + -1, + ) return (headers, data) - + + class QueueMemberPaused(_Event): """ Indicates that the pause-state of a queue member was changed. - + - 'Location': The interface added to the queue - 'MemberName' (optional): The friendly name of the member - 'Paused': '1' or '0' for 'true' and 'false', respectively - 'Queue': The queue in which the member was paused """ + def process(self): """ 'Paused' is set to a bool. """ (headers, data) = _Event.process(self) - generic_transforms.to_bool(headers, ('Paused',), truth_value='1') + generic_transforms.to_bool(headers, ("Paused",), truth_value="1") return (headers, data) + class QueueMemberRemoved(_Event): """ Indicates that a member was removed from a queue. - + - 'Location': The interface removed from the queue - 'MemberName' (optional): The friendly name of the member - 'Queue': The queue from which the member was removed """ - + + class QueueParams(_Event): """ Describes the attributes of a queue. - + - 'Abandoned': The number of calls that have gone unanswered - 'Calls': The number of current calls in the queue - 'Completed': The number of completed calls @@ -552,24 +636,45 @@ class QueueParams(_Event): - 'ServiceLevelPerf': ? - 'Weight': ? """ + def process(self): """ Translates the 'Abandoned', 'Calls', 'Completed', 'Holdtime', and 'Max' headers' values into ints, setting them to -1 on error. - + Translates the 'ServiceLevel', 'ServiceLevelPerf', and 'Weight' values into floats, setting them to -1 on error. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('Abandoned', 'Calls', 'Completed', 'Holdtime', 'Max',), -1) - generic_transforms.to_float(headers, ('ServiceLevel', 'ServiceLevelPref', 'Weight',), -1) + generic_transforms.to_int( + headers, + ( + "Abandoned", + "Calls", + "Completed", + "Holdtime", + "Max", + ), + -1, + ) + generic_transforms.to_float( + headers, + ( + "ServiceLevel", + "ServiceLevelPref", + "Weight", + ), + -1, + ) return (headers, data) - + + class QueueStatusComplete(_Event): """ Indicates that a QueueStatus request has completed. """ + class QueueSummary(_Event): """ Describes a Summary of a queue. Example: @@ -589,12 +694,22 @@ class QueueSummary(_Event): def process(self): """ - Translates the 'LoggedIn', 'Available', 'Callers', 'Holdtime', 'TalkTime' and 'LongestHoldTime' headers' - values into ints, setting them to -1 on error. + Translates the 'LoggedIn', 'Available', 'Callers', 'Holdtime', 'TalkTime' and 'LongestHoldTime' headers' + values into ints, setting them to -1 on error. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('LoggedIn', 'Available', 'Callers', 'HoldTime', 'TalkTime', - 'LongestHoldTime'), -1) + generic_transforms.to_int( + headers, + ( + "LoggedIn", + "Available", + "Callers", + "HoldTime", + "TalkTime", + "LongestHoldTime", + ), + -1, + ) return (headers, data) @@ -607,7 +722,7 @@ class QueueSummaryComplete(_Event): class RegistryEntry(_Event): """ Describes a SIP registration. - + - 'Domain': The domain in which the registration took place - 'DomainPort': The port in use in the registration domain - 'Host': The address of the host @@ -617,38 +732,52 @@ class RegistryEntry(_Event): - 'State': The current status of the registration - 'Username': The username used for the registration """ + def process(self): """ Translates the 'DomainPort', 'Port', 'Refresh', and 'RegistrationTime' values into ints, setting them to -1 on error. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('DomainPort', 'Port', 'Refresh', 'RegistrationTime',), -1) + generic_transforms.to_int( + headers, + ( + "DomainPort", + "Port", + "Refresh", + "RegistrationTime", + ), + -1, + ) return (headers, data) - + + class RegistrationsComplete(_Event): """ Indicates that all registrations have been listed. - + - 'ListItems' : The number of items returned prior to this event """ + def process(self): """ Translates the 'ListItems' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('ListItems',), -1) + generic_transforms.to_int(headers, ("ListItems",), -1) return (headers, data) - + + class Reload(_Event): """ Indicates that Asterisk's configuration was reloaded. - + - 'Message': A human-readable summary - 'Module': The affected module - 'Status': 'Enabled' """ - + + class RTCPReceived(_Event): """ A Real Time Control Protocol message emitted by Asterisk when using an RTP-based channel, @@ -666,6 +795,7 @@ class RTCPReceived(_Event): - 'SenderSSRC': Session source - 'SequenceNumberCycles': ? """ + def process(self): """ Translates the 'HighestSequence', 'LastSR', 'PacketsLost', 'ReceptionReports, @@ -678,19 +808,38 @@ def process(self): unknown. """ (headers, data) = _Event.process(self) - - _from = headers.get('From') - if _from and ':' in _from: - headers['From'] = tuple(_from.rsplit(':', 1)) + + _from = headers.get("From") + if _from and ":" in _from: + headers["From"] = tuple(_from.rsplit(":", 1)) else: - headers['From'] = None - - generic_transforms.to_int(headers, ('HighestSequence', 'LastSR', 'PacketsLost', 'ReceptionReports', 'SequenceNumberCycles',), -1) - headers['DLSR'] = (headers.get('DSLR') or '').split(' ', 1)[0] - generic_transforms.to_float(headers, ('DLSR', 'FractionLost', 'IAJitter',), -1) - + headers["From"] = None + + generic_transforms.to_int( + headers, + ( + "HighestSequence", + "LastSR", + "PacketsLost", + "ReceptionReports", + "SequenceNumberCycles", + ), + -1, + ) + headers["DLSR"] = (headers.get("DSLR") or "").split(" ", 1)[0] + generic_transforms.to_float( + headers, + ( + "DLSR", + "FractionLost", + "IAJitter", + ), + -1, + ) + return (headers, data) + class RTCPSent(_Event): """ A Real Time Control Protocol message emitted by Asterisk when using an an RTP-based channel, @@ -709,6 +858,7 @@ class RTCPSent(_Event): - 'TheirLastSR': ? (int as string) - 'To': The IP and port of the recipient, separated by a colon """ + def process(self): """ Translates the 'CumulativeLoss', 'SentOctets', 'SentPackets', 'SentRTP', and @@ -721,35 +871,57 @@ def process(self): unknown. """ (headers, data) = _Event.process(self) - - to = headers.get('To') - if to and ':' in to: - headers['To'] = tuple(to.rsplit(':', 1)) + + to = headers.get("To") + if to and ":" in to: + headers["To"] = tuple(to.rsplit(":", 1)) else: - headers['To'] = None - - generic_transforms.to_bool(headers, ('Result',), truth_value='Success') - generic_transforms.to_int(headers, ('CumulativeLoss', 'SentOctets', 'SentPackets', 'SentRTP', 'TheirLastSR',), -1) - headers['DLSR'] = (headers.get('DSLR') or '').split(' ', 1)[0] - generic_transforms.to_float(headers, ('DLSR', 'FractionLost', 'IAJitter', 'SentNTP',), -1) - + headers["To"] = None + + generic_transforms.to_bool(headers, ("Result",), truth_value="Success") + generic_transforms.to_int( + headers, + ( + "CumulativeLoss", + "SentOctets", + "SentPackets", + "SentRTP", + "TheirLastSR", + ), + -1, + ) + headers["DLSR"] = (headers.get("DSLR") or "").split(" ", 1)[0] + generic_transforms.to_float( + headers, + ( + "DLSR", + "FractionLost", + "IAJitter", + "SentNTP", + ), + -1, + ) + return (headers, data) + class Shutdown(_Event): """ Emitted when Asterisk shuts down. - + - 'Restart': "True" or "False" - 'Shutdown': "Cleanly" """ + def process(self): """ 'Restart' is set to a bool. """ (headers, data) = _Event.process(self) - generic_transforms.to_bool(headers, ('Restart',), truth_value='True') + generic_transforms.to_bool(headers, ("Restart",), truth_value="True") return (headers, data) - + + class SoftHangupRequest(_Event): """ Emitted when a request to terminate the call is exchanged. @@ -759,14 +931,15 @@ class SoftHangupRequest(_Event): - '16': ? - '32': ? - + - 'Uniqueid': An Asterisk unique value """ - + + class Status(_Event): """ Describes the current status of a channel. - + - 'Account': The billing account associated with the channel; may be empty - 'Channel': The channel being described - 'CallerID': The ID of the caller, ".+?" <.+?> @@ -780,53 +953,59 @@ class Status(_Event): - 'State': "Up" - 'Uniqueid': An Asterisk unique value """ + def process(self): """ Translates the 'Seconds' header's value into an int, setting it to -1 on error. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('Seconds',), -1) + generic_transforms.to_int(headers, ("Seconds",), -1) return (headers, data) - + + class StatusComplete(_Event): """ Indicates that all requested channel information has been provided. - + - 'Items': The number of items emitted prior to this event """ + def process(self): """ Translates the 'Items' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - generic_transforms.to_int(headers, ('Items',), -1) + generic_transforms.to_int(headers, ("Items",), -1) return (headers, data) + class UserEvent(_Event): - """ + r""" Generated in response to the UserEvent request. - + - \*: Any key-value pairs supplied with the request, as strings """ + class VarSet(_Event): """ Emitted when a variable is set, either globally or on a channel. - + - 'Channel' (optional): The channel on which the variable was set, if not global - 'Uniqueid': An Asterisk unique value - 'Value': The value of the variable, as a string - 'Variable': The name of the variable that was set """ + class VoicemailUserEntry(_Event): """ Describes a voicemail user. - + - 'AttachMessage': "Yes", "No" - - 'AttachmentFormat': unknown - - 'CallOperator': "Yes", "No" - - 'CanReview': "Yes", "No" + - 'AttachmentFormat': unknown + - 'CallOperator': "Yes", "No" + - 'CanReview': "Yes", "No" - 'Callback': unknown - 'DeleteMessage': "Yes", "No" - 'Dialout': unknown @@ -834,105 +1013,133 @@ class VoicemailUserEntry(_Event): - 'ExitContext': The context to use when leaving the mailbox - 'Fullname': unknown - 'IMAPFlags': Any associated IMAP flags (IMAP only) - - 'IMAPPort': The associated IMAP port (IMAP only) - - 'IMAPServer': The associated IMAP server (IMAP only) - - 'IMAPUser': The associated IMAP username (IMAP only) - - 'Language': The language to use for voicemail prompts - - 'MailCommand': unknown - - 'MaxMessageCount': The maximum number of messages that can be stored - - 'MaxMessageLength': The maximum length of any particular message - - 'NewMessageCount': The number of unheard messages - - 'OldMessageCount': The number of previously heard messages (IMAP only) - - 'Pager': unknown + - 'IMAPPort': The associated IMAP port (IMAP only) + - 'IMAPServer': The associated IMAP server (IMAP only) + - 'IMAPUser': The associated IMAP username (IMAP only) + - 'Language': The language to use for voicemail prompts + - 'MailCommand': unknown + - 'MaxMessageCount': The maximum number of messages that can be stored + - 'MaxMessageLength': The maximum length of any particular message + - 'NewMessageCount': The number of unheard messages + - 'OldMessageCount': The number of previously heard messages (IMAP only) + - 'Pager': unknown - 'SayCID': "Yes", "No" - 'SayDurationMinimum': The minumum amount of time a message may be - 'SayEnvelope': "Yes", "No" - 'ServerEmail': unknown - 'TimeZone': The timezone of the mailbox - 'UniqueID': unknown - - 'VMContext': The associated Asterisk context - - 'VoiceMailbox': The associated mailbox - - 'VolumeGain': A floating-point value + - 'VMContext': The associated Asterisk context + - 'VoiceMailbox': The associated mailbox + - 'VolumeGain': A floating-point value """ + def process(self): """ Translates the 'MaxMessageCount', 'MaxMessageLength', 'NewMessageCount', 'OldMessageCount', and 'SayDurationMinimum' values into ints, setting them to -1 on error. Translates the 'VolumeGain' value into a float, setting it to None on error. - + Translates the 'AttachMessage', 'CallOperator', 'CanReview', 'DeleteMessage', 'SayCID', and 'SayEnvelope' values into booleans. """ (headers, data) = _Event.process(self) - - generic_transforms.to_bool(headers, ('AttachMessage', 'CallOperator', 'CanReview', 'DeleteMessage', 'SayCID', 'SayEnvelope',), truth_value='Yes') - header_list = ['MaxMessageCount', 'MaxMessageLength', 'NewMessageCount', 'SayDurationMinimum'] - if 'OldMessageCount' in headers: - header_list.append('OldMessageCount') + + generic_transforms.to_bool( + headers, + ( + "AttachMessage", + "CallOperator", + "CanReview", + "DeleteMessage", + "SayCID", + "SayEnvelope", + ), + truth_value="Yes", + ) + header_list = [ + "MaxMessageCount", + "MaxMessageLength", + "NewMessageCount", + "SayDurationMinimum", + ] + if "OldMessageCount" in headers: + header_list.append("OldMessageCount") generic_transforms.to_int(headers, header_list, -1) - generic_transforms.to_float(headers, ('VolumeGain',), None) - + generic_transforms.to_float(headers, ("VolumeGain",), None) + return (headers, data) - + + class VoicemailUserEntryComplete(_Event): """ Indicates that all requested voicemail user definitions have been provided. - + No, its name is not a typo; it's really "Entry" in Asterisk's code. """ - - -#List-aggregation events + + +# List-aggregation events #################################################################################################### -#These define non-Asterisk-native event-types that collect multiple events (cases where multiple -#events are generated in response to a single action) and emit the bundle as a single message. +# These define non-Asterisk-native event-types that collect multiple events (cases where multiple +# events are generated in response to a single action) and emit the bundle as a single message. + class CoreShowChannels_Aggregate(_Aggregate): """ Emitted after all channels have been received in response to a CoreShowChannels request. - + Its members consist of CoreShowChannel events. - + It is finalised by CoreShowChannelsComplete. """ + _name = "CoreShowChannels_Aggregate" - + _aggregation_members = (CoreShowChannel,) _aggregation_finalisers = (CoreShowChannelsComplete,) - + def _finalise(self, event): - self._check_list_items_count(event, 'ListItems') + self._check_list_items_count(event, "ListItems") return _Aggregate._finalise(self, event) - + + class ParkedCalls_Aggregate(_Aggregate): """ Emitted after all parked calls have been received in response to a ParkedCalls request. - + Its members consist of ParkedCall events. - + It is finalised by ParkedCallsComplete. """ + _name = "ParkedCalls_Aggregate" - + _aggregation_members = (ParkedCall,) _aggregation_finalisers = (ParkedCallsComplete,) - + def _finalise(self, event): - self._check_list_items_count(event, 'Total') + self._check_list_items_count(event, "Total") return _Aggregate._finalise(self, event) - + + class QueueStatus_Aggregate(_Aggregate): """ Emitted after all queue properties have been received in response to a QueueStatus request. - + Its members consist of QueueParams, QueueMember, and QueueEntry events. - + It is finalised by QueueStatusComplete. """ + _name = "QueueStatus_Aggregate" - - _aggregation_members = (QueueParams, QueueMember, QueueEntry,) + + _aggregation_members = ( + QueueParams, + QueueMember, + QueueEntry, + ) _aggregation_finalisers = (QueueStatusComplete,) @@ -944,74 +1151,81 @@ class QueueSummary_Aggregate(_Aggregate): It is finalised by QueueSummaryComplete. """ + _name = "QueueSummary_Aggregate" _aggregation_members = (QueueSummary,) _aggregation_finalisers = (QueueSummaryComplete,) - + class SIPpeers_Aggregate(_Aggregate): """ Emitted after all queue properties have been received in response to a SIPpeers request. Its members consist of 'PeerEntry' events. - + It is finalised by PeerlistComplete. """ + _name = "SIPpeers_Aggregate" - + _aggregation_members = (PeerEntry,) _aggregation_finalisers = (PeerlistComplete,) - + def _finalise(self, event): - self._check_list_items_count(event, 'ListItems') + self._check_list_items_count(event, "ListItems") return _Aggregate._finalise(self, event) - + + class SIPshowregistry_Aggregate(_Aggregate): """ Emitted after all SIP registrants have been received in response to a SIPshowregistry request. - + Its members consist of RegistryEntry events. - + It is finalised by RegistrationsComplete. """ + _name = "SIPshowregistry_Aggregate" - + _aggregation_members = (RegistryEntry,) _aggregation_finalisers = (RegistrationsComplete,) - + def _finalise(self, event): - self._check_list_items_count(event, 'ListItems') + self._check_list_items_count(event, "ListItems") return _Aggregate._finalise(self, event) - + + class Status_Aggregate(_Aggregate): """ Emitted after all statuses have been received in response to a Status request. - + Its members consist of Status events. - + It is finalised by StatusComplete. """ + _name = "Status_Aggregate" - + _aggregation_members = (Status,) _aggregation_finalisers = (StatusComplete,) - + def _finalise(self, event): - self._check_list_items_count(event, 'Items') + self._check_list_items_count(event, "Items") return _Aggregate._finalise(self, event) - + + class VoicemailUsersList_Aggregate(_Aggregate): """ Emitted after all voicemail users have been received in response to a VoicemailUsersList request. - + Its members consist of VoicemailUserEntry events. - + It is finalised by VoicemailUserEntryComplete. """ + _name = "VoicemailUsersList_Aggregate" - + _aggregation_members = (VoicemailUserEntry,) _aggregation_finalisers = (VoicemailUserEntryComplete,) - diff --git a/pystrix/ami/dahdi.py b/pystrix/ami/dahdi.py index 717c062..79c2026 100644 --- a/pystrix/ami/dahdi.py +++ b/pystrix/ami/dahdi.py @@ -5,7 +5,7 @@ Provides classes meant to be fed to a `Manager` instance's `send_action()` function. Specifically, this module provides implementations for features specific to the DAHDI technology. - + Legal ----- @@ -33,73 +33,84 @@ The requests implemented by this module follow the definitions provided by https://wiki.asterisk.org/ """ -from pystrix.ami.ami import (_Request, ManagerError) + from pystrix.ami import dahdi_events -from pystrix.ami import generic_transforms +from pystrix.ami.ami import _Request + class DAHDIDNDoff(_Request): """ Sets a DAHDI channel's DND status to off. """ + def __init__(self, dahdi_channel): """ `dahdi_channel` is the channel to modify. """ - _Request.__init__(self, 'DAHDIDNDoff') - self['DAHDIChannel'] = dahdi_channel + _Request.__init__(self, "DAHDIDNDoff") + self["DAHDIChannel"] = dahdi_channel + class DAHDIDNDon(_Request): """ Sets a DAHDI channel's DND status to on. """ + def __init__(self, dahdi_channel): """ `dahdi_channel` is the channel to modify. """ - _Request.__init__(self, 'DAHDIDNDon') - self['DAHDIChannel'] = dahdi_channel - + _Request.__init__(self, "DAHDIDNDon") + self["DAHDIChannel"] = dahdi_channel + + class DAHDIDialOffhook(_Request): """ Dials a number on an off-hook DAHDI channel. """ + def __init__(self, dahdi_channel, number): """ `dahdi_channel` is the channel to use and `number` is the number to dial. """ - _Request.__init__(self, 'DAHDIDialOffhook') - self['DAHDIChannel'] = dahdi_channel - self['Number'] = number + _Request.__init__(self, "DAHDIDialOffhook") + self["DAHDIChannel"] = dahdi_channel + self["Number"] = number + class DAHDIHangup(_Request): """ Hangs up a DAHDI channel. """ + def __init__(self, dahdi_channel): """ `dahdi_channel` is the channel to hang up. """ - _Request.__init__(self, 'DAHDIHangup') - self['DAHDIChannel'] = dahdi_channel + _Request.__init__(self, "DAHDIHangup") + self["DAHDIChannel"] = dahdi_channel + class DAHDIRestart(_Request): """ Fully restarts all DAHDI channels. """ + def __init__(self): - _Request.__init__(self, 'DAHDIRestart') + _Request.__init__(self, "DAHDIRestart") + class DAHDIShowChannels(_Request): """ Provides the current status of all (or one) DAHDI channels through a series of 'DAHDIShowChannels' events, ending with a 'DAHDIShowChannelsComplete' event. """ + _aggregates = (dahdi_events.DAHDIShowChannels_Aggregate,) _synchronous_events_list = (dahdi_events.DAHDIShowChannels,) _synchronous_events_finalising = (dahdi_events.DAHDIShowChannelsComplete,) - + def __init__(self, dahdi_channel=None): - _Request.__init__(self, 'DAHDIShowChannels') - if not dahdi_channel is None: - self['DAHDIChannel'] = dahdi_channel - + _Request.__init__(self, "DAHDIShowChannels") + if dahdi_channel is not None: + self["DAHDIChannel"] = dahdi_channel diff --git a/pystrix/ami/dahdi_events.py b/pystrix/ami/dahdi_events.py index 901648f..338792a 100644 --- a/pystrix/ami/dahdi_events.py +++ b/pystrix/ami/dahdi_events.py @@ -31,15 +31,17 @@ The events implemented by this module follow the definitions provided by http://www.asteriskdocs.org/ and https://wiki.asterisk.org/ """ -from pystrix.ami.ami import (_Aggregate, _Event) + from pystrix.ami import generic_transforms +from pystrix.ami.ami import _Aggregate, _Event + class DAHDIShowChannels(_Event): """ Describes the current state of a DAHDI channel. - + Yes, the event's name is pluralised. - + - 'AccountCode': unknown (not present if the DAHDI channel is down) - 'Alarm': unknown - 'Channel': The channel being described (not present if the DAHDI channel is down) @@ -51,57 +53,68 @@ class DAHDIShowChannels(_Event): - 'SignallingCode': A numeric description of the current signalling state - 'Uniqueid': unknown (not present if the DAHDI channel is down) """ + def process(self): """ Translates the 'DND' header's value into a bool. - + Translates the 'DAHDIChannel' and 'SignallingCode' headers' values into ints, or -1 on failure. """ (headers, data) = _Event.process(self) - - generic_transforms.to_bool(headers, ('DND',), truth_value='Enabled') - generic_transforms.to_int(headers, ('DAHDIChannel', 'SignallingCode',), -1) - + + generic_transforms.to_bool(headers, ("DND",), truth_value="Enabled") + generic_transforms.to_int( + headers, + ( + "DAHDIChannel", + "SignallingCode", + ), + -1, + ) + return (headers, data) + class DAHDIShowChannelsComplete(_Event): """ Indicates that all DAHDI channels have been described. - + - 'Items': The number of items returned prior to this event """ + def process(self): """ Translates the 'Items' header's value into an int, or -1 on failure. """ (headers, data) = _Event.process(self) - - generic_transforms.to_int(headers, ('Items',), -1) - + + generic_transforms.to_int(headers, ("Items",), -1) + return (headers, data) - - -#List-aggregation events + + +# List-aggregation events #################################################################################################### -#These define non-Asterisk-native event-types that collect multiple events (cases where multiple -#events are generated in response to a single action) and emit the bundle as a single message. +# These define non-Asterisk-native event-types that collect multiple events (cases where multiple +# events are generated in response to a single action) and emit the bundle as a single message. + class DAHDIShowChannels_Aggregate(_Aggregate): """ Emitted after all DAHDI channels have been enumerated in response to a DAHDIShowChannels request. - + Its members consist of DAHDIShowChannels events. - + It is finalised by DAHDIShowChannelsComplete. """ + _name = "DAHDIShowChannels_Aggregate" - + _aggregation_members = (DAHDIShowChannels,) _aggregation_finalisers = (DAHDIShowChannelsComplete,) - + def _finalise(self, event): - self._check_list_items_count(event, 'Items') + self._check_list_items_count(event, "Items") return _Aggregate._finalise(self, event) - diff --git a/pystrix/ami/generic_transforms.py b/pystrix/ami/generic_transforms.py index 047507f..8543c06 100644 --- a/pystrix/ami/generic_transforms.py +++ b/pystrix/ami/generic_transforms.py @@ -28,14 +28,17 @@ - Neil Tallim """ -import sys +string_type = str -# identify string type in a python 2 and 3 compatible manner -string_type = str if sys.version_info[0] >= 3 else basestring - -def to_bool(dictionary, keys, truth_value=None, truth_function=(lambda x:bool(x)), preprocess=(lambda x:x)): +def to_bool( + dictionary, + keys, + truth_value=None, + truth_function=(lambda x: bool(x)), + preprocess=(lambda x: x), +): for key in keys: if truth_value: dictionary[key] = dictionary.get(key) == truth_value @@ -46,7 +49,7 @@ def to_bool(dictionary, keys, truth_value=None, truth_function=(lambda x:bool(x) dictionary[key] = False -def to_float(dictionary, keys, failure_value, preprocess=(lambda x:x)): +def to_float(dictionary, keys, failure_value, preprocess=(lambda x: x)): for key in keys: try: dictionary[key] = float(preprocess(dictionary.get(key))) @@ -54,7 +57,7 @@ def to_float(dictionary, keys, failure_value, preprocess=(lambda x:x)): dictionary[key] = failure_value -def to_int(dictionary, keys, failure_value, preprocess=(lambda x:x)): +def to_int(dictionary, keys, failure_value, preprocess=(lambda x: x)): for key in keys: try: dictionary[key] = int(preprocess(dictionary.get(key))) @@ -64,7 +67,7 @@ def to_int(dictionary, keys, failure_value, preprocess=(lambda x:x)): def add_result(dictionary, key, result_map): if dictionary[key] in result_map: - dictionary['Result'] = result_map[dictionary[key]] + dictionary["Result"] = result_map[dictionary[key]] def string_to_bytes(value, encoding="utf-8", errors="strict"): diff --git a/setup.py b/setup.py deleted file mode 100644 index cad7174..0000000 --- a/setup.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python -""" -Deployment script for pystrix. -""" - -from pystrix import VERSION - -from setuptools import setup -import os - - -CLASSIFIERS = [ - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Topic :: Communications :: Telephony' -] - -README = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read() - -# allow setup.py to be run from any path -os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) - -setup( - author='Marta Solano', - author_email='marta.solano@ivrtechnology.com', - name='pystrix', - version=VERSION, - description='Python bindings for Asterisk Manager Interface and Asterisk Gateway Interface', - long_description=README, - url='https://github.com/IVRTech/pystrix', - license='GNU General Public License', - platforms=['OS Independent'], - classifiers=CLASSIFIERS, - packages=[ - 'pystrix', - 'pystrix.agi', - 'pystrix.ami', - ] -) diff --git a/tests/test_agi_core.py b/tests/test_agi_core.py new file mode 100644 index 0000000..41f6655 --- /dev/null +++ b/tests/test_agi_core.py @@ -0,0 +1,196 @@ +"""Tests for AGI response parsing and helpers (`pystrix.agi.agi_core`).""" + +import pytest + +from pystrix.agi.agi_core import ( + _AGI, + AGIDeadChannelError, + AGIInvalidCommandError, + AGINoResultError, + AGIResultHangup, + AGIUnknownError, + AGIUsageError, + _Action, + quote, +) +from pystrix.agi.core import GetData + + +class _FakeReader: + """A minimal stand-in for the read end of the Asterisk pipe.""" + + def __init__(self, *lines): + self._lines = [line.encode() for line in lines] + self._index = 0 + + def readline(self): + if self._index >= len(self._lines): + return b"" + line = self._lines[self._index] + self._index += 1 + return line + + +def _agi(*lines): + # Bypass __init__ so the constructor does not try to read an AGI environment. + agi = _AGI.__new__(_AGI) + agi._environment = {} + agi._rfile = _FakeReader(*lines) + return agi + + +def test_parses_success_result(): + response = _agi("200 result=1\n")._get_result() + assert response.code == 200 + assert response.items["result"].value == "1" + + +def test_parses_result_data(): + response = _agi("200 result=1 (speech)\n")._get_result() + assert response.items["result"].value == "1" + assert response.items["result"].data == "speech" + + +def test_result_without_parenthetical_has_empty_data(): + # A result with no parenthetical reports data as '' (empty string), not None. + response = _agi("200 result=1\n")._get_result() + assert response.items["result"].data == "" + + +def test_usage_error_collects_multiline_block(): + # The 520 path reads lines until '520 End of proper usage.' and raises with + # the accumulated block. + with pytest.raises(AGIUsageError) as exc_info: + _agi( + "520 Invalid command syntax.\n", + "Usage: ANSWER\n", + "520 End of proper usage.\n", + )._get_result() + message = str(exc_info.value) + assert "520 Invalid command syntax." in message + assert "Usage: ANSWER" in message + + +def test_unrecognized_code_returns_none(): + # A line with no leading status code (for example after a signal) yields no + # result rather than raising. + assert _agi("\n")._get_result() is None + + +def test_unknown_code_raises(): + with pytest.raises(AGIUnknownError): + _agi("418 unexpected\n")._get_result() + + +def test_hangup_detected_by_data_not_value(): + # The hangup guard keys on the parenthetical data, not the result value, so a + # non-(-1) value with 'hangup' data must still raise. + with pytest.raises(AGIResultHangup): + _agi("200 result=0 (hangup)\n")._get_result() + + +def test_hangup_not_raised_when_check_disabled(): + response = _agi("200 result=-1 (hangup)\n")._get_result(check_hangup=False) + assert response.items["result"].data == "hangup" + + +def test_invalid_command_raises(): + with pytest.raises(AGIInvalidCommandError): + _agi("510 Invalid or unknown command\n")._get_result() + + +def test_dead_channel_raises(): + with pytest.raises(AGIDeadChannelError): + _agi("511 Command Not Permitted on a dead channel\n")._get_result() + + +def test_missing_result_key_raises(): + with pytest.raises(AGINoResultError): + _agi("200 foo=bar\n")._get_result() + + +def test_hangup_result_raises(): + with pytest.raises(AGIResultHangup): + _agi("200 result=-1 (hangup)\n")._get_result() + + +def test_action_command_formatting(): + assert _Action("ANSWER").command == "ANSWER\n" + # None arguments are dropped and a trailing newline is always present. + assert _Action("STREAM FILE", "demo", None, "#").command == "STREAM FILE demo #\n" + + +def test_quote_wraps_in_double_quotes(): + assert quote("demo") == '"demo"' + assert quote(5) == '"5"' + + +class _StrReader: + """A reader that yields str lines, as plain AGI's stdin does.""" + + def __init__(self, *lines): + self._lines = list(lines) + self._index = 0 + + def readline(self): + if self._index >= len(self._lines): + return "" + line = self._lines[self._index] + self._index += 1 + return line + + +def _agi_with_reader(reader): + agi = _AGI.__new__(_AGI) + agi._environment = {} + agi._rfile = reader + return agi + + +def test_str_input_is_read_without_decoding(): + # Plain AGI reads str from stdin; _read_line must not choke trying to decode it. + response = _agi_with_reader(_StrReader("200 result=1\n"))._get_result() + assert response.items["result"].value == "1" + + +def test_malformed_bytes_surface_a_decode_error(): + # A real decode failure on socket bytes propagates instead of being swallowed. + class _BadBytesReader: + def readline(self): + return b"\xff\xfe not utf-8\n" + + with pytest.raises(UnicodeDecodeError): + _agi_with_reader(_BadBytesReader())._get_result() + + +def test_getdata_timeout_result_parses_without_error(): + # Regression for #9 (fixed in 12c4bd7, 2014): a GetData timeout replies + # "result= (timeout)" with an empty value before the parenthetical. It must + # parse as value '' / data 'timeout', not raise AGINoResultError. + response = _agi("200 result= (timeout)\n")._get_result() + assert response.items["result"].value == "" + assert response.items["result"].data == "timeout" + + +def test_getdata_process_response_flags_timeout(): + response = _agi("200 result= (timeout)\n")._get_result() + keys, timed_out = GetData("prompt").process_response(response) + assert keys == "" + assert timed_out is True + + +def test_getdata_process_response_returns_digits(): + response = _agi("200 result=1234\n")._get_result() + keys, timed_out = GetData("prompt").process_response(response) + assert keys == "1234" + assert timed_out is False + + +def test_getdata_process_response_keeps_partial_digits_on_timeout(): + # The common real-world timeout: a caller enters some digits, then the + # inter-digit timer expires. Asterisk replies "result=12 (timeout)", so the + # collected digits and the timeout flag must both survive process_response. + response = _agi("200 result=12 (timeout)\n")._get_result() + keys, timed_out = GetData("prompt").process_response(response) + assert keys == "12" + assert timed_out is True diff --git a/tests/test_ami_message.py b/tests/test_ami_message.py new file mode 100644 index 0000000..066b37a --- /dev/null +++ b/tests/test_ami_message.py @@ -0,0 +1,69 @@ +"""Tests for AMI message parsing (`pystrix.ami.ami._Message`).""" + +from pystrix.ami.ami import EVENT_GENERIC, RESPONSE_GENERIC, _Message + + +def _message(*lines): + # Asterisk sends CRLF-terminated lines, which `read_message` collects into a + # list before constructing a `_Message`. Reproduce that here. + return _Message([line + "\r\n" for line in lines]) + + +def test_parses_headers(): + message = _message("Response: Success", "ActionID: abc-1") + assert message["Response"] == "Success" + assert message["ActionID"] == "abc-1" + assert message.action_id == "abc-1" + + +def test_name_prefers_event_then_response(): + assert _message("Event: Hangup", "ActionID: 1").name == "Hangup" + assert _message("Response: Success").name == "Success" + + +def test_name_prefers_event_when_both_present(): + # `name` is Event or Response, so Event wins when a message carries both. + assert _message("Event: Hangup", "Response: Success").name == "Hangup" + + +def test_equality_against_string_uses_name(): + assert _message("Event: Hangup") == "Hangup" + + +def test_data_payload_follows_headers(): + message = _message("Response: Follows", "raw output line without a colon") + assert message["Response"] == "Follows" + assert message.data == ["raw output line without a colon"] + + +def test_data_mode_is_sticky_for_later_colon_lines(): + # Once a data line is seen, later colon-bearing lines stay data and are not + # parsed as headers. + message = _message("Response: Follows", "first data line", "Looks: like-a-header") + assert message["Response"] == "Follows" + assert message.data == ["first data line", "Looks: like-a-header"] + assert "Looks" not in message + + +def test_fake_eol_line_starts_data_section(): + # A line ending in the fake-EOL pattern marks the start of the data section. + message = _Message(["Response: Follows\r\n", "payload\n\r\n"]) + assert message["Response"] == "Follows" + assert message.data == ["payload"] + + +def test_non_crlf_line_starts_data_section(): + # A line not terminated by CRLF is treated as data, not a header. + message = _Message(["Response: Follows\r\n", "payload\n"]) + assert message["Response"] == "Follows" + assert message.data == ["payload"] + + +def test_generic_response_when_only_action_id(): + # A reply with an ActionID but no Response header is salvaged as generic. + assert _message("ActionID: 7")["Response"] == RESPONSE_GENERIC + + +def test_generic_event_when_unsolicited(): + # A message with neither Response, Event, nor ActionID is a generic event. + assert _message("Foo: bar")["Event"] == EVENT_GENERIC diff --git a/tests/test_ami_monitor.py b/tests/test_ami_monitor.py new file mode 100644 index 0000000..8fe3291 --- /dev/null +++ b/tests/test_ami_monitor.py @@ -0,0 +1,133 @@ +"""Tests for the AMI connection monitor (`Manager.monitor_connection`).""" + +import threading + +from pystrix.ami.ami import Manager, ManagerError, ManagerSocketError + + +def _bare_manager(send_action, is_connected=None): + # Bypass __init__ so no real socket or reader thread is created. The monitor + # only touches is_connected() and send_action(), so those are all we supply. + manager = Manager.__new__(Manager) + manager.is_connected = is_connected or (lambda: True) + manager.send_action = send_action + # The manager was never connected, so its __del__ cleanup has nothing real + # to release; neutralize it to keep garbage collection quiet during tests. + manager.close = lambda: None + return manager + + +def _run_monitor(manager, interval=0): + # Run the monitor to completion, capturing anything that escapes the thread + # uncaught via threading.excepthook. Returns (monitor_thread, unhandled). + unhandled = [] + previous_hook = threading.excepthook + threading.excepthook = lambda args: unhandled.append(args) + try: + monitor = manager.monitor_connection(interval=interval) + monitor.join(timeout=2) + finally: + threading.excepthook = previous_hook + return monitor, unhandled + + +def test_monitor_connection_survives_socket_error(): + # Regression for #3: when Asterisk stops, the periodic Ping's send_action + # raises ManagerSocketError. The monitor thread must exit cleanly instead of + # dying with an unhandled exception (a traceback dumped to stderr). + reached = threading.Event() + + def send_action(request, *args, **kwargs): + reached.set() + raise ManagerSocketError("Asterisk service stopped") + + monitor, unhandled = _run_monitor(_bare_manager(send_action)) + + assert reached.is_set() # the monitor actually attempted a ping + assert not monitor.is_alive() # the thread terminated + assert unhandled == [] # nothing escaped the thread uncaught + + +def test_monitor_connection_survives_manager_error(): + # Race guard for #3: the connection can drop between the loop's is_connected() + # check and the liveness re-check inside send_action, which raises ManagerError + # (not ManagerSocketError). The monitor must catch that path too and exit + # cleanly rather than crash the thread. + reached = threading.Event() + + def send_action(request, *args, **kwargs): + reached.set() + raise ManagerError("Not connected to an Asterisk manager") + + monitor, unhandled = _run_monitor(_bare_manager(send_action)) + + assert reached.is_set() + assert not monitor.is_alive() + assert unhandled == [] + + +def test_monitor_connection_pings_until_disconnected(): + # The monitor pings on each loop while connected and exits cleanly when + # is_connected() turns False (the orderly-shutdown path). + checks = {"count": 0} + + def is_connected(): + checks["count"] += 1 + return checks["count"] <= 3 # connected for three iterations, then down + + pings = [] + + def send_action(request, *args, **kwargs): + pings.append(request) + + manager = _bare_manager(send_action, is_connected=is_connected) + monitor, unhandled = _run_monitor(manager) + + assert isinstance(monitor, threading.Thread) # returns a joinable thread + assert not monitor.is_alive() # exited on the first disconnected check + assert len(pings) == 3 # one ping per connected check, none after + assert [request["Action"] for request in pings] == ["Ping"] * 3 # all Pings + assert unhandled == [] + + +def test_monitor_connection_logs_reason_on_exit(): + # When a logger is configured, the monitor records why it stopped so a + # vanished monitor thread is traceable rather than silent. + class _RecordingLogger: + def __init__(self): + self.messages = [] + + def debug(self, message): + self.messages.append(message) + + logger = _RecordingLogger() + + def send_action(request, *args, **kwargs): + raise ManagerSocketError("Asterisk service stopped") + + manager = _bare_manager(send_action) + manager._logger = logger + monitor, unhandled = _run_monitor(manager) + + assert not monitor.is_alive() + assert unhandled == [] + # The log records both that the monitor stopped and the underlying reason, + # so the exception detail must survive into the message. + assert any( + "monitor stopping" in message and "Asterisk service stopped" in message + for message in logger.messages + ) + + +def test_monitor_connection_propagates_unexpected_errors(): + # The catch is deliberately narrow: only connection-loss errors stop the + # monitor quietly. An unexpected error must still surface (escape the thread) + # rather than be silently swallowed by a too-broad except. + def send_action(request, *args, **kwargs): + raise ValueError("unexpected") + + monitor, unhandled = _run_monitor(_bare_manager(send_action)) + + assert not monitor.is_alive() + assert len(unhandled) == 1 # the ValueError was not swallowed + assert unhandled[0].exc_type is ValueError diff --git a/tests/test_ami_request.py b/tests/test_ami_request.py new file mode 100644 index 0000000..189593c --- /dev/null +++ b/tests/test_ami_request.py @@ -0,0 +1,176 @@ +"""Tests for AMI request building (`pystrix.ami.ami._Request`).""" + +import array +import ctypes +import mmap + +import pytest + +from pystrix.ami.ami import _Request +from pystrix.ami.core import Originate_Application + + +def _build(request, action_id=None, **kwargs): + return request.build_request(action_id, lambda: "GEN-1", **kwargs) + + +def test_generates_action_id_when_absent(): + command, action_id = _build(_Request("Ping")) + assert action_id == "GEN-1" + assert command == "Action: Ping\r\nActionID: GEN-1\r\n\r\n" + + +def test_uses_explicit_action_id(): + command, action_id = _build(_Request("Ping"), action_id="custom") + assert action_id == "custom" + assert "ActionID: custom" in command + + +def test_action_is_always_the_first_line(): + request = _Request("Login") + request["Username"] = "admin" + command, _ = _build(request) + assert command.startswith("Action: Login\r\n") + + +def test_multi_value_header_expands_to_repeated_lines(): + request = _Request("Originate") + request["Variable"] = ("a=1", "b=2") + command, _ = _build(request) + assert "Variable: a=1\r\n" in command + assert "Variable: b=2\r\n" in command + + +def test_originate_application_treats_string_data_as_one_argument(): + request = Originate_Application("SIP/708", "Playback", "goodbye") + + command, _ = _build(request) + + assert "Data: goodbye\r\n" in command + assert "Data: g,o,o,d,b,y,e\r\n" not in command + + +def test_originate_application_preserves_sequence_data_arguments(): + request = Originate_Application("SIP/708", "Playback", ("goodbye", "noanswer")) + + command, _ = _build(request) + + assert "Data: goodbye,noanswer\r\n" in command + + +@pytest.mark.parametrize( + "data_factory", + [ + pytest.param(lambda: b"goodbye", id="bytes"), + pytest.param(lambda: b"", id="empty-bytes"), + pytest.param(lambda: bytearray(b"goodbye"), id="bytearray"), + pytest.param(lambda: bytearray(), id="empty-bytearray"), + pytest.param(lambda: memoryview(b"goodbye"), id="memoryview"), + pytest.param(lambda: memoryview(b""), id="empty-memoryview"), + pytest.param(lambda: array.array("b", b"hi"), id="array"), + pytest.param(lambda: (ctypes.c_ubyte * 2)(104, 105), id="ctypes-array"), + pytest.param(lambda: mmap.mmap(-1, 2), id="mmap"), + ], +) +def test_originate_application_rejects_binary_data(data_factory): + data = data_factory() + try: + with pytest.raises( + TypeError, + match="data must be a string or sequence of strings, not a bytes-like object", + ): + Originate_Application("SIP/708", "Playback", data) + finally: + if isinstance(data, memoryview): + data.release() + close = getattr(data, "close", None) + if close: + close() + + +def test_originate_application_omits_empty_string_data(): + request = Originate_Application("SIP/708", "Playback", "") + + command, _ = _build(request) + + assert "Data:" not in command + + +def test_rejects_header_value_containing_crlf(): + request = _Request("Originate") + request["Data"] = "goodbye\r\nInjected: x" + + with pytest.raises(ValueError, match="AMI header values must not contain CR or LF"): + _build(request) + + +def test_rejects_action_value_containing_crlf(): + request = _Request("Ping\r\nInjected: x") + + with pytest.raises(ValueError, match="AMI header values must not contain CR or LF"): + _build(request) + + +def test_rejects_header_key_containing_crlf(): + request = _Request("Originate") + request["Data\r\nInjected"] = "goodbye" + + with pytest.raises(ValueError, match="AMI header keys must not contain CR or LF"): + _build(request) + + +def test_rejects_multi_value_header_value_containing_crlf(): + request = _Request("Originate") + request["Variable"] = ("a=1\r\nInjected: x", "b=2") + + with pytest.raises(ValueError, match="AMI header values must not contain CR or LF"): + _build(request) + + +def test_rejects_action_id_containing_crlf(): + request = _Request("Ping") + + with pytest.raises(ValueError, match="AMI header values must not contain CR or LF"): + _build(request, action_id="safe\r\nInjected: x") + + +def test_multi_value_order_preserved_and_blank_line_terminator(): + request = _Request("Originate") + request["Variable"] = ("a=1", "b=2") + command, _ = _build(request) + assert command.index("Variable: a=1") < command.index("Variable: b=2") + assert command.endswith("\r\n\r\n") + + +def test_kwargs_become_headers(): + command, _ = _build(_Request("Ping"), Extra="x") + assert "Extra: x" in command + + +# build_request resolves the ActionID with the precedence documented on the +# method: an explicit argument wins, then a value already set on the request, +# then a generated one (fixed in #43). +def test_preset_action_id_used_when_none_passed(): + request = _Request("Ping") + request["ActionID"] = "preset" + command, action_id = _build(request) + assert action_id == "preset" + assert "ActionID: preset" in command + + +def test_explicit_action_id_wins_over_preset(): + request = _Request("Ping") + request["ActionID"] = "preset" + command, action_id = _build(request, action_id="explicit") + assert action_id == "explicit" + assert "ActionID: explicit" in command + + +def test_non_string_action_id_is_stringified(): + # A non-string ActionID must be returned as a string so it matches the + # string-keyed responses Asterisk sends back (see #43 review). + request = _Request("Ping") + request["ActionID"] = 7 + command, action_id = _build(request) + assert action_id == "7" + assert "ActionID: 7" in command diff --git a/tests/test_ami_send_action.py b/tests/test_ami_send_action.py new file mode 100644 index 0000000..8893d80 --- /dev/null +++ b/tests/test_ami_send_action.py @@ -0,0 +1,90 @@ +"""Tests for `Manager.send_action` connection-loss handling.""" + +import threading + +import pytest + +from pystrix.ami import core +from pystrix.ami.ami import Manager, ManagerSocketError, _Request + + +class _SynchronousRequest(_Request): + # A request that registers a (events, finalisers) entry rather than the + # plain None an asynchronous request stores. No shipped request sets + # synchronous = True, so define a minimal one to exercise that branch. + synchronous = True + + +def _bare_manager(connection): + # Bypass __init__ and supply only what send_action touches; neutralize + # __del__ cleanup to keep GC quiet. is_connected() reports True so the + # send path runs; the supplied connection decides how the send behaves. + manager = Manager.__new__(Manager) + manager.is_connected = lambda: True + manager._connection = connection + manager._connection_lock = threading.Lock() + manager._outstanding_requests = {} + manager.close = lambda: None + return manager + + +def _disconnected_manager(): + # A raced disconnect() already cleared _connection while is_connected() + # still reports True. + return _bare_manager(None) + + +class _FailingConnection: + # A connection whose send fails mid-write, as a real socket does when it + # breaks during transmission. + def send_message(self, command): + raise ManagerSocketError("Connection to Asterisk manager broken while sending") + + +def test_send_action_raises_when_connection_closed_mid_send(): + # Race guard (#3): a concurrent disconnect() can clear _connection after + # send_action's liveness check but before the send. send_action must raise + # ManagerSocketError rather than dereferencing None with an AttributeError, + # which the connection monitor's catch would miss and crash the thread. + manager = _disconnected_manager() + + with pytest.raises(ManagerSocketError): + manager.send_action(core.Ping(), action_id="race-1") + + # The request registered just before the failed send is dropped again. + assert manager._outstanding_requests == {} + + +def test_send_action_drops_synchronous_request_when_connection_closed_mid_send(): + # The same race for a synchronous request, whose tracking entry is a + # (events, finalisers) tuple rather than None. The cleanup must drop it too, + # so a synchronous caller cannot be left waiting on events that never arrive. + manager = _disconnected_manager() + + with pytest.raises(ManagerSocketError): + manager.send_action(_SynchronousRequest("Test"), action_id="race-2") + + assert manager._outstanding_requests == {} + + +def test_send_action_drops_request_when_send_fails_mid_write(): + # If the socket breaks during send_message, send_action must drop the + # request it just registered before re-raising, so it does not linger in + # _outstanding_requests with no response ever coming. + manager = _bare_manager(_FailingConnection()) + + with pytest.raises(ManagerSocketError): + manager.send_action(core.Ping(), action_id="send-fail-1") + + assert manager._outstanding_requests == {} + + +def test_send_action_drops_synchronous_request_when_send_fails_mid_write(): + # The same cleanup for a synchronous request, whose tracking entry is a + # (events, finalisers) tuple rather than None. + manager = _bare_manager(_FailingConnection()) + + with pytest.raises(ManagerSocketError): + manager.send_action(_SynchronousRequest("Test"), action_id="send-fail-2") + + assert manager._outstanding_requests == {} diff --git a/tests/test_fastagi_handler.py b/tests/test_fastagi_handler.py new file mode 100644 index 0000000..939d45e --- /dev/null +++ b/tests/test_fastagi_handler.py @@ -0,0 +1,87 @@ +"""Tests for the FastAGI client handler (`_AGIClientHandler`).""" + +import io +import types + +import pytest + +from pystrix.agi.fastagi import _AGIClientHandler + + +class _ClosedReader: + """The read end of a socket whose client has already disconnected.""" + + def readline(self): + return b"" # EOF: nothing was sent before the connection closed + + +class _EnvReader: + """Yields a minimal AGI environment block, then EOF.""" + + def __init__(self, *lines): + self._lines = list(lines) + self._index = 0 + + def readline(self): + if self._index >= len(self._lines): + return b"" + line = self._lines[self._index] + self._index += 1 + return line + + +def _handler(rfile, handler_callable=None): + # Bypass socketserver's __init__ (which would call handle() itself) and wire + # up only what handle() touches: the read/write files and the server. + instance = _AGIClientHandler.__new__(_AGIClientHandler) + instance.rfile = rfile + instance.wfile = io.BytesIO() + instance.client_address = ("198.51.100.7", 51234) + instance.server = types.SimpleNamespace( + debug=False, + get_script_handler=lambda path: (handler_callable, None), + ) + return instance + + +def test_handle_returns_quietly_when_client_disconnects_during_handshake(): + # Regression for #49: a client that closes before sending the AGI + # environment makes the handshake raise AGISIGPIPEHangup. handle() must end + # the request quietly instead of letting that propagate into a stderr + # traceback from socketserver. + invoked = [] + handler = _handler( + _ClosedReader(), handler_callable=lambda *args: invoked.append(args) + ) + + handler.handle() # must not raise + + assert invoked == [] # no script handler runs when the client is already gone + assert handler.wfile.getvalue() == b"" # nothing written back on the quiet path + + +def test_handle_returns_quietly_when_client_disconnects_mid_handshake(): + # A client that sends part of the environment then drops hits EOF inside the + # parse loop, not on the first read. Still a handshake hangup that must end + # the request quietly. + invoked = [] + reader = _EnvReader(b"agi_network_script: demo\n") # no blank terminator, then EOF + handler = _handler(reader, handler_callable=lambda *args: invoked.append(args)) + + handler.handle() # must not raise + + assert invoked == [] + + +def test_handle_propagates_handler_errors(): + # The handshake-hangup suppression is scoped to the environment read only. + # A genuine error raised by the script handler must still propagate. + def boom(agi, args, kwargs, match, path): + raise RuntimeError("handler blew up") + + handler = _handler( + _EnvReader(b"agi_network_script: demo\n", b"\n"), + handler_callable=boom, + ) + with pytest.raises(RuntimeError): + handler.handle() diff --git a/tests/test_fastagi_server.py b/tests/test_fastagi_server.py new file mode 100644 index 0000000..3f77c02 --- /dev/null +++ b/tests/test_fastagi_server.py @@ -0,0 +1,29 @@ +"""Tests for FastAGIServer listen-backlog configuration.""" + +from pystrix.agi import fastagi +from pystrix.agi.fastagi import _LISTEN_BACKLOG, FastAGIServer, _ThreadedTCPServer + + +def test_server_requests_max_listen_backlog(): + # Regression for #32: the server asks for the largest backlog the socket + # call accepts and lets the kernel cap it to the live somaxconn. Binding on + # an ephemeral port also proves the platform's listen() accepts the value + # rather than rejecting it. + server = FastAGIServer(interface="127.0.0.1", port=0) + try: + assert server.request_queue_size == _LISTEN_BACKLOG + # Lock the exact contract: the backlog must be INT_MAX, the largest value + # CPython's listen() accepts (2**31 raises OverflowError). A smaller fixed + # ceiling would also undershoot a tuned-up somaxconn. + assert _LISTEN_BACKLOG == 2**31 - 1 + finally: + server.server_close() + + +def test_backlog_sizing_does_not_shell_out(): + # The sysctl-spawning, OS-branching helper is gone and the module no longer + # imports the subprocess/platform machinery it used, so backlog sizing cannot + # shell out or raise on platforms without sysctl (regression for #32). + assert not hasattr(_ThreadedTCPServer, "get_somaxconn") + assert not hasattr(fastagi, "subprocess") + assert not hasattr(fastagi, "platform") diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 0000000..d6982e0 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,23 @@ +"""Import smoke tests that double as a cross-version compatibility check.""" + + +def test_package_imports(): + import pystrix + + assert pystrix.VERSION + + +def test_public_entry_points_import(): + from pystrix.agi import AGI, FastAGI, FastAGIServer + from pystrix.agi.fastagi import FastAGIServer as _FastAGIServer + from pystrix.ami import Manager + + # The re-export must be the same class object, and importing fastagi + # exercises the code path that has to stay free of the cgi module removed in + # Python 3.13. + assert FastAGIServer is _FastAGIServer + assert ( + isinstance(AGI, type) + and isinstance(FastAGI, type) + and isinstance(Manager, type) + )