Backends

Instrument Backends

PyTestLab supports multiple instrument communication backends, each designed for a specific class of hardware or simulation use case. This page documents the main backend classes and their roles.


Overview

A backend is the low-level driver responsible for communicating with an instrument. Backends abstract the transport mechanism (VISA, Lamb, simulation, etc.) so that high-level instrument drivers can use a unified API.

Backends are typically not used directly by end-users. Instead, they are selected automatically based on your instrument profile, connection string, and simulation settings.


Available Backends

VisaBackend

Synchronous backend for VISA-compatible instruments (e.g., GPIB, USB, TCPIP, RS232). Uses PyVISA under the hood.

pytestlab.instruments.backends.visa_backend.VisaBackend(address, timeout_ms=5000)

A backend for communicating with instruments using pyvisa (sync). This class implements the InstrumentIO protocol.

Source code in pytestlab/instruments/backends/visa_backend.py
def __init__(self, address: str, timeout_ms: int | None = 5000):
    self.address = address
    self.rm = pyvisa.ResourceManager()
    self.instrument: pyvisa.resources.MessageBasedResource | None = None
    self._timeout_ms = timeout_ms if timeout_ms is not None else 5000  # Default to 5 seconds

Attributes

address = address instance-attribute

instrument = None instance-attribute

rm = pyvisa.ResourceManager() instance-attribute

Functions

close()

Closes the connection (alias for disconnect).

Source code in pytestlab/instruments/backends/visa_backend.py
def close(self) -> None:
    """Closes the connection (alias for disconnect)."""
    self.disconnect()

connect()

Connects to the VISA resource.

Source code in pytestlab/instruments/backends/visa_backend.py
def connect(self) -> None:
    """Connects to the VISA resource."""
    if self.instrument is not None:
        # Already connected or connection attempt made, ensure clean state if re-connecting
        try:
            self.instrument.close()
        except Exception:
            # Ignore errors on close if already disconnected or in bad state
            pass
        self.instrument = None

    try:
        # Type ignore for open_resource as pyvisa's stubs might not be perfectly aligned
        # with all resource types, but MessageBasedResource is common.
        resource = self.rm.open_resource(self.address)
        if not isinstance(resource, pyvisa.resources.MessageBasedResource):
            raise InstrumentConnectionError(
                f"Resource at {self.address} is not a MessageBasedResource. Type: {type(resource).__name__}"
            )
        self.instrument = resource
        self.instrument.timeout = self._timeout_ms  # pyvisa timeout is in milliseconds
    except pyvisa.Error as e:  # Catch specific pyvisa errors
        raise InstrumentConnectionError(
            f"Failed to connect to VISA resource {self.address}: {e}"
        ) from e
    except Exception as e:  # Catch other potential errors during connection
        raise InstrumentConnectionError(
            f"An unexpected error occurred while connecting to VISA resource {self.address}: {e}"
        ) from e

disconnect()

Disconnects from the VISA resource.

Source code in pytestlab/instruments/backends/visa_backend.py
def disconnect(self) -> None:
    """Disconnects from the VISA resource."""
    if self.instrument is not None:
        try:
            self.instrument.close()
        except pyvisa.Error as e:
            # Log or handle error during close if necessary
            raise InstrumentConnectionError(
                f"Error disconnecting from VISA resource {self.address}: {e}"
            ) from e
        except Exception as e:
            raise InstrumentConnectionError(
                f"An unexpected error occurred while disconnecting VISA resource {self.address}: {e}"
            ) from e
        finally:
            self.instrument = None

get_timeout()

Gets the communication timeout in milliseconds.

Source code in pytestlab/instruments/backends/visa_backend.py
def get_timeout(self) -> int:
    """Gets the communication timeout in milliseconds."""
    # Return the locally stored timeout, as reading from instrument might not always be reliable
    # or could cause unnecessary communication. The local value is the source of truth for new connections
    # and attempts to set it on the instrument.
    return self._timeout_ms

query(cmd, delay=None)

Sends a query to the instrument and returns the string response.

