From bea4f497ee4ab27c36ecc0d1ad9f214d2ea1432b Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:40:34 +0100 Subject: [PATCH 01/27] Add event handling for TCPIP instruments, allowing SRQ interrupts, including tests --- CHANGES | 8 + docs/index.rst | 3 + pyvisa_py/attributes.py | 9 +- pyvisa_py/events.py | 290 +++++++ pyvisa_py/highlevel.py | 205 ++++- pyvisa_py/protocols/rpc.py | 2 +- pyvisa_py/protocols/vxi11.py | 219 +++++- pyvisa_py/sessions.py | 49 ++ pyvisa_py/tcpip.py | 113 +++ .../test_tcpip_resources.py | 16 +- pyvisa_py/testsuite/test_events.py | 722 ++++++++++++++++++ 11 files changed, 1619 insertions(+), 17 deletions(-) create mode 100644 pyvisa_py/events.py create mode 100644 pyvisa_py/testsuite/test_events.py diff --git a/CHANGES b/CHANGES index 86547ee9..0a6a1d80 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,14 @@ PyVISA-py Changelog A second addressed issue is that timeout values never decrement to 0. A timeout value of 0 is undefined in VXI-11 standard. It can mean "timeout immediately if no data is in buffer" or "block permanently until transfer is finished". +- Implement the VISA event subsystem for VXI-11 (TCPIP::INSTR) resources: + `viEnableEvent`, `viDisableEvent`, `viDiscardEvents`, `viWaitOnEvent`, + `viInstallHandler`, and `viUninstallHandler`. SRQ (service request) events + are now supported via both queue-based (`wait_on_event`) and handler-based + (`install_handler`) delivery. A daemon thread runs an ONC RPC UDP interrupt + server to receive VXI-11 `DEVICE_INTR_SRQ` callbacks. This also fixes the + `create_intr_chan` XDR packer in `protocols/vxi11.py`. + Other transports (GPIB, USBTMC, HiSLIP, Serial) remain unsupported for now. 0.8.1 (04-09-2025) ------------------ diff --git a/docs/index.rst b/docs/index.rst index d5eceb21..4d7569f2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -94,6 +94,9 @@ No. We have implemented those attributes and methods that are most commonly needed. We would like to reach feature parity. If there is something that you need, let us know. +Event handling (``wait_on_event``, ``install_handler``, etc.) is currently +supported for **TCPIP INSTR** (VXI-11) resources only. + Why are you developing this? ---------------------------- diff --git a/pyvisa_py/attributes.py b/pyvisa_py/attributes.py index 99ba0446..21be2bad 100644 --- a/pyvisa_py/attributes.py +++ b/pyvisa_py/attributes.py @@ -10,6 +10,9 @@ from pyvisa import constants from pyvisa.attributes import AttrVI_ATTR_TCPIP_KEEPALIVE as former_keepalive +# vicp may not exist in older pyvisa versions +_vicp = getattr(constants.InterfaceType, "vicp", None) + class AttrVI_ATTR_TCPIP_KEEPALIVE(former_keepalive): """Requests that a TCP/IP provider enable the use of keep-alive packets. @@ -27,5 +30,9 @@ class AttrVI_ATTR_TCPIP_KEEPALIVE(former_keepalive): resources = [ (constants.InterfaceType.tcpip, "SOCKET"), (constants.InterfaceType.tcpip, "INSTR"), - (constants.InterfaceType.vicp, "INSTR"), + *( + [(_vicp, "INSTR")] + if _vicp is not None + else [] + ), ] diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py new file mode 100644 index 00000000..d48f23ac --- /dev/null +++ b/pyvisa_py/events.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +"""Event handling primitives for pyvisa-py. + +This module provides the thread-safe building blocks used by the VISA event +subsystem: event contexts, queues, handler registries, and per-session state. + +""" + +from __future__ import annotations + +import collections +import random +import threading +import time +from dataclasses import dataclass, field +from typing import Any, Callable + +from pyvisa import constants + +from .common import LOGGER + + +@dataclass +class EventContext: + """Immutable description of a single VISA event occurrence.""" + + event_type: constants.EventType + status_byte: int = 0 + timestamp: float = field(default_factory=time.time) + context_id: int = field(default_factory=lambda: random.getrandbits(32)) + + +class EventQueue: + """Thread-safe FIFO queue for :class:`EventContext` objects.""" + + def __init__(self) -> None: + self._deque: collections.deque[EventContext] = collections.deque() + self._cond = threading.Condition() + + def put(self, ctx: EventContext) -> None: + """Add an event context to the queue (non-blocking).""" + with self._cond: + self._deque.append(ctx) + self._cond.notify_all() + + def get(self, timeout_ms: int | None) -> EventContext | None: + """Retrieve an event context. + + Parameters + ---------- + timeout_ms : + ``None`` blocks forever, ``0`` returns immediately if empty, + and a positive value blocks up to that many milliseconds. + + Returns + ------- + EventContext or None + The retrieved context, or ``None`` if the queue was empty. + + """ + if timeout_ms is None: + with self._cond: + while not self._deque: + self._cond.wait() + return self._deque.popleft() + if timeout_ms == 0: + with self._cond: + if self._deque: + return self._deque.popleft() + return None + deadline = time.time() + timeout_ms / 1000.0 + with self._cond: + while not self._deque: + remaining = deadline - time.time() + if remaining <= 0: + return None + self._cond.wait(remaining) + return self._deque.popleft() + + def get_matching( + self, + event_type: constants.EventType | None, + timeout_ms: int | None, + ) -> EventContext | None: + """Retrieve the first event matching *event_type*. + + If *event_type* is ``None``, matches any event. + ``timeout_ms`` semantics are the same as :meth:`get`. + """ + if timeout_ms is None: + with self._cond: + while True: + for idx, ctx in enumerate(self._deque): + if event_type is None or ctx.event_type == event_type: + del self._deque[idx] + return ctx + self._cond.wait() + if timeout_ms == 0: + with self._cond: + for idx, ctx in enumerate(self._deque): + if event_type is None or ctx.event_type == event_type: + del self._deque[idx] + return ctx + return None + deadline = time.time() + timeout_ms / 1000.0 + with self._cond: + while True: + for idx, ctx in enumerate(self._deque): + if event_type is None or ctx.event_type == event_type: + del self._deque[idx] + return ctx + remaining = deadline - time.time() + if remaining <= 0: + return None + self._cond.wait(remaining) + + def discard_all(self, event_type: constants.EventType | None = None) -> None: + """Remove items from the queue. + + If *event_type* is ``None``, the entire queue is cleared. + Otherwise only contexts whose ``event_type`` matches are removed. + + """ + with self._cond: + if event_type is None: + self._deque.clear() + else: + kept = [ + ctx for ctx in self._deque if ctx.event_type != event_type + ] + self._deque.clear() + self._deque.extend(kept) + + +HandlerCallback = Callable[[Any, constants.EventType, int, Any], None] + + +class HandlerRegistry: + """Thread-safe registry of user-installed event handlers.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + # event_type -> list of (handler, user_handle) + self._handlers: dict[ + constants.EventType, list[tuple[HandlerCallback, Any]] + ] = {} + + def install( + self, + event_type: constants.EventType, + handler: HandlerCallback, + user_handle: Any, + ) -> None: + """Register a handler for the given event type.""" + with self._lock: + self._handlers.setdefault(event_type, []).append((handler, user_handle)) + + def uninstall( + self, + event_type: constants.EventType, + handler: HandlerCallback, + user_handle: Any = None, + ) -> bool: + """Remove a previously installed handler. + + If *user_handle* is ``None``, the first entry matching *handler* + identity is removed regardless of its user handle. + + Returns ``True`` if a handler was removed, ``False`` otherwise. + + """ + with self._lock: + entries = self._handlers.get(event_type, []) + for idx, (h, uh) in enumerate(entries): + if h is handler and (user_handle is None or uh == user_handle): + entries.pop(idx) + return True + return False + + def fire( + self, + event_type: constants.EventType, + session: Any, + context_id: int, + ) -> None: + """Invoke all handlers registered for *event_type*. + + Each handler is called as ``handler(session, event_type, context_id, + user_handle)`` where *user_handle* is the value supplied at + installation. Exceptions raised by a handler are logged but do not + prevent subsequent handlers from running. + + """ + with self._lock: + handlers = list(self._handlers.get(event_type, [])) + + for handler, user_handle in handlers: + try: + handler(session, event_type, context_id, user_handle) + except Exception: + LOGGER.exception( + "Exception in event handler for %s", event_type + ) + + +class EventState: + """Per-session container for event enablement, queuing, and handlers.""" + + def __init__(self) -> None: + # {event_type: {mechanism_int}} + self._lock = threading.RLock() + self.enabled: dict[constants.EventType, set[int]] = {} + self.queue = EventQueue() + self.registry = HandlerRegistry() + self.monitor_thread: threading.Thread | None = None + self.stop_flag: threading.Event = threading.Event() + + def enable( + self, + event_type: constants.EventType, + mechanism: constants.EventMechanism, + ) -> None: + """Enable delivery of *event_type* via *mechanism*.""" + m = int(mechanism) + with self._lock: + mech_set = self.enabled.setdefault(event_type, set()) + if m == int(constants.EventMechanism.all): + for bit in (1, 2, 4): + mech_set.add(bit) + else: + for bit in (1, 2, 4): + if m & bit: + mech_set.add(bit) + + def disable( + self, + event_type: constants.EventType, + mechanism: constants.EventMechanism, + ) -> None: + """Disable delivery of *event_type* via *mechanism*.""" + m = int(mechanism) + with self._lock: + if event_type not in self.enabled: + return + mech_set = self.enabled[event_type] + if m == int(constants.EventMechanism.all): + mech_set.clear() + else: + for bit in (1, 2, 4): + if m & bit: + mech_set.discard(bit) + if not mech_set: + del self.enabled[event_type] + + def is_queue_enabled(self, event_type: constants.EventType) -> bool: + """Return whether queue delivery is enabled for *event_type*.""" + with self._lock: + return int(constants.EventMechanism.queue) in self.enabled.get( + event_type, set() + ) + + def is_handler_enabled(self, event_type: constants.EventType) -> bool: + """Return whether handler (callback) delivery is enabled for *event_type*.""" + with self._lock: + return int(constants.EventMechanism.handler) in self.enabled.get( + event_type, set() + ) + + def get_delivery_mechanisms( + self, event_type: constants.EventType + ) -> tuple[bool, bool]: + """Return (queue_enabled, handler_enabled) for *event_type*. + + The check is performed atomically under the state lock. + """ + with self._lock: + mech = self.enabled.get(event_type, set()) + return ( + int(constants.EventMechanism.queue) in mech, + int(constants.EventMechanism.handler) in mech, + ) + + def any_enabled(self) -> bool: + """Return ``True`` if any event type has any mechanism enabled.""" + with self._lock: + return any(bool(mechanisms) for mechanisms in self.enabled.values()) + + def should_monitor(self) -> bool: + """Convenience alias for :meth:`any_enabled`.""" + return self.any_enabled() diff --git a/pyvisa_py/highlevel.py b/pyvisa_py/highlevel.py index 208b538e..bc656abb 100644 --- a/pyvisa_py/highlevel.py +++ b/pyvisa_py/highlevel.py @@ -7,6 +7,8 @@ """ +from __future__ import annotations + import random from collections import OrderedDict from typing import ( @@ -197,7 +199,9 @@ def open( except OpenError as e: return VISASession(0), self.handle_return_value(None, e.error_code) - return self._register(sess), StatusCode.success + visa_session = self._register(sess) + sess._session_handle = visa_session + return visa_session, StatusCode.success def clear(self, session: VISASession) -> StatusCode: """Clears a device. @@ -790,6 +794,49 @@ def unlock(self, session: VISASession) -> StatusCode: return self.handle_return_value(session, StatusCode.error_invalid_object) return self.handle_return_value(session, sess.unlock()) + def enable_event( + self, + session: VISASession, + event_type: constants.EventType, + mechanism: constants.EventMechanism, + context: None = None, + ) -> StatusCode: + """Enable notification for an event type via the specified mechanism. + + Corresponds to viEnableEvent function of the VISA library. + + Parameters + ---------- + session : VISASession + Unique logical identifier to a session. + event_type : constants.EventType + Event type. + mechanism : constants.EventMechanism + Event handling mechanisms to be enabled. + context : None, optional + Not used in pyvisa-py. + + Returns + ------- + StatusCode + Return value of the library call. + + """ + try: + sess = self.sessions[session] + except KeyError: + return self.handle_return_value(session, StatusCode.error_invalid_object) + if event_type not in sess._supported_event_types: + return self.handle_return_value( + session, StatusCode.error_invalid_event + ) + sess._event_state.enable(event_type, mechanism) + status = sess._start_srq_monitor() + if status != StatusCode.success: + sess._event_state.disable(event_type, mechanism) + return self.handle_return_value(session, status) + return self.handle_return_value(session, StatusCode.success) + def disable_event( self, session: VISASession, @@ -815,7 +862,18 @@ def disable_event( Return value of the library call. """ - return StatusCode.error_nonimplemented_operation + try: + sess = self.sessions[session] + except KeyError: + return self.handle_return_value(session, StatusCode.error_invalid_object) + if event_type == constants.EventType.all_enabled: + for et in list(sess._event_state.enabled.keys()): + sess._event_state.disable(et, mechanism) + else: + sess._event_state.disable(event_type, mechanism) + if not sess._event_state.any_enabled(): + sess._stop_srq_monitor() + return self.handle_return_value(session, StatusCode.success) def discard_events( self, @@ -831,7 +889,7 @@ def discard_events( ---------- session : VISASession Unique logical identifier to a session. - event_type : constans.EventType + event_type : constants.EventType Logical event identifier. mechanism : constants.EventMechanism Specifies event handling mechanisms to be discarded. @@ -842,4 +900,143 @@ def discard_events( Return value of the library call. """ - return StatusCode.error_nonimplemented_operation + try: + sess = self.sessions[session] + except KeyError: + return self.handle_return_value(session, StatusCode.error_invalid_object) + mech = int(mechanism) + if mech == int(constants.EventMechanism.all) or mech & int( + constants.EventMechanism.queue + ): + et = None if event_type == constants.EventType.all_enabled else event_type + sess._event_state.queue.discard_all(et) + return self.handle_return_value(session, StatusCode.success) + + def wait_on_event( + self, session: VISASession, in_event_type: constants.EventType, timeout: int + ) -> Tuple[constants.EventType, VISAEventContext, StatusCode]: + """Wait for an event occurrence for a given type. + + Corresponds to viWaitOnEvent function of the VISA library. + + Parameters + ---------- + session : VISASession + Unique logical identifier to a session. + in_event_type : constants.EventType + Event type to wait for. + timeout : int + Timeout in milliseconds. + + Returns + ------- + constants.EventType + Type of the event that occurred. + VISAEventContext + Context identifier for the event. + StatusCode + Return value of the library call. + + """ + try: + sess = self.sessions[session] + except KeyError: + return ( + in_event_type, + 0, + self.handle_return_value(session, StatusCode.error_invalid_object), + ) + et = None if in_event_type == constants.EventType.all_enabled else in_event_type + ctx = sess._event_state.queue.get_matching(et, timeout) + if ctx is None: + return ( + in_event_type, + 0, + self.handle_return_value(session, StatusCode.error_timeout), + ) + return ctx.event_type, ctx.context_id, StatusCode.success + + def install_handler( + self, + session: VISASession, + event_type: constants.EventType, + handler: Any, + user_handle: Any, + ) -> Tuple[Any, Any, Any, StatusCode]: + """Install a handler for an event type. + + Corresponds to viInstallHandler function of the VISA library. + + Parameters + ---------- + session : VISASession + Unique logical identifier to a session. + event_type : constants.EventType + Event type. + handler : Any + Handler function to install. + user_handle : Any + User handle passed to the handler. + + Returns + ------- + Any + The handler that was installed. + Any + The user handle. + Any + The handler that was installed. + StatusCode + Return value of the library call. + + """ + try: + sess = self.sessions[session] + except KeyError: + return ( + handler, + user_handle, + handler, + self.handle_return_value(session, StatusCode.error_invalid_object), + ) + sess._event_state.registry.install(event_type, handler, user_handle) + return (handler, user_handle, handler, StatusCode.success) + + def uninstall_handler( + self, + session: VISASession, + event_type: constants.EventType, + handler: Any, + user_handle: Any = None, + ) -> StatusCode: + """Uninstall a handler for an event type. + + Corresponds to viUninstallHandler function of the VISA library. + + Parameters + ---------- + session : VISASession + Unique logical identifier to a session. + event_type : constants.EventType + Event type. + handler : Any + Handler function to uninstall. + user_handle : Any, optional + User handle associated with the handler. + + Returns + ------- + StatusCode + Return value of the library call. + + """ + try: + sess = self.sessions[session] + except KeyError: + return self.handle_return_value(session, StatusCode.error_invalid_object) + found = sess._event_state.registry.uninstall(event_type, handler, user_handle) + if not found: + return self.handle_return_value( + session, StatusCode.error_handler_not_installed + ) + return self.handle_return_value(session, StatusCode.success) diff --git a/pyvisa_py/protocols/rpc.py b/pyvisa_py/protocols/rpc.py index e8bd07ad..a642775f 100644 --- a/pyvisa_py/protocols/rpc.py +++ b/pyvisa_py/protocols/rpc.py @@ -968,7 +968,7 @@ def handle(self, call): def turn_around(self): try: self.unpacker.done() - except RuntimeError: + except (RuntimeError, xdrlib.Error): raise RPCGarbageArgs self.packer.pack_uint(AcceptStatus.success) diff --git a/pyvisa_py/protocols/vxi11.py b/pyvisa_py/protocols/vxi11.py index b3d22f98..8f22e138 100644 --- a/pyvisa_py/protocols/vxi11.py +++ b/pyvisa_py/protocols/vxi11.py @@ -11,7 +11,15 @@ """ import enum +import queue import socket +import struct +import threading + +from pyvisa import constants +from pyvisa.constants import StatusCode +from ..common import LOGGER +from ..events import EventContext from . import rpc @@ -42,11 +50,18 @@ CREATE_INTR_CHAN = 25 DESTROY_INTR_CHAN = 26 +# Status byte bit masks +STB_RQS_BIT = 0x40 # Request Service bit in serial poll status byte + # Device intr DEVICE_INTR_PROG = 0x0607B1 DEVICE_INTR_VERS = 1 DEVICE_INTR_SRQ = 30 +# Device address family for create_intr_chan (NOT IPPROTO_TCP/IPPROTO_UDP) +DEVICE_TCP = 0 +DEVICE_UDP = 1 + # Error states class ErrorCodes(enum.IntEnum): @@ -354,7 +369,7 @@ def create_intr_chan(self, host_addr, host_port, prog_num, prog_vers, prog_famil return self.make_call( CREATE_INTR_CHAN, params, - self.packer.pack_device_docmd_parms, + self.packer.pack_device_remote_func_parms, self.unpacker.unpack_device_error, ) @@ -362,3 +377,205 @@ def destroy_intr_chan(self): return self.make_call( DESTROY_INTR_CHAN, None, None, self.unpacker.unpack_device_error ) + + +class SrqInterruptServer(rpc.UDPServer): + """UDP RPC server that receives VXI-11 DEVICE_INTR_SRQ (proc 30) interrupts.""" + + def __init__(self, host, prog, vers, port, session): + super().__init__(host, prog, vers, port) + self.session = session + self._srq_queue = queue.Queue() + self._srq_worker_thread = threading.Thread(target=self._srq_worker, daemon=True) + self._srq_worker_thread.start() + + def _srq_worker(self): + while True: + try: + item = self._srq_queue.get(timeout=1.0) + except queue.Empty: + continue + if item is None: + break + self._fire_srq() + + def stop(self): + self._srq_queue.put(None) + self._srq_worker_thread.join(timeout=2.0) + + def loop(self): + """Run until the session's stop_flag is set.""" + stop_flag = self.session._event_state.stop_flag + self.sock.settimeout(0.1) + while not stop_flag.is_set(): + try: + call, host_port = self.sock.recvfrom(8192) + except socket.timeout: + continue + reply = self.handle(call) + if reply is not None: + rpc._sendto(self.sock, reply, host_port) + + def handle_30(self): + """Handle DEVICE_INTR_SRQ (procedure 30). + + Unpacks the opaque handle, sends an RPC success reply, then + asynchronously reads the STB and fires the service_request event. + """ + handle = self.unpacker.unpack_opaque() + self.turn_around() + if handle != b"srq": + LOGGER.warning("Ignoring VXI-11 SRQ with unexpected handle: %r", handle) + return + self._srq_queue.put(True) + + def _fire_srq(self): + try: + # Defensive: session may have been closed while we were spawned + if ( + self.session.interface is None + or self.session.link == 0 + ): + return + stb, status = self.session.read_stb() + if status == StatusCode.success and (stb & STB_RQS_BIT): + ctx = EventContext( + event_type=constants.EventType.service_request, + status_byte=stb, + ) + self.session._fire_event(constants.EventType.service_request, ctx) + except Exception: + LOGGER.exception("Error handling VXI-11 SRQ interrupt") + + +class SrqInterruptTCPServer(rpc.TCPServer): + """TCP RPC server that receives VXI-11 DEVICE_INTR_SRQ (proc 30) interrupts.""" + + def __init__(self, host, prog, vers, port, session): + super().__init__(host, prog, vers, port) + self.session = session + self._srq_queue = queue.Queue() + self._srq_worker_thread = threading.Thread(target=self._srq_worker, daemon=True) + self._srq_worker_thread.start() + + def _srq_worker(self): + while True: + try: + item = self._srq_queue.get(timeout=1.0) + except queue.Empty: + continue + if item is None: + break + self._fire_srq() + + def stop(self): + self._srq_queue.put(None) + self._srq_worker_thread.join(timeout=2.0) + + def connect(self): + super().connect() + self.sock.listen(1) + + def loop(self): + """Accept connections from the instrument and handle SRQ until stopped.""" + self.sock.settimeout(1.0) + stop_flag = self.session._event_state.stop_flag + while not stop_flag.is_set(): + try: + conn, addr = self.sock.accept() + except socket.timeout: + continue + except OSError: + break + try: + self._handle_connection(conn) + finally: + try: + conn.close() + except Exception: + pass + + def _handle_connection(self, conn): + """Read RPC calls from the instrument, send replies, and fire events. + + The VXI-11 interrupt channel is a persistent TCP connection. The + instrument may send multiple DEVICE_INTR_SRQ calls over the same + connection, so we keep reading until the connection is closed or + the session stop flag is set. + """ + stop_flag = self.session._event_state.stop_flag + try: + conn.settimeout(1.0) + while not stop_flag.is_set(): + try: + # Read record marker (4 bytes) + marker = self._recv_all(conn, 4) + if marker is None: + return # Connection closed by peer + except socket.timeout: + continue + + frag = struct.unpack(">I", marker)[0] + last_frag = frag >> 31 + frag_len = frag & 0x7FFFFFFF + + # Read the fragment payload + call = self._recv_all(conn, frag_len) + if call is None: + return + + # If there are more fragments, consume and append them + while not last_frag: + marker = self._recv_all(conn, 4) + if marker is None: + return + frag = struct.unpack(">I", marker)[0] + last_frag = frag >> 31 + frag_len = frag & 0x7FFFFFFF + leftover = self._recv_all(conn, frag_len) + if leftover is None: + return + call += leftover + + reply = self.handle(call) + if reply is not None: + reply_frag = struct.pack(">I", 0x80000000 | len(reply)) + reply + conn.sendall(reply_frag) + except Exception: + LOGGER.exception("Error handling TCP SRQ connection") + + def _recv_all(self, sock, n): + data = b"" + while len(data) < n: + chunk = sock.recv(n - len(data)) + if not chunk: + return None + data += chunk + return data + + def handle_30(self): + """Handle DEVICE_INTR_SRQ (procedure 30).""" + handle = self.unpacker.unpack_opaque() + self.turn_around() + if handle != b"srq": + LOGGER.warning("Ignoring VXI-11 SRQ with unexpected handle: %r", handle) + return + self._srq_queue.put(True) + + def _fire_srq(self): + try: + # Defensive: session may have been closed while we were spawned + if ( + self.session.interface is None + or self.session.link == 0 + ): + return + stb, status = self.session.read_stb() + if status == StatusCode.success and (stb & STB_RQS_BIT): + ctx = EventContext( + event_type=constants.EventType.service_request, + status_byte=stb, + ) + self.session._fire_event(constants.EventType.service_request, ctx) + except Exception: + LOGGER.exception("Error handling VXI-11 SRQ interrupt") diff --git a/pyvisa_py/sessions.py b/pyvisa_py/sessions.py index e3224556..eeb0de0e 100644 --- a/pyvisa_py/sessions.py +++ b/pyvisa_py/sessions.py @@ -7,6 +7,8 @@ """ +from __future__ import annotations + import abc import time from typing import ( @@ -27,6 +29,7 @@ from pyvisa.typing import VISAJobID, VISARMSession from .common import LOGGER, BytesBuffer, int_to_byte +from .events import EventContext, EventState #: Type var used when typing register. T = TypeVar("T", bound=Type["Session"]) @@ -141,6 +144,9 @@ def close(self) -> StatusCode: #: Session type as (Interface Type, Resource Class) session_type: Tuple[constants.InterfaceType, str] + #: Event types supported by this session class. + _supported_event_types: ClassVar[set[constants.EventType]] = set() + #: Timeout in milliseconds to use when opening the resource. open_timeout: Optional[int] @@ -328,6 +334,8 @@ def __init__( self.after_parsing() + self._event_state = EventState() + def after_parsing(self) -> None: """Override this method to provide custom initialization code, to be called after the resource name is properly parsed @@ -366,6 +374,47 @@ def after_parsing(self) -> None: """ pass + def _fire_event( + self, event_type: constants.EventType, ctx: EventContext + ) -> None: + """Dispatch an event occurrence to the queue and/or handlers. + + This method is called by transport-specific monitor threads when an + SRQ (or other asynchronous signal) is detected. + """ + queue_enabled, handler_enabled = self._event_state.get_delivery_mechanisms( + event_type + ) + if queue_enabled: + self._event_state.queue.put(ctx) + if handler_enabled: + session_handle = getattr(self, "_session_handle", self) + self._event_state.registry.fire( + event_type, session_handle, ctx.context_id + ) + + def _start_srq_monitor(self) -> StatusCode: + """Start a background thread to watch for SRQ assertions. + + Transports that support asynchronous SRQ (VXI-11, GPIB, USBTMC) + should override this method. The base implementation is a no-op. + + Returns + ------- + StatusCode + Return value of the library call. + """ + return StatusCode.success + + def _stop_srq_monitor(self) -> None: + """Stop the SRQ monitor thread. + + Transports should override this to signal their monitor thread + (via ``self._event_state.stop_flag.set()``) and join it. + The base implementation is a no-op. + """ + pass + def write(self, data: bytes) -> Tuple[int, StatusCode]: """Writes data to device or interface synchronously. diff --git a/pyvisa_py/tcpip.py b/pyvisa_py/tcpip.py index f3a0f3f7..5acb200c 100644 --- a/pyvisa_py/tcpip.py +++ b/pyvisa_py/tcpip.py @@ -7,10 +7,13 @@ """ +from __future__ import annotations + import ipaddress import random import select import socket +import threading import time import warnings from typing import Any, Dict, List, Optional, Tuple, Type, cast @@ -431,6 +434,7 @@ class Vxi11CoreClient(vxi11.CoreClient): def __init__( self, host: str, port: Optional[int], open_timeout: Optional[int] = 5000 ) -> None: + self._lock = threading.Lock() self.packer = vxi11.Vxi11Packer() self.unpacker = vxi11.Vxi11Unpacker(b"") prog, vers = vxi11.DEVICE_CORE_PROG, vxi11.DEVICE_CORE_VERS @@ -441,6 +445,10 @@ def __init__( # bypass the portmapper lookup and use the specified port instead rpc.RawTCPClient.__init__(self, host, prog, vers, port, open_timeout) + def make_call(self, proc, args, pack_func, unpack_func): + with self._lock: + return super().make_call(proc, args, pack_func, unpack_func) + class TCPIPInstrVxi11(Session): """A TCPIP Session built on socket standard library using VXI-11 protocol.""" @@ -450,6 +458,8 @@ class TCPIPInstrVxi11(Session): # need to define session_type to make the set_attribute machinery work. session_type = (constants.InterfaceType.tcpip, "INSTR") + _supported_event_types = {constants.EventType.service_request} + #: Maximum size of a chunk of data in bytes. max_recv_size: int @@ -541,6 +551,8 @@ def after_parsing(self) -> None: self.client_id = random.getrandbits(31) self.keepalive = False + self._srq_server: vxi11.SrqInterruptServer | vxi11.SrqInterruptTCPServer | None = None + self._srq_lifecycle_lock = threading.Lock() error, link, _abort_port, max_recv_size = self.interface.create_link( self.client_id, 0, self.lock_timeout, self.parsed.lan_device_name @@ -561,6 +573,7 @@ def after_parsing(self) -> None: self.attrs[attribute] = attributes.AttributesByID[attribute].default def close(self) -> StatusCode: + self._stop_srq_monitor() try: self.interface.destroy_link(self.link) except (errors.VisaIOError, socket.error, rpc.RPCError) as e: @@ -572,6 +585,106 @@ def close(self) -> StatusCode: return StatusCode.success + def _start_srq_monitor(self) -> StatusCode: + """Start the VXI-11 interrupt server and enable SRQ on the device.""" + with self._srq_lifecycle_lock: + with self._event_state._lock: + if ( + self._event_state.monitor_thread is not None + and self._event_state.monitor_thread.is_alive() + ): + return StatusCode.success + if not self._event_state.should_monitor(): + return StatusCode.success + + self._event_state.stop_flag.clear() + + server = vxi11.SrqInterruptTCPServer( + "", + vxi11.DEVICE_INTR_PROG, + vxi11.DEVICE_INTR_VERS, + 0, + self, + ) + port = server.sock.getsockname()[1] + + local_ip_str = self.interface.sock.getsockname()[0] + host_addr = int(ipaddress.IPv4Address(local_ip_str)) + + error = self.interface.create_intr_chan( + host_addr, + port, + vxi11.DEVICE_INTR_PROG, + vxi11.DEVICE_INTR_VERS, + vxi11.DEVICE_TCP, + ) + if error: + LOGGER.error("create_intr_chan failed with error %d", error) + try: + server.sock.close() + except Exception: + pass + return StatusCode.error_nonsupported_operation + + error = self.interface.device_enable_srq(self.link, True, b"srq") + if error: + LOGGER.error("device_enable_srq failed with error %d", error) + try: + self.interface.destroy_intr_chan() + except Exception: + pass + try: + server.sock.close() + except Exception: + pass + return StatusCode.error_io + + with self._event_state._lock: + if ( + self._event_state.monitor_thread is not None + and self._event_state.monitor_thread.is_alive() + ): + try: + server.sock.close() + except Exception: + pass + return StatusCode.success + thread = threading.Thread(target=server.loop, daemon=True) + self._event_state.monitor_thread = thread + self._srq_server = server + thread.start() + return StatusCode.success + + def _stop_srq_monitor(self) -> None: + """Disable SRQ and stop the interrupt server thread.""" + with self._srq_lifecycle_lock: + self._event_state.stop_flag.set() + try: + self.interface.device_enable_srq(self.link, False, b"") + except Exception: + LOGGER.exception("Error disabling VXI-11 SRQ") + try: + self.interface.destroy_intr_chan() + except Exception: + LOGGER.exception("Error destroying VXI-11 interrupt channel") + with self._event_state._lock: + thread = self._event_state.monitor_thread + self._event_state.monitor_thread = None + server = self._srq_server + self._srq_server = None + + if thread is not None: + thread.join(timeout=1.0) + if server is not None: + try: + server.stop() + except Exception: + pass + try: + server.sock.close() + except Exception: + pass + def read(self, count: int) -> Tuple[bytes, StatusCode]: """Reads data from device or interface synchronously. diff --git a/pyvisa_py/testsuite/keysight_assisted_tests/test_tcpip_resources.py b/pyvisa_py/testsuite/keysight_assisted_tests/test_tcpip_resources.py index 51615e03..a359f508 100644 --- a/pyvisa_py/testsuite/keysight_assisted_tests/test_tcpip_resources.py +++ b/pyvisa_py/testsuite/keysight_assisted_tests/test_tcpip_resources.py @@ -30,20 +30,16 @@ class TestTCPIPInstr(TCPIPInstrBaseTest): # XXX Skip test clear to see if it has some bad side effect test_clear = pytest.mark.skip(copy_func(TCPIPInstrBaseTest.test_clear)) - test_wrapping_handler = pytest.mark.xfail( - copy_func(TCPIPInstrBaseTest.test_wrapping_handler) - ) + test_wrapping_handler = copy_func(TCPIPInstrBaseTest.test_wrapping_handler) - test_managing_visa_handler = pytest.mark.xfail( - copy_func(TCPIPInstrBaseTest.test_managing_visa_handler) + test_managing_visa_handler = copy_func( + TCPIPInstrBaseTest.test_managing_visa_handler ) - test_wait_on_event = pytest.mark.xfail( - copy_func(TCPIPInstrBaseTest.test_wait_on_event) - ) + test_wait_on_event = copy_func(TCPIPInstrBaseTest.test_wait_on_event) - test_wait_on_event_timeout = pytest.mark.xfail( - copy_func(TCPIPInstrBaseTest.test_wait_on_event_timeout) + test_wait_on_event_timeout = copy_func( + TCPIPInstrBaseTest.test_wait_on_event_timeout ) test_getting_unknown_buffer = pytest.mark.xfail( diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py new file mode 100644 index 00000000..669638aa --- /dev/null +++ b/pyvisa_py/testsuite/test_events.py @@ -0,0 +1,722 @@ +"""Unit tests for the pyvisa-py event handling subsystem. + +These tests cover the core event primitives in ``events.py``, the high-level +library methods in ``highlevel.py``, and transport-specific SRQ logic for +VXI-11 (mocked). + +""" + +from __future__ import annotations + +import ipaddress +import threading +import time +from unittest.mock import MagicMock, patch + +import pytest +from pyvisa import constants, errors +from pyvisa.constants import StatusCode + +from pyvisa_py.events import EventContext, EventQueue, EventState, HandlerRegistry +from pyvisa_py.highlevel import PyVisaLibrary +from pyvisa_py.protocols import vxi11 + + +# --------------------------------------------------------------------------- +# EventContext +# --------------------------------------------------------------------------- + + +class TestEventContext: + def test_defaults(self): + ctx = EventContext(event_type=constants.EventType.service_request) + assert ctx.event_type == constants.EventType.service_request + assert ctx.status_byte == 0 + assert ctx.timestamp <= time.time() + assert isinstance(ctx.context_id, int) + assert 0 <= ctx.context_id < 2**32 + + def test_context_id_randomness(self): + ctx1 = EventContext(event_type=constants.EventType.service_request) + ctx2 = EventContext(event_type=constants.EventType.service_request) + # Extremely unlikely to collide on 32-bit random space + assert ctx1.context_id != ctx2.context_id + + def test_explicit_values(self): + ctx = EventContext( + event_type=constants.EventType.io_completion, + status_byte=0x42, + timestamp=1234.5, + context_id=99, + ) + assert ctx.event_type == constants.EventType.io_completion + assert ctx.status_byte == 0x42 + assert ctx.timestamp == 1234.5 + assert ctx.context_id == 99 + + +# --------------------------------------------------------------------------- +# EventQueue +# --------------------------------------------------------------------------- + + +class TestEventQueue: + def test_put_get_roundtrip(self): + q = EventQueue() + ctx = EventContext(event_type=constants.EventType.service_request) + q.put(ctx) + assert q.get(timeout_ms=None) is ctx + + def test_get_zero_timeout_empty(self): + q = EventQueue() + assert q.get(timeout_ms=0) is None + + def test_get_positive_timeout_returns_item(self): + q = EventQueue() + ctx = EventContext(event_type=constants.EventType.service_request) + q.put(ctx) + assert q.get(timeout_ms=100) is ctx + + def test_get_positive_timeout_blocks_then_none(self): + q = EventQueue() + start = time.time() + result = q.get(timeout_ms=50) + elapsed = time.time() - start + assert result is None + assert elapsed >= 0.04 # generous tolerance + + def test_get_none_blocks_forever(self): + q = EventQueue() + ctx = EventContext(event_type=constants.EventType.service_request) + + def delayed_put(): + time.sleep(0.05) + q.put(ctx) + + t = threading.Thread(target=delayed_put) + t.start() + assert q.get(timeout_ms=None) is ctx + t.join() + + def test_discard_all_matching_event_type(self): + q = EventQueue() + ctx_srq = EventContext(event_type=constants.EventType.service_request) + ctx_io = EventContext(event_type=constants.EventType.io_completion) + q.put(ctx_srq) + q.put(ctx_io) + q.discard_all(constants.EventType.service_request) + assert q.get(timeout_ms=0) is ctx_io + assert q.get(timeout_ms=0) is None + + def test_discard_all_none_clears_everything(self): + q = EventQueue() + q.put(EventContext(event_type=constants.EventType.service_request)) + q.put(EventContext(event_type=constants.EventType.io_completion)) + q.discard_all(None) + assert q.get(timeout_ms=0) is None + + def test_get_matching_returns_matching_event(self): + q = EventQueue() + ctx_srq = EventContext(event_type=constants.EventType.service_request) + ctx_io = EventContext(event_type=constants.EventType.io_completion) + q.put(ctx_io) + q.put(ctx_srq) + assert ( + q.get_matching(constants.EventType.service_request, timeout_ms=0) + is ctx_srq + ) + assert ( + q.get_matching(constants.EventType.io_completion, timeout_ms=0) + is ctx_io + ) + + def test_get_matching_non_matching_returns_none(self): + q = EventQueue() + q.put(EventContext(event_type=constants.EventType.io_completion)) + assert q.get_matching(constants.EventType.service_request, timeout_ms=0) is None + + def test_get_matching_blocks_until_match(self): + q = EventQueue() + ctx = EventContext(event_type=constants.EventType.service_request) + + def delayed_put(): + time.sleep(0.05) + q.put(ctx) + + t = threading.Thread(target=delayed_put) + t.start() + assert ( + q.get_matching(constants.EventType.service_request, timeout_ms=None) + is ctx + ) + t.join() + + def test_get_matching_positive_timeout(self): + q = EventQueue() + start = time.time() + result = q.get_matching(constants.EventType.service_request, timeout_ms=50) + elapsed = time.time() - start + assert result is None + assert elapsed >= 0.04 + + def test_get_matching_positive_timeout_event_arrives(self): + q = EventQueue() + ctx = EventContext(event_type=constants.EventType.service_request) + + def delayed_put(): + time.sleep(0.02) + q.put(ctx) + + t = threading.Thread(target=delayed_put) + t.start() + assert ( + q.get_matching(constants.EventType.service_request, timeout_ms=200) + is ctx + ) + t.join() + + def test_get_matching_none_event_type_returns_any(self): + q = EventQueue() + ctx = EventContext(event_type=constants.EventType.io_completion) + q.put(ctx) + assert q.get_matching(None, timeout_ms=0) is ctx + + +# --------------------------------------------------------------------------- +# HandlerRegistry +# --------------------------------------------------------------------------- + + +class TestHandlerRegistry: + def test_install_and_fire(self): + reg = HandlerRegistry() + calls = [] + + def handler(sess, etype, cid, uhandle): + calls.append((sess, etype, cid, uhandle)) + + reg.install(constants.EventType.service_request, handler, "h1") + reg.fire(constants.EventType.service_request, "session", 42) + assert calls == [("session", constants.EventType.service_request, 42, "h1")] + + def test_multiple_handlers_fire(self): + reg = HandlerRegistry() + calls = [] + + def h1(sess, etype, cid, uhandle): + calls.append("h1") + + def h2(sess, etype, cid, uhandle): + calls.append("h2") + + reg.install(constants.EventType.service_request, h1, None) + reg.install(constants.EventType.service_request, h2, None) + reg.fire(constants.EventType.service_request, "session", 1) + assert set(calls) == {"h1", "h2"} + + def test_uninstall_by_identity_and_handle(self): + reg = HandlerRegistry() + + def h1(*_): + pass + + def h2(*_): + pass + + reg.install(constants.EventType.service_request, h1, "a") + reg.install(constants.EventType.service_request, h2, "b") + assert reg.uninstall(constants.EventType.service_request, h1, "a") is True + assert reg.uninstall(constants.EventType.service_request, h2, "wrong") is False + assert reg.uninstall(constants.EventType.service_request, h2, "b") is True + assert reg.uninstall(constants.EventType.service_request, h1, "a") is False + + def test_uninstall_with_none_user_handle(self): + reg = HandlerRegistry() + + def h1(*_): + pass + + reg.install(constants.EventType.service_request, h1, "any") + assert reg.uninstall(constants.EventType.service_request, h1, None) is True + assert reg.uninstall(constants.EventType.service_request, h1, None) is False + + def test_fire_catches_exceptions(self): + reg = HandlerRegistry() + calls = [] + + def bad(*_): + raise RuntimeError("boom") + + def good(*_): + calls.append("good") + + reg.install(constants.EventType.service_request, bad, None) + reg.install(constants.EventType.service_request, good, None) + # Should not raise + reg.fire(constants.EventType.service_request, "session", 1) + assert calls == ["good"] + + def test_fire_no_handlers_noop(self): + reg = HandlerRegistry() + # Should not raise + reg.fire(constants.EventType.service_request, "session", 1) + + +# --------------------------------------------------------------------------- +# EventState +# --------------------------------------------------------------------------- + + +class TestEventState: + def test_enable_disable(self): + st = EventState() + st.enable(constants.EventType.service_request, constants.EventMechanism.queue) + assert st.is_queue_enabled(constants.EventType.service_request) is True + assert st.is_handler_enabled(constants.EventType.service_request) is False + st.enable( + constants.EventType.service_request, constants.EventMechanism.handler + ) + assert st.is_handler_enabled(constants.EventType.service_request) is True + st.disable(constants.EventType.service_request, constants.EventMechanism.queue) + assert st.is_queue_enabled(constants.EventType.service_request) is False + assert st.is_handler_enabled(constants.EventType.service_request) is True + st.disable( + constants.EventType.service_request, constants.EventMechanism.handler + ) + assert st.any_enabled() is False + + def test_any_enabled_and_should_monitor(self): + st = EventState() + assert st.any_enabled() is False + assert st.should_monitor() is False + st.enable(constants.EventType.io_completion, constants.EventMechanism.queue) + assert st.any_enabled() is True + assert st.should_monitor() is True + + def test_disable_removes_empty_event_type(self): + st = EventState() + st.enable(constants.EventType.service_request, constants.EventMechanism.queue) + st.disable(constants.EventType.service_request, constants.EventMechanism.queue) + # Internal dict should be clean + assert constants.EventType.service_request not in st.enabled + + def test_enable_combined_bitmask(self): + st = EventState() + combined = constants.EventMechanism.queue | constants.EventMechanism.handler + st.enable(constants.EventType.service_request, combined) + assert st.is_queue_enabled(constants.EventType.service_request) is True + assert st.is_handler_enabled(constants.EventType.service_request) is True + + def test_disable_combined_bitmask(self): + st = EventState() + combined = constants.EventMechanism.queue | constants.EventMechanism.handler + st.enable(constants.EventType.service_request, combined) + st.disable(constants.EventType.service_request, combined) + assert st.is_queue_enabled(constants.EventType.service_request) is False + assert st.is_handler_enabled(constants.EventType.service_request) is False + assert st.any_enabled() is False + + def test_disable_all_clears_everything(self): + st = EventState() + st.enable( + constants.EventType.service_request, constants.EventMechanism.queue + ) + st.enable( + constants.EventType.service_request, constants.EventMechanism.handler + ) + st.disable( + constants.EventType.service_request, constants.EventMechanism.all + ) + assert st.is_queue_enabled(constants.EventType.service_request) is False + assert st.is_handler_enabled(constants.EventType.service_request) is False + assert constants.EventType.service_request not in st.enabled + + +# --------------------------------------------------------------------------- +# highlevel.py (mocked session) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def lib_and_session(): + lib = PyVisaLibrary() + sess = MagicMock() + sess._event_state = EventState() + sess._supported_event_types = {constants.EventType.service_request} + sess._start_srq_monitor.return_value = StatusCode.success + session_id = lib._register(sess) + return lib, sess, session_id + + +class TestHighlevelEventMethods: + def test_enable_event_delegates_and_starts_monitor(self, lib_and_session): + lib, sess, sid = lib_and_session + result = lib.enable_event( + sid, + constants.EventType.service_request, + constants.EventMechanism.queue, + ) + assert result == StatusCode.success + assert sess._event_state.is_queue_enabled( + constants.EventType.service_request + ) + sess._start_srq_monitor.assert_called_once() + + def test_disable_event_delegates_and_stops_monitor(self, lib_and_session): + lib, sess, sid = lib_and_session + # First enable + lib.enable_event( + sid, + constants.EventType.service_request, + constants.EventMechanism.queue, + ) + # Then disable + result = lib.disable_event( + sid, + constants.EventType.service_request, + constants.EventMechanism.queue, + ) + assert result == StatusCode.success + assert not sess._event_state.is_queue_enabled( + constants.EventType.service_request + ) + sess._stop_srq_monitor.assert_called_once() + + def test_disable_event_does_not_stop_when_other_enabled(self, lib_and_session): + lib, sess, sid = lib_and_session + lib.enable_event( + sid, + constants.EventType.service_request, + constants.EventMechanism.queue, + ) + lib.enable_event( + sid, + constants.EventType.service_request, + constants.EventMechanism.handler, + ) + sess._start_srq_monitor.reset_mock() + sess._stop_srq_monitor.reset_mock() + lib.disable_event( + sid, + constants.EventType.service_request, + constants.EventMechanism.queue, + ) + # Handler still enabled -> monitor should NOT be stopped + sess._stop_srq_monitor.assert_not_called() + + def test_discard_events_queue(self, lib_and_session): + lib, sess, sid = lib_and_session + sess._event_state.queue.put( + EventContext(event_type=constants.EventType.service_request) + ) + result = lib.discard_events( + sid, + constants.EventType.service_request, + constants.EventMechanism.queue, + ) + assert result == StatusCode.success + assert sess._event_state.queue.get(timeout_ms=0) is None + + def test_discard_events_all_mechanism(self, lib_and_session): + lib, sess, sid = lib_and_session + sess._event_state.queue.put( + EventContext(event_type=constants.EventType.service_request) + ) + result = lib.discard_events( + sid, + constants.EventType.service_request, + constants.EventMechanism.all, + ) + assert result == StatusCode.success + assert sess._event_state.queue.get(timeout_ms=0) is None + + def test_install_handler(self, lib_and_session): + lib, sess, sid = lib_and_session + + def my_handler(*_): + pass + + result = lib.install_handler( + sid, + constants.EventType.service_request, + my_handler, + "uh", + ) + assert result == (my_handler, "uh", my_handler, StatusCode.success) + handlers = sess._event_state.registry._handlers[ + constants.EventType.service_request + ] + assert handlers == [(my_handler, "uh")] + + def test_uninstall_handler_success(self, lib_and_session): + lib, sess, sid = lib_and_session + + def my_handler(*_): + pass + + sess._event_state.registry.install( + constants.EventType.service_request, my_handler, "uh" + ) + result = lib.uninstall_handler( + sid, + constants.EventType.service_request, + my_handler, + "uh", + ) + assert result == StatusCode.success + + def test_uninstall_handler_not_installed_raises(self, lib_and_session): + lib, sess, sid = lib_and_session + + def my_handler(*_): + pass + + with pytest.raises(errors.VisaIOError) as exc_info: + lib.uninstall_handler( + sid, + constants.EventType.service_request, + my_handler, + ) + assert exc_info.value.error_code == StatusCode.error_handler_not_installed + + def test_wait_on_event_success(self, lib_and_session): + lib, sess, sid = lib_and_session + ctx = EventContext( + event_type=constants.EventType.service_request, context_id=123 + ) + sess._event_state.queue.put(ctx) + etype, ectx, status = lib.wait_on_event( + sid, constants.EventType.service_request, 1000 + ) + assert etype == constants.EventType.service_request + assert ectx == 123 + assert status == StatusCode.success + + def test_wait_on_event_timeout_raises(self, lib_and_session): + lib, sess, sid = lib_and_session + with pytest.raises(errors.VisaIOError) as exc_info: + lib.wait_on_event( + sid, constants.EventType.service_request, 50 + ) + assert exc_info.value.error_code == StatusCode.error_timeout + + def test_wait_on_event_zero_timeout_raises(self, lib_and_session): + lib, sess, sid = lib_and_session + with pytest.raises(errors.VisaIOError) as exc_info: + lib.wait_on_event( + sid, constants.EventType.service_request, 0 + ) + assert exc_info.value.error_code == StatusCode.error_timeout + + def test_wait_on_event_invalid_session(self, lib_and_session): + lib, _, _ = lib_and_session + with pytest.raises(errors.VisaIOError) as exc_info: + lib.wait_on_event(999999, constants.EventType.service_request, 0) + assert exc_info.value.error_code == StatusCode.error_invalid_object + + def test_enable_event_invalid_session(self, lib_and_session): + lib, _, _ = lib_and_session + with pytest.raises(errors.VisaIOError) as exc_info: + lib.enable_event( + 999999, + constants.EventType.service_request, + constants.EventMechanism.queue, + ) + assert exc_info.value.error_code == StatusCode.error_invalid_object + + def test_enable_event_unsupported_returns_error(self, lib_and_session): + lib, sess, sid = lib_and_session + sess._supported_event_types = set() + with pytest.raises(errors.VisaIOError) as exc_info: + lib.enable_event( + sid, + constants.EventType.service_request, + constants.EventMechanism.queue, + ) + assert exc_info.value.error_code == StatusCode.error_invalid_event + + def test_enable_event_supported_returns_success(self, lib_and_session): + lib, sess, sid = lib_and_session + sess._supported_event_types = {constants.EventType.service_request} + result = lib.enable_event( + sid, + constants.EventType.service_request, + constants.EventMechanism.queue, + ) + assert result == StatusCode.success + + def test_enable_event_rollback_on_monitor_failure(self, lib_and_session): + lib, sess, sid = lib_and_session + sess._supported_event_types = {constants.EventType.service_request} + sess._start_srq_monitor.return_value = StatusCode.error_io + with pytest.raises(errors.VisaIOError) as exc_info: + lib.enable_event( + sid, + constants.EventType.service_request, + constants.EventMechanism.queue, + ) + assert exc_info.value.error_code == StatusCode.error_io + assert not sess._event_state.is_queue_enabled( + constants.EventType.service_request + ) + + def test_discard_events_queue_and_handler_discards_queue(self, lib_and_session): + lib, sess, sid = lib_and_session + sess._event_state.queue.put( + EventContext(event_type=constants.EventType.service_request) + ) + combined = constants.EventMechanism.queue | constants.EventMechanism.handler + result = lib.discard_events( + sid, + constants.EventType.service_request, + combined, + ) + assert result == StatusCode.success + assert sess._event_state.queue.get(timeout_ms=0) is None + + def test_discard_events_handler_alone_does_not_discard_queue(self, lib_and_session): + lib, sess, sid = lib_and_session + ctx = EventContext(event_type=constants.EventType.service_request) + sess._event_state.queue.put(ctx) + result = lib.discard_events( + sid, + constants.EventType.service_request, + constants.EventMechanism.handler, + ) + assert result == StatusCode.success + assert sess._event_state.queue.get(timeout_ms=0) is ctx + + +# --------------------------------------------------------------------------- +# VXI-11 SRQ flow (mocked transport) +# --------------------------------------------------------------------------- + + +class TestVxi11SrqFlow: + @pytest.fixture + def mock_vxi11_session(self): + """Return a partially-initialised TCPIPInstrVxi11 with a mocked iface.""" + from pyvisa_py.tcpip import TCPIPInstrVxi11 + + sess = MagicMock(spec=TCPIPInstrVxi11) + sess._event_state = EventState() + sess.link = 1 + sess.interface = MagicMock() + sess.interface.create_intr_chan.return_value = 0 + sess.interface.device_enable_srq.return_value = 0 + sess.interface.destroy_intr_chan.return_value = 0 + sess._srq_server = None + sess._srq_lifecycle_lock = threading.Lock() + return sess + + def test_start_srq_monitor_calls_enable(self, mock_vxi11_session): + from pyvisa_py.tcpip import TCPIPInstrVxi11 + + sess = mock_vxi11_session + sess._event_state.enable( + constants.EventType.service_request, constants.EventMechanism.queue + ) + sess.interface.sock.getsockname.return_value = ("192.168.1.2", 12345) + + # Patch SrqInterruptTCPServer so we don't bind a real TCP socket + with patch( + "pyvisa_py.tcpip.vxi11.SrqInterruptTCPServer" + ) as MockServer: + mock_sock = MagicMock() + mock_sock.getsockname.return_value = ("127.0.0.1", 65432) + MockServer.return_value.sock = mock_sock + + result = TCPIPInstrVxi11._start_srq_monitor(sess) + + assert result == StatusCode.success + sess.interface.create_intr_chan.assert_called_once_with( + int(ipaddress.IPv4Address("192.168.1.2")), + 65432, + vxi11.DEVICE_INTR_PROG, + vxi11.DEVICE_INTR_VERS, + vxi11.DEVICE_TCP, + ) + sess.interface.device_enable_srq.assert_called_once_with( + sess.link, True, b"srq" + ) + assert sess._event_state.monitor_thread is not None + # Clean up + sess._event_state.stop_flag.set() + if sess._event_state.monitor_thread is not None: + sess._event_state.monitor_thread.join(timeout=0.5) + + def test_start_srq_monitor_create_intr_chan_error(self, mock_vxi11_session): + from pyvisa_py.tcpip import TCPIPInstrVxi11 + + sess = mock_vxi11_session + sess._event_state.enable( + constants.EventType.service_request, constants.EventMechanism.queue + ) + sess.interface.sock.getsockname.return_value = ("192.168.1.2", 12345) + sess.interface.create_intr_chan.return_value = 8 + + with patch("pyvisa_py.tcpip.vxi11.SrqInterruptTCPServer") as MockServer: + mock_sock = MagicMock() + mock_sock.getsockname.return_value = ("127.0.0.1", 65432) + MockServer.return_value.sock = mock_sock + + result = TCPIPInstrVxi11._start_srq_monitor(sess) + + assert result == StatusCode.error_nonsupported_operation + assert sess._event_state.monitor_thread is None + + def test_stop_srq_monitor_calls_disable(self, mock_vxi11_session): + from pyvisa_py.tcpip import TCPIPInstrVxi11 + + sess = mock_vxi11_session + sess._event_state.monitor_thread = None + TCPIPInstrVxi11._stop_srq_monitor(sess) + sess.interface.device_enable_srq.assert_called_once_with( + sess.link, False, b"" + ) + sess.interface.destroy_intr_chan.assert_called_once() + + def test_fire_event_then_wait_on_event(self, lib_and_session): + """Simulate an SRQ by calling _fire_event on a mocked session.""" + lib, sess, sid = lib_and_session + sess._event_state.enable( + constants.EventType.service_request, constants.EventMechanism.queue + ) + ctx = EventContext( + event_type=constants.EventType.service_request, + status_byte=0x50, + context_id=9876, + ) + # Use the real Session._fire_event logic via a partial call + from pyvisa_py.sessions import Session + + Session._fire_event(sess, constants.EventType.service_request, ctx) + etype, ectx, status = lib.wait_on_event( + sid, constants.EventType.service_request, 1000 + ) + assert etype == constants.EventType.service_request + assert ectx == 9876 + assert status == StatusCode.success + + def test_vxi11_fire_event_handler_mechanism(self, lib_and_session): + lib, sess, sid = lib_and_session + calls = [] + + def my_handler(session, event_type, context_id, user_handle): + calls.append((event_type, context_id, user_handle)) + + sess._event_state.registry.install( + constants.EventType.service_request, my_handler, "uh" + ) + sess._event_state.enable( + constants.EventType.service_request, constants.EventMechanism.handler + ) + ctx = EventContext( + event_type=constants.EventType.service_request, context_id=5555 + ) + from pyvisa_py.sessions import Session + + Session._fire_event(sess, constants.EventType.service_request, ctx) + assert calls == [ + (constants.EventType.service_request, 5555, "uh") + ] From c76b9aeb3c39b8be69d2f341395c94d623c26720 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 22:06:51 +0000 Subject: [PATCH 02/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyvisa_py/attributes.py | 6 +--- pyvisa_py/events.py | 8 ++--- pyvisa_py/highlevel.py | 4 +-- pyvisa_py/protocols/vxi11.py | 10 ++---- pyvisa_py/sessions.py | 8 ++--- pyvisa_py/tcpip.py | 4 ++- pyvisa_py/testsuite/test_events.py | 54 ++++++++---------------------- 7 files changed, 25 insertions(+), 69 deletions(-) diff --git a/pyvisa_py/attributes.py b/pyvisa_py/attributes.py index 21be2bad..7ed3d105 100644 --- a/pyvisa_py/attributes.py +++ b/pyvisa_py/attributes.py @@ -30,9 +30,5 @@ class AttrVI_ATTR_TCPIP_KEEPALIVE(former_keepalive): resources = [ (constants.InterfaceType.tcpip, "SOCKET"), (constants.InterfaceType.tcpip, "INSTR"), - *( - [(_vicp, "INSTR")] - if _vicp is not None - else [] - ), + *([(_vicp, "INSTR")] if _vicp is not None else []), ] diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index d48f23ac..e4951f46 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -125,9 +125,7 @@ def discard_all(self, event_type: constants.EventType | None = None) -> None: if event_type is None: self._deque.clear() else: - kept = [ - ctx for ctx in self._deque if ctx.event_type != event_type - ] + kept = [ctx for ctx in self._deque if ctx.event_type != event_type] self._deque.clear() self._deque.extend(kept) @@ -198,9 +196,7 @@ def fire( try: handler(session, event_type, context_id, user_handle) except Exception: - LOGGER.exception( - "Exception in event handler for %s", event_type - ) + LOGGER.exception("Exception in event handler for %s", event_type) class EventState: diff --git a/pyvisa_py/highlevel.py b/pyvisa_py/highlevel.py index bc656abb..d0ae5b01 100644 --- a/pyvisa_py/highlevel.py +++ b/pyvisa_py/highlevel.py @@ -827,9 +827,7 @@ def enable_event( except KeyError: return self.handle_return_value(session, StatusCode.error_invalid_object) if event_type not in sess._supported_event_types: - return self.handle_return_value( - session, StatusCode.error_invalid_event - ) + return self.handle_return_value(session, StatusCode.error_invalid_event) sess._event_state.enable(event_type, mechanism) status = sess._start_srq_monitor() if status != StatusCode.success: diff --git a/pyvisa_py/protocols/vxi11.py b/pyvisa_py/protocols/vxi11.py index 8f22e138..d56b958a 100644 --- a/pyvisa_py/protocols/vxi11.py +++ b/pyvisa_py/protocols/vxi11.py @@ -432,10 +432,7 @@ def handle_30(self): def _fire_srq(self): try: # Defensive: session may have been closed while we were spawned - if ( - self.session.interface is None - or self.session.link == 0 - ): + if self.session.interface is None or self.session.link == 0: return stb, status = self.session.read_stb() if status == StatusCode.success and (stb & STB_RQS_BIT): @@ -565,10 +562,7 @@ def handle_30(self): def _fire_srq(self): try: # Defensive: session may have been closed while we were spawned - if ( - self.session.interface is None - or self.session.link == 0 - ): + if self.session.interface is None or self.session.link == 0: return stb, status = self.session.read_stb() if status == StatusCode.success and (stb & STB_RQS_BIT): diff --git a/pyvisa_py/sessions.py b/pyvisa_py/sessions.py index eeb0de0e..41c881f6 100644 --- a/pyvisa_py/sessions.py +++ b/pyvisa_py/sessions.py @@ -374,9 +374,7 @@ def after_parsing(self) -> None: """ pass - def _fire_event( - self, event_type: constants.EventType, ctx: EventContext - ) -> None: + def _fire_event(self, event_type: constants.EventType, ctx: EventContext) -> None: """Dispatch an event occurrence to the queue and/or handlers. This method is called by transport-specific monitor threads when an @@ -389,9 +387,7 @@ def _fire_event( self._event_state.queue.put(ctx) if handler_enabled: session_handle = getattr(self, "_session_handle", self) - self._event_state.registry.fire( - event_type, session_handle, ctx.context_id - ) + self._event_state.registry.fire(event_type, session_handle, ctx.context_id) def _start_srq_monitor(self) -> StatusCode: """Start a background thread to watch for SRQ assertions. diff --git a/pyvisa_py/tcpip.py b/pyvisa_py/tcpip.py index 5acb200c..8c58fe73 100644 --- a/pyvisa_py/tcpip.py +++ b/pyvisa_py/tcpip.py @@ -551,7 +551,9 @@ def after_parsing(self) -> None: self.client_id = random.getrandbits(31) self.keepalive = False - self._srq_server: vxi11.SrqInterruptServer | vxi11.SrqInterruptTCPServer | None = None + self._srq_server: ( + vxi11.SrqInterruptServer | vxi11.SrqInterruptTCPServer | None + ) = None self._srq_lifecycle_lock = threading.Lock() error, link, _abort_port, max_recv_size = self.interface.create_link( diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py index 669638aa..095bcd12 100644 --- a/pyvisa_py/testsuite/test_events.py +++ b/pyvisa_py/testsuite/test_events.py @@ -122,13 +122,9 @@ def test_get_matching_returns_matching_event(self): q.put(ctx_io) q.put(ctx_srq) assert ( - q.get_matching(constants.EventType.service_request, timeout_ms=0) - is ctx_srq - ) - assert ( - q.get_matching(constants.EventType.io_completion, timeout_ms=0) - is ctx_io + q.get_matching(constants.EventType.service_request, timeout_ms=0) is ctx_srq ) + assert q.get_matching(constants.EventType.io_completion, timeout_ms=0) is ctx_io def test_get_matching_non_matching_returns_none(self): q = EventQueue() @@ -146,8 +142,7 @@ def delayed_put(): t = threading.Thread(target=delayed_put) t.start() assert ( - q.get_matching(constants.EventType.service_request, timeout_ms=None) - is ctx + q.get_matching(constants.EventType.service_request, timeout_ms=None) is ctx ) t.join() @@ -170,8 +165,7 @@ def delayed_put(): t = threading.Thread(target=delayed_put) t.start() assert ( - q.get_matching(constants.EventType.service_request, timeout_ms=200) - is ctx + q.get_matching(constants.EventType.service_request, timeout_ms=200) is ctx ) t.join() @@ -273,9 +267,7 @@ def test_enable_disable(self): st.enable(constants.EventType.service_request, constants.EventMechanism.queue) assert st.is_queue_enabled(constants.EventType.service_request) is True assert st.is_handler_enabled(constants.EventType.service_request) is False - st.enable( - constants.EventType.service_request, constants.EventMechanism.handler - ) + st.enable(constants.EventType.service_request, constants.EventMechanism.handler) assert st.is_handler_enabled(constants.EventType.service_request) is True st.disable(constants.EventType.service_request, constants.EventMechanism.queue) assert st.is_queue_enabled(constants.EventType.service_request) is False @@ -318,15 +310,9 @@ def test_disable_combined_bitmask(self): def test_disable_all_clears_everything(self): st = EventState() - st.enable( - constants.EventType.service_request, constants.EventMechanism.queue - ) - st.enable( - constants.EventType.service_request, constants.EventMechanism.handler - ) - st.disable( - constants.EventType.service_request, constants.EventMechanism.all - ) + st.enable(constants.EventType.service_request, constants.EventMechanism.queue) + st.enable(constants.EventType.service_request, constants.EventMechanism.handler) + st.disable(constants.EventType.service_request, constants.EventMechanism.all) assert st.is_queue_enabled(constants.EventType.service_request) is False assert st.is_handler_enabled(constants.EventType.service_request) is False assert constants.EventType.service_request not in st.enabled @@ -357,9 +343,7 @@ def test_enable_event_delegates_and_starts_monitor(self, lib_and_session): constants.EventMechanism.queue, ) assert result == StatusCode.success - assert sess._event_state.is_queue_enabled( - constants.EventType.service_request - ) + assert sess._event_state.is_queue_enabled(constants.EventType.service_request) sess._start_srq_monitor.assert_called_once() def test_disable_event_delegates_and_stops_monitor(self, lib_and_session): @@ -495,17 +479,13 @@ def test_wait_on_event_success(self, lib_and_session): def test_wait_on_event_timeout_raises(self, lib_and_session): lib, sess, sid = lib_and_session with pytest.raises(errors.VisaIOError) as exc_info: - lib.wait_on_event( - sid, constants.EventType.service_request, 50 - ) + lib.wait_on_event(sid, constants.EventType.service_request, 50) assert exc_info.value.error_code == StatusCode.error_timeout def test_wait_on_event_zero_timeout_raises(self, lib_and_session): lib, sess, sid = lib_and_session with pytest.raises(errors.VisaIOError) as exc_info: - lib.wait_on_event( - sid, constants.EventType.service_request, 0 - ) + lib.wait_on_event(sid, constants.EventType.service_request, 0) assert exc_info.value.error_code == StatusCode.error_timeout def test_wait_on_event_invalid_session(self, lib_and_session): @@ -619,9 +599,7 @@ def test_start_srq_monitor_calls_enable(self, mock_vxi11_session): sess.interface.sock.getsockname.return_value = ("192.168.1.2", 12345) # Patch SrqInterruptTCPServer so we don't bind a real TCP socket - with patch( - "pyvisa_py.tcpip.vxi11.SrqInterruptTCPServer" - ) as MockServer: + with patch("pyvisa_py.tcpip.vxi11.SrqInterruptTCPServer") as MockServer: mock_sock = MagicMock() mock_sock.getsockname.return_value = ("127.0.0.1", 65432) MockServer.return_value.sock = mock_sock @@ -671,9 +649,7 @@ def test_stop_srq_monitor_calls_disable(self, mock_vxi11_session): sess = mock_vxi11_session sess._event_state.monitor_thread = None TCPIPInstrVxi11._stop_srq_monitor(sess) - sess.interface.device_enable_srq.assert_called_once_with( - sess.link, False, b"" - ) + sess.interface.device_enable_srq.assert_called_once_with(sess.link, False, b"") sess.interface.destroy_intr_chan.assert_called_once() def test_fire_event_then_wait_on_event(self, lib_and_session): @@ -717,6 +693,4 @@ def my_handler(session, event_type, context_id, user_handle): from pyvisa_py.sessions import Session Session._fire_event(sess, constants.EventType.service_request, ctx) - assert calls == [ - (constants.EventType.service_request, 5555, "uh") - ] + assert calls == [(constants.EventType.service_request, 5555, "uh")] From ddba517d8eebc99e65edd7cba5fc0b34ef920bbd Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Wed, 6 May 2026 21:14:15 +0100 Subject: [PATCH 03/27] revert _vicp guards --- pyvisa_py/attributes.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pyvisa_py/attributes.py b/pyvisa_py/attributes.py index 7ed3d105..99ba0446 100644 --- a/pyvisa_py/attributes.py +++ b/pyvisa_py/attributes.py @@ -10,9 +10,6 @@ from pyvisa import constants from pyvisa.attributes import AttrVI_ATTR_TCPIP_KEEPALIVE as former_keepalive -# vicp may not exist in older pyvisa versions -_vicp = getattr(constants.InterfaceType, "vicp", None) - class AttrVI_ATTR_TCPIP_KEEPALIVE(former_keepalive): """Requests that a TCP/IP provider enable the use of keep-alive packets. @@ -30,5 +27,5 @@ class AttrVI_ATTR_TCPIP_KEEPALIVE(former_keepalive): resources = [ (constants.InterfaceType.tcpip, "SOCKET"), (constants.InterfaceType.tcpip, "INSTR"), - *([(_vicp, "INSTR")] if _vicp is not None else []), + (constants.InterfaceType.vicp, "INSTR"), ] From 524aebf4ae3aac230f3561e3fe324bd6c05d1c28 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Wed, 6 May 2026 21:16:38 +0100 Subject: [PATCH 04/27] freeze @dataclass and add slots --- pyvisa_py/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index e4951f46..5c251766 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -20,7 +20,7 @@ from .common import LOGGER -@dataclass +@dataclass(frozen=True, slots=True) class EventContext: """Immutable description of a single VISA event occurrence.""" From e0b54a88bafa66f3a5a8cd113bc99a8df686a0f0 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Wed, 6 May 2026 22:33:13 +0100 Subject: [PATCH 05/27] replace integer values with flags --- pyvisa_py/events.py | 78 +++++++++++++++++++----------- pyvisa_py/testsuite/test_events.py | 20 +++++++- 2 files changed, 68 insertions(+), 30 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 5c251766..804ac4dc 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -17,6 +17,26 @@ from pyvisa import constants +import enum + + +class EventMechanism(enum.Flag): + """Internal Flag enum mirroring VISA event-delivery mechanisms. + + ``ALL = OxFFFF`` is an *atypical* sentinel: it does **not** + bitwise-compose from the auto-generated flags (``QUEUE | HANDLER | + SUSPEND == 7``), but it is accepted from ``constants.EventMechanism.all`` + and is canonicalised to the three real flags on store so that bitwise + ``~`` works correctly. + """ + + NONE = 0 + QUEUE = 1 # VI_QUEUE (1) + HANDLER = 2 # VI_HNDLR (2) + SUSPEND = 4 # VI_SUSPEND_HNDLR (4) + ALL = 0xFFFF # VI_ALL_MECH (0xFFFF) + + from .common import LOGGER @@ -203,9 +223,9 @@ class EventState: """Per-session container for event enablement, queuing, and handlers.""" def __init__(self) -> None: - # {event_type: {mechanism_int}} + # {event_type: EventMechanism} self._lock = threading.RLock() - self.enabled: dict[constants.EventType, set[int]] = {} + self.enabled: dict[constants.EventType, EventMechanism] = {} self.queue = EventQueue() self.registry = HandlerRegistry() self.monitor_thread: threading.Thread | None = None @@ -217,16 +237,16 @@ def enable( mechanism: constants.EventMechanism, ) -> None: """Enable delivery of *event_type* via *mechanism*.""" - m = int(mechanism) + m = EventMechanism(int(mechanism)) with self._lock: - mech_set = self.enabled.setdefault(event_type, set()) - if m == int(constants.EventMechanism.all): - for bit in (1, 2, 4): - mech_set.add(bit) + if m is EventMechanism.ALL: + self.enabled[event_type] = ( + EventMechanism.QUEUE | EventMechanism.HANDLER | EventMechanism.SUSPEND + ) else: - for bit in (1, 2, 4): - if m & bit: - mech_set.add(bit) + self.enabled[event_type] = ( + self.enabled.get(event_type, EventMechanism.NONE) | m + ) def disable( self, @@ -234,33 +254,33 @@ def disable( mechanism: constants.EventMechanism, ) -> None: """Disable delivery of *event_type* via *mechanism*.""" - m = int(mechanism) + m = EventMechanism(int(mechanism)) with self._lock: if event_type not in self.enabled: return - mech_set = self.enabled[event_type] - if m == int(constants.EventMechanism.all): - mech_set.clear() - else: - for bit in (1, 2, 4): - if m & bit: - mech_set.discard(bit) - if not mech_set: + if m is EventMechanism.ALL: del self.enabled[event_type] + else: + new = self.enabled[event_type] & ~m + if new is EventMechanism.NONE: + del self.enabled[event_type] + else: + self.enabled[event_type] = new def is_queue_enabled(self, event_type: constants.EventType) -> bool: """Return whether queue delivery is enabled for *event_type*.""" with self._lock: - return int(constants.EventMechanism.queue) in self.enabled.get( - event_type, set() - ) + return ( + self.enabled.get(event_type, EventMechanism.NONE) & EventMechanism.QUEUE + ) is not EventMechanism.NONE def is_handler_enabled(self, event_type: constants.EventType) -> bool: """Return whether handler (callback) delivery is enabled for *event_type*.""" with self._lock: - return int(constants.EventMechanism.handler) in self.enabled.get( - event_type, set() - ) + return ( + self.enabled.get(event_type, EventMechanism.NONE) + & EventMechanism.HANDLER + ) is not EventMechanism.NONE def get_delivery_mechanisms( self, event_type: constants.EventType @@ -270,16 +290,16 @@ def get_delivery_mechanisms( The check is performed atomically under the state lock. """ with self._lock: - mech = self.enabled.get(event_type, set()) + mech = self.enabled.get(event_type, EventMechanism.NONE) return ( - int(constants.EventMechanism.queue) in mech, - int(constants.EventMechanism.handler) in mech, + (mech & EventMechanism.QUEUE) is not EventMechanism.NONE, + (mech & EventMechanism.HANDLER) is not EventMechanism.NONE, ) def any_enabled(self) -> bool: """Return ``True`` if any event type has any mechanism enabled.""" with self._lock: - return any(bool(mechanisms) for mechanisms in self.enabled.values()) + return any(m is not EventMechanism.NONE for m in self.enabled.values()) def should_monitor(self) -> bool: """Convenience alias for :meth:`any_enabled`.""" diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py index 095bcd12..4deb09f0 100644 --- a/pyvisa_py/testsuite/test_events.py +++ b/pyvisa_py/testsuite/test_events.py @@ -17,7 +17,7 @@ from pyvisa import constants, errors from pyvisa.constants import StatusCode -from pyvisa_py.events import EventContext, EventQueue, EventState, HandlerRegistry +from pyvisa_py.events import EventContext, EventMechanism, EventQueue, EventState, HandlerRegistry from pyvisa_py.highlevel import PyVisaLibrary from pyvisa_py.protocols import vxi11 @@ -265,16 +265,23 @@ class TestEventState: def test_enable_disable(self): st = EventState() st.enable(constants.EventType.service_request, constants.EventMechanism.queue) + assert st.enabled[constants.EventType.service_request] is EventMechanism.QUEUE assert st.is_queue_enabled(constants.EventType.service_request) is True assert st.is_handler_enabled(constants.EventType.service_request) is False st.enable(constants.EventType.service_request, constants.EventMechanism.handler) + assert ( + st.enabled[constants.EventType.service_request] + is (EventMechanism.QUEUE | EventMechanism.HANDLER) + ) assert st.is_handler_enabled(constants.EventType.service_request) is True st.disable(constants.EventType.service_request, constants.EventMechanism.queue) + assert st.enabled[constants.EventType.service_request] is EventMechanism.HANDLER assert st.is_queue_enabled(constants.EventType.service_request) is False assert st.is_handler_enabled(constants.EventType.service_request) is True st.disable( constants.EventType.service_request, constants.EventMechanism.handler ) + assert constants.EventType.service_request not in st.enabled assert st.any_enabled() is False def test_any_enabled_and_should_monitor(self): @@ -282,12 +289,14 @@ def test_any_enabled_and_should_monitor(self): assert st.any_enabled() is False assert st.should_monitor() is False st.enable(constants.EventType.io_completion, constants.EventMechanism.queue) + assert st.enabled[constants.EventType.io_completion] is EventMechanism.QUEUE assert st.any_enabled() is True assert st.should_monitor() is True def test_disable_removes_empty_event_type(self): st = EventState() st.enable(constants.EventType.service_request, constants.EventMechanism.queue) + assert st.enabled[constants.EventType.service_request] is EventMechanism.QUEUE st.disable(constants.EventType.service_request, constants.EventMechanism.queue) # Internal dict should be clean assert constants.EventType.service_request not in st.enabled @@ -296,6 +305,10 @@ def test_enable_combined_bitmask(self): st = EventState() combined = constants.EventMechanism.queue | constants.EventMechanism.handler st.enable(constants.EventType.service_request, combined) + assert ( + st.enabled[constants.EventType.service_request] + is (EventMechanism.QUEUE | EventMechanism.HANDLER) + ) assert st.is_queue_enabled(constants.EventType.service_request) is True assert st.is_handler_enabled(constants.EventType.service_request) is True @@ -304,6 +317,7 @@ def test_disable_combined_bitmask(self): combined = constants.EventMechanism.queue | constants.EventMechanism.handler st.enable(constants.EventType.service_request, combined) st.disable(constants.EventType.service_request, combined) + assert constants.EventType.service_request not in st.enabled assert st.is_queue_enabled(constants.EventType.service_request) is False assert st.is_handler_enabled(constants.EventType.service_request) is False assert st.any_enabled() is False @@ -312,6 +326,10 @@ def test_disable_all_clears_everything(self): st = EventState() st.enable(constants.EventType.service_request, constants.EventMechanism.queue) st.enable(constants.EventType.service_request, constants.EventMechanism.handler) + assert ( + st.enabled[constants.EventType.service_request] + is (EventMechanism.QUEUE | EventMechanism.HANDLER) + ) st.disable(constants.EventType.service_request, constants.EventMechanism.all) assert st.is_queue_enabled(constants.EventType.service_request) is False assert st.is_handler_enabled(constants.EventType.service_request) is False From 7f18f96dd0023e925fc863acedfe5ce6effd9c28 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Wed, 6 May 2026 22:57:25 +0100 Subject: [PATCH 06/27] use defaultdict in HandlerRegistry --- pyvisa_py/events.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 804ac4dc..98502d91 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -159,9 +159,9 @@ class HandlerRegistry: def __init__(self) -> None: self._lock = threading.RLock() # event_type -> list of (handler, user_handle) - self._handlers: dict[ + self._handlers: collections.defaultdict[ constants.EventType, list[tuple[HandlerCallback, Any]] - ] = {} + ] = collections.defaultdict(list) def install( self, @@ -171,7 +171,7 @@ def install( ) -> None: """Register a handler for the given event type.""" with self._lock: - self._handlers.setdefault(event_type, []).append((handler, user_handle)) + self._handlers[event_type].append((handler, user_handle)) def uninstall( self, From aa22d016a900b8db22ecfa210a5f7e3551367ea3 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Wed, 6 May 2026 23:03:22 +0100 Subject: [PATCH 07/27] add docstring argument descriptions to HandlerCallback --- pyvisa_py/events.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 98502d91..16bbce33 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -150,6 +150,11 @@ def discard_all(self, event_type: constants.EventType | None = None) -> None: self._deque.extend(kept) +# HandlerCallback: callable invoked when a VISA event fires. +# Arg 0 (Any): session handle (vi) +# Arg 1 (constants.EventType): the event type that fired +# Arg 2 (int): event context id +# Arg 3 (Any): user-supplied handle passed at install_handler time HandlerCallback = Callable[[Any, constants.EventType, int, Any], None] From 0a9eae55a8f1ea2bf7e3197292214b5bd512c925 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Wed, 6 May 2026 23:31:59 +0100 Subject: [PATCH 08/27] remove annotations import from __future__ and add readability lines between logic sections --- pyvisa_py/events.py | 2 -- pyvisa_py/highlevel.py | 8 ++++++-- pyvisa_py/sessions.py | 2 -- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 16bbce33..e6225d39 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -6,8 +6,6 @@ """ -from __future__ import annotations - import collections import random import threading diff --git a/pyvisa_py/highlevel.py b/pyvisa_py/highlevel.py index d0ae5b01..eab009f9 100644 --- a/pyvisa_py/highlevel.py +++ b/pyvisa_py/highlevel.py @@ -7,8 +7,6 @@ """ -from __future__ import annotations - import random from collections import OrderedDict from typing import ( @@ -826,6 +824,7 @@ def enable_event( sess = self.sessions[session] except KeyError: return self.handle_return_value(session, StatusCode.error_invalid_object) + if event_type not in sess._supported_event_types: return self.handle_return_value(session, StatusCode.error_invalid_event) sess._event_state.enable(event_type, mechanism) @@ -864,6 +863,7 @@ def disable_event( sess = self.sessions[session] except KeyError: return self.handle_return_value(session, StatusCode.error_invalid_object) + if event_type == constants.EventType.all_enabled: for et in list(sess._event_state.enabled.keys()): sess._event_state.disable(et, mechanism) @@ -902,6 +902,7 @@ def discard_events( sess = self.sessions[session] except KeyError: return self.handle_return_value(session, StatusCode.error_invalid_object) + mech = int(mechanism) if mech == int(constants.EventMechanism.all) or mech & int( constants.EventMechanism.queue @@ -944,6 +945,7 @@ def wait_on_event( 0, self.handle_return_value(session, StatusCode.error_invalid_object), ) + et = None if in_event_type == constants.EventType.all_enabled else in_event_type ctx = sess._event_state.queue.get_matching(et, timeout) if ctx is None: @@ -997,6 +999,7 @@ def install_handler( handler, self.handle_return_value(session, StatusCode.error_invalid_object), ) + sess._event_state.registry.install(event_type, handler, user_handle) return (handler, user_handle, handler, StatusCode.success) @@ -1032,6 +1035,7 @@ def uninstall_handler( sess = self.sessions[session] except KeyError: return self.handle_return_value(session, StatusCode.error_invalid_object) + found = sess._event_state.registry.uninstall(event_type, handler, user_handle) if not found: return self.handle_return_value( diff --git a/pyvisa_py/sessions.py b/pyvisa_py/sessions.py index 41c881f6..b846ab38 100644 --- a/pyvisa_py/sessions.py +++ b/pyvisa_py/sessions.py @@ -7,8 +7,6 @@ """ -from __future__ import annotations - import abc import time from typing import ( From 40834d737490c391e7c6e04ac99fe7cd72572b22 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Wed, 6 May 2026 23:45:17 +0100 Subject: [PATCH 09/27] rename _start and _stop _srq_monitor to _event_monitor to be more generic --- pyvisa_py/highlevel.py | 4 ++-- pyvisa_py/sessions.py | 10 +++++----- pyvisa_py/tcpip.py | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pyvisa_py/highlevel.py b/pyvisa_py/highlevel.py index eab009f9..5dd2b522 100644 --- a/pyvisa_py/highlevel.py +++ b/pyvisa_py/highlevel.py @@ -828,7 +828,7 @@ def enable_event( if event_type not in sess._supported_event_types: return self.handle_return_value(session, StatusCode.error_invalid_event) sess._event_state.enable(event_type, mechanism) - status = sess._start_srq_monitor() + status = sess._start_event_monitor() if status != StatusCode.success: sess._event_state.disable(event_type, mechanism) return self.handle_return_value(session, status) @@ -870,7 +870,7 @@ def disable_event( else: sess._event_state.disable(event_type, mechanism) if not sess._event_state.any_enabled(): - sess._stop_srq_monitor() + sess._stop_event_monitor() return self.handle_return_value(session, StatusCode.success) def discard_events( diff --git a/pyvisa_py/sessions.py b/pyvisa_py/sessions.py index b846ab38..d6f2717a 100644 --- a/pyvisa_py/sessions.py +++ b/pyvisa_py/sessions.py @@ -387,10 +387,10 @@ def _fire_event(self, event_type: constants.EventType, ctx: EventContext) -> Non session_handle = getattr(self, "_session_handle", self) self._event_state.registry.fire(event_type, session_handle, ctx.context_id) - def _start_srq_monitor(self) -> StatusCode: - """Start a background thread to watch for SRQ assertions. + def _start_event_monitor(self) -> StatusCode: + """Start a background thread to watch for event assertions. - Transports that support asynchronous SRQ (VXI-11, GPIB, USBTMC) + Transports that support asynchronous events (VXI-11, GPIB, USBTMC) should override this method. The base implementation is a no-op. Returns @@ -400,8 +400,8 @@ def _start_srq_monitor(self) -> StatusCode: """ return StatusCode.success - def _stop_srq_monitor(self) -> None: - """Stop the SRQ monitor thread. + def _stop_event_monitor(self) -> None: + """Stop the event monitor thread. Transports should override this to signal their monitor thread (via ``self._event_state.stop_flag.set()``) and join it. diff --git a/pyvisa_py/tcpip.py b/pyvisa_py/tcpip.py index 8c58fe73..592d6b99 100644 --- a/pyvisa_py/tcpip.py +++ b/pyvisa_py/tcpip.py @@ -575,7 +575,7 @@ def after_parsing(self) -> None: self.attrs[attribute] = attributes.AttributesByID[attribute].default def close(self) -> StatusCode: - self._stop_srq_monitor() + self._stop_event_monitor() try: self.interface.destroy_link(self.link) except (errors.VisaIOError, socket.error, rpc.RPCError) as e: @@ -587,8 +587,8 @@ def close(self) -> StatusCode: return StatusCode.success - def _start_srq_monitor(self) -> StatusCode: - """Start the VXI-11 interrupt server and enable SRQ on the device.""" + def _start_event_monitor(self) -> StatusCode: + """Start the VXI-11 interrupt server and enable events on the device.""" with self._srq_lifecycle_lock: with self._event_state._lock: if ( @@ -657,8 +657,8 @@ def _start_srq_monitor(self) -> StatusCode: thread.start() return StatusCode.success - def _stop_srq_monitor(self) -> None: - """Disable SRQ and stop the interrupt server thread.""" + def _stop_event_monitor(self) -> None: + """Disable events and stop the interrupt server thread.""" with self._srq_lifecycle_lock: self._event_state.stop_flag.set() try: From 92b915b5c33413faaadd7b75c1d75fe466f92236 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Thu, 7 May 2026 00:27:45 +0100 Subject: [PATCH 10/27] replace error logging with warning --- pyvisa_py/events.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index e6225d39..494b7ec4 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -10,6 +10,7 @@ import random import threading import time +import warnings from dataclasses import dataclass, field from typing import Any, Callable @@ -208,8 +209,8 @@ def fire( Each handler is called as ``handler(session, event_type, context_id, user_handle)`` where *user_handle* is the value supplied at - installation. Exceptions raised by a handler are logged but do not - prevent subsequent handlers from running. + installation. Exceptions raised by a handler are warned via + ``warnings.warn`` and do not prevent subsequent handlers from running. """ with self._lock: @@ -218,8 +219,11 @@ def fire( for handler, user_handle in handlers: try: handler(session, event_type, context_id, user_handle) - except Exception: - LOGGER.exception("Exception in event handler for %s", event_type) + except Exception as exc: + warnings.warn( + f"Event handler {handler!r} raised an exception: {exc!r}", + stacklevel=2, + ) class EventState: From 9b2c1c235ebb7187a66975b2cfc8ed868cda9348 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Thu, 7 May 2026 00:29:15 +0100 Subject: [PATCH 11/27] update tests with warning instead of error logging --- pyvisa_py/testsuite/test_events.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py index 4deb09f0..ed74c719 100644 --- a/pyvisa_py/testsuite/test_events.py +++ b/pyvisa_py/testsuite/test_events.py @@ -246,8 +246,8 @@ def good(*_): reg.install(constants.EventType.service_request, bad, None) reg.install(constants.EventType.service_request, good, None) - # Should not raise - reg.fire(constants.EventType.service_request, "session", 1) + with pytest.warns(UserWarning, match="boom"): + reg.fire(constants.EventType.service_request, "session", 1) assert calls == ["good"] def test_fire_no_handlers_noop(self): @@ -347,7 +347,7 @@ def lib_and_session(): sess = MagicMock() sess._event_state = EventState() sess._supported_event_types = {constants.EventType.service_request} - sess._start_srq_monitor.return_value = StatusCode.success + sess._start_event_monitor.return_value = StatusCode.success session_id = lib._register(sess) return lib, sess, session_id @@ -362,7 +362,7 @@ def test_enable_event_delegates_and_starts_monitor(self, lib_and_session): ) assert result == StatusCode.success assert sess._event_state.is_queue_enabled(constants.EventType.service_request) - sess._start_srq_monitor.assert_called_once() + sess._start_event_monitor.assert_called_once() def test_disable_event_delegates_and_stops_monitor(self, lib_and_session): lib, sess, sid = lib_and_session @@ -382,7 +382,7 @@ def test_disable_event_delegates_and_stops_monitor(self, lib_and_session): assert not sess._event_state.is_queue_enabled( constants.EventType.service_request ) - sess._stop_srq_monitor.assert_called_once() + sess._stop_event_monitor.assert_called_once() def test_disable_event_does_not_stop_when_other_enabled(self, lib_and_session): lib, sess, sid = lib_and_session @@ -396,15 +396,15 @@ def test_disable_event_does_not_stop_when_other_enabled(self, lib_and_session): constants.EventType.service_request, constants.EventMechanism.handler, ) - sess._start_srq_monitor.reset_mock() - sess._stop_srq_monitor.reset_mock() + sess._start_event_monitor.reset_mock() + sess._stop_event_monitor.reset_mock() lib.disable_event( sid, constants.EventType.service_request, constants.EventMechanism.queue, ) # Handler still enabled -> monitor should NOT be stopped - sess._stop_srq_monitor.assert_not_called() + sess._stop_event_monitor.assert_not_called() def test_discard_events_queue(self, lib_and_session): lib, sess, sid = lib_and_session @@ -546,7 +546,7 @@ def test_enable_event_supported_returns_success(self, lib_and_session): def test_enable_event_rollback_on_monitor_failure(self, lib_and_session): lib, sess, sid = lib_and_session sess._supported_event_types = {constants.EventType.service_request} - sess._start_srq_monitor.return_value = StatusCode.error_io + sess._start_event_monitor.return_value = StatusCode.error_io with pytest.raises(errors.VisaIOError) as exc_info: lib.enable_event( sid, @@ -607,7 +607,7 @@ def mock_vxi11_session(self): sess._srq_lifecycle_lock = threading.Lock() return sess - def test_start_srq_monitor_calls_enable(self, mock_vxi11_session): + def test_start_event_monitor_calls_enable(self, mock_vxi11_session): from pyvisa_py.tcpip import TCPIPInstrVxi11 sess = mock_vxi11_session @@ -622,7 +622,7 @@ def test_start_srq_monitor_calls_enable(self, mock_vxi11_session): mock_sock.getsockname.return_value = ("127.0.0.1", 65432) MockServer.return_value.sock = mock_sock - result = TCPIPInstrVxi11._start_srq_monitor(sess) + result = TCPIPInstrVxi11._start_event_monitor(sess) assert result == StatusCode.success sess.interface.create_intr_chan.assert_called_once_with( @@ -641,7 +641,7 @@ def test_start_srq_monitor_calls_enable(self, mock_vxi11_session): if sess._event_state.monitor_thread is not None: sess._event_state.monitor_thread.join(timeout=0.5) - def test_start_srq_monitor_create_intr_chan_error(self, mock_vxi11_session): + def test_start_event_monitor_create_intr_chan_error(self, mock_vxi11_session): from pyvisa_py.tcpip import TCPIPInstrVxi11 sess = mock_vxi11_session @@ -656,17 +656,17 @@ def test_start_srq_monitor_create_intr_chan_error(self, mock_vxi11_session): mock_sock.getsockname.return_value = ("127.0.0.1", 65432) MockServer.return_value.sock = mock_sock - result = TCPIPInstrVxi11._start_srq_monitor(sess) + result = TCPIPInstrVxi11._start_event_monitor(sess) assert result == StatusCode.error_nonsupported_operation assert sess._event_state.monitor_thread is None - def test_stop_srq_monitor_calls_disable(self, mock_vxi11_session): + def test_stop_event_monitor_calls_disable(self, mock_vxi11_session): from pyvisa_py.tcpip import TCPIPInstrVxi11 sess = mock_vxi11_session sess._event_state.monitor_thread = None - TCPIPInstrVxi11._stop_srq_monitor(sess) + TCPIPInstrVxi11._stop_event_monitor(sess) sess.interface.device_enable_srq.assert_called_once_with(sess.link, False, b"") sess.interface.destroy_intr_chan.assert_called_once() From 649f1fdca5bc1727565f50466770158c4e9e6475 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Thu, 7 May 2026 01:03:19 +0100 Subject: [PATCH 12/27] Replace getattr(self, '_session_handle', self) with a direct attribute access: session_handle = self._session_handle Rationale: - Every session that can legitimately fire events is an instrument session created through highlevel.open(), which unconditionally sets _session_handle. - The fallback self is spec-violating and serves only to hide the incomplete test fixture. - Direct attribute access makes the invariant obviously correct: 'a session firing events must have a VISA handle'. --- pyvisa_py/sessions.py | 2 +- pyvisa_py/testsuite/test_events.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyvisa_py/sessions.py b/pyvisa_py/sessions.py index d6f2717a..d5a08cc9 100644 --- a/pyvisa_py/sessions.py +++ b/pyvisa_py/sessions.py @@ -384,7 +384,7 @@ def _fire_event(self, event_type: constants.EventType, ctx: EventContext) -> Non if queue_enabled: self._event_state.queue.put(ctx) if handler_enabled: - session_handle = getattr(self, "_session_handle", self) + session_handle = self._session_handle self._event_state.registry.fire(event_type, session_handle, ctx.context_id) def _start_event_monitor(self) -> StatusCode: diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py index ed74c719..7ac6d014 100644 --- a/pyvisa_py/testsuite/test_events.py +++ b/pyvisa_py/testsuite/test_events.py @@ -349,6 +349,7 @@ def lib_and_session(): sess._supported_event_types = {constants.EventType.service_request} sess._start_event_monitor.return_value = StatusCode.success session_id = lib._register(sess) + sess._session_handle = session_id return lib, sess, session_id @@ -697,7 +698,7 @@ def test_vxi11_fire_event_handler_mechanism(self, lib_and_session): calls = [] def my_handler(session, event_type, context_id, user_handle): - calls.append((event_type, context_id, user_handle)) + calls.append((session, event_type, context_id, user_handle)) sess._event_state.registry.install( constants.EventType.service_request, my_handler, "uh" @@ -711,4 +712,4 @@ def my_handler(session, event_type, context_id, user_handle): from pyvisa_py.sessions import Session Session._fire_event(sess, constants.EventType.service_request, ctx) - assert calls == [(constants.EventType.service_request, 5555, "uh")] + assert calls == [(sid, constants.EventType.service_request, 5555, "uh")] From 556c1ffdb22514bb9a4a951d66d7a7d2209b772a Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Thu, 7 May 2026 01:37:48 +0100 Subject: [PATCH 13/27] fix mypy pyvisa_py/sessions.py:387: error: Session' has no attribute '_session_handle' [attr-defined] --- pyvisa_py/sessions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyvisa_py/sessions.py b/pyvisa_py/sessions.py index d5a08cc9..54e10530 100644 --- a/pyvisa_py/sessions.py +++ b/pyvisa_py/sessions.py @@ -24,7 +24,7 @@ from pyvisa import attributes, constants, rname from pyvisa.constants import ResourceAttribute, StatusCode -from pyvisa.typing import VISAJobID, VISARMSession +from pyvisa.typing import VISAJobID, VISARMSession, VISASession from .common import LOGGER, BytesBuffer, int_to_byte from .events import EventContext, EventState @@ -148,6 +148,9 @@ def close(self) -> StatusCode: #: Timeout in milliseconds to use when opening the resource. open_timeout: Optional[int] + #: VISA session handle assigned by the library after registration. + _session_handle: VISASession + #: Value of the timeout in seconds used for general operation timeout: Optional[float] @@ -333,6 +336,7 @@ def __init__( self.after_parsing() self._event_state = EventState() + self._session_handle = VISASession(0) def after_parsing(self) -> None: """Override this method to provide custom initialization code, to be From 269f3a212fb8b8c3fd4c49c8d2941be468a7ce5c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 00:07:44 +0000 Subject: [PATCH 14/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyvisa_py/events.py | 12 +++++++----- pyvisa_py/testsuite/test_events.py | 23 +++++++++++++---------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 494b7ec4..7c505458 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -30,10 +30,10 @@ class EventMechanism(enum.Flag): """ NONE = 0 - QUEUE = 1 # VI_QUEUE (1) - HANDLER = 2 # VI_HNDLR (2) - SUSPEND = 4 # VI_SUSPEND_HNDLR (4) - ALL = 0xFFFF # VI_ALL_MECH (0xFFFF) + QUEUE = 1 # VI_QUEUE (1) + HANDLER = 2 # VI_HNDLR (2) + SUSPEND = 4 # VI_SUSPEND_HNDLR (4) + ALL = 0xFFFF # VI_ALL_MECH (0xFFFF) from .common import LOGGER @@ -248,7 +248,9 @@ def enable( with self._lock: if m is EventMechanism.ALL: self.enabled[event_type] = ( - EventMechanism.QUEUE | EventMechanism.HANDLER | EventMechanism.SUSPEND + EventMechanism.QUEUE + | EventMechanism.HANDLER + | EventMechanism.SUSPEND ) else: self.enabled[event_type] = ( diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py index 7ac6d014..0f0742a4 100644 --- a/pyvisa_py/testsuite/test_events.py +++ b/pyvisa_py/testsuite/test_events.py @@ -17,7 +17,13 @@ from pyvisa import constants, errors from pyvisa.constants import StatusCode -from pyvisa_py.events import EventContext, EventMechanism, EventQueue, EventState, HandlerRegistry +from pyvisa_py.events import ( + EventContext, + EventMechanism, + EventQueue, + EventState, + HandlerRegistry, +) from pyvisa_py.highlevel import PyVisaLibrary from pyvisa_py.protocols import vxi11 @@ -269,9 +275,8 @@ def test_enable_disable(self): assert st.is_queue_enabled(constants.EventType.service_request) is True assert st.is_handler_enabled(constants.EventType.service_request) is False st.enable(constants.EventType.service_request, constants.EventMechanism.handler) - assert ( - st.enabled[constants.EventType.service_request] - is (EventMechanism.QUEUE | EventMechanism.HANDLER) + assert st.enabled[constants.EventType.service_request] is ( + EventMechanism.QUEUE | EventMechanism.HANDLER ) assert st.is_handler_enabled(constants.EventType.service_request) is True st.disable(constants.EventType.service_request, constants.EventMechanism.queue) @@ -305,9 +310,8 @@ def test_enable_combined_bitmask(self): st = EventState() combined = constants.EventMechanism.queue | constants.EventMechanism.handler st.enable(constants.EventType.service_request, combined) - assert ( - st.enabled[constants.EventType.service_request] - is (EventMechanism.QUEUE | EventMechanism.HANDLER) + assert st.enabled[constants.EventType.service_request] is ( + EventMechanism.QUEUE | EventMechanism.HANDLER ) assert st.is_queue_enabled(constants.EventType.service_request) is True assert st.is_handler_enabled(constants.EventType.service_request) is True @@ -326,9 +330,8 @@ def test_disable_all_clears_everything(self): st = EventState() st.enable(constants.EventType.service_request, constants.EventMechanism.queue) st.enable(constants.EventType.service_request, constants.EventMechanism.handler) - assert ( - st.enabled[constants.EventType.service_request] - is (EventMechanism.QUEUE | EventMechanism.HANDLER) + assert st.enabled[constants.EventType.service_request] is ( + EventMechanism.QUEUE | EventMechanism.HANDLER ) st.disable(constants.EventType.service_request, constants.EventMechanism.all) assert st.is_queue_enabled(constants.EventType.service_request) is False From f4d4485d4d39bf644c889afd84ad0ffc2f5d3212 Mon Sep 17 00:00:00 2001 From: SimonMerrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 17:29:53 +0100 Subject: [PATCH 15/27] Update pyvisa_py/highlevel.py add line spaces for readability Co-authored-by: Matthieu Dartiailh --- pyvisa_py/highlevel.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyvisa_py/highlevel.py b/pyvisa_py/highlevel.py index 5dd2b522..d5dfd229 100644 --- a/pyvisa_py/highlevel.py +++ b/pyvisa_py/highlevel.py @@ -827,11 +827,13 @@ def enable_event( if event_type not in sess._supported_event_types: return self.handle_return_value(session, StatusCode.error_invalid_event) + sess._event_state.enable(event_type, mechanism) status = sess._start_event_monitor() if status != StatusCode.success: sess._event_state.disable(event_type, mechanism) return self.handle_return_value(session, status) + return self.handle_return_value(session, StatusCode.success) def disable_event( From b69ad8f60958c193f8f2b96f5e47927ff778f4e2 Mon Sep 17 00:00:00 2001 From: SimonMerrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 17:30:25 +0100 Subject: [PATCH 16/27] Update pyvisa_py/highlevel.py Add line for readability Co-authored-by: Matthieu Dartiailh --- pyvisa_py/highlevel.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyvisa_py/highlevel.py b/pyvisa_py/highlevel.py index d5dfd229..7c26b2c4 100644 --- a/pyvisa_py/highlevel.py +++ b/pyvisa_py/highlevel.py @@ -911,6 +911,7 @@ def discard_events( ): et = None if event_type == constants.EventType.all_enabled else event_type sess._event_state.queue.discard_all(et) + return self.handle_return_value(session, StatusCode.success) def wait_on_event( From 6f43fdc65ab94fc49f013ce6041835f1d2819114 Mon Sep 17 00:00:00 2001 From: SimonMerrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 17:31:49 +0100 Subject: [PATCH 17/27] Update pyvisa_py/highlevel.py Add lines for readability Co-authored-by: Matthieu Dartiailh --- pyvisa_py/highlevel.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyvisa_py/highlevel.py b/pyvisa_py/highlevel.py index 7c26b2c4..a4cec1c5 100644 --- a/pyvisa_py/highlevel.py +++ b/pyvisa_py/highlevel.py @@ -871,8 +871,10 @@ def disable_event( sess._event_state.disable(et, mechanism) else: sess._event_state.disable(event_type, mechanism) + if not sess._event_state.any_enabled(): sess._stop_event_monitor() + return self.handle_return_value(session, StatusCode.success) def discard_events( From a6238b4dab2bd37c0842d29d205905e0eef1d713 Mon Sep 17 00:00:00 2001 From: SimonMerrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 17:58:11 +0100 Subject: [PATCH 18/27] Update pyvisa_py/events.py use `bool(...)` for improved style Co-authored-by: Matthieu Dartiailh --- pyvisa_py/events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 7c505458..043cebc2 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -279,9 +279,9 @@ def disable( def is_queue_enabled(self, event_type: constants.EventType) -> bool: """Return whether queue delivery is enabled for *event_type*.""" with self._lock: - return ( + return bool( self.enabled.get(event_type, EventMechanism.NONE) & EventMechanism.QUEUE - ) is not EventMechanism.NONE + ) def is_handler_enabled(self, event_type: constants.EventType) -> bool: """Return whether handler (callback) delivery is enabled for *event_type*.""" From 870c4d3b98764bc2a49ea1f423cbf15b712d98ea Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 16:58:19 +0000 Subject: [PATCH 19/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyvisa_py/events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 043cebc2..6d575c2a 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -279,9 +279,9 @@ def disable( def is_queue_enabled(self, event_type: constants.EventType) -> bool: """Return whether queue delivery is enabled for *event_type*.""" with self._lock: - return bool( + return bool( self.enabled.get(event_type, EventMechanism.NONE) & EventMechanism.QUEUE - ) + ) def is_handler_enabled(self, event_type: constants.EventType) -> bool: """Return whether handler (callback) delivery is enabled for *event_type*.""" From 032225b8a555b5dab92369c98fc5a3e0bd4c826d Mon Sep 17 00:00:00 2001 From: SimonMerrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 17:58:47 +0100 Subject: [PATCH 20/27] Update pyvisa_py/events.py use `bool(...)` for improved style Co-authored-by: Matthieu Dartiailh --- pyvisa_py/events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 6d575c2a..55691242 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -301,8 +301,8 @@ def get_delivery_mechanisms( with self._lock: mech = self.enabled.get(event_type, EventMechanism.NONE) return ( - (mech & EventMechanism.QUEUE) is not EventMechanism.NONE, - (mech & EventMechanism.HANDLER) is not EventMechanism.NONE, + bool(mech & EventMechanism.QUEUE), + bool(mech & EventMechanism.HANDLER), ) def any_enabled(self) -> bool: From 88ea6ef0f17df91eebaf70c0ae27c439f11a8f61 Mon Sep 17 00:00:00 2001 From: SimonMerrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 17:59:32 +0100 Subject: [PATCH 21/27] Update pyvisa_py/events.py use `bool(...)` for improved style Co-authored-by: Matthieu Dartiailh --- pyvisa_py/events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 55691242..fe9523c6 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -286,10 +286,10 @@ def is_queue_enabled(self, event_type: constants.EventType) -> bool: def is_handler_enabled(self, event_type: constants.EventType) -> bool: """Return whether handler (callback) delivery is enabled for *event_type*.""" with self._lock: - return ( + return bool( self.enabled.get(event_type, EventMechanism.NONE) & EventMechanism.HANDLER - ) is not EventMechanism.NONE + ) def get_delivery_mechanisms( self, event_type: constants.EventType From 665278d190d56b80718c4d6f378db301c35da8fe Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 20:19:06 +0100 Subject: [PATCH 22/27] update EventMechanism class to EventMechanismFlag and improve handling of flag types, primarily with from_int() method, and remove should_monitor() --- pyvisa_py/events.py | 71 ++++++++++++++---------------- pyvisa_py/highlevel.py | 7 ++- pyvisa_py/tcpip.py | 2 +- pyvisa_py/testsuite/test_events.py | 20 ++++----- 4 files changed, 45 insertions(+), 55 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index fe9523c6..5c2897a0 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -19,21 +19,26 @@ import enum -class EventMechanism(enum.Flag): +class EventMechanismFlag(enum.Flag): """Internal Flag enum mirroring VISA event-delivery mechanisms. - ``ALL = OxFFFF`` is an *atypical* sentinel: it does **not** - bitwise-compose from the auto-generated flags (``QUEUE | HANDLER | - SUSPEND == 7``), but it is accepted from ``constants.EventMechanism.all`` - and is canonicalised to the three real flags on store so that bitwise - ``~`` works correctly. + ``ALL`` is a convenience alias for ``QUEUE | HANDLER | SUSPEND``. + The `from_int` classmethod canonicalises the VISA sentinel + ``0xFFFF`` (``constants.EventMechanism.all``) to this composite + value so that bitwise ``~`` works correctly. """ NONE = 0 QUEUE = 1 # VI_QUEUE (1) HANDLER = 2 # VI_HNDLR (2) SUSPEND = 4 # VI_SUSPEND_HNDLR (4) - ALL = 0xFFFF # VI_ALL_MECH (0xFFFF) + ALL = QUEUE | HANDLER | SUSPEND # = 7, not VI_ALL_MECH (0xFFFF) + + @classmethod + def from_int(cls, value: int) -> "EventMechanismFlag": + if value == int(constants.EventMechanism.all): # 0xFFFF + return cls.ALL + return cls(value & (cls.QUEUE | cls.HANDLER | cls.SUSPEND).value) from .common import LOGGER @@ -230,9 +235,9 @@ class EventState: """Per-session container for event enablement, queuing, and handlers.""" def __init__(self) -> None: - # {event_type: EventMechanism} + # {event_type: EventMechanismFlag} self._lock = threading.RLock() - self.enabled: dict[constants.EventType, EventMechanism] = {} + self.enabled: dict[constants.EventType, EventMechanismFlag] = {} self.queue = EventQueue() self.registry = HandlerRegistry() self.monitor_thread: threading.Thread | None = None @@ -243,52 +248,42 @@ def enable( event_type: constants.EventType, mechanism: constants.EventMechanism, ) -> None: - """Enable delivery of *event_type* via *mechanism*.""" - m = EventMechanism(int(mechanism)) + """Enable delivery of *event_type* via *mechanism_flag*.""" + m = EventMechanismFlag.from_int(int(mechanism)) with self._lock: - if m is EventMechanism.ALL: - self.enabled[event_type] = ( - EventMechanism.QUEUE - | EventMechanism.HANDLER - | EventMechanism.SUSPEND - ) - else: - self.enabled[event_type] = ( - self.enabled.get(event_type, EventMechanism.NONE) | m - ) + self.enabled[event_type] = ( + self.enabled.get(event_type, EventMechanismFlag.NONE) | m + ) def disable( self, event_type: constants.EventType, mechanism: constants.EventMechanism, ) -> None: - """Disable delivery of *event_type* via *mechanism*.""" - m = EventMechanism(int(mechanism)) + """Disable delivery of *event_type* via *mechanism_flag*.""" + m = EventMechanismFlag.from_int(int(mechanism)) with self._lock: if event_type not in self.enabled: return - if m is EventMechanism.ALL: + new = self.enabled[event_type] & ~m + if new is EventMechanismFlag.NONE: del self.enabled[event_type] else: - new = self.enabled[event_type] & ~m - if new is EventMechanism.NONE: - del self.enabled[event_type] - else: - self.enabled[event_type] = new + self.enabled[event_type] = new def is_queue_enabled(self, event_type: constants.EventType) -> bool: """Return whether queue delivery is enabled for *event_type*.""" with self._lock: return bool( - self.enabled.get(event_type, EventMechanism.NONE) & EventMechanism.QUEUE + self.enabled.get(event_type, EventMechanismFlag.NONE) & EventMechanismFlag.QUEUE ) def is_handler_enabled(self, event_type: constants.EventType) -> bool: """Return whether handler (callback) delivery is enabled for *event_type*.""" with self._lock: return bool( - self.enabled.get(event_type, EventMechanism.NONE) - & EventMechanism.HANDLER + self.enabled.get(event_type, EventMechanismFlag.NONE) + & EventMechanismFlag.HANDLER ) def get_delivery_mechanisms( @@ -299,17 +294,15 @@ def get_delivery_mechanisms( The check is performed atomically under the state lock. """ with self._lock: - mech = self.enabled.get(event_type, EventMechanism.NONE) + mech = self.enabled.get(event_type, EventMechanismFlag.NONE) return ( - bool(mech & EventMechanism.QUEUE), - bool(mech & EventMechanism.HANDLER), + bool(mech & EventMechanismFlag.QUEUE), + bool(mech & EventMechanismFlag.HANDLER), ) def any_enabled(self) -> bool: """Return ``True`` if any event type has any mechanism enabled.""" with self._lock: - return any(m is not EventMechanism.NONE for m in self.enabled.values()) + return any(m is not EventMechanismFlag.NONE for m in self.enabled.values()) + - def should_monitor(self) -> bool: - """Convenience alias for :meth:`any_enabled`.""" - return self.any_enabled() diff --git a/pyvisa_py/highlevel.py b/pyvisa_py/highlevel.py index a4cec1c5..928abf9e 100644 --- a/pyvisa_py/highlevel.py +++ b/pyvisa_py/highlevel.py @@ -27,6 +27,7 @@ from pyvisa.util import DebugInfo, LibraryPath from .common import LOGGER +from .events import EventMechanismFlag from .sessions import OpenError, Session @@ -907,10 +908,8 @@ def discard_events( except KeyError: return self.handle_return_value(session, StatusCode.error_invalid_object) - mech = int(mechanism) - if mech == int(constants.EventMechanism.all) or mech & int( - constants.EventMechanism.queue - ): + mech = EventMechanismFlag.from_int(int(mechanism)) + if mech & EventMechanismFlag.QUEUE: et = None if event_type == constants.EventType.all_enabled else event_type sess._event_state.queue.discard_all(et) diff --git a/pyvisa_py/tcpip.py b/pyvisa_py/tcpip.py index 592d6b99..31650c38 100644 --- a/pyvisa_py/tcpip.py +++ b/pyvisa_py/tcpip.py @@ -596,7 +596,7 @@ def _start_event_monitor(self) -> StatusCode: and self._event_state.monitor_thread.is_alive() ): return StatusCode.success - if not self._event_state.should_monitor(): + if not self._event_state.any_enabled(): return StatusCode.success self._event_state.stop_flag.clear() diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py index 0f0742a4..6cf892a1 100644 --- a/pyvisa_py/testsuite/test_events.py +++ b/pyvisa_py/testsuite/test_events.py @@ -19,7 +19,7 @@ from pyvisa_py.events import ( EventContext, - EventMechanism, + EventMechanismFlag, EventQueue, EventState, HandlerRegistry, @@ -271,16 +271,16 @@ class TestEventState: def test_enable_disable(self): st = EventState() st.enable(constants.EventType.service_request, constants.EventMechanism.queue) - assert st.enabled[constants.EventType.service_request] is EventMechanism.QUEUE + assert st.enabled[constants.EventType.service_request] is EventMechanismFlag.QUEUE assert st.is_queue_enabled(constants.EventType.service_request) is True assert st.is_handler_enabled(constants.EventType.service_request) is False st.enable(constants.EventType.service_request, constants.EventMechanism.handler) assert st.enabled[constants.EventType.service_request] is ( - EventMechanism.QUEUE | EventMechanism.HANDLER + EventMechanismFlag.QUEUE | EventMechanismFlag.HANDLER ) assert st.is_handler_enabled(constants.EventType.service_request) is True st.disable(constants.EventType.service_request, constants.EventMechanism.queue) - assert st.enabled[constants.EventType.service_request] is EventMechanism.HANDLER + assert st.enabled[constants.EventType.service_request] is EventMechanismFlag.HANDLER assert st.is_queue_enabled(constants.EventType.service_request) is False assert st.is_handler_enabled(constants.EventType.service_request) is True st.disable( @@ -289,19 +289,17 @@ def test_enable_disable(self): assert constants.EventType.service_request not in st.enabled assert st.any_enabled() is False - def test_any_enabled_and_should_monitor(self): + def test_any_enabled(self): st = EventState() assert st.any_enabled() is False - assert st.should_monitor() is False st.enable(constants.EventType.io_completion, constants.EventMechanism.queue) - assert st.enabled[constants.EventType.io_completion] is EventMechanism.QUEUE + assert st.enabled[constants.EventType.io_completion] is EventMechanismFlag.QUEUE assert st.any_enabled() is True - assert st.should_monitor() is True def test_disable_removes_empty_event_type(self): st = EventState() st.enable(constants.EventType.service_request, constants.EventMechanism.queue) - assert st.enabled[constants.EventType.service_request] is EventMechanism.QUEUE + assert st.enabled[constants.EventType.service_request] is EventMechanismFlag.QUEUE st.disable(constants.EventType.service_request, constants.EventMechanism.queue) # Internal dict should be clean assert constants.EventType.service_request not in st.enabled @@ -311,7 +309,7 @@ def test_enable_combined_bitmask(self): combined = constants.EventMechanism.queue | constants.EventMechanism.handler st.enable(constants.EventType.service_request, combined) assert st.enabled[constants.EventType.service_request] is ( - EventMechanism.QUEUE | EventMechanism.HANDLER + EventMechanismFlag.QUEUE | EventMechanismFlag.HANDLER ) assert st.is_queue_enabled(constants.EventType.service_request) is True assert st.is_handler_enabled(constants.EventType.service_request) is True @@ -331,7 +329,7 @@ def test_disable_all_clears_everything(self): st.enable(constants.EventType.service_request, constants.EventMechanism.queue) st.enable(constants.EventType.service_request, constants.EventMechanism.handler) assert st.enabled[constants.EventType.service_request] is ( - EventMechanism.QUEUE | EventMechanism.HANDLER + EventMechanismFlag.QUEUE | EventMechanismFlag.HANDLER ) st.disable(constants.EventType.service_request, constants.EventMechanism.all) assert st.is_queue_enabled(constants.EventType.service_request) is False From f4c0bc9a60c68937e3ae8a001e16d68cc33ff2c6 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 20:45:56 +0100 Subject: [PATCH 23/27] remove udp srq server --- pyvisa_py/protocols/vxi11.py | 66 ------------------------------------ pyvisa_py/tcpip.py | 4 +-- 2 files changed, 1 insertion(+), 69 deletions(-) diff --git a/pyvisa_py/protocols/vxi11.py b/pyvisa_py/protocols/vxi11.py index d56b958a..7b991f47 100644 --- a/pyvisa_py/protocols/vxi11.py +++ b/pyvisa_py/protocols/vxi11.py @@ -379,72 +379,6 @@ def destroy_intr_chan(self): ) -class SrqInterruptServer(rpc.UDPServer): - """UDP RPC server that receives VXI-11 DEVICE_INTR_SRQ (proc 30) interrupts.""" - - def __init__(self, host, prog, vers, port, session): - super().__init__(host, prog, vers, port) - self.session = session - self._srq_queue = queue.Queue() - self._srq_worker_thread = threading.Thread(target=self._srq_worker, daemon=True) - self._srq_worker_thread.start() - - def _srq_worker(self): - while True: - try: - item = self._srq_queue.get(timeout=1.0) - except queue.Empty: - continue - if item is None: - break - self._fire_srq() - - def stop(self): - self._srq_queue.put(None) - self._srq_worker_thread.join(timeout=2.0) - - def loop(self): - """Run until the session's stop_flag is set.""" - stop_flag = self.session._event_state.stop_flag - self.sock.settimeout(0.1) - while not stop_flag.is_set(): - try: - call, host_port = self.sock.recvfrom(8192) - except socket.timeout: - continue - reply = self.handle(call) - if reply is not None: - rpc._sendto(self.sock, reply, host_port) - - def handle_30(self): - """Handle DEVICE_INTR_SRQ (procedure 30). - - Unpacks the opaque handle, sends an RPC success reply, then - asynchronously reads the STB and fires the service_request event. - """ - handle = self.unpacker.unpack_opaque() - self.turn_around() - if handle != b"srq": - LOGGER.warning("Ignoring VXI-11 SRQ with unexpected handle: %r", handle) - return - self._srq_queue.put(True) - - def _fire_srq(self): - try: - # Defensive: session may have been closed while we were spawned - if self.session.interface is None or self.session.link == 0: - return - stb, status = self.session.read_stb() - if status == StatusCode.success and (stb & STB_RQS_BIT): - ctx = EventContext( - event_type=constants.EventType.service_request, - status_byte=stb, - ) - self.session._fire_event(constants.EventType.service_request, ctx) - except Exception: - LOGGER.exception("Error handling VXI-11 SRQ interrupt") - - class SrqInterruptTCPServer(rpc.TCPServer): """TCP RPC server that receives VXI-11 DEVICE_INTR_SRQ (proc 30) interrupts.""" diff --git a/pyvisa_py/tcpip.py b/pyvisa_py/tcpip.py index 31650c38..c58fe9bb 100644 --- a/pyvisa_py/tcpip.py +++ b/pyvisa_py/tcpip.py @@ -551,9 +551,7 @@ def after_parsing(self) -> None: self.client_id = random.getrandbits(31) self.keepalive = False - self._srq_server: ( - vxi11.SrqInterruptServer | vxi11.SrqInterruptTCPServer | None - ) = None + self._srq_server: vxi11.SrqInterruptTCPServer | None = None self._srq_lifecycle_lock = threading.Lock() error, link, _abort_port, max_recv_size = self.interface.create_link( From 3017cb5cf38601189f12e1d1a325c749b58c3908 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 21:00:28 +0100 Subject: [PATCH 24/27] strongly type HandlerCallback session param --- pyvisa_py/events.py | 23 ++++++++++++++++------- pyvisa_py/testsuite/test_events.py | 11 ++++++----- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 5c2897a0..d8b9d717 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -15,6 +15,7 @@ from typing import Any, Callable from pyvisa import constants +from pyvisa.typing import VISASession import enum @@ -154,12 +155,20 @@ def discard_all(self, event_type: constants.EventType | None = None) -> None: self._deque.extend(kept) -# HandlerCallback: callable invoked when a VISA event fires. -# Arg 0 (Any): session handle (vi) -# Arg 1 (constants.EventType): the event type that fired -# Arg 2 (int): event context id -# Arg 3 (Any): user-supplied handle passed at install_handler time -HandlerCallback = Callable[[Any, constants.EventType, int, Any], None] +HandlerCallback = Callable[[VISASession, constants.EventType, int, Any], None] +"""Callable invoked when a VISA event fires. + +Parameters +---------- +session : VISASession + Session handle (vi). +event_type : constants.EventType + The event type that fired. +context_id : int + Event context id. +user_handle : Any + User-supplied handle passed at install_handler time. +""" class HandlerRegistry: @@ -207,7 +216,7 @@ def uninstall( def fire( self, event_type: constants.EventType, - session: Any, + session: VISASession, context_id: int, ) -> None: """Invoke all handlers registered for *event_type*. diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py index 6cf892a1..fff7400b 100644 --- a/pyvisa_py/testsuite/test_events.py +++ b/pyvisa_py/testsuite/test_events.py @@ -16,6 +16,7 @@ import pytest from pyvisa import constants, errors from pyvisa.constants import StatusCode +from pyvisa.typing import VISASession from pyvisa_py.events import ( EventContext, @@ -196,8 +197,8 @@ def handler(sess, etype, cid, uhandle): calls.append((sess, etype, cid, uhandle)) reg.install(constants.EventType.service_request, handler, "h1") - reg.fire(constants.EventType.service_request, "session", 42) - assert calls == [("session", constants.EventType.service_request, 42, "h1")] + reg.fire(constants.EventType.service_request, VISASession(42), 42) + assert calls == [(VISASession(42), constants.EventType.service_request, 42, "h1")] def test_multiple_handlers_fire(self): reg = HandlerRegistry() @@ -211,7 +212,7 @@ def h2(sess, etype, cid, uhandle): reg.install(constants.EventType.service_request, h1, None) reg.install(constants.EventType.service_request, h2, None) - reg.fire(constants.EventType.service_request, "session", 1) + reg.fire(constants.EventType.service_request, VISASession(1), 1) assert set(calls) == {"h1", "h2"} def test_uninstall_by_identity_and_handle(self): @@ -253,13 +254,13 @@ def good(*_): reg.install(constants.EventType.service_request, bad, None) reg.install(constants.EventType.service_request, good, None) with pytest.warns(UserWarning, match="boom"): - reg.fire(constants.EventType.service_request, "session", 1) + reg.fire(constants.EventType.service_request, VISASession(1), 1) assert calls == ["good"] def test_fire_no_handlers_noop(self): reg = HandlerRegistry() # Should not raise - reg.fire(constants.EventType.service_request, "session", 1) + reg.fire(constants.EventType.service_request, VISASession(1), 1) # --------------------------------------------------------------------------- From 659fffa32f6807e316912bec503aae8d74df5f64 Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 21:04:50 +0100 Subject: [PATCH 25/27] fix ruff linting errors: import sorting, unused LOGGER import removal, underscore prefix unused test variables --- pyvisa_py/events.py | 5 +---- pyvisa_py/protocols/vxi11.py | 6 +++--- pyvisa_py/testsuite/test_events.py | 8 ++++---- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index d8b9d717..301c92fc 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -7,6 +7,7 @@ """ import collections +import enum import random import threading import time @@ -17,8 +18,6 @@ from pyvisa import constants from pyvisa.typing import VISASession -import enum - class EventMechanismFlag(enum.Flag): """Internal Flag enum mirroring VISA event-delivery mechanisms. @@ -42,8 +41,6 @@ def from_int(cls, value: int) -> "EventMechanismFlag": return cls(value & (cls.QUEUE | cls.HANDLER | cls.SUSPEND).value) -from .common import LOGGER - @dataclass(frozen=True, slots=True) class EventContext: diff --git a/pyvisa_py/protocols/vxi11.py b/pyvisa_py/protocols/vxi11.py index 7b991f47..aa667a0d 100644 --- a/pyvisa_py/protocols/vxi11.py +++ b/pyvisa_py/protocols/vxi11.py @@ -18,10 +18,10 @@ from pyvisa import constants from pyvisa.constants import StatusCode -from ..common import LOGGER -from ..events import EventContext from . import rpc +from ..common import LOGGER +from ..events import EventContext # fmt: off # VXI-11 RPC constants @@ -413,7 +413,7 @@ def loop(self): stop_flag = self.session._event_state.stop_flag while not stop_flag.is_set(): try: - conn, addr = self.sock.accept() + conn, _addr = self.sock.accept() except socket.timeout: continue except OSError: diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py index fff7400b..d839871c 100644 --- a/pyvisa_py/testsuite/test_events.py +++ b/pyvisa_py/testsuite/test_events.py @@ -471,7 +471,7 @@ def my_handler(*_): assert result == StatusCode.success def test_uninstall_handler_not_installed_raises(self, lib_and_session): - lib, sess, sid = lib_and_session + lib, _sess, sid = lib_and_session def my_handler(*_): pass @@ -498,13 +498,13 @@ def test_wait_on_event_success(self, lib_and_session): assert status == StatusCode.success def test_wait_on_event_timeout_raises(self, lib_and_session): - lib, sess, sid = lib_and_session + lib, _sess, sid = lib_and_session with pytest.raises(errors.VisaIOError) as exc_info: lib.wait_on_event(sid, constants.EventType.service_request, 50) assert exc_info.value.error_code == StatusCode.error_timeout def test_wait_on_event_zero_timeout_raises(self, lib_and_session): - lib, sess, sid = lib_and_session + lib, _sess, sid = lib_and_session with pytest.raises(errors.VisaIOError) as exc_info: lib.wait_on_event(sid, constants.EventType.service_request, 0) assert exc_info.value.error_code == StatusCode.error_timeout @@ -696,7 +696,7 @@ def test_fire_event_then_wait_on_event(self, lib_and_session): assert status == StatusCode.success def test_vxi11_fire_event_handler_mechanism(self, lib_and_session): - lib, sess, sid = lib_and_session + _lib, sess, sid = lib_and_session calls = [] def my_handler(session, event_type, context_id, user_handle): From 878c0334abeceff407676876d4a5ec7ce9ddbb0b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 20:05:25 +0000 Subject: [PATCH 26/27] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyvisa_py/events.py | 16 +++++++--------- pyvisa_py/testsuite/test_events.py | 17 +++++++++++++---- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 301c92fc..3d63b3cd 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -22,10 +22,10 @@ class EventMechanismFlag(enum.Flag): """Internal Flag enum mirroring VISA event-delivery mechanisms. - ``ALL`` is a convenience alias for ``QUEUE | HANDLER | SUSPEND``. - The `from_int` classmethod canonicalises the VISA sentinel - ``0xFFFF`` (``constants.EventMechanism.all``) to this composite - value so that bitwise ``~`` works correctly. + ``ALL`` is a convenience alias for ``QUEUE | HANDLER | SUSPEND``. + The `from_int` classmethod canonicalises the VISA sentinel + ``0xFFFF`` (``constants.EventMechanism.all``) to this composite + value so that bitwise ``~`` works correctly. """ NONE = 0 @@ -33,7 +33,7 @@ class EventMechanismFlag(enum.Flag): HANDLER = 2 # VI_HNDLR (2) SUSPEND = 4 # VI_SUSPEND_HNDLR (4) ALL = QUEUE | HANDLER | SUSPEND # = 7, not VI_ALL_MECH (0xFFFF) - + @classmethod def from_int(cls, value: int) -> "EventMechanismFlag": if value == int(constants.EventMechanism.all): # 0xFFFF @@ -41,7 +41,6 @@ def from_int(cls, value: int) -> "EventMechanismFlag": return cls(value & (cls.QUEUE | cls.HANDLER | cls.SUSPEND).value) - @dataclass(frozen=True, slots=True) class EventContext: """Immutable description of a single VISA event occurrence.""" @@ -281,7 +280,8 @@ def is_queue_enabled(self, event_type: constants.EventType) -> bool: """Return whether queue delivery is enabled for *event_type*.""" with self._lock: return bool( - self.enabled.get(event_type, EventMechanismFlag.NONE) & EventMechanismFlag.QUEUE + self.enabled.get(event_type, EventMechanismFlag.NONE) + & EventMechanismFlag.QUEUE ) def is_handler_enabled(self, event_type: constants.EventType) -> bool: @@ -310,5 +310,3 @@ def any_enabled(self) -> bool: """Return ``True`` if any event type has any mechanism enabled.""" with self._lock: return any(m is not EventMechanismFlag.NONE for m in self.enabled.values()) - - diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py index d839871c..febd3fbe 100644 --- a/pyvisa_py/testsuite/test_events.py +++ b/pyvisa_py/testsuite/test_events.py @@ -198,7 +198,9 @@ def handler(sess, etype, cid, uhandle): reg.install(constants.EventType.service_request, handler, "h1") reg.fire(constants.EventType.service_request, VISASession(42), 42) - assert calls == [(VISASession(42), constants.EventType.service_request, 42, "h1")] + assert calls == [ + (VISASession(42), constants.EventType.service_request, 42, "h1") + ] def test_multiple_handlers_fire(self): reg = HandlerRegistry() @@ -272,7 +274,9 @@ class TestEventState: def test_enable_disable(self): st = EventState() st.enable(constants.EventType.service_request, constants.EventMechanism.queue) - assert st.enabled[constants.EventType.service_request] is EventMechanismFlag.QUEUE + assert ( + st.enabled[constants.EventType.service_request] is EventMechanismFlag.QUEUE + ) assert st.is_queue_enabled(constants.EventType.service_request) is True assert st.is_handler_enabled(constants.EventType.service_request) is False st.enable(constants.EventType.service_request, constants.EventMechanism.handler) @@ -281,7 +285,10 @@ def test_enable_disable(self): ) assert st.is_handler_enabled(constants.EventType.service_request) is True st.disable(constants.EventType.service_request, constants.EventMechanism.queue) - assert st.enabled[constants.EventType.service_request] is EventMechanismFlag.HANDLER + assert ( + st.enabled[constants.EventType.service_request] + is EventMechanismFlag.HANDLER + ) assert st.is_queue_enabled(constants.EventType.service_request) is False assert st.is_handler_enabled(constants.EventType.service_request) is True st.disable( @@ -300,7 +307,9 @@ def test_any_enabled(self): def test_disable_removes_empty_event_type(self): st = EventState() st.enable(constants.EventType.service_request, constants.EventMechanism.queue) - assert st.enabled[constants.EventType.service_request] is EventMechanismFlag.QUEUE + assert ( + st.enabled[constants.EventType.service_request] is EventMechanismFlag.QUEUE + ) st.disable(constants.EventType.service_request, constants.EventMechanism.queue) # Internal dict should be clean assert constants.EventType.service_request not in st.enabled From fbcb8e4c573a5a902af2fd5e313d2c9e6ca7a6ff Mon Sep 17 00:00:00 2001 From: Simon Merrett <26767525+SimonMerrett@users.noreply.github.com> Date: Sun, 24 May 2026 22:20:05 +0100 Subject: [PATCH 27/27] organise imports to satisfy ruff --- pyvisa_py/events.py | 2 +- pyvisa_py/protocols/vxi11.py | 2 +- pyvisa_py/testsuite/test_events.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/pyvisa_py/events.py b/pyvisa_py/events.py index 301c92fc..5d55518a 100644 --- a/pyvisa_py/events.py +++ b/pyvisa_py/events.py @@ -33,7 +33,7 @@ class EventMechanismFlag(enum.Flag): HANDLER = 2 # VI_HNDLR (2) SUSPEND = 4 # VI_SUSPEND_HNDLR (4) ALL = QUEUE | HANDLER | SUSPEND # = 7, not VI_ALL_MECH (0xFFFF) - + @classmethod def from_int(cls, value: int) -> "EventMechanismFlag": if value == int(constants.EventMechanism.all): # 0xFFFF diff --git a/pyvisa_py/protocols/vxi11.py b/pyvisa_py/protocols/vxi11.py index aa667a0d..6e00a656 100644 --- a/pyvisa_py/protocols/vxi11.py +++ b/pyvisa_py/protocols/vxi11.py @@ -19,9 +19,9 @@ from pyvisa import constants from pyvisa.constants import StatusCode -from . import rpc from ..common import LOGGER from ..events import EventContext +from . import rpc # fmt: off # VXI-11 RPC constants diff --git a/pyvisa_py/testsuite/test_events.py b/pyvisa_py/testsuite/test_events.py index d839871c..7f2e6600 100644 --- a/pyvisa_py/testsuite/test_events.py +++ b/pyvisa_py/testsuite/test_events.py @@ -14,10 +14,10 @@ from unittest.mock import MagicMock, patch import pytest + from pyvisa import constants, errors from pyvisa.constants import StatusCode from pyvisa.typing import VISASession - from pyvisa_py.events import ( EventContext, EventMechanismFlag, @@ -28,7 +28,6 @@ from pyvisa_py.highlevel import PyVisaLibrary from pyvisa_py.protocols import vxi11 - # --------------------------------------------------------------------------- # EventContext # ---------------------------------------------------------------------------