From 92a453476c90141fdae009d2bb0cc89d453df1ca Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Sun, 25 Apr 2021 11:47:41 -0500 Subject: [PATCH 01/39] Close socket in a cleaner fashion when connection to Asterisk fails. --- pystrix/ami/ami.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pystrix/ami/ami.py b/pystrix/ami/ami.py index ce34531..2633985 100644 --- a/pystrix/ami/ami.py +++ b/pystrix/ami/ami.py @@ -1083,6 +1083,9 @@ def close(self): """ Release resources associated with this connection. """ + if self._socket_write_lock is None: + self._close() + return with self._socket_write_lock as write_lock: self._close() From 8abf2544e810f73c8888a9d72f2a9245014809ef Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Sun, 25 Apr 2021 11:58:47 -0500 Subject: [PATCH 02/39] Bumped version to 1.1.9 --- pystrix/__init__.py | 2 +- setup.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pystrix/__init__.py b/pystrix/__init__.py index 1a1d497..ade4bd2 100644 --- a/pystrix/__init__.py +++ b/pystrix/__init__.py @@ -42,5 +42,5 @@ import pystrix.agi import pystrix.ami -VERSION = '1.1.8' +VERSION = '1.1.9' COPYRIGHT = '2013, Neil Tallim ' diff --git a/setup.py b/setup.py index cad7174..29167ab 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', 'Topic :: Communications :: Telephony' ] From 0874fa49411f12e8a1b9ac0829f77d48687e380a Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Sun, 11 Sep 2022 04:15:23 -0500 Subject: [PATCH 03/39] Increase maximum concurrent calls in FastAGI - Added code to read SOMAXCONN from system configuration. - Increased request queue size to SOMAXCONN to allow handling more calls concurrently. --- pystrix/agi/fastagi.py | 44 +++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/pystrix/agi/fastagi.py b/pystrix/agi/fastagi.py index 55d0799..2471cd6 100644 --- a/pystrix/agi/fastagi.py +++ b/pystrix/agi/fastagi.py @@ -36,19 +36,17 @@ - Neil Tallim """ import cgi -import re +import platform import socket +import subprocess import threading -import types from pystrix.agi.agi_core import * from pystrix.agi.agi_core import _AGI try: - import socketserver -except: - import SocketServer as socketserver - - + import socketserver +except ModuleNotFoundError: + import SocketServer as socketserver class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): @@ -56,6 +54,38 @@ class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): Provides a variant of the TCPServer that spawns a new thread to handle each request. """ + + @staticmethod + def get_somaxconn(): + """ + Returns the value of SOMAXCONN configured in the system. + """ + # determine the OS appropriate management informations base (MIB) + # name to determine SOMAXCONN + system = platform.system() + if "Linux" == system: + sysctl_mib_somaxconn = "net.core.somaxconn" + sysctl_output_delimiter = "=" + elif "Darwin" == system: + sysctl_mib_somaxconn = "kern.ipc.somaxconn" + sysctl_output_delimiter = ":" + else: + raise NotImplementedError( + "Determining SOMAXCONN is not implemented for {} system.".format(system) + ) + # run the cmd to determine the SOMAXCONN + cmd_result = subprocess.check_output(["sysctl", sysctl_mib_somaxconn]) + + # parse the output of the cmd to return the value of SOMAXCONN + return int(cmd_result.decode().split(sysctl_output_delimiter)[-1].strip()) + + def __init__(self, *args, **kwargs): + # adjust request queue size to a saner value for modern systems + # further adjustments are automatically picked up for kernel + # settings on server start + self.request_queue_size = max(socket.SOMAXCONN, self.get_somaxconn()) + super().__init__(*args, **kwargs) + def server_bind(self): self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) socketserver.TCPServer.server_bind(self) From 1019e0afabaa0b7e585b3146987ba8e5d87ad953 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Sun, 11 Sep 2022 04:17:13 -0500 Subject: [PATCH 04/39] Leverage allow_reuse_address in socketserver.TCPServer This provides same functionality as _ThreadedTCPServer.server_bind, which, as a result can be removed. --- pystrix/agi/fastagi.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pystrix/agi/fastagi.py b/pystrix/agi/fastagi.py index 2471cd6..2025ae2 100644 --- a/pystrix/agi/fastagi.py +++ b/pystrix/agi/fastagi.py @@ -84,12 +84,10 @@ def __init__(self, *args, **kwargs): # further adjustments are automatically picked up for kernel # settings on server start self.request_queue_size = max(socket.SOMAXCONN, self.get_somaxconn()) + self.allow_reuse_address = True super().__init__(*args, **kwargs) - def server_bind(self): - self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - socketserver.TCPServer.server_bind(self) - + class _AGIClientHandler(socketserver.StreamRequestHandler): """ Handles TCP connections. From cd457767de6f4783960b003353cc1ce186312a04 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Sun, 11 Sep 2022 04:21:32 -0500 Subject: [PATCH 05/39] Bumped version to 1.2.0. --- pystrix/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pystrix/__init__.py b/pystrix/__init__.py index ade4bd2..2744c3b 100644 --- a/pystrix/__init__.py +++ b/pystrix/__init__.py @@ -42,5 +42,5 @@ import pystrix.agi import pystrix.ami -VERSION = '1.1.9' +VERSION = '1.2.0' COPYRIGHT = '2013, Neil Tallim ' From 258e9ff1e4748abee794cea2d2ba86e25e8a6102 Mon Sep 17 00:00:00 2001 From: Vyacheslav Yurkov Date: Sun, 14 Jun 2026 09:19:43 +0000 Subject: [PATCH 06/39] Fix compatibility with Python 3.13 Signed-off-by: Vyacheslav Yurkov --- pystrix/agi/fastagi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pystrix/agi/fastagi.py b/pystrix/agi/fastagi.py index 2025ae2..89d11d6 100644 --- a/pystrix/agi/fastagi.py +++ b/pystrix/agi/fastagi.py @@ -35,11 +35,11 @@ - Neil Tallim """ -import cgi import platform import socket import subprocess import threading +from urllib.parse import parse_qs from pystrix.agi.agi_core import * from pystrix.agi.agi_core import _AGI @@ -125,7 +125,7 @@ def _extract_query_elements(self, agi_instance): 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): """ From 86ba2c149c2ff5c0e230c9c686ed43e6930daf21 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 20:21:27 -0400 Subject: [PATCH 07/39] Modernize project documentation and build hygiene Documentation content: - Convert README.rst to README.md; fix version, install URL, and license - Add AGI, FastAGI, and AMI quick-start examples and a Contributing section - Add AGENTS.md (architecture and contributor guidance) and a CLAUDE.md pointer - Make AUTHORS the source of truth for credits and reduce README credits to a link - Fix the _Response.time description in the AMI docs and correct typos Build hygiene: - Stop tracking doc/_build output and macOS ._ metadata files - Fix the .gitignore rule that tracked them and add macOS and venv entries - Remove the dead RPM packaging path from build-release.py Build tooling: - Modernize doc/conf.py and add .readthedocs.yaml and doc/requirements.txt - Declare Python 3.9+ in setup.py via python_requires and trove classifiers - Add CHANGELOG.md and record this pass in the Unreleased section Refs #37 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 12 +++- .readthedocs.yaml | 17 ++++++ AGENTS.md | 95 +++++++++++++++++++++++++++++ AUTHORS | 35 ++++++----- CHANGELOG.md | 40 ++++++++++++ CLAUDE.md | 3 + README.md | 132 ++++++++++++++++++++++++++++++++++++++++ README.rst | 83 ------------------------- build-release.py | 6 -- doc/_build/._ | 1 - doc/_static/._ | 1 - doc/_static/.gitkeep | 0 doc/_templates/._ | 1 - doc/_templates/.gitkeep | 0 doc/ami/index.rst | 8 +-- doc/conf.py | 12 +++- doc/requirements.txt | 3 + setup.py | 14 +++-- 18 files changed, 341 insertions(+), 122 deletions(-) create mode 100644 .readthedocs.yaml create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 README.md delete mode 100644 README.rst delete mode 100644 doc/_build/._ delete mode 100644 doc/_static/._ create mode 100644 doc/_static/.gitkeep delete mode 100644 doc/_templates/._ create mode 100644 doc/_templates/.gitkeep create mode 100644 doc/requirements.txt diff --git a/.gitignore b/.gitignore index cb80cc8..335a417 100644 --- a/.gitignore +++ b/.gitignore @@ -51,10 +51,20 @@ 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/ + +# macOS +.DS_Store +._* \ No newline at end of file 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..6f902b0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# 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 supports Python 2.7 and 3.4+ and targets 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 (currently 1.2.0) +├── 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/ +setup.py Packaging; reads README.md for long_description +build-release.py Release helper +``` + +## 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:52`) sizes `request_queue_size` from the system `SOMAXCONN` (read via `sysctl` on Linux and Darwin) 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. + +## 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 2/3 compatibility + +The code still supports Python 2.7, so keep changes compatible: + +- Import shims like `try: import queue except: import Queue as queue` (`ami.py:45`). +- `basestring` detection in `generic_transforms.py`. +- Explicit bytes/string conversion at the socket boundary via `string_to_bytes` and `bytes_to_string`. +- The AMI socket opens in binary mode (`makefile(mode="rb")`, `ami.py:1195`) on purpose. Text mode silently drops carriage returns and breaks Asterisk's CRLF message framing. Do not change this. + +## Working in this repo + +- There is no test suite and no linter config. Verify changes against the runnable examples in `doc/examples/` and, where possible, a live Asterisk server. +- `VERSION` in `pystrix/__init__.py` is the single source of truth. `setup.py` imports it. Bump it for releases. +- `setup.py` reads `README.md`. Keep that filename in sync if the README is ever renamed again. +- 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 index 42530cb..a70c37a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,22 +1,25 @@ -[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. +pystrix authors and contributors +================================= -[Neil Tallim](http://uguu.ca/) - * Development lead - * Programming +Funding and initial development +------------------------------- +Ivrnet, inc. + Funded the initial development of pystrix. Ivrnet is a software-as-a-service + company that develops and operates intelligent software applications, + delivered through traditional phone networks and over the Internet. -Other contributions -------------------- +Neil Tallim + Development lead and programming. -[Marta Solano](marta.solano@ivrtechnology.com ) - * Bug solving - Programming - * Pip package maintenance +Contributors and current maintenance +------------------------------------ -[Eric Lee](eric@ivrtechnology.com) - * Python 2 to 3 migration - compatibility - * Programming +Marta Solano + Bug fixes, programming, and pip package maintenance. -[Karthic Raghupathi](karthicr@gmail.com) - * Bug Fixes / Programming +Eric Lee + Python 2 to 3 migration and compatibility, programming. + +Karthic Raghupathi + Bug fixes, programming, and pip package maintenance. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0fdc33d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# 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] + +### Added +- `AGENTS.md` with an architecture overview and contributor guidance. +- `CLAUDE.md` pointing to `AGENTS.md`. +- `Contributing` section in the README. +- `.readthedocs.yaml` and `doc/requirements.txt` for reproducible documentation builds. +- This changelog. + +### Changed +- Converted `README.rst` to `README.md` and corrected the version, install URL, and license details. +- Declared Python 3.9+ support in `setup.py` (`python_requires` and trove classifiers); dropped Python 2 and end-of-life 3.x classifiers. +- Rewrote `AUTHORS` as the single source of truth for credits. +- Modernized `doc/conf.py` (`exclude_patterns`, Read the Docs theme with a fallback, raw-string regex). + +### Fixed +- `build-release.py` no longer fails on the missing `pystrix.spec`; the RPM packaging path was removed. +- Corrected the `_Response.time` description in the AMI docs and fixed several typos. +- Stopped tracking `doc/_build/` output and macOS `._` metadata files; 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.2.0...HEAD +[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/README.md b/README.md new file mode 100644 index 0000000..2cb02a8 --- /dev/null +++ b/README.md @@ -0,0 +1,132 @@ +# pystrix + +**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+ on any platform. 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.2.0**. The canonical version lives in `pystrix/__init__.py`. New releases follow `..`, with a patch release cut for each bug fix. + +## 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 sizes its listen backlog from the system `SOMAXCONN` value, so it absorbs large bursts of simultaneous calls. + +### 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 . +``` + +The editable install (`-e`) means your local edits take effect without reinstalling. + +A few things to know before you send a change: + +- **No test suite.** The project has no automated tests and no CI. Verify your change by running the matching example in `doc/examples/` against a live Asterisk server. AMI listens on port 5038 and FastAGI on port 4573. +- **Target Python 3.9+.** The codebase still carries Python 2 compatibility shims (the `Queue` import fallback, `basestring` checks). These are being removed during modernization, so don't add new ones. +- **Build the docs** when you touch them: `pip install sphinx`, then `cd doc && make html`. +- **Version bumps** go in `pystrix/__init__.py`. `setup.py` reads `VERSION` from there. +- **Keep docstrings complete** and written in reStructuredText. The reference docs are generated from them. + +## License + +pystrix is dual-licensed under the GNU GPLv3 (`COPYING`) and the GNU LGPLv3 (`COPYING.LESSER`). You may use it under the terms of either license. + +## Credits + +[Ivrnet, inc.](https://www.ivrnet.com/) funded the initial development of pystrix, led by [Neil Tallim](http://uguu.ca/). See [AUTHORS](AUTHORS) for the full list of contributors and current maintainers. 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 index 0c66f77..9fc6cde 100644 --- a/build-release.py +++ b/build-release.py @@ -3,7 +3,6 @@ Release-building script for pystrix. """ import tarfile -import fileinput from pystrix import VERSION @@ -31,11 +30,6 @@ def doc_filter(tarinfo): 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") 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..3d5fd1a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -13,14 +13,20 @@ project = u'pystrix' copyright = module.COPYRIGHT -version = re.match('^(\d+\.\d+)', module.VERSION).group(1) +version = re.match(r'^(\d+\.\d+)', module.VERSION).group(1) release = module.VERSION -exclude_trees = ['_build'] +exclude_patterns = ['_build'] pygments_style = 'sphinx' -html_theme = 'default' +# 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 = 'sphinx_rtd_theme' +except ImportError: + html_theme = 'alabaster' html_static_path = ['_static'] html_show_sourcelink = False diff --git a/doc/requirements.txt b/doc/requirements.txt new file mode 100644 index 0000000..770d3ca --- /dev/null +++ b/doc/requirements.txt @@ -0,0 +1,3 @@ +# Documentation build dependencies +sphinx>=5.0 +sphinx_rtd_theme>=1.0 diff --git a/setup.py b/setup.py index 29167ab..a668333 100644 --- a/setup.py +++ b/setup.py @@ -14,16 +14,16 @@ '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', - 'Programming Language :: Python :: 3.6', + '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' ] -README = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read() +README = open(os.path.join(os.path.dirname(__file__), 'README.md')).read() # allow setup.py to be run from any path os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) @@ -35,10 +35,12 @@ version=VERSION, description='Python bindings for Asterisk Manager Interface and Asterisk Gateway Interface', long_description=README, + long_description_content_type='text/markdown', url='https://github.com/IVRTech/pystrix', license='GNU General Public License', platforms=['OS Independent'], classifiers=CLASSIFIERS, + python_requires='>=3.9', packages=[ 'pystrix', 'pystrix.agi', From e87597082fa7ea436589ae71570f6a2c92c5fe1a Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 20:45:38 -0400 Subject: [PATCH 08/39] Address review panel findings - Correct the license statement: LGPLv3-or-later, not dual-licensed. Fix the README License section, the setup.py license field and trove classifier, and the changelog wording. - Add README.md, CHANGELOG.md, and AUTHORS to the release tarball so a build from the sdist can read the long description. - Drop the Python 3.13 classifier; fastagi.py imports the cgi module, which is removed in 3.13 (see #36). - Update AGENTS.md to target Python 3.9+ and reframe the legacy Python 2 shims as pending removal; note the cgi gap tracked in #36. - Soften the "any platform" claim: the FastAGI server requires Linux or macOS because it reads SOMAXCONN via sysctl. - Use the canonical sphinx-rtd-theme PyPI name and add trailing newlines. Refs #37 --- .gitignore | 2 +- AGENTS.md | 13 +++++++++---- CHANGELOG.md | 2 +- README.md | 6 +++--- build-release.py | 4 ++++ doc/requirements.txt | 2 +- setup.py | 5 ++--- 7 files changed, 21 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 335a417..2719fbe 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,4 @@ venv/ # macOS .DS_Store -._* \ No newline at end of file +._* diff --git a/AGENTS.md b/AGENTS.md index 6f902b0..6c5fe65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Guidance for AI agents working in the pystrix repository. Read this before makin ## 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 supports Python 2.7 and 3.4+ and targets Asterisk 1.10+. +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: @@ -77,15 +77,20 @@ This fork's main feature is FastAGI throughput. `_ThreadedTCPServer` (`fastagi.p - **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 2/3 compatibility +## Python version and legacy shims -The code still supports Python 2.7, so keep changes compatible: +The project targets Python 3.9+. The source still carries Python 2 compatibility shims left over from the 2-to-3 migration. These are slated for removal, so don't add new ones: -- Import shims like `try: import queue except: import Queue as queue` (`ami.py:45`). +- Import fallbacks like `try: import queue except: import Queue as queue` (`ami.py:45`). - `basestring` detection in `generic_transforms.py`. + +Two related details are not Python 2 baggage and must stay: + - Explicit bytes/string conversion at the socket boundary via `string_to_bytes` and `bytes_to_string`. - The AMI socket opens in binary mode (`makefile(mode="rb")`, `ami.py:1195`) on purpose. Text mode silently drops carriage returns and breaks Asterisk's CRLF message framing. Do not change this. +Known gap: `pystrix/agi/fastagi.py` still uses the removed `cgi.urlparse.parse_qs`, which breaks FastAGI query-string parsing on Python 3 and blocks Python 3.13. Tracked in #36. + ## Working in this repo - There is no test suite and no linter config. Verify changes against the runnable examples in `doc/examples/` and, where possible, a live Asterisk server. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fdc33d..a87087d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - This changelog. ### Changed -- Converted `README.rst` to `README.md` and corrected the version, install URL, and license details. +- Converted `README.rst` to `README.md` and corrected the version, install URL, and license wording. - Declared Python 3.9+ support in `setup.py` (`python_requires` and trove classifiers); dropped Python 2 and end-of-life 3.x classifiers. - Rewrote `AUTHORS` as the single source of truth for credits. - Modernized `doc/conf.py` (`exclude_patterns`, Read the Docs theme with a fallback, raw-string regex). diff --git a/README.md b/README.md index 2cb02a8..48ab23f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## Overview -pystrix runs on Python 3.9+ on any platform. It targets Asterisk 1.10+ and provides a rich, easy-to-extend set of bindings for three Asterisk integration protocols: +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. @@ -69,7 +69,7 @@ server.register_script_handler(None, demo_handler) # default handler server.serve_forever() ``` -The FastAGI server sizes its listen backlog from the system `SOMAXCONN` value, so it absorbs large bursts of simultaneous calls. +The FastAGI server sizes its listen backlog from the system `SOMAXCONN` value, so it absorbs large bursts of simultaneous calls. It reads that value with `sysctl`, so the server currently runs on Linux and macOS only. AMI and AGI have no such restriction. ### AMI — control and monitor the server @@ -125,7 +125,7 @@ A few things to know before you send a change: ## License -pystrix is dual-licensed under the GNU GPLv3 (`COPYING`) and the GNU LGPLv3 (`COPYING.LESSER`). You may use it under the terms of either 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 diff --git a/build-release.py b/build-release.py index 9fc6cde..c3b90eb 100644 --- a/build-release.py +++ b/build-release.py @@ -33,6 +33,10 @@ def doc_filter(tarinfo): f.add('COPYING', arcname="%s/COPYING" % base_name) f.add('COPYING.LESSER', arcname="%s/COPYING.LESSER" % base_name) print("\tAdded license files") + f.add('README.md', arcname="%s/README.md" % base_name) + f.add('CHANGELOG.md', arcname="%s/CHANGELOG.md" % base_name) + f.add('AUTHORS', arcname="%s/AUTHORS" % base_name) + print("\tAdded README, changelog, and authors") 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) diff --git a/doc/requirements.txt b/doc/requirements.txt index 770d3ca..c611803 100644 --- a/doc/requirements.txt +++ b/doc/requirements.txt @@ -1,3 +1,3 @@ # Documentation build dependencies sphinx>=5.0 -sphinx_rtd_theme>=1.0 +sphinx-rtd-theme>=1.0 diff --git a/setup.py b/setup.py index a668333..5d1f379 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ CLASSIFIERS = [ 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', + 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', @@ -19,7 +19,6 @@ 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', - 'Programming Language :: Python :: 3.13', 'Topic :: Communications :: Telephony' ] @@ -37,7 +36,7 @@ long_description=README, long_description_content_type='text/markdown', url='https://github.com/IVRTech/pystrix', - license='GNU General Public License', + license='GNU Lesser General Public License v3 or later', platforms=['OS Independent'], classifiers=CLASSIFIERS, python_requires='>=3.9', From 468730e8f5b429472127d0181b3bac5e4cdb8ae6 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 21:06:22 -0400 Subject: [PATCH 09/39] Drop AUTHORS file; track contributors via git and GitHub Remove the hand-maintained AUTHORS file. It had already drifted from git history (four contributors uncredited) and the per-person role notes added little value. - Move provenance (created by Neil Tallim, funded by Ivrnet) into the README Credits section, which now points to the GitHub contributors page for the live list. - Stop archiving AUTHORS in build-release.py. - Update the changelog. No .mailmap was added, to avoid publishing contributor email addresses; identity controls stay in the GitHub UI. Refs #37 --- AUTHORS | 25 ------------------------- CHANGELOG.md | 2 +- README.md | 2 +- build-release.py | 3 +-- 4 files changed, 3 insertions(+), 29 deletions(-) delete mode 100644 AUTHORS diff --git a/AUTHORS b/AUTHORS deleted file mode 100644 index a70c37a..0000000 --- a/AUTHORS +++ /dev/null @@ -1,25 +0,0 @@ -pystrix authors and contributors -================================= - -Funding and initial development -------------------------------- - -Ivrnet, inc. - Funded the initial development of pystrix. Ivrnet is a software-as-a-service - company that develops and operates intelligent software applications, - delivered through traditional phone networks and over the Internet. - -Neil Tallim - Development lead and programming. - -Contributors and current maintenance ------------------------------------- - -Marta Solano - Bug fixes, programming, and pip package maintenance. - -Eric Lee - Python 2 to 3 migration and compatibility, programming. - -Karthic Raghupathi - Bug fixes, programming, and pip package maintenance. diff --git a/CHANGELOG.md b/CHANGELOG.md index a87087d..9bebcae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Changed - Converted `README.rst` to `README.md` and corrected the version, install URL, and license wording. - Declared Python 3.9+ support in `setup.py` (`python_requires` and trove classifiers); dropped Python 2 and end-of-life 3.x classifiers. -- Rewrote `AUTHORS` as the single source of truth for credits. +- 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). ### Fixed diff --git a/README.md b/README.md index 48ab23f..dc29d85 100644 --- a/README.md +++ b/README.md @@ -129,4 +129,4 @@ pystrix is licensed under the GNU Lesser General Public License v3 or later (`CO ## Credits -[Ivrnet, inc.](https://www.ivrnet.com/) funded the initial development of pystrix, led by [Neil Tallim](http://uguu.ca/). See [AUTHORS](AUTHORS) for the full list of contributors and current maintainers. +[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/build-release.py b/build-release.py index c3b90eb..6bda84c 100644 --- a/build-release.py +++ b/build-release.py @@ -35,8 +35,7 @@ def doc_filter(tarinfo): print("\tAdded license files") f.add('README.md', arcname="%s/README.md" % base_name) f.add('CHANGELOG.md', arcname="%s/CHANGELOG.md" % base_name) - f.add('AUTHORS', arcname="%s/AUTHORS" % base_name) - print("\tAdded README, changelog, and authors") + print("\tAdded README and changelog") 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) From 633e59bd1e1c10850108cd432a92a806c6b1a2ec Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 21:07:40 -0400 Subject: [PATCH 10/39] Reconcile changelog with the full docs pass Capture the review-panel fixes in the Unreleased section: the LGPL license statement, the Python 3.13 classifier drop and its cgi reason (#36), the narrowed platform claim, and the release-tarball README and changelog additions. Remove semicolons to match the writing style. Refs #37 --- CHANGELOG.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bebcae..e7f6774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,20 +9,22 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added - `AGENTS.md` with an architecture overview and contributor guidance. - `CLAUDE.md` pointing to `AGENTS.md`. -- `Contributing` section in the README. +- A Contributing section and AGI, FastAGI, and AMI quick-start examples in the README. - `.readthedocs.yaml` and `doc/requirements.txt` for reproducible documentation builds. - This changelog. ### Changed -- Converted `README.rst` to `README.md` and corrected the version, install URL, and license wording. -- Declared Python 3.9+ support in `setup.py` (`python_requires` and trove classifiers); dropped Python 2 and end-of-life 3.x classifiers. +- 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 `setup.py`. pystrix is not dual-licensed. Both `COPYING` and `COPYING.LESSER` ship because the LGPL extends the GPL. +- Declared Python 3.9+ in `setup.py` through `python_requires` and trove classifiers, and dropped Python 2 and the end-of-life 3.x entries. The classifiers stop at 3.12, because `pystrix/agi/fastagi.py` imports the `cgi` module that Python 3.13 removed (tracked in #36). +- 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). ### Fixed -- `build-release.py` no longer fails on the missing `pystrix.spec`; the RPM packaging path was removed. +- `build-release.py` no longer fails on the missing `pystrix.spec`. The RPM packaging path was removed, and the release tarball now ships `README.md` and `CHANGELOG.md` so a build from the sdist can read the long description. - Corrected the `_Response.time` description in the AMI docs and fixed several typos. -- Stopped tracking `doc/_build/` output and macOS `._` metadata files; fixed the `.gitignore` rule that let them in. +- Stopped tracking `doc/_build/` output and macOS `._` metadata files, and fixed the `.gitignore` rule that let them in. ## [1.2.0] From baa4ebadb68c938e08d3c5437cd39c2321ecb8bc Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 21:22:17 -0400 Subject: [PATCH 11/39] Add GitHub Actions CI across Python 3.9-3.13 Establish a CI foundation for the project, which previously had none. - A test job runs an import smoke check on Python 3.9 through 3.13. The smoke check imports the package and the FastAGI and AMI entry points, so it catches version-compatibility breaks like the cgi removal. - A docs job builds the Sphinx documentation with warnings as errors. - Re-add the Python 3.13 trove classifier, now that #36 removed the cgi dependency that blocked it. - Record the cgi fix and the CI addition in the changelog. The workflow interpolates only the fixed python-version matrix, so it carries no untrusted-input injection surface. --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 4 +++- setup.py | 1 + 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3af28c0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install package + run: pip install -e . + - name: Import smoke test + run: | + python -c "import pystrix; from pystrix.agi import FastAGIServer; from pystrix.ami import Manager; print('pystrix', pystrix.VERSION, 'imports on', __import__('sys').version.split()[0])" + + docs: + name: Documentation build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f6774..1f925d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,17 +11,19 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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 installs the package and runs an import smoke test across Python 3.9 through 3.13, plus a documentation build check. - 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 `setup.py`. pystrix is not dual-licensed. Both `COPYING` and `COPYING.LESSER` ship because the LGPL extends the GPL. -- Declared Python 3.9+ in `setup.py` through `python_requires` and trove classifiers, and dropped Python 2 and the end-of-life 3.x entries. The classifiers stop at 3.12, because `pystrix/agi/fastagi.py` imports the `cgi` module that Python 3.13 removed (tracked in #36). +- Declared Python 3.9+ in `setup.py` 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). ### Fixed +- 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). - `build-release.py` no longer fails on the missing `pystrix.spec`. The RPM packaging path was removed, and the release tarball now ships `README.md` and `CHANGELOG.md` so a build from the sdist can read the long description. - 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. diff --git a/setup.py b/setup.py index 5d1f379..d16c20d 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Topic :: Communications :: Telephony' ] From 4f54b17e9c82681dd8586743d068f439b6266901 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 21:34:16 -0400 Subject: [PATCH 12/39] Harden CI workflow per security review - Add a least-privilege top-level permissions block (contents: read). - Set timeout-minutes on both jobs to contain hung runs. Action SHA-pinning was deferred to pair with Dependabot in the tooling track. --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3af28c0..57744f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,14 @@ on: branches: [master] pull_request: +permissions: + contents: read + jobs: test: name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -28,6 +32,7 @@ jobs: docs: name: Documentation build runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Set up Python From abf7237e8692432380e4c0a86609da0f94dca0e5 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 21:47:52 -0400 Subject: [PATCH 13/39] Add an initial pytest unit-test suite The project had no tests. Add unit tests for the parts of the core that run without a live Asterisk server, and run them in CI. - tests/ covers AMI message parsing (_Message), AMI request building (_Request.build_request), AGI response parsing (_AGI._get_result), and AGI action and helper formatting (_Action.command, quote). - An import test loads the full package and the FastAGI and AMI entry points, keeping the cross-version compatibility check inside the suite. - Add a `test` extra in setup.py and run `pytest` in the CI test job across Python 3.9 through 3.13, replacing the import-only smoke step. Closes #41 --- .github/workflows/ci.yml | 9 +++-- CHANGELOG.md | 3 +- setup.py | 3 ++ tests/test_agi_core.py | 73 +++++++++++++++++++++++++++++++++++++++ tests/test_ami_message.py | 40 +++++++++++++++++++++ tests/test_ami_request.py | 38 ++++++++++++++++++++ tests/test_import.py | 15 ++++++++ 7 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 tests/test_agi_core.py create mode 100644 tests/test_ami_message.py create mode 100644 tests/test_ami_request.py create mode 100644 tests/test_import.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57744f6..d53e6e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,11 +23,10 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install package - run: pip install -e . - - name: Import smoke test - run: | - python -c "import pystrix; from pystrix.agi import FastAGIServer; from pystrix.ami import Manager; print('pystrix', pystrix.VERSION, 'imports on', __import__('sys').version.split()[0])" + - name: Install package with test dependencies + run: pip install -e '.[test]' + - name: Run tests + run: pytest -q docs: name: Documentation build diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f925d1..cac92b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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 installs the package and runs an import smoke test across Python 3.9 through 3.13, plus a documentation build check. +- A GitHub Actions CI workflow that runs the test suite 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 a `test` extra in `setup.py` (`pip install -e .[test]`). - This changelog. ### Changed diff --git a/setup.py b/setup.py index d16c20d..f9fc421 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,9 @@ platforms=['OS Independent'], classifiers=CLASSIFIERS, python_requires='>=3.9', + extras_require={ + 'test': ['pytest'], + }, packages=[ 'pystrix', 'pystrix.agi', diff --git a/tests/test_agi_core.py b/tests/test_agi_core.py new file mode 100644 index 0000000..25113f4 --- /dev/null +++ b/tests/test_agi_core.py @@ -0,0 +1,73 @@ +"""Tests for AGI response parsing and helpers (`pystrix.agi.agi_core`).""" +import pytest + +from pystrix.agi.agi_core import ( + _AGI, _Action, quote, + AGIInvalidCommandError, AGIDeadChannelError, AGIResultHangup, AGINoResultError, +) + + +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_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"' diff --git a/tests/test_ami_message.py b/tests/test_ami_message.py new file mode 100644 index 0000000..160eccf --- /dev/null +++ b/tests/test_ami_message.py @@ -0,0 +1,40 @@ +"""Tests for AMI message parsing (`pystrix.ami.ami._Message`).""" +from pystrix.ami.ami import _Message, RESPONSE_GENERIC, EVENT_GENERIC + + +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_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_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_request.py b/tests/test_ami_request.py new file mode 100644 index 0000000..e30ed8e --- /dev/null +++ b/tests/test_ami_request.py @@ -0,0 +1,38 @@ +"""Tests for AMI request building (`pystrix.ami.ami._Request`).""" +from pystrix.ami.ami import _Request + + +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_kwargs_become_headers(): + command, _ = _build(_Request('Ping'), Extra='x') + assert 'Extra: x' in command diff --git a/tests/test_import.py b/tests/test_import.py new file mode 100644 index 0000000..643a740 --- /dev/null +++ b/tests/test_import.py @@ -0,0 +1,15 @@ +"""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, FastAGIServer, FastAGI + from pystrix.ami import Manager + + # Importing FastAGIServer exercises pystrix/agi/fastagi.py, which must not + # pull in the cgi module that Python 3.13 removed. + assert AGI and FastAGIServer and FastAGI and Manager From 27c6311aea64e0bed39974093f9dcdbac581716b Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 22:02:59 -0400 Subject: [PATCH 14/39] Measure coverage with pytest-cov and add a CI status badge - Add pytest-cov to the test extra and run pytest with coverage in CI. Coverage prints to the CI logs (term-missing) and nothing is uploaded to any third-party service, per the project's data-privacy stance. - Add a .coveragerc scoped to the pystrix package. - Add a GitHub Actions CI status badge to the README. Refs #41 --- .coveragerc | 5 +++++ .github/workflows/ci.yml | 4 ++-- CHANGELOG.md | 6 ++++-- README.md | 2 ++ setup.py | 2 +- 5 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 .coveragerc 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index d53e6e6..20fd973 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install package with test dependencies run: pip install -e '.[test]' - - name: Run tests - run: pytest -q + - name: Run tests with coverage + run: pytest -q --cov --cov-report=term-missing docs: name: Documentation build diff --git a/CHANGELOG.md b/CHANGELOG.md index cac92b3..d2a1561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,10 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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 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 a `test` extra in `setup.py` (`pip install -e .[test]`). +- 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 in `setup.py` (`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. - This changelog. ### Changed diff --git a/README.md b/README.md index dc29d85..b6f28d1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # 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 diff --git a/setup.py b/setup.py index f9fc421..5b5f1c2 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ classifiers=CLASSIFIERS, python_requires='>=3.9', extras_require={ - 'test': ['pytest'], + 'test': ['pytest', 'pytest-cov'], }, packages=[ 'pystrix', From 9839202a7a82e9ae0b5374d814f3113367f24348 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 22:20:36 -0400 Subject: [PATCH 15/39] Expand test suite per review panel findings Address the codex and pr-test-analyzer review of the initial suite. AGI (test_agi_core.py): - 520 multi-line usage path, unrecognized code (returns None), unknown code (AGIUnknownError). - Hangup detected by parenthetical data rather than value, and the check_hangup=False path. - Result with no parenthetical reports data as '' not None. AMI message (test_ami_message.py): - Data-mode stickiness, fake-EOL and non-CRLF framing, and Event-over- Response precedence in name. AMI request (test_ami_request.py): - Multi-value header ordering and the blank-line terminator. - Strict xfail tests encoding build_request's documented ActionID contract, which the code currently violates (see #43). Import (test_import.py): - Replace the vacuous truthiness assert with a re-export identity check. Refs #41 --- tests/test_agi_core.py | 44 +++++++++++++++++++++++++++++++++++++++ tests/test_ami_message.py | 28 +++++++++++++++++++++++++ tests/test_ami_request.py | 33 +++++++++++++++++++++++++++++ tests/test_import.py | 9 +++++--- 4 files changed, 111 insertions(+), 3 deletions(-) diff --git a/tests/test_agi_core.py b/tests/test_agi_core.py index 25113f4..0fed8ae 100644 --- a/tests/test_agi_core.py +++ b/tests/test_agi_core.py @@ -4,6 +4,7 @@ from pystrix.agi.agi_core import ( _AGI, _Action, quote, AGIInvalidCommandError, AGIDeadChannelError, AGIResultHangup, AGINoResultError, + AGIUsageError, AGIUnknownError, ) @@ -42,6 +43,49 @@ def test_parses_result_data(): 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() diff --git a/tests/test_ami_message.py b/tests/test_ami_message.py index 160eccf..9322121 100644 --- a/tests/test_ami_message.py +++ b/tests/test_ami_message.py @@ -20,6 +20,11 @@ def test_name_prefers_event_then_response(): 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' @@ -30,6 +35,29 @@ def test_data_payload_follows_headers(): 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 diff --git a/tests/test_ami_request.py b/tests/test_ami_request.py index e30ed8e..2a45d28 100644 --- a/tests/test_ami_request.py +++ b/tests/test_ami_request.py @@ -1,4 +1,6 @@ """Tests for AMI request building (`pystrix.ami.ami._Request`).""" +import pytest + from pystrix.ami.ami import _Request @@ -33,6 +35,37 @@ def test_multi_value_header_expands_to_repeated_lines(): assert 'Variable: b=2\r\n' in command +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 + + +# The two cases below encode build_request's documented ActionID contract. +# The implementation currently violates it (see issue #43): a pre-set ActionID +# is dropped when action_id is None, and it overrides an explicit action_id. +# These are strict xfails, so they will fail loudly once #43 is fixed, prompting +# removal of the markers. +@pytest.mark.xfail(reason="build_request drops a pre-set ActionID; see #43", strict=True) +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 + + +@pytest.mark.xfail(reason="explicit action_id loses to a pre-set ActionID; see #43", strict=True) +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 diff --git a/tests/test_import.py b/tests/test_import.py index 643a740..63ea4d2 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -9,7 +9,10 @@ def test_package_imports(): def test_public_entry_points_import(): from pystrix.agi import AGI, FastAGIServer, FastAGI from pystrix.ami import Manager + from pystrix.agi.fastagi import FastAGIServer as _FastAGIServer - # Importing FastAGIServer exercises pystrix/agi/fastagi.py, which must not - # pull in the cgi module that Python 3.13 removed. - assert AGI and FastAGIServer and FastAGI and 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) From 05e0f38bcaa61303c0fec21a4d39863fabd56806 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 22:25:34 -0400 Subject: [PATCH 16/39] Fix build_request ActionID precedence (#43) build_request contradicted its own docstring when a request carried a pre-set ActionID: - with action_id=None the pre-set value was dropped entirely (the command carried no ActionID and None was returned) - with an explicit action_id the pre-set value overrode the argument Resolve the ActionID with the documented precedence instead: an explicit argument wins, then a value already set on the request, then a generated one. Every request still gets an ActionID. Flip the two strict xfail guards added in #42 to normal regression tests now that the behavior is correct. Closes #43 --- CHANGELOG.md | 1 + pystrix/ami/ami.py | 11 +++++------ tests/test_ami_request.py | 12 +++--------- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2a1561..bd269e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Modernized `doc/conf.py` (`exclude_patterns`, Read the Docs theme with a fallback, raw-string regex). ### Fixed +- `_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 (#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). - `build-release.py` no longer fails on the missing `pystrix.spec`. The RPM packaging path was removed, and the release tarball now ships `README.md` and `CHANGELOG.md` so a build from the sdist can read the long description. - Corrected the `_Response.time` description in the AMI docs and fixed several typos. diff --git a/pystrix/ami/ami.py b/pystrix/ami/ami.py index 2633985..4efdd21 100644 --- a/pystrix/ami/ami.py +++ b/pystrix/ami/ami.py @@ -915,12 +915,11 @@ def build_request(self, action_id, id_generator, **kwargs): 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)) + #Resolve the ActionID using the precedence documented above: an explicit + #argument wins, then any value already set on the request, then a generated one. + if not action_id: + action_id = self.get(KEY_ACTIONID) or str(id_generator()) + items.append((KEY_ACTIONID, action_id)) return ( _EOL.join(['%(key)s: %(value)s' % { diff --git a/tests/test_ami_request.py b/tests/test_ami_request.py index 2a45d28..d30f5c1 100644 --- a/tests/test_ami_request.py +++ b/tests/test_ami_request.py @@ -1,6 +1,4 @@ """Tests for AMI request building (`pystrix.ami.ami._Request`).""" -import pytest - from pystrix.ami.ami import _Request @@ -48,12 +46,9 @@ def test_kwargs_become_headers(): assert 'Extra: x' in command -# The two cases below encode build_request's documented ActionID contract. -# The implementation currently violates it (see issue #43): a pre-set ActionID -# is dropped when action_id is None, and it overrides an explicit action_id. -# These are strict xfails, so they will fail loudly once #43 is fixed, prompting -# removal of the markers. -@pytest.mark.xfail(reason="build_request drops a pre-set ActionID; see #43", strict=True) +# 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' @@ -62,7 +57,6 @@ def test_preset_action_id_used_when_none_passed(): assert 'ActionID: preset' in command -@pytest.mark.xfail(reason="explicit action_id loses to a pre-set ActionID; see #43", strict=True) def test_explicit_action_id_wins_over_preset(): request = _Request('Ping') request['ActionID'] = 'preset' From c1cff1a71e9014c4680806c644eda691b3870137 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Tue, 23 Jun 2026 22:42:57 -0400 Subject: [PATCH 17/39] Address codex review: None-check and stringify ActionID - Fall back to the request value or a generated id only when action_id is None, not whenever it is falsy, so an explicit '' or 0 is honored (matches the docstring contract). - Coerce the resolved ActionID to a string. A pre-set non-string value (e.g. an int) previously went onto the wire as a string but was returned unnormalized, so send_action's lookup could never match the string-keyed response and the request would time out. - Update send_action to pass None when no action_id was given. - Add a regression test for the string coercion. Refs #43 --- CHANGELOG.md | 2 +- pystrix/ami/ami.py | 11 +++++++---- tests/test_ami_request.py | 10 ++++++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd269e6..84ceb28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Modernized `doc/conf.py` (`exclude_patterns`, Read the Docs theme with a fallback, raw-string regex). ### Fixed -- `_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 (#43). +- `_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). - `build-release.py` no longer fails on the missing `pystrix.spec`. The RPM packaging path was removed, and the release tarball now ships `README.md` and `CHANGELOG.md` so a build from the sdist can read the long description. - Corrected the `_Response.time` description in the AMI docs and fixed several typos. diff --git a/pystrix/ami/ami.py b/pystrix/ami/ami.py index 4efdd21..d171ccd 100644 --- a/pystrix/ami/ami.py +++ b/pystrix/ami/ami.py @@ -514,7 +514,7 @@ def send_action(self, request, action_id=None, **kwargs): 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) @@ -916,9 +916,12 @@ def build_request(self, action_id, id_generator, **kwargs): items.append((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. - if not action_id: - action_id = self.get(KEY_ACTIONID) or str(id_generator()) + #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((KEY_ACTIONID, action_id)) return ( diff --git a/tests/test_ami_request.py b/tests/test_ami_request.py index d30f5c1..57015f2 100644 --- a/tests/test_ami_request.py +++ b/tests/test_ami_request.py @@ -63,3 +63,13 @@ def test_explicit_action_id_wins_over_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 From 3f3f3831755592583a1bbd52617f5ad976d66569 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 08:17:20 -0400 Subject: [PATCH 18/39] Add ruff linting and pre-commit hooks (#45) Lint-only for now. Formatting is deferred (ruff format would rewrite ~4,000 lines); it will pair with the Python 2 shim removal. - ruff.toml: curated rules (E4/E7/E9, F, W605), with per-file ignores for the public re-exports in __init__ modules and the AGI star imports. - Apply safe lint fixes: not-is/not-in rewrites, unused-import removal, two invalid escape sequences turned into raw strings, and one multiple-imports-on-one-line split. Rename an ambiguous `l` and drop an unused `with ... as` binding. - noqa the intentional legacy patterns: the Python 2 import shims and the basestring fallback, plus one fragile type()==bool boolean idiom. - .pre-commit-config.yaml: ruff-check plus non-mutating hygiene hooks. - CI: add a lint job running ruff check. - README: refresh Contributing for the test suite, CI, and lint setup, which the old text predated. Closes #45 --- .github/workflows/ci.yml | 15 ++++++++++++ .pre-commit-config.yaml | 19 +++++++++++++++ CHANGELOG.md | 2 ++ README.md | 7 +++--- doc/conf.py | 4 +++- pystrix/agi/agi_core.py | 7 +++--- pystrix/ami/ami.py | 10 ++++---- pystrix/ami/app_confbridge.py | 3 +-- pystrix/ami/app_meetme.py | 5 ++-- pystrix/ami/core.py | 39 +++++++++++++++---------------- pystrix/ami/core_events.py | 2 +- pystrix/ami/dahdi.py | 5 ++-- pystrix/ami/generic_transforms.py | 2 +- ruff.toml | 21 +++++++++++++++++ 14 files changed, 98 insertions(+), 43 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 ruff.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20fd973..a3ddf7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,21 @@ permissions: contents: read jobs: + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install ruff + run: pip install ruff==0.15.19 + - name: Run ruff + run: ruff check . + test: name: Python ${{ matrix.python-version }} runs-on: ubuntu-latest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..41d6356 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +# Pre-commit hooks for pystrix. Run `pre-commit install` once to enable, then +# the hooks run on each commit. See https://pre-commit.com. +# +# Formatting hooks are intentionally omitted for now (see issue #45); ruff is +# used for linting only until a dedicated formatting pass. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.19 + hooks: + - id: ruff-check + args: [--fix] + + - 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/CHANGELOG.md b/CHANGELOG.md index 84ceb28..bd87583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 in `setup.py` (`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` lint configuration (`ruff.toml`), a `.pre-commit-config.yaml`, and a CI lint job. Linting only for now; formatting is deferred. - This changelog. ### Changed @@ -26,6 +27,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Modernized `doc/conf.py` (`exclude_patterns`, Read the Docs theme with a fallback, raw-string regex). ### Fixed +- 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. The Python 2 shims and 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). - `build-release.py` no longer fails on the missing `pystrix.spec`. The RPM packaging path was removed, and the release tarball now ships `README.md` and `CHANGELOG.md` so a build from the sdist can read the long description. diff --git a/README.md b/README.md index b6f28d1..1baa13b 100644 --- a/README.md +++ b/README.md @@ -112,16 +112,17 @@ pystrix has no third-party runtime dependencies, so setup is short. git clone https://github.com/IVRTech/pystrix.git cd pystrix python3 -m venv .venv && source .venv/bin/activate -pip install -e . +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: -- **No test suite.** The project has no automated tests and no CI. Verify your change by running the matching example in `doc/examples/` against a live Asterisk server. AMI listens on port 5038 and FastAGI on port 4573. +- **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 with ruff.** `ruff check .` must pass, and CI enforces it. Install the hooks to lint on each commit: `pip install pre-commit && pre-commit install`. Formatting is not enforced yet. - **Target Python 3.9+.** The codebase still carries Python 2 compatibility shims (the `Queue` import fallback, `basestring` checks). These are being removed during modernization, so don't add new ones. -- **Build the docs** when you touch them: `pip install sphinx`, then `cd doc && make html`. +- **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`. `setup.py` reads `VERSION` from there. - **Keep docstrings complete** and written in reStructuredText. The reference docs are generated from them. diff --git a/doc/conf.py b/doc/conf.py index 3d5fd1a..91d1481 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- -import sys, os, re +import sys +import os +import re sys.path.append(os.path.abspath('..')) import pystrix as module diff --git a/pystrix/agi/agi_core.py b/pystrix/agi/agi_core.py index ce04b6d..556d062 100644 --- a/pystrix/agi/agi_core.py +++ b/pystrix/agi/agi_core.py @@ -36,7 +36,6 @@ """ import collections import re -import time import sys @@ -148,7 +147,7 @@ def _get_result(self, check_hangup=True): 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. + 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) @@ -208,7 +207,7 @@ def _read_line(self, should_strip=True): line = self._rfile.readline() try: line = line.decode() # decode line if it comes in bytes, example if it comes from a socket - except: + except: # noqa: E722 pass # line it's a string, so nothing to change - it's string if it's using stdin as _rfile for example # 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 @@ -271,7 +270,7 @@ def __init__(self, command, *arguments): @property def command(self): - command = ' '.join([self._command.strip()] + [str(arg) for arg in self._arguments if not arg is None]).strip() + 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 diff --git a/pystrix/ami/ami.py b/pystrix/ami/ami.py index d171ccd..bc7599d 100644 --- a/pystrix/ami/ami.py +++ b/pystrix/ami/ami.py @@ -44,7 +44,7 @@ try: import queue -except: +except: # noqa: E722 import Queue as queue from pystrix.ami import generic_transforms @@ -835,8 +835,8 @@ 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) self[key.strip()] = value.strip() @@ -907,7 +907,7 @@ def build_request(self, action_id, id_generator, **kwargs): 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()): + 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: @@ -1088,7 +1088,7 @@ def close(self): if self._socket_write_lock is None: self._close() return - with self._socket_write_lock as write_lock: + with self._socket_write_lock: self._close() def _close(self): diff --git a/pystrix/ami/app_confbridge.py b/pystrix/ami/app_confbridge.py index d80b4ff..d2f3a4f 100644 --- a/pystrix/ami/app_confbridge.py +++ b/pystrix/ami/app_confbridge.py @@ -34,10 +34,9 @@ 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.ami import (_Request) from pystrix.ami import core_events from pystrix.ami import app_confbridge_events -from pystrix.ami import generic_transforms class ConfbridgeKick(_Request): """ diff --git a/pystrix/ami/app_meetme.py b/pystrix/ami/app_meetme.py index b285058..e554c6a 100644 --- a/pystrix/ami/app_meetme.py +++ b/pystrix/ami/app_meetme.py @@ -34,9 +34,8 @@ 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.ami import (_Request) from pystrix.ami import app_meetme_events -from pystrix.ami import generic_transforms class MeetmeList(_Request): """ @@ -56,7 +55,7 @@ def __init__(self, conference=None): `conference` is the optional identifier of the bridge. """ _Request.__init__(self, 'MeetmeList') - if not conference is None: + if conference is not None: self['Conference'] = conference class MeetmeListRooms(_Request): diff --git a/pystrix/ami/core.py b/pystrix/ami/core.py index b9dc9a8..54d4ea3 100644 --- a/pystrix/ami/core.py +++ b/pystrix/ami/core.py @@ -39,7 +39,6 @@ """ import hashlib import time -import types from pystrix.ami.ami import (_Request, ManagerError) from pystrix.ami import core_events @@ -124,7 +123,7 @@ def __init__(self, channel, command, command_id=None): _Request.__init__(self, 'AGI') self['Channel'] = channel self['Command'] = command - if not command_id is None: + if command_id is not None: self['CommandID'] = str(command_id) class Bridge(_Request): @@ -244,7 +243,7 @@ def __init__(self, family, key=None): """ _Request.__init__(self, 'DBDelTree') self['Family'] = family - if not key is None: + if key is not None: self['Key'] = key class DBGet(_Request): @@ -380,7 +379,7 @@ def __init__(self, variable, channel=None): """ _Request.__init__(self, 'Getvar') self['Variable'] = variable - if not channel is None: + if channel is not None: self['Channel'] = channel class Hangup(_Request): @@ -455,7 +454,7 @@ def __init__(self, username, secret, events=True, challenge=None, authtype=AUTHT _Request.__init__(self, 'Login') self['Username'] = username - if not challenge is None and authtype: + if challenge is not None and authtype: self['AuthType'] = authtype if authtype == AUTHTYPE_MD5: self['Key'] = hashlib.md5( @@ -589,7 +588,7 @@ def __init__(self, load_type, module=None): """ _Request.__init__(self, 'ModuleLoad') self['LoadType'] = load_type - if not module is None: + if module is not None: self['Module'] = module class Monitor(_Request): @@ -909,11 +908,11 @@ def __init__(self, queue, event, interface=None, uniqueid=None, message=None): _Request.__init__(self, "QueueLog") self['Queue'] = queue self['Event'] = event - if not uniqueid is None: + if uniqueid is not None: self['Uniqueid'] = uniqueid - if not interface is None: + if interface is not None: self['Interface'] = interface - if not message is None: + if message is not None: self['Message'] = message class QueuePause(_Request): @@ -932,7 +931,7 @@ def __init__(self, interface, paused, queue=None): _Request.__init__(self, "QueuePause") self['Interface'] = interface self['Paused'] = paused and 'true' or 'false' - if not queue is None: + if queue is not None: self['Queue'] = queue class QueuePenalty(_Request): @@ -949,7 +948,7 @@ def __init__(self, interface, penalty, queue=None): _Request.__init__(self, "QueuePenalty") self['Interface'] = interface self['Penalty'] = str(penalty) - if not queue is None: + if queue is not None: self['Queue'] = queue class QueueReload(_Request): @@ -973,7 +972,7 @@ def __init__(self, queue=None, members='yes', rules='yes', parameters='yes'): self['Members'] = members self['Rules'] = rules self['Parameters'] = parameters - if not queue is None: + if queue is not None: self['Queue'] = queue class QueueRemove(_Request): @@ -1010,7 +1009,7 @@ 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: + if queue is not None: self['Queue'] = queue @@ -1030,7 +1029,7 @@ 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: + if queue is not None: self['Queue'] = queue @@ -1066,7 +1065,7 @@ def __init__(self, module=None): extension. """ _Request.__init__(self, "Reload") - if not module is None: + if module is not None: self['Module'] = module class SendText(_Request): @@ -1164,7 +1163,7 @@ def __init__(self, peer): self['Peer'] = peer class SIPshowpeer(_Request): - """ + r""" Provides detailed information about a SIP peer. The response has the following key-value pairs: @@ -1340,7 +1339,7 @@ def __init__(self, src_filename, dst_filename, changes, reload=True): _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 + self['Reload'] = type(reload) == bool and (reload and 'true' or 'false') or reload # noqa: E721 for (i, (action, category, variable, value, match)) in enumerate(changes): index = '%(index)06i' % { @@ -1348,11 +1347,11 @@ def __init__(self, src_filename, dst_filename, changes, reload=True): } self['Action-' + index] = action self['Cat-' + index] = category - if not variable is None: + if variable is not None: self['Var-' + index] = variable - if not value is None: + if value is not None: self['Value-' + index] = value - if not match is None: + if match is not None: self['Match-' + index] = match class UserEvent(_Request): diff --git a/pystrix/ami/core_events.py b/pystrix/ami/core_events.py index 3e0f02c..c1af5ed 100644 --- a/pystrix/ami/core_events.py +++ b/pystrix/ami/core_events.py @@ -803,7 +803,7 @@ def process(self): return (headers, data) class UserEvent(_Event): - """ + r""" Generated in response to the UserEvent request. - \*: Any key-value pairs supplied with the request, as strings diff --git a/pystrix/ami/dahdi.py b/pystrix/ami/dahdi.py index 717c062..221ee89 100644 --- a/pystrix/ami/dahdi.py +++ b/pystrix/ami/dahdi.py @@ -33,9 +33,8 @@ 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.ami import (_Request) from pystrix.ami import dahdi_events -from pystrix.ami import generic_transforms class DAHDIDNDoff(_Request): """ @@ -100,6 +99,6 @@ class DAHDIShowChannels(_Request): def __init__(self, dahdi_channel=None): _Request.__init__(self, 'DAHDIShowChannels') - if not dahdi_channel is None: + if dahdi_channel is not None: self['DAHDIChannel'] = dahdi_channel diff --git a/pystrix/ami/generic_transforms.py b/pystrix/ami/generic_transforms.py index 047507f..4f04b73 100644 --- a/pystrix/ami/generic_transforms.py +++ b/pystrix/ami/generic_transforms.py @@ -32,7 +32,7 @@ # identify string type in a python 2 and 3 compatible manner -string_type = str if sys.version_info[0] >= 3 else basestring +string_type = str if sys.version_info[0] >= 3 else basestring # noqa: F821 def to_bool(dictionary, keys, truth_value=None, truth_function=(lambda x:bool(x)), preprocess=(lambda x:x)): diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..494fdad --- /dev/null +++ b/ruff.toml @@ -0,0 +1,21 @@ +# Lint configuration for pystrix. +# +# Formatting is intentionally not enabled yet (see issue #45): the existing +# style is non-standard and `ruff format` would rewrite ~4,000 lines. A later +# pass, paired with the Python 2 shim removal, will own formatting. When the +# project moves to pyproject.toml, this config can move there. +target-version = "py39" + +[lint] +# pycodestyle errors/warnings and pyflakes, minus the whitespace, indentation, +# and line-length families that a formatting pass will own. +select = ["E4", "E7", "E9", "F", "W605"] + +[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"] From 754e0481cbd0290d87c328a55bea9857f5461a7c Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 11:36:22 -0400 Subject: [PATCH 19/39] Remove Python 2 compatibility shims (#47) The codebase targets Python 3.9+, so the Python 2 remnants are dead code. - ami.py: `import queue` directly (drop the Queue fallback and its noqa). - fastagi.py: `import socketserver` directly (drop the SocketServer fallback). - generic_transforms.py: `string_type = str` (drop the basestring branch and its noqa); remove the now-unused `import sys`. - Drop the explicit `(object)` base from five class definitions. The decode() bare-except in agi_core.py is left as-is: it handles socket bytes versus stdin strings on Python 3, not a Python 2 shim. Closes #47 --- CHANGELOG.md | 3 +++ pystrix/agi/agi_core.py | 4 ++-- pystrix/agi/fastagi.py | 5 +---- pystrix/ami/ami.py | 11 ++++------- pystrix/ami/generic_transforms.py | 6 +----- 5 files changed, 11 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd87583..fab7b63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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). +### 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. + ### Fixed - 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. The Python 2 shims and 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). diff --git a/pystrix/agi/agi_core.py b/pystrix/agi/agi_core.py index 556d062..2f650d4 100644 --- a/pystrix/agi/agi_core.py +++ b/pystrix/agi/agi_core.py @@ -62,7 +62,7 @@ def quote(value): #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 @@ -256,7 +256,7 @@ def _test_hangup(self): """ return -class _Action(object): +class _Action: """ Provides the basis for assembling and issuing an action via AGI. """ diff --git a/pystrix/agi/fastagi.py b/pystrix/agi/fastagi.py index 89d11d6..4224d1f 100644 --- a/pystrix/agi/fastagi.py +++ b/pystrix/agi/fastagi.py @@ -43,10 +43,7 @@ from pystrix.agi.agi_core import * from pystrix.agi.agi_core import _AGI -try: - import socketserver -except ModuleNotFoundError: - import SocketServer as socketserver +import socketserver class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): diff --git a/pystrix/ami/ami.py b/pystrix/ami/ami.py index bc7599d..78ff08f 100644 --- a/pystrix/ami/ami.py +++ b/pystrix/ami/ami.py @@ -42,10 +42,7 @@ import traceback import warnings -try: - import queue -except: # noqa: E722 - import Queue as queue +import queue from pystrix.ami import generic_transforms @@ -91,7 +88,7 @@ def _format_socket_error(exception): except Exception: return str(exception) -class Manager(object): +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 @@ -647,7 +644,7 @@ 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. """ @@ -1047,7 +1044,7 @@ def run(self): 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. """ diff --git a/pystrix/ami/generic_transforms.py b/pystrix/ami/generic_transforms.py index 4f04b73..f8da774 100644 --- a/pystrix/ami/generic_transforms.py +++ b/pystrix/ami/generic_transforms.py @@ -28,11 +28,7 @@ - Neil Tallim """ -import sys - - -# identify string type in a python 2 and 3 compatible manner -string_type = str if sys.version_info[0] >= 3 else basestring # noqa: F821 +string_type = str def to_bool(dictionary, keys, truth_value=None, truth_function=(lambda x:bool(x)), preprocess=(lambda x:x)): From 830ff27a6d254acc0aa7d973f4809e7d19d09e26 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 11:46:18 -0400 Subject: [PATCH 20/39] Sync docs with Python 3-only state (review panel follow-up) The review panel flagged docs that the shim removal made stale: - README: the Contributing bullet no longer claims Py2 shims are present. - AGENTS.md: the Python-version section now says the source is Python 3 only, and the obsolete cgi "Known gap" (fixed in #36) is dropped. - CHANGELOG: trim the stale "Python 2 shims ... marked with targeted ignores" clause from the lint entry; the ignores were removed here. Refs #47 --- AGENTS.md | 15 +++++---------- CHANGELOG.md | 2 +- README.md | 2 +- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c5fe65..54497d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,19 +77,14 @@ This fork's main feature is FastAGI throughput. `_ThreadedTCPServer` (`fastagi.p - **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 legacy shims +## Python version and Python 3 boundaries -The project targets Python 3.9+. The source still carries Python 2 compatibility shims left over from the 2-to-3 migration. These are slated for removal, so don't add new ones: +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. -- Import fallbacks like `try: import queue except: import Queue as queue` (`ami.py:45`). -- `basestring` detection in `generic_transforms.py`. +Two bytes/string details at the I/O boundary are not Python 2 baggage and must stay: -Two related details are not Python 2 baggage and must stay: - -- Explicit bytes/string conversion at the socket boundary via `string_to_bytes` and `bytes_to_string`. -- The AMI socket opens in binary mode (`makefile(mode="rb")`, `ami.py:1195`) on purpose. Text mode silently drops carriage returns and breaks Asterisk's CRLF message framing. Do not change this. - -Known gap: `pystrix/agi/fastagi.py` still uses the removed `cgi.urlparse.parse_qs`, which breaks FastAGI query-string parsing on Python 3 and blocks Python 3.13. Tracked in #36. +- 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index fab7b63..dfb36ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. ### Fixed -- 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. The Python 2 shims and intentional re-exports are marked with targeted ignores. +- 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). - `build-release.py` no longer fails on the missing `pystrix.spec`. The RPM packaging path was removed, and the release tarball now ships `README.md` and `CHANGELOG.md` so a build from the sdist can read the long description. diff --git a/README.md b/README.md index 1baa13b..8e0bc05 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ 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 with ruff.** `ruff check .` must pass, and CI enforces it. Install the hooks to lint on each commit: `pip install pre-commit && pre-commit install`. Formatting is not enforced yet. -- **Target Python 3.9+.** The codebase still carries Python 2 compatibility shims (the `Queue` import fallback, `basestring` checks). These are being removed during modernization, so don't add new ones. +- **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`. `setup.py` reads `VERSION` from there. - **Keep docstrings complete** and written in reStructuredText. The reference docs are generated from them. From 2d38d123f9cf23fb318922d79e30cfdb8b2175c1 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 12:36:45 -0400 Subject: [PATCH 21/39] Narrow the decode() bare-except in _AGI._read_line (#50) Replace the bare `try: line = line.decode() except: pass` with an `isinstance(line, bytes)` check. Plain AGI's stdin str still passes through untouched, FastAGI socket bytes still decode, but a genuine UnicodeDecodeError on malformed bytes now surfaces instead of being swallowed and leaving raw bytes flowing into string-only logic. Removes the last bare-except and its noqa from the file. Add regression tests for the str-input path and the malformed-bytes path. Closes #50 --- CHANGELOG.md | 1 + pystrix/agi/agi_core.py | 6 ++---- tests/test_agi_core.py | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfb36ba..bee8226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. ### 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). diff --git a/pystrix/agi/agi_core.py b/pystrix/agi/agi_core.py index 2f650d4..bc4d783 100644 --- a/pystrix/agi/agi_core.py +++ b/pystrix/agi/agi_core.py @@ -205,10 +205,8 @@ def _read_line(self, should_strip=True): """ try: line = self._rfile.readline() - try: - line = line.decode() # decode line if it comes in bytes, example if it comes from a socket - except: # noqa: E722 - 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) diff --git a/tests/test_agi_core.py b/tests/test_agi_core.py index 0fed8ae..761b645 100644 --- a/tests/test_agi_core.py +++ b/tests/test_agi_core.py @@ -115,3 +115,41 @@ def test_action_command_formatting(): 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() From ec97f1253c2ff6d96446d3503cc7b00facdc5971 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 13:06:36 -0400 Subject: [PATCH 22/39] Enable ruff format and import sorting (config) (#52) Add the I (isort) rule to ruff.toml, a ruff-format pre-commit hook, and a ruff format --check step in CI. Update the README and changelog. The mechanical reformat lands in the next commit so it can be isolated in .git-blame-ignore-revs. --- .github/workflows/ci.yml | 2 ++ .pre-commit-config.yaml | 4 +--- CHANGELOG.md | 1 + README.md | 2 +- ruff.toml | 15 +++++++-------- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3ddf7d..8db09ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: 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 }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 41d6356..f06928e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,12 @@ # Pre-commit hooks for pystrix. Run `pre-commit install` once to enable, then # the hooks run on each commit. See https://pre-commit.com. -# -# Formatting hooks are intentionally omitted for now (see issue #45); ruff is -# used for linting only until a dedicated formatting pass. 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index bee8226..2f37df8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. ### 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. diff --git a/README.md b/README.md index 8e0bc05..e0ec876 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ The editable install (`-e`) means your local edits take effect without reinstall 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 with ruff.** `ruff check .` must pass, and CI enforces it. Install the hooks to lint on each commit: `pip install pre-commit && pre-commit install`. Formatting is not enforced yet. +- **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`. `setup.py` reads `VERSION` from there. diff --git a/ruff.toml b/ruff.toml index 494fdad..d866ebd 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,15 +1,14 @@ -# Lint configuration for pystrix. +# Lint and format configuration for pystrix. # -# Formatting is intentionally not enabled yet (see issue #45): the existing -# style is non-standard and `ruff format` would rewrite ~4,000 lines. A later -# pass, paired with the Python 2 shim removal, will own formatting. When the -# project moves to pyproject.toml, this config can move there. +# Formatting is handled by `ruff format` at its default line length of 88. +# When the project moves to pyproject.toml, this config can move there. target-version = "py39" [lint] -# pycodestyle errors/warnings and pyflakes, minus the whitespace, indentation, -# and line-length families that a formatting pass will own. -select = ["E4", "E7", "E9", "F", "W605"] +# pycodestyle errors, pyflakes, import sorting (isort), and invalid escape +# sequences. The whitespace, indentation, and line-length families are owned by +# `ruff format`. +select = ["E4", "E7", "E9", "F", "I", "W605"] [lint.per-file-ignores] # The __init__ modules deliberately re-export their public API. From a494c88d84f14d26c9ce35dabafe15eb7124cb3e Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 13:08:44 -0400 Subject: [PATCH 23/39] Reformat with ruff format and sort imports Pure mechanical reformat (ruff format, line length 88) plus import sorting (ruff's I rule). No behavior change. This commit is listed in .git-blame-ignore-revs so git blame skips it. One non-mechanical detail: the # noqa: E721 on the legacy type()==bool check in core.py was moved onto the comparison line, since the reformat wrapped the statement and separated the comment from the violation. --- build-release.py | 50 +- doc/conf.py | 43 +- pystrix/__init__.py | 9 +- pystrix/agi/__init__.py | 34 +- pystrix/agi/agi.py | 25 +- pystrix/agi/agi_core.py | 209 ++++--- pystrix/agi/core.py | 506 ++++++++------- pystrix/agi/fastagi.py | 57 +- pystrix/ami/__init__.py | 49 +- pystrix/ami/ami.py | 877 +++++++++++++++----------- pystrix/ami/app_confbridge.py | 129 ++-- pystrix/ami/app_confbridge_events.py | 134 ++-- pystrix/ami/app_meetme.py | 42 +- pystrix/ami/app_meetme_events.py | 126 ++-- pystrix/ami/core.py | 893 +++++++++++++++++---------- pystrix/ami/core_events.py | 652 ++++++++++++------- pystrix/ami/dahdi.py | 46 +- pystrix/ami/dahdi_events.py | 59 +- pystrix/ami/generic_transforms.py | 15 +- setup.py | 56 +- tests/test_agi_core.py | 71 ++- tests/test_ami_message.py | 51 +- tests/test_ami_request.py | 67 +- tests/test_import.py | 11 +- 24 files changed, 2562 insertions(+), 1649 deletions(-) diff --git a/build-release.py b/build-release.py index 6bda84c..6eaef15 100644 --- a/build-release.py +++ b/build-release.py @@ -2,43 +2,51 @@ """ Release-building script for pystrix. """ + import tarfile from pystrix import VERSION + def _filter(tarinfo, acceptable_extensions): - if tarinfo.name.startswith('.'): #Ignore hidden files + if tarinfo.name.startswith("."): # Ignore hidden files return None - - if tarinfo.isdir(): #Directories are good + + if tarinfo.isdir(): # Directories are good return tarinfo - - if tarinfo.name.endswith(acceptable_extensions): #It's a file we want + + if tarinfo.name.endswith(acceptable_extensions): # It's a file we want print("\tAdded " + tarinfo.name) return tarinfo - - return None #DO NOT WANT - + + return None # DO NOT WANT + + def python_filter(tarinfo): - return _filter(tarinfo, ('.py',)) + return _filter(tarinfo, (".py",)) + def doc_filter(tarinfo): - return _filter(tarinfo, ('.py','.rst','Makefile')) - -if __name__ == '__main__': + 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") - f.add('COPYING', arcname="%s/COPYING" % base_name) - f.add('COPYING.LESSER', arcname="%s/COPYING.LESSER" % 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('README.md', arcname="%s/README.md" % base_name) - f.add('CHANGELOG.md', arcname="%s/CHANGELOG.md" % base_name) + f.add("README.md", arcname="%s/README.md" % base_name) + f.add("CHANGELOG.md", arcname="%s/CHANGELOG.md" % base_name) print("\tAdded README and changelog") - 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.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/conf.py b/doc/conf.py index 91d1481..c495f52 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -1,40 +1,47 @@ # -*- coding: utf-8 -*- -import sys 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(r'^(\d+\.\d+)', module.VERSION).group(1) +version = re.match(r"^(\d+\.\d+)", module.VERSION).group(1) release = module.VERSION -exclude_patterns = ['_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 = 'sphinx_rtd_theme' + + html_theme = "sphinx_rtd_theme" except ImportError: - html_theme = 'alabaster' -html_static_path = ['_static'] + 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", + re.search(", (.*?) <", module.COPYRIGHT).group(1), + "manual", + ), ] diff --git a/pystrix/__init__.py b/pystrix/__init__.py index 2744c3b..bad110c 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.2.0' -COPYRIGHT = '2013, Neil Tallim ' +VERSION = "1.2.0" +COPYRIGHT = "2013, Neil Tallim " 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 bc4d783..8070b09 100644 --- a/pystrix/agi/agi_core.py +++ b/pystrix/agi/agi_core.py @@ -34,21 +34,23 @@ - Neil Tallim """ + import collections import re - 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): """ @@ -56,23 +58,26 @@ def quote(value): necessary. """ return '"%(value)s"' % { - 'value': str(value), + "value": str(value), } -#Classes +# Classes ############################################################################### 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. @@ -80,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() @@ -95,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. @@ -127,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 _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) + 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. @@ -171,61 +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() - if isinstance(line, bytes): # bytes from a FastAGI socket; already str from stdin (AGI) + 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. @@ -236,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: """ 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 arg is not 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 @@ -281,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 4224d1f..43bc693 100644 --- a/pystrix/agi/fastagi.py +++ b/pystrix/agi/fastagi.py @@ -28,23 +28,24 @@ 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 platform import socket +import socketserver import subprocess import threading from urllib.parse import parse_qs + from pystrix.agi.agi_core import * from pystrix.agi.agi_core import _AGI -import socketserver - class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): """ @@ -89,6 +90,7 @@ 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, @@ -98,7 +100,7 @@ def handle(self): (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) @@ -109,8 +111,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): """ @@ -118,29 +120,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, 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) @@ -169,7 +179,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) @@ -177,7 +187,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): @@ -200,12 +210,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): @@ -218,16 +228,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`. @@ -236,6 +248,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 78ff08f..7722146 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,73 +44,100 @@ import traceback import warnings -import 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 +_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 -_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 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: - _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): + _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. @@ -117,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) @@ -129,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() @@ -157,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. @@ -174,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: @@ -218,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 @@ -272,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. @@ -314,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 @@ -339,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): @@ -377,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. @@ -394,21 +455,24 @@ 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. """ + def _monitor_connection(): from pystrix.ami import core while self.is_connected(): self.send_action(core.Ping()) 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() - + def _compile_callback_definition(self, event, function): """ Provides a triple of type, match-criteria, and callback for the given event-identifier and @@ -424,9 +488,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 @@ -435,7 +501,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. @@ -452,42 +518,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 @@ -498,10 +564,10 @@ 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. @@ -510,24 +576,38 @@ def send_action(self, request, action_id=None, **kwargs): """ if not self.is_connected(): raise ManagerError("Not connected to an Asterisk manager") - - (command, action_id) = request.build_request(None if action_id is None else 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 ( + 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: @@ -535,26 +615,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, @@ -564,20 +654,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: @@ -591,45 +684,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 @@ -644,12 +743,14 @@ def _serve_outstanding_request(self, action_id): del self._outstanding_requests[action_id] return served + 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 @@ -658,71 +759,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): @@ -731,30 +836,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. """ @@ -765,35 +877,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 @@ -801,29 +914,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. @@ -832,10 +948,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 + 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 @@ -844,7 +962,7 @@ def action_id(self): Provides the action-ID associated with the message, if any. """ return self.get(KEY_ACTIONID) - + @property def name(self): """ @@ -852,11 +970,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 @@ -864,47 +984,51 @@ 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. - + 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 k not in (KEY_ACTION, KEY_ACTIONID)] + list(kwargs.items()): + 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: @@ -912,60 +1036,76 @@ def build_request(self, action_id, id_generator, **kwargs): else: items.append((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. + # 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((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 @@ -973,7 +1113,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 @@ -982,14 +1122,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 @@ -1003,7 +1143,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. @@ -1013,50 +1153,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: """ 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 @@ -1064,20 +1219,20 @@ 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. @@ -1087,7 +1242,7 @@ def close(self): return with self._socket_write_lock: self._close() - + def _close(self): """ Performs the actual closing; needed to avoid a deadlock. @@ -1097,14 +1252,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. @@ -1118,48 +1273,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. @@ -1168,16 +1332,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. @@ -1194,36 +1361,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 d2f3a4f..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,38 +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) -from pystrix.ami import core_events -from pystrix.ami import app_confbridge_events + +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): """ @@ -74,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. @@ -186,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): """ @@ -206,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 e554c6a..52249ab 100644 --- a/pystrix/ami/app_meetme.py +++ b/pystrix/ami/app_meetme.py @@ -34,8 +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) + from pystrix.ami import app_meetme_events +from pystrix.ami.ami import _Request + class MeetmeList(_Request): """ @@ -46,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') + _Request.__init__(self, "MeetmeList") if conference is not None: - self['Conference'] = conference - + self["Conference"] = conference + + class MeetmeListRooms(_Request): """ Lists all conferences. @@ -65,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 54d4ea3..687a307 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,31 +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 -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 @@ -75,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 @@ -120,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 + _Request.__init__(self, "AGI") + self["Channel"] = channel + self["Command"] = command if command_id is not None: - self['CommandID'] = str(command_id) + self["CommandID"] = str(command_id) + class Bridge(_Request): """ @@ -132,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 + _Request.__init__(self, "DBDelTree") + self["Family"] = family if key is not None: - self['Key'] = key - + 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 + _Request.__init__(self, "Getvar") + self["Variable"] = variable if channel is not None: - self['Channel'] = channel - + 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): """ @@ -414,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): """ @@ -429,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 - + _Request.__init__(self, "Login") + self["Username"] = username + if challenge is not None and authtype: - self['AuthType'] = 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): """ @@ -554,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): """ @@ -567,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' @@ -586,24 +657,26 @@ def __init__(self, load_type, module=None): * 'manager' * 'rtp' """ - _Request.__init__(self, 'ModuleLoad') - self['LoadType'] = load_type + _Request.__init__(self, "ModuleLoad") + self["LoadType"] = load_type if module is not None: - self['Module'] = module - + 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 @@ -616,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. @@ -679,31 +766,52 @@ 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". @@ -729,18 +837,33 @@ 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 data: - self['Data'] = ','.join((str(d) for d in 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". @@ -766,17 +889,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 @@ -786,10 +913,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): """ @@ -798,37 +926,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. @@ -836,32 +969,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): """ @@ -871,8 +1007,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`. @@ -882,12 +1019,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): """ @@ -895,6 +1033,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. @@ -906,14 +1045,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 + self["Queue"] = queue + self["Event"] = event if uniqueid is not None: - self['Uniqueid'] = uniqueid + self["Uniqueid"] = uniqueid if interface is not None: - self['Interface'] = interface + self["Interface"] = interface if message is not None: - self['Message'] = message + self["Message"] = message + class QueuePause(_Request): """ @@ -923,16 +1063,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' + self["Interface"] = interface + self["Paused"] = paused and "true" or "false" if queue is not None: - self['Queue'] = queue + self["Queue"] = queue + class QueuePenalty(_Request): """ @@ -940,16 +1082,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) + self["Interface"] = interface + self["Penalty"] = str(penalty) if queue is not None: - self['Queue'] = queue + self["Queue"] = queue + class QueueReload(_Request): """ @@ -957,24 +1101,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 + self["Members"] = members + self["Rules"] = rules + self["Parameters"] = parameters if queue is not None: - self['Queue'] = queue - + self["Queue"] = queue + + class QueueRemove(_Request): """ Removes a member from a queue. @@ -983,15 +1129,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): """ @@ -1000,19 +1148,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 queue is not None: - self['Queue'] = queue + self["Queue"] = queue + - class QueueSummary(_Request): """ Describes the Summary of one (or all) queues. @@ -1020,6 +1173,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,15 +1184,16 @@ def __init__(self, queue=None): """ _Request.__init__(self, "QueueSummary") if queue is not None: - self['Queue'] = queue - - + 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. @@ -1048,17 +1203,19 @@ 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 @@ -1066,53 +1223,60 @@ def __init__(self, module=None): """ _Request.__init__(self, "Reload") if module is not None: - self['Module'] = module + 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): """ @@ -1120,6 +1284,7 @@ class SIPnotify(_Request): Requires call """ + def __init__(self, channel, headers={}): """ `channel` is the channel along which to send the NOTIFY. @@ -1127,9 +1292,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): """ @@ -1140,13 +1315,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. @@ -1155,19 +1332,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 @@ -1179,10 +1358,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': ? @@ -1213,37 +1392,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. @@ -1253,13 +1449,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. @@ -1268,44 +1466,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): """ @@ -1313,6 +1517,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`. @@ -1321,76 +1526,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 # noqa: E721 - - 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 + self["Action-" + index] = action + self["Cat-" + index] = category if variable is not None: - self['Var-' + index] = variable + self["Var-" + index] = variable if value is not None: - self['Value-' + index] = value + self["Value-" + index] = value if match is not None: - self['Match-' + index] = match + 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 c1af5ed..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 221ee89..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,72 +33,84 @@ The requests implemented by this module follow the definitions provided by https://wiki.asterisk.org/ """ -from pystrix.ami.ami import (_Request) + from pystrix.ami import dahdi_events +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') + _Request.__init__(self, "DAHDIShowChannels") if dahdi_channel is not None: - self['DAHDIChannel'] = dahdi_channel - + 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 f8da774..8543c06 100644 --- a/pystrix/ami/generic_transforms.py +++ b/pystrix/ami/generic_transforms.py @@ -28,10 +28,17 @@ - Neil Tallim """ + string_type = str -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 @@ -42,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))) @@ -50,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))) @@ -60,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 index 5b5f1c2..6dbfd47 100644 --- a/setup.py +++ b/setup.py @@ -3,50 +3,50 @@ Deployment script for pystrix. """ -from pystrix import VERSION +import os from setuptools import setup -import os +from pystrix import VERSION CLASSIFIERS = [ - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)', - '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' + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", + "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", ] -README = open(os.path.join(os.path.dirname(__file__), 'README.md')).read() +README = open(os.path.join(os.path.dirname(__file__), "README.md")).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', + author="Marta Solano", + author_email="marta.solano@ivrtechnology.com", + name="pystrix", version=VERSION, - description='Python bindings for Asterisk Manager Interface and Asterisk Gateway Interface', + description="Python bindings for Asterisk Manager Interface and Asterisk Gateway Interface", long_description=README, - long_description_content_type='text/markdown', - url='https://github.com/IVRTech/pystrix', - license='GNU Lesser General Public License v3 or later', - platforms=['OS Independent'], + long_description_content_type="text/markdown", + url="https://github.com/IVRTech/pystrix", + license="GNU Lesser General Public License v3 or later", + platforms=["OS Independent"], classifiers=CLASSIFIERS, - python_requires='>=3.9', + python_requires=">=3.9", extras_require={ - 'test': ['pytest', 'pytest-cov'], + "test": ["pytest", "pytest-cov"], }, packages=[ - 'pystrix', - 'pystrix.agi', - 'pystrix.ami', - ] + "pystrix", + "pystrix.agi", + "pystrix.ami", + ], ) diff --git a/tests/test_agi_core.py b/tests/test_agi_core.py index 761b645..e7c3403 100644 --- a/tests/test_agi_core.py +++ b/tests/test_agi_core.py @@ -1,10 +1,17 @@ """Tests for AGI response parsing and helpers (`pystrix.agi.agi_core`).""" + import pytest from pystrix.agi.agi_core import ( - _AGI, _Action, quote, - AGIInvalidCommandError, AGIDeadChannelError, AGIResultHangup, AGINoResultError, - AGIUsageError, AGIUnknownError, + _AGI, + AGIDeadChannelError, + AGIInvalidCommandError, + AGINoResultError, + AGIResultHangup, + AGIUnknownError, + AGIUsageError, + _Action, + quote, ) @@ -17,7 +24,7 @@ def __init__(self, *lines): def readline(self): if self._index >= len(self._lines): - return b'' + return b"" line = self._lines[self._index] self._index += 1 return line @@ -32,21 +39,21 @@ def _agi(*lines): def test_parses_success_result(): - response = _agi('200 result=1\n')._get_result() + response = _agi("200 result=1\n")._get_result() assert response.code == 200 - assert response.items['result'].value == '1' + 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' + 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 == '' + response = _agi("200 result=1\n")._get_result() + assert response.items["result"].data == "" def test_usage_error_collects_multiline_block(): @@ -54,66 +61,66 @@ def test_usage_error_collects_multiline_block(): # 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', + "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 + 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 + assert _agi("\n")._get_result() is None def test_unknown_code_raises(): with pytest.raises(AGIUnknownError): - _agi('418 unexpected\n')._get_result() + _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() + _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' + 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() + _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() + _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() + _agi("200 foo=bar\n")._get_result() def test_hangup_result_raises(): with pytest.raises(AGIResultHangup): - _agi('200 result=-1 (hangup)\n')._get_result() + _agi("200 result=-1 (hangup)\n")._get_result() def test_action_command_formatting(): - assert _Action('ANSWER').command == 'ANSWER\n' + 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' + assert _Action("STREAM FILE", "demo", None, "#").command == "STREAM FILE demo #\n" def test_quote_wraps_in_double_quotes(): - assert quote('demo') == '"demo"' + assert quote("demo") == '"demo"' assert quote(5) == '"5"' @@ -126,7 +133,7 @@ def __init__(self, *lines): def readline(self): if self._index >= len(self._lines): - return '' + return "" line = self._lines[self._index] self._index += 1 return line @@ -141,15 +148,15 @@ def _agi_with_reader(reader): 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' + 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' + return b"\xff\xfe not utf-8\n" with pytest.raises(UnicodeDecodeError): _agi_with_reader(_BadBytesReader())._get_result() diff --git a/tests/test_ami_message.py b/tests/test_ami_message.py index 9322121..066b37a 100644 --- a/tests/test_ami_message.py +++ b/tests/test_ami_message.py @@ -1,68 +1,69 @@ """Tests for AMI message parsing (`pystrix.ami.ami._Message`).""" -from pystrix.ami.ami import _Message, RESPONSE_GENERIC, EVENT_GENERIC + +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]) + 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' + 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' + 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' + assert _message("Event: Hangup", "Response: Success").name == "Hangup" def test_equality_against_string_uses_name(): - assert _message('Event: Hangup') == 'Hangup' + 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'] + 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 + 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'] + 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'] + 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 + 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 + assert _message("Foo: bar")["Event"] == EVENT_GENERIC diff --git a/tests/test_ami_request.py b/tests/test_ami_request.py index 57015f2..316712f 100644 --- a/tests/test_ami_request.py +++ b/tests/test_ami_request.py @@ -1,75 +1,76 @@ """Tests for AMI request building (`pystrix.ami.ami._Request`).""" + from pystrix.ami.ami import _Request def _build(request, action_id=None, **kwargs): - return request.build_request(action_id, lambda: 'GEN-1', **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' + 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 + 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' + request = _Request("Login") + request["Username"] = "admin" command, _ = _build(request) - assert command.startswith('Action: Login\r\n') + 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') + 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 + assert "Variable: a=1\r\n" in command + assert "Variable: b=2\r\n" in command def test_multi_value_order_preserved_and_blank_line_terminator(): - request = _Request('Originate') - request['Variable'] = ('a=1', 'b=2') + 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') + 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 + 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' + request = _Request("Ping") + request["ActionID"] = "preset" command, action_id = _build(request) - assert action_id == 'preset' - assert 'ActionID: preset' in command + 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 + 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 + request = _Request("Ping") + request["ActionID"] = 7 command, action_id = _build(request) - assert action_id == '7' - assert 'ActionID: 7' in command + assert action_id == "7" + assert "ActionID: 7" in command diff --git a/tests/test_import.py b/tests/test_import.py index 63ea4d2..d6982e0 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -3,16 +3,21 @@ def test_package_imports(): import pystrix + assert pystrix.VERSION def test_public_entry_points_import(): - from pystrix.agi import AGI, FastAGIServer, FastAGI - from pystrix.ami import Manager + 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) + assert ( + isinstance(AGI, type) + and isinstance(FastAGI, type) + and isinstance(Manager, type) + ) From 0ee20554edb7ff94629e16937407da331952adf4 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 13:08:44 -0400 Subject: [PATCH 24/39] Add .git-blame-ignore-revs for the ruff reformat (#52) List the mechanical reformat commit so git blame and GitHub attribute lines to their real authors. Enable locally with: git config blame.ignoreRevsFile .git-blame-ignore-revs --- .git-blame-ignore-revs | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .git-blame-ignore-revs 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 From c427ff7c0c65daac4e3fc8970f9ee25efa4be84f Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 13:54:15 -0400 Subject: [PATCH 25/39] Trim stale changelog clause (review panel follow-up) The lint-config Added entry still said 'formatting is deferred', which contradicts the new ruff format Changed entry in the same Unreleased block. Drop the clause. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f37df8..f39365e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 in `setup.py` (`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` lint configuration (`ruff.toml`), a `.pre-commit-config.yaml`, and a CI lint job. Linting only for now; formatting is deferred. +- A curated `ruff` lint configuration (`ruff.toml`), a `.pre-commit-config.yaml`, and a CI lint job. - This changelog. ### Changed From 78c2304cbec88a7b516d1aec09b17ef94581eb18 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 14:32:00 -0400 Subject: [PATCH 26/39] Migrate packaging to pyproject.toml with an SPDX license (#54) - Add pyproject.toml: PEP 621 metadata, requires-python >=3.9, the test extra, project URLs, and a dynamic version read from pystrix.VERSION. - PEP 639 SPDX license: license = "LGPL-3.0-or-later" + license-files for COPYING and COPYING.LESSER. Drop the deprecated License :: classifier. - Pin setuptools>=77 in [build-system] (PEP 639 support; supplied by build isolation). - Move the ruff config from ruff.toml into [tool.ruff]. - Remove setup.py, build-release.py, and ruff.toml. Build with `python -m build`. - Update README and AGENTS.md references (and refresh stale AGENTS notes that predated the test suite and linter), and the changelog. Verified: `python -m build` produces sdist + wheel with the SPDX license and bundled license files; `pip install -e '.[test]'` works; ruff and the 37 tests pass. Closes #54 --- AGENTS.md | 9 ++++--- CHANGELOG.md | 9 +++---- MANIFEST.in | 3 +-- README.md | 2 +- build-release.py | 52 ---------------------------------------- pyproject.toml | 62 ++++++++++++++++++++++++++++++++++++++++++++++++ ruff.toml | 20 ---------------- setup.py | 52 ---------------------------------------- 8 files changed, 73 insertions(+), 136 deletions(-) delete mode 100644 build-release.py create mode 100644 pyproject.toml delete mode 100644 ruff.toml delete mode 100644 setup.py diff --git a/AGENTS.md b/AGENTS.md index 54497d0..b507d04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,8 +37,7 @@ pystrix/ └── core.py ~45 Action classes (Answer, StreamFile, SayNumber, ...) doc/ Sphinx/reStructuredText docs + runnable examples in doc/examples/ -setup.py Packaging; reads README.md for long_description -build-release.py Release helper +pyproject.toml Packaging (PEP 621) and ruff config; version read from pystrix.VERSION ``` ## Architecture notes @@ -88,8 +87,8 @@ Two bytes/string details at the I/O boundary are not Python 2 baggage and must s ## Working in this repo -- There is no test suite and no linter config. Verify changes against the runnable examples in `doc/examples/` and, where possible, a live Asterisk server. -- `VERSION` in `pystrix/__init__.py` is the single source of truth. `setup.py` imports it. Bump it for releases. -- `setup.py` reads `README.md`. Keep that filename in sync if the README is ever renamed again. +- 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/CHANGELOG.md b/CHANGELOG.md index f39365e..a5f0a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,20 +12,21 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 in `setup.py` (`pip install -e '.[test]'`). +- 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` lint configuration (`ruff.toml`), a `.pre-commit-config.yaml`, and a CI lint job. +- 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 `setup.py`. pystrix is not dual-licensed. Both `COPYING` and `COPYING.LESSER` ship because the LGPL extends the GPL. -- Declared Python 3.9+ in `setup.py` through `python_requires` and trove classifiers (3.9 through 3.13), and dropped Python 2 and the end-of-life 3.x entries. +- 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. 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 index e0ec876..26ce47c 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ A few things to know before you send a change: - **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`. `setup.py` reads `VERSION` from there. +- **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 diff --git a/build-release.py b/build-release.py deleted file mode 100644 index 6eaef15..0000000 --- a/build-release.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python -""" -Release-building script for pystrix. -""" - -import tarfile - -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") - f.add("COPYING", arcname="%s/COPYING" % base_name) - f.add("COPYING.LESSER", arcname="%s/COPYING.LESSER" % base_name) - print("\tAdded license files") - f.add("README.md", arcname="%s/README.md" % base_name) - f.add("CHANGELOG.md", arcname="%s/CHANGELOG.md" % base_name) - print("\tAdded README and changelog") - 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..52eaafe --- /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 = "Marta Solano", email = "marta.solano@ivrtechnology.com" }] +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/ruff.toml b/ruff.toml deleted file mode 100644 index d866ebd..0000000 --- a/ruff.toml +++ /dev/null @@ -1,20 +0,0 @@ -# Lint and format configuration for pystrix. -# -# Formatting is handled by `ruff format` at its default line length of 88. -# When the project moves to pyproject.toml, this config can move there. -target-version = "py39" - -[lint] -# pycodestyle errors, pyflakes, import sorting (isort), and invalid escape -# sequences. The whitespace, indentation, and line-length families are owned by -# `ruff format`. -select = ["E4", "E7", "E9", "F", "I", "W605"] - -[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/setup.py b/setup.py deleted file mode 100644 index 6dbfd47..0000000 --- a/setup.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python -""" -Deployment script for pystrix. -""" - -import os - -from setuptools import setup - -from pystrix import VERSION - -CLASSIFIERS = [ - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)", - "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", -] - -README = open(os.path.join(os.path.dirname(__file__), "README.md")).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, - long_description_content_type="text/markdown", - url="https://github.com/IVRTech/pystrix", - license="GNU Lesser General Public License v3 or later", - platforms=["OS Independent"], - classifiers=CLASSIFIERS, - python_requires=">=3.9", - extras_require={ - "test": ["pytest", "pytest-cov"], - }, - packages=[ - "pystrix", - "pystrix.agi", - "pystrix.ami", - ], -) From ddf36ef5f21c927d4739c03332de824125c9a655 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 14:40:30 -0400 Subject: [PATCH 27/39] Set maintainer to IVR Technology Group (org), no email Per maintainer decision: the package metadata names the maintaining organization rather than enumerating individuals (who stay credited via the GitHub contributors graph and README). No emails, so no contributor PII is published. Author remains Neil Tallim. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 52eaafe..115d467 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ requires-python = ">=3.9" license = "LGPL-3.0-or-later" license-files = ["COPYING", "COPYING.LESSER"] authors = [{ name = "Neil Tallim" }] -maintainers = [{ name = "Marta Solano", email = "marta.solano@ivrtechnology.com" }] +maintainers = [{ name = "IVR Technology Group" }] keywords = ["asterisk", "ami", "agi", "fastagi", "telephony"] classifiers = [ "Intended Audience :: Developers", From 9faa660b3c21143f56d4de7302e1692985df0b95 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 14:50:17 -0400 Subject: [PATCH 28/39] Address review panel: CI package job + drop stale changelog entry - Add a `package` CI job that runs `python -m build`, validates metadata with `twine check`, installs the wheel, and imports the package. The packaging migration's main regression surface was previously unguarded in CI (codex finding). - Remove the stale `build-release.py` Fixed changelog entry: it credited a fix to a script this branch deletes, contradicting the migration entry in the same Unreleased block (consistency finding). The dynamic-version reorder suggestion was dropped: codex verified the static AST attr resolution returns the version without importing the package, so no change is needed. --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ CHANGELOG.md | 1 - 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8db09ae..80f52a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,3 +61,24 @@ jobs: 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@v4 + - name: Set up Python + uses: actions/setup-python@v5 + 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/CHANGELOG.md b/CHANGELOG.md index a5f0a93..09a51aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,6 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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). -- `build-release.py` no longer fails on the missing `pystrix.spec`. The RPM packaging path was removed, and the release tarball now ships `README.md` and `CHANGELOG.md` so a build from the sdist can read the long description. - 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. From 1ded62ecccd924fc315af00d09ae099177740766 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 15:07:45 -0400 Subject: [PATCH 29/39] Prepare 1.3.0 release - Bump VERSION to 1.3.0. - Move the changelog Unreleased entries into [1.3.0] - 2026-06-24 and add the compare links. - Add a Python compatibility matrix to the README. Dropping Python 2 is strictly breaking, but Py2 support was already nominal and EOL, so 1.3.0 (minor) is the pragmatic version. Closes #56 --- CHANGELOG.md | 5 ++++- README.md | 11 ++++++++++- pystrix/__init__.py | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09a51aa..374eb73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [1.3.0] - 2026-06-24 + ### Added - `AGENTS.md` with an architecture overview and contributor guidance. - `CLAUDE.md` pointing to `AGENTS.md`. @@ -51,5 +53,6 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases before 1.2.0 are recorded in the git commit history. -[Unreleased]: https://github.com/IVRTech/pystrix/compare/v1.2.0...HEAD +[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/README.md b/README.md index 26ce47c..943e948 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,16 @@ pystrix runs on Python 3.9+. It targets Asterisk 1.10+ and provides a rich, easy 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.2.0**. The canonical version lives in `pystrix/__init__.py`. New releases follow `..`, with a patch release cut for each bug fix. +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.0 and later | 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 diff --git a/pystrix/__init__.py b/pystrix/__init__.py index bad110c..cb15305 100644 --- a/pystrix/__init__.py +++ b/pystrix/__init__.py @@ -43,5 +43,5 @@ import pystrix.agi import pystrix.ami -VERSION = "1.2.0" +VERSION = "1.3.0" COPYRIGHT = "2013, Neil Tallim " From 3afffc58860a7e9e6653c2f8afbd3d094225ab7e Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 15:20:09 -0400 Subject: [PATCH 30/39] Address release review panel feedback - AGENTS.md: drop the stale "(currently 1.2.0)" version note from the repo-layout tree (replaced with "single source of truth" so it can't go stale again). - README compatibility matrix: scope the supported row to "1.3.x" instead of "1.3.0 and later", which overpromised future versions. - CHANGELOG: note that the Python 2 drop is a pragmatic minor bump, not a 2.0.0 major, since Python 2 support was already nominal and EOL. --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b507d04..864c6e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ The core split to keep in mind: AGI is one blocking call per script; AMI is one ``` pystrix/ -├── __init__.py VERSION lives here (currently 1.2.0) +├── __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, ...) diff --git a/CHANGELOG.md b/CHANGELOG.md index 374eb73..17a8fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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. +- 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). diff --git a/README.md b/README.md index 943e948..5c839c2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This repository is version **1.3.0**. The canonical version lives in `pystrix/__ | pystrix | Python | | --- | --- | -| 1.3.0 and later | 3.9 – 3.13 | +| 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+. From 0270e3802145172c03e8f77f256f9b1a60cd922c Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 15:31:37 -0400 Subject: [PATCH 31/39] Add PyPI auto-publish on release via Trusted Publishing (#58) A publish.yml workflow builds the sdist + wheel and uploads to PyPI when a GitHub release is published. Authentication is PyPI Trusted Publishing (OIDC) -- no API token is stored. The publish job requests id-token:write and runs in a 'pypi' GitHub Environment, which also allows an optional manual-approval gate. Requires a one-time PyPI Trusted Publisher config (owner IVRTech, repo pystrix, workflow publish.yml, environment pypi). Closes #58 --- .github/workflows/publish.yml | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..e13a281 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,37 @@ +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. +# +# 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: + publish: + name: Publish to PyPI + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: pypi + url: https://pypi.org/project/pystrix/ + permissions: + id-token: write # required for Trusted Publishing (OIDC) + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Build sdist and wheel + run: | + python -m pip install build + python -m build + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 From dbfccd3fc06627b2ceea1d711c2dd94093d809c0 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 16:10:01 -0400 Subject: [PATCH 32/39] Harden publish workflow per review panel - Split into an unprivileged `build` job and an OIDC `publish` job (needs: build), passing dist/ via artifact. Only the publish job holds id-token: write, so the build backend never has the publishing identity (PyPA-recommended structure). - Add contents: read to the publish job's permissions (job-level perms do not inherit the top-level block). - Add a verify step: non-empty dist/, twine check, and a built-version vs release-tag match (read from an env var to avoid injection). Kept pypa/gh-action-pypi-publish@release/v1 (PyPA's recommended ref) rather than a SHA pin; SHA-pinning is deferred to pair with Dependabot. --- .github/workflows/publish.yml | 47 +++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e13a281..ca29440 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,6 +2,8 @@ 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", @@ -14,15 +16,10 @@ permissions: contents: read jobs: - publish: - name: Publish to PyPI + build: + name: Build distribution runs-on: ubuntu-latest timeout-minutes: 10 - environment: - name: pypi - url: https://pypi.org/project/pystrix/ - permissions: - id-token: write # required for Trusted Publishing (OIDC) steps: - uses: actions/checkout@v4 - name: Set up Python @@ -31,7 +28,41 @@ jobs: python-version: "3.11" - name: Build sdist and wheel run: | - python -m pip install build + 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@v4 + 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@v4 + with: + name: dist + path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 From b40c078c3464fddfd3efe69349273a762537784c Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 19:24:07 -0400 Subject: [PATCH 33/39] Add regression test for GetData timeout parsing (#9) (#61) * Add regression test for GetData timeout parsing (#9) Issue #9 reported that a GetData timeout ("result= (timeout)") raised AGINoResultError. It was fixed in 12c4bd7 (2014) by making the value group optional in the KV regex, but the migrated issue was never closed and the behavior was untested. Add regression tests pinning it: the empty-value-with-data parse, and GetData.process_response returning ("", True) on timeout and ("", False) otherwise. Closes #9 * Add partial-digits timeout regression test (#9) Cover the common real-world GetData timeout where a caller enters some digits before the inter-digit timer expires. Asterisk replies "result=12 (timeout)", and process_response must return both the collected digits and the timeout flag. Pins the value/data split that the existing empty-value and no-timeout cases do not exercise together. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- tests/test_agi_core.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_agi_core.py b/tests/test_agi_core.py index e7c3403..41f6655 100644 --- a/tests/test_agi_core.py +++ b/tests/test_agi_core.py @@ -13,6 +13,7 @@ _Action, quote, ) +from pystrix.agi.core import GetData class _FakeReader: @@ -160,3 +161,36 @@ def readline(self): 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 From d7c902bba79ddefe5c5c3453e4e7f6b088363542 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 22:01:31 -0400 Subject: [PATCH 34/39] Stop monitor_connection thread from crashing on dropped connection (#62) * Stop monitor_connection thread from crashing on dropped connection (#3) When Asterisk stops, the monitor thread's periodic Ping fails: send_action raises ManagerSocketError. Nothing caught it, so the exception propagated out of the thread target and dumped a traceback to stderr while the monitor died silently. Catch ManagerSocketError in the ping loop and break, mirroring how _MessageReader.run already handles a dead socket. Also return the monitoring thread so callers can join it, which makes the behavior testable without relying on sleep timing. Add a regression test that drives a monitor whose send_action raises and asserts the thread exits cleanly with no unhandled exception. Co-Authored-By: Claude Opus 4.8 * Also catch ManagerError race and log monitor exit (#3 review) Review (Codex) found the catch was too narrow. The monitor checks is_connected(), then send_action(Ping()) runs its own liveness re-check and raises ManagerError -- a sibling of ManagerSocketError, not a subclass -- if the connection dropped in between. That race re-introduced the #3 crash via an uncaught ManagerError. Catch both ManagerError and ManagerSocketError, and log the reason at debug level when a logger is set, so a stopped monitor thread is traceable. Tests: add the ManagerError race path, the normal ping-until-disconnected exit path (which also pins that a joinable thread is returned), and a guard that an unexpected non-connection error still propagates rather than being swallowed by a too-broad except. Co-Authored-By: Claude Opus 4.8 * Guard send_action against disconnect race; address review info items (#3) Review (PR #62 panel) found the monitor could still crash: send_action passes its liveness check, registers the request, then a concurrent disconnect() nulls _connection before the send, so send_message is called on None -> AttributeError, which the monitor's (ManagerError, ManagerSocketError) catch misses. Recheck _connection under the lock already held for the send; if it is gone, drop the just-registered request and raise ManagerSocketError instead of dereferencing None. This converts the raw crash into the documented exception the monitor already handles, completing the #3 fix. Supersedes the separately-filed #63. Also from the panel: - Assert each monitored ping is actually a Ping request. - Add a test for the debug-log-on-exit branch. - Add a send_action test proving the race raises ManagerSocketError, not AttributeError. Co-Authored-By: Claude Opus 4.8 * Address full-panel findings on the send_action race delta (#3) From the review panel on the race-fix delta: - Tighten the monitor logging test to assert the exception detail survives into the message, not just the static prefix (a dropped "% exc" would otherwise pass). - Add a synchronous-request variant of the race test. No shipped request sets synchronous = True, so a minimal subclass exercises the (events, finalisers) tracking entry and confirms the cleanup drops it too, so a synchronous caller is not left waiting on events that never arrive. - Reword the ManagerSocketError message to match the "Asterisk manager" family used by the other socket-error raises. - Note in the send_action docstring that a concurrent disconnect surfaces as ManagerSocketError. Co-Authored-By: Claude Opus 4.8 * Clean up outstanding request when send_message fails mid-write (#3) Review follow-up: the disconnect race fix only dropped the registered request on the _connection-is-None path. A live socket that breaks inside send_message raises ManagerSocketError before the normal cleanup at _serve_outstanding_request runs, leaking the just-registered action_id in _outstanding_requests. Wrap send_message in except ManagerSocketError, pop the action_id, and re-raise. Add fake-connection tests that raise from send_message and assert the request is dropped for both async and synchronous requests. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 4 + pystrix/ami/ami.py | 39 +++++++++- tests/test_ami_monitor.py | 133 ++++++++++++++++++++++++++++++++++ tests/test_ami_send_action.py | 90 +++++++++++++++++++++++ 4 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 tests/test_ami_monitor.py create mode 100644 tests/test_ami_send_action.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a8fb8..96ddc6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### 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). + ## [1.3.0] - 2026-06-24 ### Added diff --git a/pystrix/ami/ami.py b/pystrix/ami/ami.py index 7722146..ad59254 100644 --- a/pystrix/ami/ami.py +++ b/pystrix/ami/ami.py @@ -458,13 +458,29 @@ def monitor_connection(self, interval=2.5): `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( @@ -472,6 +488,7 @@ def _monitor_connection(): ) monitor.daemon = True monitor.start() + return monitor def _compile_callback_definition(self, event, function): """ @@ -570,7 +587,8 @@ def send_action(self, request, action_id=None, **kwargs): 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. This function is thread-safe. """ @@ -584,7 +602,22 @@ def send_action(self, request, action_id=None, **kwargs): ) events = self._add_outstanding_request(action_id, request) with self._connection_lock: - self._connection.send_message(command) + 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 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_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 == {} From 19f1b82bffb079a459eb5d103d262be44df775b8 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 22:33:22 -0400 Subject: [PATCH 35/39] Quietly end FastAGI request when a client disconnects during the handshake (#64) * Quietly end FastAGI request on handshake disconnect (#49) When a FastAGI client closes before sending the full AGI environment, the handshake read raises AGISIGPIPEHangup from inside _AGIClientHandler.handle(). It propagated uncaught, so socketserver printed a full traceback to stderr for a routine event (caller hung up, Asterisk aborted the leg, or a bare TCP probe). Wrap only the FastAGI(...) handshake construction in except AGIHangup and return early. The suppression is scoped to the environment read, so genuine errors from the script handler, which runs after this guard, still propagate. Add regression tests: a client that closes during the handshake makes handle() return without raising, and a handler that raises still propagates. Co-Authored-By: Claude Opus 4.8 * Narrow handshake catch to AGISIGPIPEHangup; strengthen tests (#49 review) Review panel convergence: - Narrow the handler guard from the AGIHangup base to AGISIGPIPEHangup, the only hangup reachable during the environment read (no command is sent, so AGIResultHangup cannot arise). This documents intent precisely, makes the code match the comment/CHANGELOG/test which already name AGISIGPIPEHangup, and avoids silently swallowing a future non-disconnect hangup introduced into construction. - Strengthen the disconnect test: assert the script handler is never invoked and nothing is written back on the quiet path. - Add a mid-handshake disconnect test (partial environment then EOF) covering the parse-loop EOF path, distinct from the first-read EOF case. - CHANGELOG: say the traceback is printed to stderr rather than "logged", since socketserver writes directly to stderr. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 1 + pystrix/agi/fastagi.py | 12 ++++- tests/test_fastagi_handler.py | 87 +++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/test_fastagi_handler.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ddc6e..dc12401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### 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). ## [1.3.0] - 2026-06-24 diff --git a/pystrix/agi/fastagi.py b/pystrix/agi/fastagi.py index 43bc693..8758c99 100644 --- a/pystrix/agi/fastagi.py +++ b/pystrix/agi/fastagi.py @@ -96,7 +96,17 @@ 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) 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() From b4014a82fdd9b192bc680db6a40766d212e20127 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Wed, 24 Jun 2026 23:17:13 -0400 Subject: [PATCH 36/39] Size FastAGI listen backlog via kernel cap instead of sysctl (#65) * Size FastAGI listen backlog via kernel cap, not sysctl (#32) get_somaxconn() shelled out to `sysctl` and parsed OS-specific output to read the system somaxconn, branched on Linux vs Darwin, and raised NotImplementedError on every other platform -- so FastAGIServer could not even start on, e.g., Windows or BSD. Reading the value is unnecessary. listen() already caps the backlog to the live system maximum, so passing the largest value the call accepts yields exactly that maximum and still tracks an administrator's tuned-up somaxconn automatically. Set request_queue_size to INT_MAX (2**31 - 1) and let the kernel clamp it. INT_MAX rather than a fixed ceiling like 65535: on modern kernels somaxconn is a 32-bit value that can be tuned above 65535, so any fixed ceiling could undershoot; the kernel's own limit is the only safe cap. Verified that listen() clamps (not rejects) a large backlog on Linux and on macOS/BSD. Removes the platform/socket/subprocess imports (only get_somaxconn used them) and the Linux/macOS-only restriction from the README and AGENTS docs. The _LISTEN_BACKLOG constant carries the full rationale inline. Add tests: the server sets request_queue_size to INT_MAX (binding on an ephemeral port also proves listen() accepts it), and the sysctl helper is gone. Co-Authored-By: Claude Opus 4.8 * Document INT_MAX ceiling and Windows path; tighten backlog tests (#32 review) Review panel convergence (all comment/test polish, no behavior change): - Extend the _LISTEN_BACKLOG comment with two facts the edge-case lane verified: the value must be exactly INT_MAX because CPython raises OverflowError for a listen() backlog above a signed 32-bit int (so a future "round up" would crash startup), and Windows accepts INT_MAX not via a somaxconn clamp but because Winsock's SOMAXCONN constant is itself 0x7fffffff, a sentinel meaning "use a maximum reasonable backlog". - Test: import and assert against _LISTEN_BACKLOG instead of re-hardcoding the literal, and assert it exceeds 65535 so the "no fixed ceiling" rationale is executable. - Test: also assert the module no longer imports subprocess/platform, a behavioral guard against reintroducing the shell-out that survives a rename of the old helper. Co-Authored-By: Claude Opus 4.8 * Lock exact INT_MAX backlog in test; scope clamp wording to Unix (#32 review) Review feedback: - The regression test asserted only `_LISTEN_BACKLOG > 65535`, which a future fixed ceiling like 1000000 would pass while violating the documented exactly-INT_MAX contract. Assert `_LISTEN_BACKLOG == 2**31 - 1` instead. - The "kernel caps to live somaxconn" wording read as universal but only holds on Unix-like systems; Windows accepts INT_MAX via the Winsock SOMAXCONN sentinel, not a somaxconn clamp. Scope the wording accordingly in the inline comment, README, CHANGELOG, and AGENTS.md so it no longer contradicts the block comment's Windows note. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- AGENTS.md | 2 +- CHANGELOG.md | 1 + README.md | 2 +- pystrix/agi/fastagi.py | 64 +++++++++++++++++++----------------- tests/test_fastagi_server.py | 29 ++++++++++++++++ 5 files changed, 65 insertions(+), 33 deletions(-) create mode 100644 tests/test_fastagi_server.py diff --git a/AGENTS.md b/AGENTS.md index 864c6e3..5a5e016 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ Each raw message parses into a generic `_Message`. The reader then looks up the ### FastAGI scaling -This fork's main feature is FastAGI throughput. `_ThreadedTCPServer` (`fastagi.py:52`) sizes `request_queue_size` from the system `SOMAXCONN` (read via `sysctl` on Linux and Darwin) 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. +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index dc12401..0c0fd4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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). ## [1.3.0] - 2026-06-24 diff --git a/README.md b/README.md index 5c839c2..877cd78 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ server.register_script_handler(None, demo_handler) # default handler server.serve_forever() ``` -The FastAGI server sizes its listen backlog from the system `SOMAXCONN` value, so it absorbs large bursts of simultaneous calls. It reads that value with `sysctl`, so the server currently runs on Linux and macOS only. AMI and AGI have no such restriction. +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 diff --git a/pystrix/agi/fastagi.py b/pystrix/agi/fastagi.py index 8758c99..45870ef 100644 --- a/pystrix/agi/fastagi.py +++ b/pystrix/agi/fastagi.py @@ -36,16 +36,43 @@ - Neil Tallim """ -import platform -import socket import socketserver -import subprocess import threading from urllib.parse import parse_qs from pystrix.agi.agi_core import * from pystrix.agi.agi_core import _AGI +# 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): """ @@ -53,35 +80,10 @@ class _ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer): request. """ - @staticmethod - def get_somaxconn(): - """ - Returns the value of SOMAXCONN configured in the system. - """ - # determine the OS appropriate management informations base (MIB) - # name to determine SOMAXCONN - system = platform.system() - if "Linux" == system: - sysctl_mib_somaxconn = "net.core.somaxconn" - sysctl_output_delimiter = "=" - elif "Darwin" == system: - sysctl_mib_somaxconn = "kern.ipc.somaxconn" - sysctl_output_delimiter = ":" - else: - raise NotImplementedError( - "Determining SOMAXCONN is not implemented for {} system.".format(system) - ) - # run the cmd to determine the SOMAXCONN - cmd_result = subprocess.check_output(["sysctl", sysctl_mib_somaxconn]) - - # parse the output of the cmd to return the value of SOMAXCONN - return int(cmd_result.decode().split(sysctl_output_delimiter)[-1].strip()) - def __init__(self, *args, **kwargs): - # adjust request queue size to a saner value for modern systems - # further adjustments are automatically picked up for kernel - # settings on server start - self.request_queue_size = max(socket.SOMAXCONN, self.get_somaxconn()) + # 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) 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") From f248c1668b3e924ffcc410c45628f493d196e117 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Sat, 27 Jun 2026 11:27:17 -0400 Subject: [PATCH 37/39] fix(ami): handle Originate application string data Treat plain string data for Originate_Application as a single application argument. Reject bytes-like data before it can serialize as integer values, and reject AMI headers containing CR or LF. Closes #11. --- .gitignore | 3 ++ CHANGELOG.md | 3 ++ pystrix/ami/ami.py | 25 ++++++++-- pystrix/ami/core.py | 22 +++++++-- tests/test_ami_request.py | 100 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 2719fbe..1cae4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,9 @@ target/ .venv/ venv/ +# Git worktrees +.worktrees/ + # macOS .DS_Store ._* diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0fd4f..868e776 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `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 diff --git a/pystrix/ami/ami.py b/pystrix/ami/ami.py index ad59254..017fcc9 100644 --- a/pystrix/ami/ami.py +++ b/pystrix/ami/ami.py @@ -590,6 +590,8 @@ def send_action(self, request, action_id=None, **kwargs): 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(): @@ -1056,18 +1058,33 @@ def build_request(self, action_id, id_generator, **kwargs): `**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])] + + 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))) + 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 @@ -1076,7 +1093,7 @@ def build_request(self, action_id, id_generator, **kwargs): 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((KEY_ACTIONID, action_id)) + items.append(validate_header(KEY_ACTIONID, action_id)) return ( _EOL.join( diff --git a/pystrix/ami/core.py b/pystrix/ami/core.py index 687a307..9224473 100644 --- a/pystrix/ami/core.py +++ b/pystrix/ami/core.py @@ -816,9 +816,10 @@ def __init__( `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 @@ -841,7 +842,22 @@ def __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: + # 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)) diff --git a/tests/test_ami_request.py b/tests/test_ami_request.py index 316712f..189593c 100644 --- a/tests/test_ami_request.py +++ b/tests/test_ami_request.py @@ -1,6 +1,13 @@ """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): @@ -34,6 +41,99 @@ def test_multi_value_header_expands_to_repeated_lines(): 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") From 7c29de2407579756a3a5570d391a3d12cfb9e42b Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Sat, 27 Jun 2026 14:02:17 -0400 Subject: [PATCH 38/39] chore(ci): refresh actions and docs metadata Refresh documentation copyright metadata, update GitHub Actions workflow dependencies to current Node 24-backed major versions, and add Dependabot coverage for GitHub Actions updates. Addresses #60. --- .github/dependabot.yml | 6 ++++++ .github/workflows/ci.yml | 16 ++++++++-------- .github/workflows/publish.yml | 8 ++++---- CHANGELOG.md | 3 +++ doc/conf.py | 3 ++- pystrix/__init__.py | 2 +- 6 files changed, 24 insertions(+), 14 deletions(-) create mode 100644 .github/dependabot.yml 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 index 80f52a5..a3f1689 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install ruff @@ -35,9 +35,9 @@ jobs: matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install package with test dependencies @@ -50,9 +50,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Install dependencies @@ -67,9 +67,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Build sdist and wheel diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ca29440..581d274 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,9 +21,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" - name: Build sdist and wheel @@ -42,7 +42,7 @@ jobs: exit 1 fi - name: Upload distribution artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 # keep paired with download-artifact@v8 in the publish job with: name: dist path: dist/ @@ -60,7 +60,7 @@ jobs: id-token: write # required for Trusted Publishing (OIDC) steps: - name: Download distribution artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 # keep paired with upload-artifact@v7 in the build job with: name: dist path: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 868e776..69138a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ 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). diff --git a/doc/conf.py b/doc/conf.py index c495f52..85eaae1 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -16,6 +16,7 @@ project = "pystrix" copyright = module.COPYRIGHT +copyright_holder = module.COPYRIGHT.split(", ", 1)[1] version = re.match(r"^(\d+\.\d+)", module.VERSION).group(1) release = module.VERSION @@ -41,7 +42,7 @@ "index", "pystrix.tex", "pystrix Documentation", - re.search(", (.*?) <", module.COPYRIGHT).group(1), + copyright_holder, "manual", ), ] diff --git a/pystrix/__init__.py b/pystrix/__init__.py index cb15305..72a4960 100644 --- a/pystrix/__init__.py +++ b/pystrix/__init__.py @@ -44,4 +44,4 @@ import pystrix.ami VERSION = "1.3.0" -COPYRIGHT = "2013, Neil Tallim " +COPYRIGHT = "2026, IVR Technology Group" From aaed730d8edb04d8bd2a8b30d34190c435bf4ee9 Mon Sep 17 00:00:00 2001 From: Karthic Raghupathi Date: Mon, 29 Jun 2026 16:10:34 -0400 Subject: [PATCH 39/39] docs(agi): document FastAGI hangup signaling (#68) --- doc/examples/fastagi.rst | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) 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`.