Source code in pytestlab/instruments/backends/visa_backend.py
def query(self, cmd: str, delay: float | None = None) -> str:
    """Sends a query to the instrument and returns the string response."""
    if self.instrument is None:
        raise InstrumentConnectionError("Not connected to VISA resource. Call connect() first.")
    try:
        response = self.instrument.query(cmd, delay=delay)
        return response.strip()
    except pyvisa.Error as e:  # pyvisa.VisaIOError is a common one here
        raise InstrumentCommunicationError(
            f"Failed to query '{cmd}' from {self.address}: {e}"
        ) from e
    except Exception as e:
        raise InstrumentCommunicationError(
            f"An unexpected error occurred querying '{cmd}' from {self.address}: {e}"
        ) from e

query_raw(cmd, delay=None)

Sends a query and returns the raw bytes response.

Source code in pytestlab/instruments/backends/visa_backend.py
def query_raw(self, cmd: str, delay: float | None = None) -> bytes:
    """Sends a query and returns the raw bytes response."""
    if self.instrument is None:
        raise InstrumentConnectionError("Not connected to VISA resource. Call connect() first.")
    try:
        # pyvisa's query_binary_values might be more appropriate for some raw data,
        # but query_ascii_values(..., converter='s') or direct read after write can also work.
        # For simplicity and generality, using write then read_raw.
        # Ensure the instrument is configured for binary transfer if needed.
        self.instrument.write(cmd)
        if delay is not None:
            time.sleep(delay)
        data = self.instrument.read_bytes(self.instrument.chunk_size)  # Or read_raw()
        return data
    except pyvisa.Error as e:
        raise InstrumentCommunicationError(
            f"Failed to query_raw '{cmd}' from {self.address}: {e}"
        ) from e
    except Exception as e:
        raise InstrumentCommunicationError(
            f"An unexpected error occurred during query_raw '{cmd}' from {self.address}: {e}"
        ) from e

set_timeout(timeout_ms)

Sets the communication timeout in milliseconds.

Source code in pytestlab/instruments/backends/visa_backend.py
def set_timeout(self, timeout_ms: int) -> None:
    """Sets the communication timeout in milliseconds."""
    if timeout_ms <= 0:
        raise ValueError("Timeout must be positive.")
    self._timeout_ms = timeout_ms
    if self.instrument:
        try:
            self.instrument.timeout = timeout_ms
        except pyvisa.Error as e:
            # Log this, but don't necessarily fail the operation if instrument is disconnected
            # or doesn't support dynamic timeout setting in its current state.
            # Consider raising InstrumentConfigurationError if strictness is required.
            print(
                f"Warning: Could not set timeout on VISA resource {self.address} (instrument may be disconnected or unresponsive): {e}"
            )
        except Exception as e:
            print(
                f"Warning: An unexpected error occurred setting timeout on VISA resource {self.address}: {e}"
            )

write(cmd)

Writes a command to the instrument.

Source code in pytestlab/instruments/backends/visa_backend.py
def write(self, cmd: str) -> None:
    """Writes a command to the instrument."""
    if self.instrument is None:
        raise InstrumentConnectionError("Not connected to VISA resource. Call connect() first.")
    try:
        self.instrument.write(cmd)
    except pyvisa.Error as e:
        raise InstrumentCommunicationError(
            f"Failed to write command '{cmd}' to {self.address}: {e}"
        ) from e
    except Exception as e:
        raise InstrumentCommunicationError(
            f"An unexpected error occurred writing command '{cmd}' to {self.address}: {e}"
        ) from e

LambBackend

Backend for instruments accessible via the Lamb remote instrument server protocol. Uses synchronous HTTP requests to communicate with Lamb daemons.

pytestlab.instruments.backends.lamb.LambBackend(address=None, url='http://lamb-server:8000', timeout_ms=10000, model_name=None, serial_number=None)

Bases: InstrumentIO

An backend for communicating with instruments via a Lamb server. Supports both direct visa_string and auto-connect via model/serial_number.

PARAMETER DESCRIPTION
address

The visa_string or unique instrument address. If not provided, model_name and serial_number must be provided.

