diff --git a/doc/examples/ami.rst b/doc/examples/ami.rst index d6806f8..1c9589a 100644 --- a/doc/examples/ami.rst +++ b/doc/examples/ami.rst @@ -86,36 +86,52 @@ for information:: self._manager.monitor_connection() def _register_callbacks(self): - #This sets up some event callbacks, so that interesting things, like calls being - #established or torn down, will be processed by your application's logic. Of course, - #since this is just an example, the same event will be registered using two different - #methods. + #This sets up some event callbacks so that interesting things, like calls being + #established or torn down, are processed by the application's logic. - #The event that will be registered is 'FullyBooted', sent by Asterisk immediately after - #connecting, to indicate that everything is online. What the following code does is - #register two different callback-handlers for this event using two different - #match-methods: string comparison and class-match. String-matching and class-resolution - #are equal in performance, so choose whichever you think looks better. + #pystrix supports four callback patterns: + # + # 1. Exact string match — fires when the wire event name matches exactly. + # 2. Class match — fires when the event has been typed to the given class. + # This works for all built-in classes and any class registered with + # pystrix.ami.register_event_class() (see below). + # 3. Empty string '' — a catch-all that fires for every event, useful for + # logging or debugging. + # 4. None — fires for responses not associated with any request, which + # typically indicates a glitch or a timed-out request. + # + #For events pystrix does not recognise, the event still arrives as a + #generic _Event object with all headers intact. Register against its wire + #name as a plain string to receive it. + + #Register 'FullyBooted' two ways to illustrate that string and class + #matching are equivalent in performance and semantics. self._manager.register_callback('FullyBooted', self._handle_string_event) self._manager.register_callback(pystrix.ami.core_events.FullyBooted, self._handle_class_event) - #Now, when 'FullyBooted' is received, both handlers will be invoked in the order in - #which they were registered. + #Both handlers are invoked in registration order when 'FullyBooted' arrives. - #A catch-all-handler can be set using the empty string as a qualifier, causing it to - #receive every event emitted by Asterisk, which may be useful for debugging purposes. + #Catch every event, regardless of type. self._manager.register_callback('', self._handle_event) - #Additionally, an orphan-handler may be provided using the special qualifier None, - #causing any responses not associated with a request to be received. This should only - #apply to glitches in pre-production versions of Asterisk or requests that timed out - #while waiting for a response, which is also indicative of glitchy behaviour. This - #handler could be used to process the orphaned response in special cases, but is likely - #best relegated to a logging role. + #Catch orphaned responses (those with no matching request). self._manager.register_callback(None, self._handle_event) - #And here's another example of a registered event, this time catching Asterisk's - #Shutdown signal, emitted when the system is shutting down. + #Catch Asterisk's Shutdown signal using a plain string. self._manager.register_callback('Shutdown', self._handle_shutdown) + + #To use a custom typed class for an event that pystrix does not define + #natively, register it before calling connect(). After registration, + #both type-mutation on arrival and class-based callbacks work normally. + # + # class MyQueueCallerJoin(pystrix.ami.ami._Event): + # pass + # + # pystrix.ami.register_event_class(MyQueueCallerJoin) + # self._manager.register_callback(MyQueueCallerJoin, self._handle_queue_event) + # + #Supply name= when the wire event name differs from the class name: + # + # pystrix.ami.register_event_class(MyClass, name='QueueCallerJoin') def _handle_shutdown(self, event, manager): self._kill_flag = True diff --git a/pystrix/ami/__init__.py b/pystrix/ami/__init__.py index f17b585..69b0459 100644 --- a/pystrix/ami/__init__.py +++ b/pystrix/ami/__init__.py @@ -59,6 +59,10 @@ Manager, ManagerError, ManagerSocketError, + _Aggregate, + _Event, + register_event_class, + unregister_event_class, ) for module in ( @@ -67,9 +71,16 @@ app_confbridge_events, app_meetme_events, ): - 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 + for name in (e for e in dir(module) if not e.startswith("_")): + class_object = getattr(module, name) + if not ( + isinstance(class_object, type) + and issubclass(class_object, (_Event, _Aggregate)) + ): + continue + _EVENT_REGISTRY[name] = class_object + _EVENT_REGISTRY_REV[class_object] = name del _EVENT_REGISTRY del _EVENT_REGISTRY_REV +del _Event +del _Aggregate diff --git a/pystrix/ami/ami.py b/pystrix/ami/ami.py index 017fcc9..dfd87ce 100644 --- a/pystrix/ami/ami.py +++ b/pystrix/ami/ami.py @@ -49,6 +49,66 @@ _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 + +def register_event_class(event_class, name=None): + """ + Registers a custom event class so that pystrix can type-mutate incoming AMI events + to it and class-based callbacks work with ``register_callback``. + + ``event_class`` must be a subclass of :class:`_Event`; a :exc:`ValueError` is raised + otherwise. ``name`` defaults to ``event_class.__name__``, which should match the + wire event name Asterisk sends. Supply an explicit ``name`` only when the desired + wire name differs from the class name. + + Register custom classes before calling :meth:`Manager.connect` to avoid a race + where events arrive before the class is registered. Registration is not + thread-safe; call this function from the main thread before connecting. + + Re-registering a class under a different name after callbacks have been wired + with :meth:`Manager.register_callback` does not update those callbacks; they + remain bound to the original wire name. + """ + if not (isinstance(event_class, type) and issubclass(event_class, _Event)): + raise ValueError( + f"event_class must be a subclass of _Event, got {event_class!r}" + ) + event_name = name or event_class.__name__ + # Evict any class that previously held this wire name to prevent a stale reverse entry. + old_class = _EVENT_REGISTRY.get(event_name) + if old_class is not None and old_class is not event_class: + _EVENT_REGISTRY_REV.pop(old_class, None) + # Evict any wire name this class was previously registered under to prevent a zombie + # forward entry when the same class is re-registered under a different name. + old_name = _EVENT_REGISTRY_REV.get(event_class) + if old_name is not None and old_name != event_name: + _EVENT_REGISTRY.pop(old_name, None) + _EVENT_REGISTRY[event_name] = event_class + _EVENT_REGISTRY_REV[event_class] = event_name + + +def unregister_event_class(event_class_or_name): + """ + Removes the registration previously added with :func:`register_event_class`. + + ``event_class_or_name`` may be the class object or the wire name string that was + passed as the ``name=`` argument to :func:`register_event_class` (which may differ + from ``event_class.__name__`` when an explicit name was supplied). + Returns ``True`` if a registration was removed, ``False`` if nothing matched. + """ + if isinstance(event_class_or_name, str): + event_class = _EVENT_REGISTRY.pop(event_class_or_name, None) + if event_class is None: + return False + _EVENT_REGISTRY_REV.pop(event_class, None) + return True + else: + event_name = _EVENT_REGISTRY_REV.pop(event_class_or_name, None) + if event_name is None: + return False + _EVENT_REGISTRY.pop(event_name, None) + return True + + _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 = ( diff --git a/pystrix/ami/core_events.py b/pystrix/ami/core_events.py index 810bf97..81ce716 100644 --- a/pystrix/ami/core_events.py +++ b/pystrix/ami/core_events.py @@ -1080,6 +1080,170 @@ class VoicemailUserEntryComplete(_Event): """ +# Modern channel and bridge events +#################################################################################################### +# These cover events added in Asterisk 13+ that are commonly needed in call-control applications. +# Wire names confirmed against Asterisk 13 main/manager_channels.c and main/manager_bridges.c. + + +class BridgeCreate(_Event): + """ + Emitted when a bridge is created. + + - 'BridgeType': The type of bridge + - 'BridgeUniqueid': A unique identifier for the bridge + - 'BridgeTechnology': The technology used by the bridge + - 'BridgeNumChannels': The number of channels currently in the bridge (int) + """ + + def process(self): + """ + Translates the 'BridgeNumChannels' header's value into an int, or None on failure. + """ + (headers, data) = _Event.process(self) + generic_transforms.to_int(headers, ("BridgeNumChannels",), None) + return (headers, data) + + +class BridgeDestroy(_Event): + """ + Emitted when a bridge is destroyed. + + - 'BridgeType': The type of bridge + - 'BridgeUniqueid': A unique identifier for the bridge + - 'BridgeTechnology': The technology used by the bridge + - 'BridgeNumChannels': The number of channels at time of destruction (int) + """ + + def process(self): + """ + Translates the 'BridgeNumChannels' header's value into an int, or None on failure. + """ + (headers, data) = _Event.process(self) + generic_transforms.to_int(headers, ("BridgeNumChannels",), None) + return (headers, data) + + +class BridgeEnter(_Event): + """ + Emitted when a channel enters a bridge. + + - 'BridgeType': The type of bridge + - 'BridgeUniqueid': A unique identifier for the bridge + - 'BridgeTechnology': The technology used by the bridge + - 'BridgeNumChannels': The number of channels now in the bridge (int) + - 'Channel': The channel that entered the bridge + - 'Uniqueid': The Asterisk unique identifier for the channel + - 'SwapUniqueid': The unique identifier of a channel swapped out (empty when no swap) + """ + + def process(self): + """ + Translates the 'BridgeNumChannels' header's value into an int, or None on failure. + """ + (headers, data) = _Event.process(self) + generic_transforms.to_int(headers, ("BridgeNumChannels",), None) + return (headers, data) + + +class BridgeLeave(_Event): + """ + Emitted when a channel leaves a bridge. + + - 'BridgeType': The type of bridge + - 'BridgeUniqueid': A unique identifier for the bridge + - 'BridgeTechnology': The technology used by the bridge + - 'BridgeNumChannels': The number of channels remaining in the bridge (int) + - 'Channel': The channel that left the bridge + - 'Uniqueid': The Asterisk unique identifier for the channel + """ + + def process(self): + """ + Translates the 'BridgeNumChannels' header's value into an int, or None on failure. + """ + (headers, data) = _Event.process(self) + generic_transforms.to_int(headers, ("BridgeNumChannels",), None) + return (headers, data) + + +class DialBegin(_Event): + """ + Emitted when a dial operation begins on a channel. + + - 'Channel': The calling channel + - 'Uniqueid': The Asterisk unique identifier for the calling channel + - 'DestChannel': The channel being dialled + - 'DestUniqueid': The Asterisk unique identifier for the destination channel + - 'DialString': The dial string used to reach the destination + """ + + +class DialEnd(_Event): + """ + Emitted when a dial operation ends. + + - 'Channel': The calling channel + - 'Uniqueid': The Asterisk unique identifier for the calling channel + - 'DestChannel': The channel that was dialled + - 'DestUniqueid': The Asterisk unique identifier for the destination channel + - 'DialStatus': The outcome: "ANSWER", "BUSY", "CANCEL", "CONGESTION", "NOANSWER", "CHANUNAVAIL" + - 'Forward' (optional): The forwarding destination, if the call was forwarded + """ + + +class Hold(_Event): + """ + Emitted when a channel is placed on hold. + + - 'MusicClass' (optional): The suggested music-on-hold class + """ + + +class MusicOnHoldStart(_Event): + """ + Emitted when music on hold starts on a channel. + + - 'Class': The music-on-hold class being played + """ + + +class MusicOnHoldStop(_Event): + """ + Emitted when music on hold stops on a channel. + """ + + +class NewCallerid(_Event): + """ + Emitted when a channel receives new Caller ID information. + + - 'Channel': The channel whose Caller ID changed + - 'Uniqueid': The Asterisk unique identifier for the channel + - 'CallerIDNum': The new caller ID number + - 'CallerIDName': The new caller ID name + - 'CID-CallingPres': A description of the Caller ID presentation, e.g. + ``'0 (Presentation Allowed, Not Screened)'``; always a string, not an int + """ + + +class NewConnectedLine(_Event): + """ + Emitted when a channel's connected line information changes. + + - 'Channel': The channel whose connected line changed + - 'Uniqueid': The Asterisk unique identifier for the channel + - 'ConnectedLineNum': The connected line number (may be alphanumeric) + - 'ConnectedLineName': The connected line name + """ + + +class Unhold(_Event): + """ + Emitted when a channel is taken off hold. + """ + + # List-aggregation events #################################################################################################### # These define non-Asterisk-native event-types that collect multiple events (cases where multiple diff --git a/tests/test_ami_events.py b/tests/test_ami_events.py new file mode 100644 index 0000000..f99383f --- /dev/null +++ b/tests/test_ami_events.py @@ -0,0 +1,279 @@ +""" +Tests for the AMI event-registration API and the built-in event registry. + +Covers: +- register_event_class / unregister_event_class public API +- registry-build quality (no junk, aggregates preserved) +- new built-in classes (DialBegin, DialEnd, Bridge*) +""" + +import inspect + +import pytest + +import pystrix.ami.ami as ami +import pystrix.ami.core_events as core_events +from pystrix.ami.ami import ( + _EVENT_REGISTRY, + _EVENT_REGISTRY_REV, + _Aggregate, + _Event, + register_event_class, + unregister_event_class, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _MyEvent(_Event): + """A custom event class for testing.""" + + +class _MyEventAlt(_Event): + """A second custom event class for testing an explicit name override.""" + + +class _NotAnEvent: + """A class that is not a subclass of _Event.""" + + +def _cleanup(*classes_or_names): + """Remove test registrations so tests don't bleed into each other.""" + for c in classes_or_names: + unregister_event_class(c) + + +# --------------------------------------------------------------------------- +# register_event_class +# --------------------------------------------------------------------------- + + +class TestRegisterEventClass: + def teardown_method(self): + _cleanup(_MyEvent, _MyEventAlt, "CustomWireName", "AltName") + + def test_registers_by_class_name_by_default(self): + register_event_class(_MyEvent) + assert _EVENT_REGISTRY.get("_MyEvent") is _MyEvent + assert _EVENT_REGISTRY_REV.get(_MyEvent) == "_MyEvent" + + def test_explicit_name_overrides_class_name(self): + register_event_class(_MyEvent, name="CustomWireName") + assert _EVENT_REGISTRY.get("CustomWireName") is _MyEvent + assert _EVENT_REGISTRY_REV.get(_MyEvent) == "CustomWireName" + assert "_MyEvent" not in _EVENT_REGISTRY + + def test_non_event_subclass_raises_value_error(self): + with pytest.raises(ValueError): + register_event_class(_NotAnEvent) + + def test_non_class_raises_value_error(self): + with pytest.raises(ValueError): + register_event_class("not_a_class") + + def test_overwrite_replaces_previous_registration(self): + register_event_class(_MyEvent) + register_event_class(_MyEventAlt, name="_MyEvent") + assert _EVENT_REGISTRY.get("_MyEvent") is _MyEventAlt + assert _EVENT_REGISTRY_REV.get(_MyEventAlt) == "_MyEvent" + assert _MyEvent not in _EVENT_REGISTRY_REV + + def test_re_register_same_class_under_new_name_evicts_old_forward_entry(self): + register_event_class(_MyEvent) + register_event_class(_MyEvent, name="AltName") + assert "_MyEvent" not in _EVENT_REGISTRY + assert _EVENT_REGISTRY.get("AltName") is _MyEvent + assert _EVENT_REGISTRY_REV.get(_MyEvent) == "AltName" + + def test_overwrite_different_class_evicts_old_reverse_entry(self): + register_event_class(_MyEvent, name="SharedName") + register_event_class(_MyEventAlt, name="SharedName") + assert _EVENT_REGISTRY.get("SharedName") is _MyEventAlt + assert _MyEvent not in _EVENT_REGISTRY_REV + + def _make_partial_manager(self): + """Return a Manager with only the attributes needed by _compile_callback_definition.""" + import threading + + manager = ami.Manager.__new__(ami.Manager) + manager._event_callbacks = [] + manager._event_callbacks_lock = threading.Lock() + manager._connection_lock = threading.Lock() + return manager + + def test_registered_class_resolves_via_compile_callback_definition(self): + register_event_class(_MyEvent) + manager = self._make_partial_manager() + + # _compile_callback_definition should resolve to a REFERENCE callback now + from pystrix.ami.ami import _CALLBACK_TYPE_REFERENCE + + result = manager._compile_callback_definition(_MyEvent, lambda e, m: None) + assert result[0] == _CALLBACK_TYPE_REFERENCE + assert result[1] == "_MyEvent" + + def test_unregistered_class_still_raises_in_callback(self): + # Without registration, passing a custom class should raise ValueError + manager = self._make_partial_manager() + with pytest.raises(ValueError): + manager._compile_callback_definition(_MyEvent, lambda e, m: None) + + +# --------------------------------------------------------------------------- +# unregister_event_class +# --------------------------------------------------------------------------- + + +class TestUnregisterEventClass: + def setup_method(self): + register_event_class(_MyEvent) + + def teardown_method(self): + _cleanup(_MyEvent) + + def test_unregister_by_class_object(self): + result = unregister_event_class(_MyEvent) + assert result is True + assert "_MyEvent" not in _EVENT_REGISTRY + assert _MyEvent not in _EVENT_REGISTRY_REV + + def test_unregister_by_string_name(self): + result = unregister_event_class("_MyEvent") + assert result is True + assert "_MyEvent" not in _EVENT_REGISTRY + assert _MyEvent not in _EVENT_REGISTRY_REV + + def test_unregister_unknown_returns_false(self): + assert unregister_event_class("DoesNotExist") is False + assert unregister_event_class(_MyEventAlt) is False + + def test_unregister_is_symmetric(self): + unregister_event_class(_MyEvent) + # Re-registration should work cleanly + register_event_class(_MyEvent) + assert _EVENT_REGISTRY.get("_MyEvent") is _MyEvent + + +# --------------------------------------------------------------------------- +# Registry build quality +# --------------------------------------------------------------------------- + + +class TestRegistryContents: + def test_no_module_imports_in_registry(self): + """re and generic_transforms must not appear.""" + assert "re" not in _EVENT_REGISTRY + assert "generic_transforms" not in _EVENT_REGISTRY + + def test_all_values_are_classes(self): + for name, obj in _EVENT_REGISTRY.items(): + assert inspect.isclass(obj), f"{name!r} maps to non-class {obj!r}" + + def test_all_event_classes_are_message_template_subclasses(self): + from pystrix.ami.ami import _MessageTemplate + + for name, obj in _EVENT_REGISTRY.items(): + assert issubclass(obj, _MessageTemplate), ( + f"{name!r} ({obj!r}) is not a _MessageTemplate subclass" + ) + + def test_aggregates_are_preserved(self): + """_Aggregate subclasses must stay in the registry for synchronous actions.""" + agg_names = [n for n, c in _EVENT_REGISTRY.items() if issubclass(c, _Aggregate)] + assert len(agg_names) >= 10, f"Expected at least 10 aggregates, got {agg_names}" + + def test_forward_and_reverse_maps_are_consistent(self): + for name, cls in _EVENT_REGISTRY.items(): + rev = _EVENT_REGISTRY_REV.get(cls) + # A class may be keyed under multiple names (forward only); reverse + # stores the canonical name, which is the last one written. + # We just check it round-trips through the reverse direction. + assert rev is not None, f"No reverse entry for {name!r} -> {cls!r}" + assert _EVENT_REGISTRY.get(rev) is cls, ( + f"Reverse map for {cls!r} points to {rev!r} but forward map disagrees" + ) + + def test_known_builtin_events_present(self): + for name in ("Hangup", "FullyBooted", "Newexten", "AGIExec", "Shutdown"): + assert name in _EVENT_REGISTRY, f"Expected built-in event {name!r} missing" + + +# --------------------------------------------------------------------------- +# New built-in event classes +# --------------------------------------------------------------------------- + + +class TestNewBuiltinEvents: + @pytest.mark.parametrize( + "name", + [ + "BridgeCreate", + "BridgeDestroy", + "BridgeEnter", + "BridgeLeave", + "DialBegin", + "DialEnd", + "Hold", + "MusicOnHoldStart", + "MusicOnHoldStop", + "NewCallerid", + "NewConnectedLine", + "Unhold", + ], + ) + def test_event_class_exists_in_core_events(self, name): + assert hasattr(core_events, name), f"core_events.{name} not found" + cls = getattr(core_events, name) + assert inspect.isclass(cls) + assert issubclass(cls, _Event) + + @pytest.mark.parametrize( + "name", + [ + "BridgeCreate", + "BridgeDestroy", + "BridgeEnter", + "BridgeLeave", + "DialBegin", + "DialEnd", + "Hold", + "MusicOnHoldStart", + "MusicOnHoldStop", + "NewCallerid", + "NewConnectedLine", + "Unhold", + ], + ) + def test_event_class_is_in_registry(self, name): + assert name in _EVENT_REGISTRY, f"{name!r} not found in _EVENT_REGISTRY" + assert _EVENT_REGISTRY[name] is getattr(core_events, name) + + def test_newexten_present_not_newexten_alias(self): + """Wire name is Newexten (Asterisk 13 manager_channels.c:616); NewExten is not added.""" + assert "Newexten" in _EVENT_REGISTRY + assert "NewExten" not in _EVENT_REGISTRY + + @pytest.mark.parametrize( + "name", ["BridgeCreate", "BridgeDestroy", "BridgeEnter", "BridgeLeave"] + ) + def test_bridgenumchannels_coerced_to_int(self, name): + cls = getattr(core_events, name) + event = cls([f"Event: {name}\r\n", "BridgeNumChannels: 2\r\n"]) + headers, _ = event.process() + assert headers["BridgeNumChannels"] == 2 + assert isinstance(headers["BridgeNumChannels"], int) + + def test_bridgenumchannels_non_numeric_becomes_none(self): + event = core_events.BridgeEnter( + ["Event: BridgeEnter\r\n", "BridgeNumChannels: abc\r\n"] + ) + headers, _ = event.process() + assert headers["BridgeNumChannels"] is None + + def test_bridgenumchannels_missing_becomes_none(self): + event = core_events.BridgeCreate(["Event: BridgeCreate\r\n"]) + headers, _ = event.process() + assert "BridgeNumChannels" in headers + assert headers["BridgeNumChannels"] is None