TYPE: str | None DEFAULT: None

url

Lamb server base URL.

TYPE: str DEFAULT: 'http://lamb-server:8000'

timeout_ms

Communication timeout in ms.

TYPE: int | None DEFAULT: 10000

model_name

Model name for auto-connect.

TYPE: str | None DEFAULT: None

serial_number

Serial number for auto-connect.

TYPE: str | None DEFAULT: None

Source code in pytestlab/instruments/backends/lamb.py
def __init__(
    self,
    address: str | None = None,
    url: str = "http://lamb-server:8000",
    timeout_ms: int | None = 10000,
    model_name: str | None = None,
    serial_number: str | None = None,
):
    """
    Args:
        address: The visa_string or unique instrument address. If not provided, model_name and serial_number must be provided.
        url: Lamb server base URL.
        timeout_ms: Communication timeout in ms.
        model_name: Model name for auto-connect.
        serial_number: Serial number for auto-connect.
    """
    self.base_url: str = url.rstrip("/")
    self.instrument_address: str | None = address  # visa_string
    self.model_name: str | None = model_name
    self.serial_number: str | None = serial_number
    self._timeout_sec: float = (timeout_ms / 1000.0) if timeout_ms and timeout_ms > 0 else 5.0
    self._client: httpx.Client | None = None
    self._auto_connect_performed: bool = False

    lamb_logger.info(
        f"LambBackend initialized for address='{address}', model='{model_name}', serial='{serial_number}' at URL '{url}'"
    )

Attributes

base_url = url.rstrip('/') instance-attribute

instrument_address = address instance-attribute

model_name = model_name instance-attribute

serial_number = serial_number instance-attribute

Functions

close()

Source code in pytestlab/instruments/backends/lamb.py
def close(self) -> None:
    self.disconnect()

connect()

Ensures the instrument is registered with Lamb and ready.

Source code in pytestlab/instruments/backends/lamb.py
def connect(self) -> None:
    """
    Ensures the instrument is registered with Lamb and ready.
    """
    self._ensure_connected()
    # Optionally, ping instrument status endpoint here
    lamb_logger.info(f"Connected to Lamb instrument '{self.instrument_address}'.")

disconnect()

Source code in pytestlab/instruments/backends/lamb.py
def disconnect(self) -> None:
    lamb_logger.info(
        f"LambBackend for '{self.instrument_address}' disconnected (simulated, as client is per-request or context-managed)."
    )
    pass

get_timeout()

Source code in pytestlab/instruments/backends/lamb.py
def get_timeout(self) -> int:
    return int(self._timeout_sec * 1000)

query(cmd, delay=None)

Source code in pytestlab/instruments/backends/lamb.py
def query(self, cmd: str, delay: float | None = None) -> str:
    self._ensure_connected()
    lamb_logger.debug(f"QUERY to '{self.instrument_address}': {cmd}")
    try:
        with httpx.Client(timeout=self._timeout_sec) as client:
            response = client.post(
                f"{self.base_url}/instrument/query",
                json={"visa_string": self.instrument_address, "command": cmd},
                headers={"Accept": "application/json", "Accept-Charset": "utf-8"},
            )
            response.raise_for_status()
            content: str = response.content.decode("utf-8")
            return content.strip()
    except httpx.HTTPStatusError as e:
        raise InstrumentCommunicationError(
            f"Lamb server query failed: {e.response.status_code} - {e.response.text}"
        ) from e
    except httpx.RequestError as e:
        raise InstrumentCommunicationError(f"Network error during Lamb query: {e}") from e

query_raw(cmd, delay=None)

Source code in pytestlab/instruments/backends/lamb.py
def query_raw(self, cmd: str, delay: float | None = None) -> bytes:
    self._ensure_connected()
    lamb_logger.debug(f"QUERY_RAW to '{self.instrument_address}': {cmd}")
    try:
        with httpx.Client(timeout=self._timeout_sec) as client:
            response = client.post(
                f"{self.base_url}/instrument/query_raw",
                json={"visa_string": self.instrument_address, "command": cmd},
                headers={"Accept": "application/octet-stream"},
            )
            response.raise_for_status()
            return response.content
    except httpx.HTTPStatusError as e:
        raise InstrumentCommunicationError(
            f"Lamb server query_raw failed: {e.response.status_code} - {e.response.text}"
        ) from e
    except httpx.RequestError as e:
        raise InstrumentCommunicationError(f"Network error during Lamb query_raw: {e}") from e

set_timeout(timeout_ms)

Source code in pytestlab/instruments/backends/lamb.py
def set_timeout(self, timeout_ms: int) -> None:
    if timeout_ms <= 0:
        self._timeout_sec = 0.001
    else:
        self._timeout_sec = timeout_ms / 1000.0
    lamb_logger.debug(f"LambBackend timeout set to {self._timeout_sec} seconds.")

write(cmd)

Source code in pytestlab/instruments/backends/lamb.py
def write(self, cmd: str) -> None:
    self._ensure_connected()
    lamb_logger.debug(f"WRITE to '{self.instrument_address}': {cmd}")
    try:
        with httpx.Client(timeout=self._timeout_sec) as client:
            response = client.post(
                f"{self.base_url}/instrument/write",
                json={"visa_string": self.instrument_address, "command": cmd},
                headers={"Accept": "application/json", "Accept-Charset": "utf-8"},
            )
            response.raise_for_status()
    except httpx.HTTPStatusError as e:
        raise InstrumentCommunicationError(
            f"Lamb server write failed: {e.response.status_code} - {e.response.text}"
        ) from e
    except httpx.RequestError as e:
        raise InstrumentCommunicationError(f"Network error during Lamb write: {e}") from e

SimBackend

The YAML-driven simulation backend for realistic instrument behavior.

pytestlab.instruments.backends.sim_backend.SimBackend(profile_path, *, model=None, timeout_ms=None)

Bases: InstrumentIO

Drop-in replacement for the existing SimBackend with vastly richer functionality (see module docstring for highlights).

Source code in pytestlab/instruments/backends/sim_backend.py
def __init__(
    self,
    profile_path: str | os.PathLike,
    *,
    model: str | None = None,
    timeout_ms: int | None = None,
) -> None:
    self.profile_path = Path(profile_path)
    self.timeout_ms = timeout_ms or self.DEFAULT_TIMEOUT_MS
    self.model = model or self.profile_path.stem
    # main data
    self._profile = self._load_profile()
    self._initial_state = copy.deepcopy(self._profile["simulation"].get("initial_state", {}))
    self._state: dotdict = dotdict(copy.deepcopy(self._initial_state))
    self._error_queue: list[tuple[int, str]] = []
    # dispatcher
    self._exact_map: dict[str, Any] = {}
    self._pattern_rules: list[_PatternRule] = []
    self._build_dispatch_tables()
    logger.info("SimBackend initialised for %s", self.model)

Attributes

DEFAULT_TIMEOUT_MS = 5000 class-attribute instance-attribute

USER_OVERRIDE_ROOT = Path.home() / '.pytestlab' / 'sim_profiles' class-attribute instance-attribute

model = model or self.profile_path.stem instance-attribute

profile_path = Path(profile_path) instance-attribute

timeout_ms = timeout_ms or self.DEFAULT_TIMEOUT_MS instance-attribute

Functions

close()

Source code in pytestlab/instruments/backends/sim_backend.py
def close(self) -> None:
    self.disconnect()

connect()

Establish connection (no-op in simulation).

Source code in pytestlab/instruments/backends/sim_backend.py
def connect(self) -> None:  # noqa: D401
    "Establish connection (no-op in simulation)."
    logger.debug("%s: connect()", self.model)

disconnect()

Close connection (no-op).

Source code in pytestlab/instruments/backends/sim_backend.py
def disconnect(self) -> None:
    "Close connection (no-op)."
    logger.debug("%s: disconnect()", self.model)

get_timeout()

Source code in pytestlab/instruments/backends/sim_backend.py
def get_timeout(self) -> int:
    return self.timeout_ms

query(cmd, delay=None)

Handle a SCPI query and return a decoded string.

Source code in pytestlab/instruments/backends/sim_backend.py
def query(self, cmd: str, delay: float | None = None) -> str:
    "Handle a SCPI query and return a **decoded** string."
    if delay:
        time.sleep(delay)
    response = self._handle_command(cmd, expect_response=True)
    if isinstance(response, bytes):
        resp_str = response.decode("utf-8", errors="ignore")
    else:
        resp_str = response
    logger.debug("%s QUERY ‹%s› → %s", self.model, cmd.strip(), resp_str)
    return resp_str

query_raw(cmd, delay=None)

Source code in pytestlab/instruments/backends/sim_backend.py
def query_raw(self, cmd: str, delay: float | None = None) -> bytes:
    if delay:
        time.sleep(delay)
    # Execute in raw mode to preserve binary responses
    resp = self._handle_command_raw(cmd)
    return resp

set_timeout(timeout_ms)

Source code in pytestlab/instruments/backends/sim_backend.py
def set_timeout(self, timeout_ms: int) -> None:
    self.timeout_ms = timeout_ms

write(cmd)

Handle a SCPI write.

Source code in pytestlab/instruments/backends/sim_backend.py
def write(self, cmd: str) -> None:
    "Handle a SCPI write."
    logger.debug("%s WRITE ‹%s›", self.model, cmd.strip())
    self._handle_command(cmd)

RecordingBackend

A backend that wraps another backend and records all SCPI commands and responses. Used for generating simulation profiles and debugging.

pytestlab.instruments.backends.recording_backend.RecordingBackend(backend, output_path=None, base_profile=None)

A backend that records interactions to a simulation profile.

Source code in pytestlab/instruments/backends/recording_backend.py
def __init__(self, backend, output_path=None, base_profile=None):
    self.backend = backend
    self.output_path = output_path
    self.base_profile = base_profile if base_profile is not None else {}
    self.log = []
    self.start_time = time.monotonic()

Attributes

backend = backend instance-attribute

base_profile = base_profile if base_profile is not None else {} instance-attribute

log = [] instance-attribute

output_path = output_path instance-attribute

start_time = time.monotonic() instance-attribute

Functions

__getattr__(name)

Delegate other attributes to the wrapped backend.

Source code in pytestlab/instruments/backends/recording_backend.py
def __getattr__(self, name):
    """Delegate other attributes to the wrapped backend."""
    return getattr(self.backend, name)

close()

Close the backend and write the simulation profile.

Source code in pytestlab/instruments/backends/recording_backend.py
def close(self):
    """Close the backend and write the simulation profile."""
    if hasattr(self.backend, "close") and callable(self.backend.close):
        self.backend.close()
    print("DEBUG: Calling generate_profile from RecordingBackend.close()")
    self.generate_profile()

generate_profile()

Generate the YAML simulation profile from the log.

Source code in pytestlab/instruments/backends/recording_backend.py
def generate_profile(self):
    """Generate the YAML simulation profile from the log."""
    print(f"DEBUG: generate_profile called. Output path: {self.output_path}")
    scpi_map = {}
    for entry in self.log:
        if entry["type"] == "query":
            scpi_map[entry["command"]] = entry["response"]
        elif entry["type"] == "query_raw":
            command_slug = re.sub(r"[^a-zA-Z0-9]", "_", entry["command"])
            binary_filename = f"{command_slug}.bin"
            binary_filepath = Path(self.output_path).parent / binary_filename
            with open(binary_filepath, "wb") as f:
                f.write(entry["response"])
            scpi_map[entry["command"]] = {"binary": binary_filename}
        elif entry["type"] == "write":
            # For writes, we record the command with an empty response,
            # which is suitable for commands that don't return a value.
            scpi_map[entry["command"]] = ""

    profile = self.base_profile
    if "simulation" not in profile:
        profile["simulation"] = {}
    profile["simulation"]["scpi"] = scpi_map
    print(f"DEBUG: Profile data to be written: {profile}")
    if self.output_path:
        try:
            output_file = Path(self.output_path)
            print(f"DEBUG: Creating parent directory for {output_file}")
            output_file.parent.mkdir(parents=True, exist_ok=True)
            print(f"DEBUG: Writing to file {output_file}")
            with open(output_file, "w") as f:
                yaml.dump(profile, f, sort_keys=False)
            print("DEBUG: File write complete.")
            LOGGER.info(f"Simulation profile saved to {self.output_path}")
        except Exception as e:
            print(f"DEBUG: ERROR in generate_profile: {e}")
    else:
        # In a real scenario, this would go to a user cache directory.
        # For now, let's just print it if no path is provided.
        print("DEBUG: No output path provided. Printing to stdout.")
        print(yaml.dump(profile, sort_keys=False))

query(command, *args, **kwargs)

Query to the instrument, log it, and return the response.

Source code in pytestlab/instruments/backends/recording_backend.py
def query(self, command: str, *args, **kwargs):
    """Query to the instrument, log it, and return the response."""
    if hasattr(self.backend, "query") and callable(self.backend.query):
        response = self.backend.query(command, *args, **kwargs)
        self.log.append(
            {
                "type": "query",
                "command": command.strip(),
                "response": getattr(response, "strip", lambda: response)(),
            }
        )
        return response
    raise NotImplementedError("Backend does not support query method.")

query_raw(command, *args, **kwargs)

Query to the instrument, log it, and return the response.

Source code in pytestlab/instruments/backends/recording_backend.py
def query_raw(self, command: str, *args, **kwargs):
    """Query to the instrument, log it, and return the response."""
    if hasattr(self.backend, "query_raw") and callable(self.backend.query_raw):
        response = self.backend.query_raw(command, *args, **kwargs)
        self.log.append({"type": "query_raw", "command": command.strip(), "response": response})
        return response
    raise NotImplementedError("Backend does not support query_raw method.")

read()

Read from the instrument and log it.

Source code in pytestlab/instruments/backends/recording_backend.py
def read(self) -> str:
    """Read from the instrument and log it."""
    response = self.backend.read()
    self.log.append({"type": "read", "response": response.strip()})
    return response

write(command, *args, **kwargs)

Write a command to the instrument and log it.

Source code in pytestlab/instruments/backends/recording_backend.py
def write(self, command: str, *args, **kwargs):
    """Write a command to the instrument and log it."""
    self.log.append({"type": "write", "command": command.strip()})
    if hasattr(self.backend, "write") and callable(self.backend.write):
        result = self.backend.write(command, *args, **kwargs)
        return result
    raise NotImplementedError("Backend does not support write method.")

ReplayBackend

Backend for replaying recorded instrument sessions with strict sequence validation. Used for reproducible measurements and regression testing.

pytestlab.instruments.backends.replay_backend.ReplayBackend(session_file, profile_key)

Bases: InstrumentIO

A backend that replays a previously recorded session from a log file.

This backend ensures that a script interacts with the simulated instrument in the exact sequence it was recorded. Any deviation will result in a ReplayMismatchError.

Initialize ReplayBackend with session file and profile key.

PARAMETER DESCRIPTION
session_file

Path to the YAML session file or list of command log entries

TYPE: str | Path | list[dict[str, Any]]

profile_key

Key identifying the instrument profile in the session data

TYPE: str

Source code in pytestlab/instruments/backends/replay_backend.py
def __init__(self, session_file: str | Path | list[dict[str, Any]], profile_key: str):
    """
    Initialize ReplayBackend with session file and profile key.

    Args:
        session_file: Path to the YAML session file or list of command log entries
        profile_key: Key identifying the instrument profile in the session data
    """
    self.profile_key = profile_key

    # Handle direct log data vs file path
    if isinstance(session_file, list):
        # Direct log data provided
        self._command_log = session_file
        self.session_file = None
        self.session_data = None
    else:
        # File path provided
        self.session_file = str(session_file)

        # Load session data from file
        try:
            with open(session_file) as f:
                self.session_data = yaml.safe_load(f)
        except FileNotFoundError as e:
            raise FileNotFoundError(f"Session file not found: {session_file}") from e

        # Validate profile key exists
        if profile_key not in self.session_data:
            raise KeyError(f"'{profile_key}' not found in session data")

        # Extract command log and initialize tracking
        profile_data = self.session_data[profile_key]
        self._command_log = profile_data.get("log", [])

    self._log_index = 0
    self._model_name = profile_key

Attributes

profile_key = profile_key instance-attribute

session_data = yaml.safe_load(f) instance-attribute

session_file = None instance-attribute

Functions

close()

Close the backend connection.

Source code in pytestlab/instruments/backends/replay_backend.py
def close(self) -> None:
    """Close the backend connection."""
    self.disconnect()

connect()

Source code in pytestlab/instruments/backends/replay_backend.py
def connect(self) -> None:
    LOGGER.debug("ReplayBackend for '%s': Connected.", self._model_name)

disconnect()

Source code in pytestlab/instruments/backends/replay_backend.py
def disconnect(self) -> None:
    LOGGER.debug("ReplayBackend for '%s': Disconnected.", self._model_name)

from_session_file(session_file, profile_key) classmethod

Create a ReplayBackend from a session file.

Source code in pytestlab/instruments/backends/replay_backend.py
@classmethod
def from_session_file(cls, session_file: str | Path, profile_key: str) -> "ReplayBackend":
    """Create a ReplayBackend from a session file."""
    return cls(session_file, profile_key)

get_timeout()

Get timeout (returns default for replay).

Source code in pytestlab/instruments/backends/replay_backend.py
def get_timeout(self) -> int:
    """Get timeout (returns default for replay)."""
    return 5000  # Default value

query(cmd, delay=None)

Execute a query command and return the response.

Source code in pytestlab/instruments/backends/replay_backend.py
def query(self, cmd: str, delay: float | None = None) -> str:
    """Execute a query command and return the response."""
    entry = self._get_next_log_entry("query", cmd)
    return entry.get("response", "")

query_raw(cmd, delay=None)

Execute a raw query command and return bytes response.

Source code in pytestlab/instruments/backends/replay_backend.py
def query_raw(self, cmd: str, delay: float | None = None) -> bytes:
    """Execute a raw query command and return bytes response."""
    entry = self._get_next_log_entry("query_raw", cmd)
    # Assuming the response is stored as a string that needs encoding
    return str(entry.get("response", "")).encode("utf-8")

set_timeout(timeout_ms)

Set timeout (no-op for replay).

Source code in pytestlab/instruments/backends/replay_backend.py
def set_timeout(self, timeout_ms: int) -> None:
    """Set timeout (no-op for replay)."""
    pass

write(cmd)

Execute a write command.

Source code in pytestlab/instruments/backends/replay_backend.py
def write(self, cmd: str) -> None:
    """Execute a write command."""
    self._get_next_log_entry("write", cmd)

SessionRecordingBackend

Backend that wraps real instrument backends to record all interactions for later replay. Used in conjunction with ReplayBackend for record-and-replay workflows.

pytestlab.instruments.backends.session_recording_backend.SessionRecordingBackend(original_backend, output_file_or_log, profile_key=None)

Bases: InstrumentIO

A backend wrapper that records all interactions into a session file. This is used by the pytestlab replay record command.

Source code in pytestlab/instruments/backends/session_recording_backend.py
def __init__(
    self,
    original_backend: InstrumentIO,
    output_file_or_log: str | list[dict[str, Any]],
    profile_key: str | None = None,
):
    self.original_backend = original_backend
    # Predefine attributes for consistent typing
    self._command_log: list[dict[str, Any]] = []
    self.output_file: str | None = None

    # Handle both file output and direct log recording
    if isinstance(output_file_or_log, list):
        self._command_log = output_file_or_log
        self.output_file = None
    else:
        self.output_file = output_file_or_log
        self._command_log = []

    self.profile_key = profile_key
    self.start_time = time.monotonic()

Attributes

backend property

Alias for original_backend for compatibility.

original_backend = original_backend instance-attribute

output_file = None instance-attribute

profile_key = profile_key instance-attribute

start_time = time.monotonic() instance-attribute

Functions

close()

Source code in pytestlab/instruments/backends/session_recording_backend.py
def close(self):
    # The file writing is now handled by save_session or CLI command
    self.original_backend.close()

connect()

Source code in pytestlab/instruments/backends/session_recording_backend.py
def connect(self) -> None:
    self.original_backend.connect()

disconnect()

Source code in pytestlab/instruments/backends/session_recording_backend.py
def disconnect(self) -> None:
    self.original_backend.disconnect()

get_timeout()

Source code in pytestlab/instruments/backends/session_recording_backend.py
def get_timeout(self) -> int:
    return self.original_backend.get_timeout()

query(cmd, delay=None)

Source code in pytestlab/instruments/backends/session_recording_backend.py
def query(self, cmd: str, delay: float | None = None) -> str:
    # Handle the case where the underlying backend doesn't support delay parameter
    try:
        response = self.original_backend.query(cmd, delay=delay)
    except TypeError:
        # Fallback for backends that don't support delay parameter
        response = self.original_backend.query(cmd)

    self._log_event({"type": "query", "command": cmd.strip(), "response": response.strip()})
    return response

query_raw(cmd, delay=None)

Source code in pytestlab/instruments/backends/session_recording_backend.py
def query_raw(self, cmd: str, delay: float | None = None) -> bytes:
    try:
        response = self.original_backend.query_raw(cmd, delay=delay)
    except TypeError:
        response = self.original_backend.query_raw(cmd)

    # Note: Storing raw bytes in YAML is tricky. Consider base64 encoding for robustness.
    # For simplicity here, we'll decode assuming it's representable as a string.
    try:
        response_str = response.decode("utf-8", errors="ignore")
    except Exception:
        response_str = f"<binary data of length {len(response)}>"

    self._log_event({"type": "query_raw", "command": cmd.strip(), "response": response_str})
    return response

save_session(profile_key)

Save the recorded session to the output file.

Source code in pytestlab/instruments/backends/session_recording_backend.py
def save_session(self, profile_key: str):
    """Save the recorded session to the output file."""
    if self.output_file is None:
        # No file output configured, session is stored in the list
        return

    # Map profile keys to instrument types for test compatibility
    instrument_key = self._get_instrument_key(profile_key)

    session_data = {instrument_key: {"profile": profile_key, "log": self._command_log}}

    # Load existing session data if file exists
    existing_data: dict[str, Any] = {}
    if os.path.exists(self.output_file):
        try:
            with open(self.output_file) as f:
                existing_data = yaml.safe_load(f) or {}
        except Exception:
            # If file is corrupted or empty, start fresh
            existing_data = {}

    # Merge with existing data
    existing_data.update(session_data)

    # Create parent directory if it doesn't exist
    try:
        os.makedirs(os.path.dirname(self.output_file), exist_ok=True)
    except (OSError, PermissionError) as e:
        raise FileNotFoundError(f"Cannot create directory for {self.output_file}: {e}") from e

    # Write to file
    with open(self.output_file, "w") as f:
        yaml.dump(existing_data, f, default_flow_style=False)

set_timeout(timeout_ms)

Source code in pytestlab/instruments/backends/session_recording_backend.py
def set_timeout(self, timeout_ms: int) -> None:
    self.original_backend.set_timeout(timeout_ms)

write(cmd)

Source code in pytestlab/instruments/backends/session_recording_backend.py
def write(self, cmd: str) -> None:
    self._log_event({"type": "write", "command": cmd.strip()})
    self.original_backend.write(cmd)

Backend Selection Logic

PyTestLab chooses the backend automatically based on:

  • The simulate flag (in code or bench.yaml)
  • The instrument's address (e.g., "sim" triggers simulation)
  • The backend or backend_defaults fields in your configuration

You can override backend selection by specifying backend_type_hint when creating an instrument.


Extending Backends

To add support for a new hardware interface, implement the InstrumentIO protocol (synchronous methods such as connect, disconnect, write, query, etc.). See the source code and existing backends for examples.


For more details on simulation, see the Simulation Guide.