ibm3270 API documentation

# ibm3270

View Source

     1from ibm3270.errors import (
     2    SessionBusyError,
     3    SessionDisconnectedError,
     4    SessionError,
     5    SessionProcessError,
     6    SessionTimeoutError,
     7    UnexpectedScreenError,
     8)
     9from ibm3270.session_manager import SessionManager
    10from ibm3270.terminal import Terminal
    11from ibm3270.transport import Transport
    12from ibm3270.types import (
    13    ConnectionState,
    14    EmulatorMode,
    15    FieldDefinition,
    16    FieldDefinitionRecord,
    17    FieldProtection,
    18    KeyboardState,
    19    ScreenFormatting,
    20    ScreenPosition,
    21    ScreenSize,
    22    SessionState,
    23    StatusFlag,
    24    StatusInfo,
    25    TerminalMode,
    26    TerminalOptions,
    27    TerminalResponse,
    28    TerminalSetting,
    29    field,
    30)
    31
    32__version__ = "0.1.0"
    33
    34__all__ = [
    35    "ConnectionState",
    36    "EmulatorMode",
    37    "FieldDefinition",
    38    "FieldDefinitionRecord",
    39    "FieldProtection",
    40    "KeyboardState",
    41    "ScreenFormatting",
    42    "ScreenPosition",
    43    "ScreenSize",
    44    "SessionBusyError",
    45    "SessionDisconnectedError",
    46    "SessionError",
    47    "SessionManager",
    48    "SessionProcessError",
    49    "SessionState",
    50    "SessionTimeoutError",
    51    "StatusFlag",
    52    "StatusInfo",
    53    "Terminal",
    54    "Transport",
    55    "TerminalMode",
    56    "TerminalOptions",
    57    "TerminalResponse",
    58    "TerminalSetting",
    59    "UnexpectedScreenError",
    60    "__version__",
    61    "field",
    62]

class ConnectionState(enum.StrEnum): View Source

    60class ConnectionState(StrEnum):
    61    Connected = "C"
    62    NotConnected = "N"

Connected = <ConnectionState.Connected: 'C'>
NotConnected = <ConnectionState.NotConnected: 'N'>
class EmulatorMode(enum.StrEnum): View Source

    65class EmulatorMode(StrEnum):
    66    Mode3270 = "I"
    67    NVTLine = "L"
    68    NVTCharacter = "C"
    69    Unnegotiated = "P"
    70    NotConnected = "N"

Mode3270 = <EmulatorMode.Mode3270: 'I'>
NVTLine = <EmulatorMode.NVTLine: 'L'>
NVTCharacter = <EmulatorMode.NVTCharacter: 'C'>
Unnegotiated = <EmulatorMode.Unnegotiated: 'P'>
NotConnected = <EmulatorMode.NotConnected: 'N'>
@dataclass
class FieldDefinition: View Source

    168@dataclass
    169class FieldDefinition:
    170    """Descriptor for a single screen field used with `Terminal.read_many`.
    171
    172    Attributes:
    173        row: 1-based row.
    174        col: 1-based column.
    175        length: Number of characters to read.
    176        type: `"string"` (default) or `"number"` for float coercion.
    177        trim: Strip trailing whitespace. Defaults to `True`.
    178        name: Optional key in the result dict. Defaults to `"row,col"`.
    179    """
    180
    181    row: int
    182    col: int
    183    length: int
    184    type: Literal["string", "number"] = "string"
    185    trim: bool = True
    186    name: str | None = None

Descriptor for a single screen field used with `Terminal.read_many`.

###### Attributes:

- row: 1-based row.
- col: 1-based column.
- length: Number of characters to read.
- type: `"string"` (default) or `"number"` for float coercion.
- trim: Strip trailing whitespace. Defaults to `True`.
- name: Optional key in the result dict. Defaults to `"row,col"`.

FieldDefinition( row: int, col: int, length: int, type: Literal['string', 'number'] = 'string', trim: bool = True, name: str | None = None)
row: int
col: int
length: int
type: Literal['string', 'number'] = 'string'
trim: bool = True
name: str | None = None
FieldDefinitionRecord = dict[str, str | float | None]
class FieldProtection(enum.StrEnum): View Source

    55class FieldProtection(StrEnum):
    56    Protected = "P"
    57    Unprotected = "U"

Protected = <FieldProtection.Protected: 'P'>
Unprotected = <FieldProtection.Unprotected: 'U'>
class KeyboardState(enum.StrEnum): View Source

    44class KeyboardState(StrEnum):
    45    Unlocked = "U"
    46    Locked = "L"
    47    Error = "E"

Unlocked = <KeyboardState.Unlocked: 'U'>
Locked = <KeyboardState.Locked: 'L'>
Error = <KeyboardState.Error: 'E'>
class ScreenFormatting(enum.StrEnum): View Source

    50class ScreenFormatting(StrEnum):
    51    Formatted = "F"
    52    Unformatted = "U"

Formatted = <ScreenFormatting.Formatted: 'F'>
Unformatted = <ScreenFormatting.Unformatted: 'U'>
@dataclass
class ScreenPosition: View Source

    118@dataclass
    119class ScreenPosition:
    120    """1-based (row, col) coordinate on the terminal screen."""
    121
    122    row: int
    123    col: int

1-based (row, col) coordinate on the terminal screen.

ScreenPosition(row: int, col: int)
row: int
col: int
@dataclass
class ScreenSize: View Source

    126@dataclass
    127class ScreenSize:
    128    """Terminal screen dimensions in rows and columns."""
    129
    130    rows: int
    131    cols: int

Terminal screen dimensions in rows and columns.

ScreenSize(rows: int, cols: int)
rows: int
cols: int
class SessionBusyError(ibm3270.SessionError): View Source

    19class SessionBusyError(SessionError):
    20    """Raised when a second operation is attempted while one is in flight."""

Raised when a second operation is attempted while one is in flight.

class SessionDisconnectedError(ibm3270.SessionError): View Source

    15class SessionDisconnectedError(SessionError):
    16    """Raised when an operation requires a running session."""

Raised when an operation requires a running session.

class SessionError(builtins.Exception): View Source

    7class SessionError(Exception):
    8    """Base exception for session-level failures."""

Base exception for session-level failures.

class SessionManager: View Source

    13class SessionManager:
    14    """Manage multiple isolated terminal sessions.
    15
    16    Thread-safe registry of `Terminal` instances keyed
    17    by auto-generated UUID session IDs.
    18    """
    19
    20    def __init__(self) -> None:
    21        self._sessions: dict[str, Terminal] = {}
    22        self._lock = Lock()
    23
    24    def create_session(self, options: TerminalOptions | None = None, *, start: bool = False) -> str:
    25        """Create a new terminal session and register it.
    26
    27        Args:
    28            options: Configuration for the underlying s3270 process. Defaults to `TerminalOptions()`.
    29            start: If `True`, call `Terminal.start` immediately.
    30
    31        Returns:
    32            The new session ID (UUID string).
    33        """
    34        session_id = str(uuid4())
    35        session = Terminal(options=options, session_id=session_id)
    36        if start:
    37            session.start()
    38        with self._lock:
    39            self._sessions[session_id] = session
    40        return session_id
    41
    42    def get_session(self, session_id: str) -> Terminal:
    43        """Retrieve a session by ID.
    44
    45        Args:
    46            session_id: The UUID string returned by `create_session`.
    47
    48        Returns:
    49            The `Terminal` for that session.
    50
    51        Raises:
    52            KeyError: If *session_id* is not registered.
    53        """
    54        with self._lock:
    55            session = self._sessions.get(session_id)
    56        if session is None:
    57            raise KeyError(f"Unknown session id: {session_id}")
    58        return session
    59
    60    def close_session(self, session_id: str) -> None:
    61        """Stop and remove a session.
    62
    63        Args:
    64            session_id: The UUID string of the session to close.
    65
    66        No-op if the session ID is unknown.
    67        """
    68        with self._lock:
    69            session = self._sessions.pop(session_id, None)
    70        if session is not None:
    71            session.stop()
    72
    73    def close_all(self) -> None:
    74        """Stop and remove all registered sessions."""
    75        with self._lock:
    76            sessions = list(self._sessions.values())
    77            self._sessions.clear()
    78        for session in sessions:
    79            session.stop()
    80
    81    def list_sessions(self) -> list[str]:
    82        """Return the IDs of all registered sessions."""
    83        with self._lock:
    84            return list(self._sessions.keys())

Manage multiple isolated terminal sessions.

Thread-safe registry of `Terminal` instances keyed by auto-generated UUID session IDs.

def create_session( self, options: TerminalOptions | None = None, *, start: bool = False) -> str: View Source

    24    def create_session(self, options: TerminalOptions | None = None, *, start: bool = False) -> str:
    25        """Create a new terminal session and register it.
    26
    27        Args:
    28            options: Configuration for the underlying s3270 process. Defaults to `TerminalOptions()`.
    29            start: If `True`, call `Terminal.start` immediately.
    30
    31        Returns:
    32            The new session ID (UUID string).
    33        """
    34        session_id = str(uuid4())
    35        session = Terminal(options=options, session_id=session_id)
    36        if start:
    37            session.start()
    38        with self._lock:
    39            self._sessions[session_id] = session
    40        return session_id

Create a new terminal session and register it.

###### Arguments:

- options: Configuration for the underlying s3270 process. Defaults to `TerminalOptions()`.
- start: If `True`, call `Terminal.start` immediately.

###### Returns:

> The new session ID (UUID string).

def get_session(self, session_id: str) -> Terminal: View Source

    42    def get_session(self, session_id: str) -> Terminal:
    43        """Retrieve a session by ID.
    44
    45        Args:
    46            session_id: The UUID string returned by `create_session`.
    47
    48        Returns:
    49            The `Terminal` for that session.
    50
    51        Raises:
    52            KeyError: If *session_id* is not registered.
    53        """
    54        with self._lock:
    55            session = self._sessions.get(session_id)
    56        if session is None:
    57            raise KeyError(f"Unknown session id: {session_id}")
    58        return session

Retrieve a session by ID.

###### Arguments:

- session_id: The UUID string returned by `create_session`.

###### Returns:

> The `Terminal` for that session.

###### Raises:

- KeyError: If session_id is not registered.

def close_session(self, session_id: str) -> None: View Source

    60    def close_session(self, session_id: str) -> None:
    61        """Stop and remove a session.
    62
    63        Args:
    64            session_id: The UUID string of the session to close.
    65
    66        No-op if the session ID is unknown.
    67        """
    68        with self._lock:
    69            session = self._sessions.pop(session_id, None)
    70        if session is not None:
    71            session.stop()

Stop and remove a session.

###### Arguments:

- session_id: The UUID string of the session to close.

No-op if the session ID is unknown.

def close_all(self) -> None: View Source

    73    def close_all(self) -> None:
    74        """Stop and remove all registered sessions."""
    75        with self._lock:
    76            sessions = list(self._sessions.values())
    77            self._sessions.clear()
    78        for session in sessions:
    79            session.stop()

Stop and remove all registered sessions.

def list_sessions(self) -> list[str]: View Source

    81    def list_sessions(self) -> list[str]:
    82        """Return the IDs of all registered sessions."""
    83        with self._lock:
    84            return list(self._sessions.keys())

Return the IDs of all registered sessions.

class SessionProcessError(ibm3270.SessionError): View Source

    23class SessionProcessError(SessionError):
    24    """Raised when the emulator process dies or I/O fails unexpectedly."""

Raised when the emulator process dies or I/O fails unexpectedly.

class SessionState(enum.StrEnum): View Source

    73class SessionState(StrEnum):
    74    """Lifecycle state of a `Terminal` session."""
    75
    76    Initial = "INITIAL"
    77    Started = "STARTED"
    78    Connected = "CONNECTED"
    79    Disconnected = "DISCONNECTED"
    80    Stopped = "STOPPED"
    81    Failed = "FAILED"

Lifecycle state of a `Terminal` session.

Initial = <SessionState.Initial: 'INITIAL'>
Started = <SessionState.Started: 'STARTED'>
Connected = <SessionState.Connected: 'CONNECTED'>
Disconnected = <SessionState.Disconnected: 'DISCONNECTED'>
Stopped = <SessionState.Stopped: 'STOPPED'>
Failed = <SessionState.Failed: 'FAILED'>
class SessionTimeoutError(ibm3270.SessionError, builtins.TimeoutError): View Source

    11class SessionTimeoutError(SessionError, TimeoutError):
    12    """Raised when a blocking session operation exceeds its timeout."""

Raised when a blocking session operation exceeds its timeout.

class StatusFlag(enum.StrEnum): View Source

    34class StatusFlag(StrEnum):
    35    """Boolean status fields readable via `Terminal.is_`."""
    36
    37    Formatted = "Formatted"
    38    KeyboardLock = "KeyboardLock"
    39    Printer = "Printer"
    40    Secure = "Secure"
    41    Tn3270e = "Tn3270e"

Boolean status fields readable via `Terminal.is_`.

Formatted = <StatusFlag.Formatted: 'Formatted'>
KeyboardLock = <StatusFlag.KeyboardLock: 'KeyboardLock'>
Printer = <StatusFlag.Printer: 'Printer'>
Secure = <StatusFlag.Secure: 'Secure'>
Tn3270e = <StatusFlag.Tn3270e: 'Tn3270e'>
@dataclass
class StatusInfo: View Source

    134@dataclass
    135class StatusInfo:
    136    """Parsed s3270 status line, updated after each successful command.
    137
    138    Attributes:
    139        keyboard_state: Whether the keyboard is unlocked, locked, or in error.
    140        screen_formatting: Formatted or unformatted screen.
    141        field_protection: Protection state of the current field.
    142        connection_state: Connected or not connected.
    143        host: Hostname from the status line, or `None` if not connected.
    144        emulator_mode: Current emulator negotiation mode.
    145        model_number: Active 3270 model number.
    146        rows: Screen height in rows.
    147        cols: Screen width in columns.
    148        cursor_row: 0-based cursor row from s3270.
    149        cursor_col: 0-based cursor column from s3270.
    150        window_id: X11 window ID or `"0"` on non-X platforms.
    151        command_execution_time: Last command latency in seconds, or `None`.
    152    """
    153    keyboard_state: KeyboardState
    154    screen_formatting: ScreenFormatting
    155    field_protection: FieldProtection
    156    connection_state: ConnectionState
    157    host: str | None
    158    emulator_mode: EmulatorMode
    159    model_number: int
    160    rows: int
    161    cols: int
    162    cursor_row: int
    163    cursor_col: int
    164    window_id: str
    165    command_execution_time: float | None

Parsed s3270 status line, updated after each successful command.

###### Attributes:

- keyboard_state: Whether the keyboard is unlocked, locked, or in error.
- screen_formatting: Formatted or unformatted screen.
- field_protection: Protection state of the current field.
- connection_state: Connected or not connected.
- host: Hostname from the status line, or `None` if not connected.
- emulator_mode: Current emulator negotiation mode.
- model_number: Active 3270 model number.
- rows: Screen height in rows.
- cols: Screen width in columns.
- cursor_row: 0-based cursor row from s3270.
- cursor_col: 0-based cursor column from s3270.
- window_id: X11 window ID or `"0"` on non-X platforms.
- command_execution_time: Last command latency in seconds, or `None`.

StatusInfo( keyboard_state: KeyboardState, screen_formatting: ScreenFormatting, field_protection: FieldProtection, connection_state: ConnectionState, host: str | None, emulator_mode: EmulatorMode, model_number: int, rows: int, cols: int, cursor_row: int, cursor_col: int, window_id: str, command_execution_time: float | None)
keyboard_state: KeyboardState
screen_formatting: ScreenFormatting
field_protection: FieldProtection
connection_state: ConnectionState
host: str | None
emulator_mode: EmulatorMode
model_number: int
rows: int
cols: int
cursor_row: int
cursor_col: int
window_id: str
command_execution_time: float | None
class Terminal: View Source

     37class Terminal:
     38    """High-level synchronous wrapper around a single s3270 process.
     39
     40    Manages the full lifecycle of a terminal session: start, connect, interact,
     41    disconnect, and stop. All operations are synchronous and blocking.
     42
     43    Args:
     44        options: Configuration for the s3270 process. Defaults to `TerminalOptions()`.
     45        session_id: Stable identifier for this session. Auto-generated if omitted.
     46    """
     47
     48    def __init__(
     49        self,
     50        options: TerminalOptions | None = None,
     51        *,
     52        session_id: str | None = None,
     53    ) -> None:
     54        self._options = options or TerminalOptions()
     55        self._session_id = session_id or str(uuid4())
     56        self._transport = Transport(default_timeout_ms=self._options.timeout)
     57        self._connected: bool = False
     58        self._screen_buffer: list[str] = []
     59        self._last_status_info: StatusInfo | None = None
     60        self._state = SessionState.Initial
     61
     62    # ------------------------------------------------------------------
     63    # Internal helpers
     64    # ------------------------------------------------------------------
     65
     66    def _assert_running(self) -> None:
     67        if not self._transport.available():
     68            self._state = SessionState.Failed
     69            raise SessionDisconnectedError("Terminal is not running")
     70
     71    def _get_status_field(self, flag: StatusFlag) -> str:
     72        if self._last_status_info is None:
     73            return "false"
     74        s = self._last_status_info
     75        if flag == StatusFlag.Formatted:
     76            return "true" if s.screen_formatting == ScreenFormatting.Formatted else "false"
     77        if flag == StatusFlag.KeyboardLock:
     78            return "false" if s.keyboard_state == KeyboardState.Unlocked else "true"
     79        if flag == StatusFlag.Tn3270e:
     80            return "true" if s.emulator_mode == EmulatorMode.Mode3270 else "false"
     81        return "false"
     82
     83    def _to_emulator_coordinate(self, row: int, col: int) -> tuple[int, int]:
     84        if row < 1:
     85            raise ValueError(f"row must be >= 1, got {row}")
     86        if col < 1:
     87            raise ValueError(f"col must be >= 1, got {col}")
     88        return row - 1, col - 1
     89
     90    def _to_display_position(self, row: int, col: int) -> ScreenPosition:
     91        return ScreenPosition(row + 1, col + 1)
     92
     93    def _resolved_start_args(self) -> list[str]:
     94        args = list(self._options.args)
     95        if "-model" in args:
     96            return args
     97        for index, arg in enumerate(args[:-1]):
     98            if arg == "-xrm" and "s3270.model:" in args[index + 1]:
     99                return args
    100        return ["-model", _DEFAULT_MODEL, *args]
    101
    102    # ------------------------------------------------------------------
    103    # Lifecycle
    104    # ------------------------------------------------------------------
    105
    106    def available(self) -> bool:
    107        """Return `True` if the underlying s3270 process is running."""
    108        return self._transport.available()
    109
    110    @property
    111    def state(self) -> SessionState:
    112        """Current lifecycle state of the session."""
    113        return self._state
    114
    115    @property
    116    def session_id(self) -> str:
    117        """Unique identifier for this session."""
    118        return self._session_id
    119
    120    def start(self) -> None:
    121        """Launch the s3270 process. No-op if already running."""
    122        if self.available():
    123            return
    124        self._transport.start(self._options.executable, self._resolved_start_args())
    125        self._state = SessionState.Started
    126
    127    def stop(self) -> None:
    128        """Terminate the s3270 process and release resources."""
    129        self._transport.stop()
    130        self._connected = False
    131        self._state = SessionState.Stopped
    132
    133    # ------------------------------------------------------------------
    134    # Command dispatch
    135    # ------------------------------------------------------------------
    136
    137    def command(self, cmd: str, *, timeout: int | None = None) -> TerminalResponse:
    138        """Send a raw s3270 command and return the response.
    139
    140        Args:
    141            cmd: The s3270 command string, e.g. `"Enter"` or `"String(text)"`.
    142            timeout: Milliseconds to wait for a response. Uses the session default if omitted.
    143
    144        Returns:
    145            A `TerminalResponse` with `ok`, `data`, and `status`.
    146
    147        Raises:
    148            SessionDisconnectedError: If the s3270 process is not running.
    149            SessionTimeoutError: If the command does not complete within *timeout*.
    150        """
    151        self._assert_running()
    152        started_at = time.monotonic()
    153        try:
    154            response = self._transport.execute(cmd, timeout=timeout)
    155        except (SessionDisconnectedError, SessionProcessError):
    156            self._state = SessionState.Failed
    157            raise
    158
    159        if response.status:
    160            self._last_status_info = parse_status(response.status)
    161
    162        if response.ok:
    163            elapsed_ms = int((time.monotonic() - started_at) * 1000)
    164            _LOG.debug(
    165                "session=%s pid=%s cmd=%s elapsed_ms=%d ok=true",
    166                self._session_id,
    167                self._transport.pid(),
    168                cmd,
    169                elapsed_ms,
    170            )
    171        else:
    172            elapsed_ms = int((time.monotonic() - started_at) * 1000)
    173            _LOG.debug(
    174                "session=%s pid=%s cmd=%s elapsed_ms=%d ok=false data=%s",
    175                self._session_id,
    176                self._transport.pid(),
    177                cmd,
    178                elapsed_ms,
    179                response.data,
    180            )
    181        return response
    182
    183    def run_step(self, cmd: str, *, timeout: int | None = None) -> TerminalResponse:
    184        """Alias for `command`. Sends a single raw s3270 command.
    185
    186        Args:
    187            cmd: The s3270 command string.
    188            timeout: Milliseconds to wait. Uses the session default if omitted.
    189        """
    190        return self.command(cmd, timeout=timeout)
    191
    192    def run_workflow(self, commands: list[str], *, timeout: int | None = None) -> list[TerminalResponse]:
    193        """Execute a sequence of raw s3270 commands in order.
    194
    195        Args:
    196            commands: List of s3270 command strings to execute sequentially.
    197            timeout: Per-command timeout in milliseconds. Uses the session default if omitted.
    198
    199        Returns:
    200            List of `TerminalResponse` objects, one per command.
    201        """
    202        return [self.command(cmd, timeout=timeout) for cmd in commands]
    203
    204    # ------------------------------------------------------------------
    205    # Connectivity
    206    # ------------------------------------------------------------------
    207
    208    def connect(
    209        self,
    210        hostname: str,
    211        port: int,
    212        *,
    213        mode: TerminalMode | None = None,
    214        lu_name: str | None = None,
    215    ) -> TerminalResponse:
    216        """Connect to a TN3270 host.
    217
    218        Args:
    219            hostname: Hostname or IP address of the mainframe.
    220            port: TCP port (commonly 23 or 992 for TLS).
    221            mode: Optional `TerminalMode` prefix (e.g. `NoTN3270E`).
    222            lu_name: Optional LU name for LU-to-LU connections.
    223
    224        Returns:
    225            `TerminalResponse` — `ok` if the connection was established.
    226        """
    227        addr = f"{hostname.lower()}:{port}"
    228        if lu_name:
    229            addr = f"{lu_name}@{addr}"
    230        if mode:
    231            addr = f"{mode.value}:{addr}"
    232        resp = self.command(f"Connect({addr})")
    233        self._connected = resp.ok
    234        if self._connected:
    235            self._state = SessionState.Connected
    236        return resp
    237
    238    def disconnect(self) -> TerminalResponse:
    239        """Disconnect from the host. No-op (returns ok) if not connected."""
    240        if not self._connected:
    241            return TerminalResponse(ok=True, data="", status="", raw=[])
    242        resp = self.command("Disconnect")
    243        self._connected = False
    244        self._state = SessionState.Disconnected
    245        return resp
    246
    247    # ------------------------------------------------------------------
    248    # Query / status getters
    249    # ------------------------------------------------------------------
    250
    251    def query(self, setting: TerminalSetting) -> TerminalResponse:
    252        """Issue a `Query()` command for the given setting.
    253
    254        Args:
    255            setting: The `TerminalSetting` to query (e.g. `Host`, `Model`).
    256
    257        Returns:
    258            `TerminalResponse` with the value in `data`.
    259        """
    260        return self.command(f"Query({setting.value})")
    261
    262    def get(self, setting: TerminalSetting) -> str:
    263        """Return the string value of a terminal setting.
    264
    265        Args:
    266            setting: The `TerminalSetting` to retrieve.
    267
    268        Returns:
    269            The setting value as a plain string.
    270        """
    271        resp = self.query(setting)
    272        return resp.data
    273
    274    def cursor(self) -> ScreenPosition | None:
    275        """Return the current cursor position (1-based row/col), or `None` if unknown."""
    276        if self._last_status_info is None:
    277            return None
    278        s = self._last_status_info
    279        return self._to_display_position(s.cursor_row, s.cursor_col)
    280
    281    def screen_size(self) -> ScreenSize | None:
    282        """Return the current screen dimensions, or `None` if not yet known."""
    283        if self._last_status_info is None:
    284            return None
    285        return ScreenSize(self._last_status_info.rows, self._last_status_info.cols)
    286
    287    def is_(self, flag: StatusFlag) -> bool:
    288        """Test a boolean status flag from the last s3270 status line.
    289
    290        Args:
    291            flag: A `StatusFlag` such as `Formatted` or `KeyboardLock`.
    292
    293        Returns:
    294            `True` if the flag is set.
    295        """
    296        val = self._get_status_field(flag)
    297        if flag == StatusFlag.KeyboardLock:
    298            return val != "false"
    299        return val in ("true", "1")
    300
    301    def current_field(self) -> ScreenPosition | None:
    302        """Return the position of the current input field (same as `cursor`)."""
    303        return self.cursor()
    304
    305    # ------------------------------------------------------------------
    306    # Screen buffer
    307    # ------------------------------------------------------------------
    308
    309    def refresh(self) -> TerminalResponse:
    310        """Wait for the host and update the internal screen buffer via `Ascii1()`.
    311
    312        Returns:
    313            `TerminalResponse` from the `Ascii1()` command.
    314        """
    315        self.wait_ready()
    316        resp = self.command("Ascii1()")
    317        self._screen_buffer = resp.data.splitlines()
    318        return resp
    319
    320    def screen(self) -> str:
    321        """Return the last refreshed screen as a single newline-joined string."""
    322        return "\n".join(self._screen_buffer)
    323
    324    def get_screen_buffer(self) -> list[str]:
    325        """Return the last refreshed screen as a list of row strings."""
    326        return list(self._screen_buffer)
    327
    328    # ------------------------------------------------------------------
    329    # Read / write / check
    330    # ------------------------------------------------------------------
    331
    332    def read(self, row: int, col: int, length: int, trim: bool = True) -> str:
    333        """Read a region from the screen buffer.
    334
    335        Args:
    336            row: 1-based row number.
    337            col: 1-based column number.
    338            length: Number of characters to read.
    339            trim: Strip trailing whitespace from the result. Defaults to `True`.
    340
    341        Returns:
    342            The extracted string, or `""` if the row is out of range.
    343        """
    344        if row < 1 or row > len(self._screen_buffer):
    345            return ""
    346        line = self._screen_buffer[row - 1]
    347        result = line[col - 1 : col - 1 + length]
    348        return result.rstrip() if trim else result
    349
    350    def write(self, text: str, row: int, col: int, length: int | None = None) -> TerminalResponse:
    351        """Move the cursor then type *text* with `String()`.
    352
    353        Args:
    354            text: Text to send.
    355            row: 1-based destination row.
    356            col: 1-based destination column.
    357            length: If provided, right-justify *text* in a field of this width.
    358        """
    359        if length is not None:
    360            text = text[:length].rjust(length)
    361        self.move(row, col)
    362        return self.string(text)
    363
    364    def check(self, text: str, row: int, col: int) -> bool:
    365        """Return `True` if *text* appears at the given screen position.
    366
    367        Args:
    368            text: Expected string.
    369            row: 1-based row.
    370            col: 1-based column.
    371        """
    372        return self.read(row, col, len(text)) == text
    373
    374    def read_many(self, fields: list[FieldDefinition]) -> FieldDefinitionRecord:
    375        """Refresh the screen and read multiple fields in one call.
    376
    377        Args:
    378            fields: List of `FieldDefinition` descriptors.
    379
    380        Returns:
    381            Dict mapping each field's `name` (or `"row,col"` key) to its value.
    382            Numeric fields are coerced to `float`; blank numeric fields become `None`.
    383        """
    384        self.refresh()
    385        result: FieldDefinitionRecord = {}
    386        for field in fields:
    387            raw = self.read(field.row, field.col, field.length, trim=field.trim)
    388            key = field.name or f"{field.row},{field.col}"
    389            if field.type == "number":
    390                try:
    391                    result[key] = float(raw) if raw else None
    392                except ValueError:
    393                    result[key] = raw
    394            else:
    395                result[key] = raw
    396        return result
    397
    398    # ------------------------------------------------------------------
    399    # Text / input helpers
    400    # ------------------------------------------------------------------
    401
    402    def string(self, text: str) -> TerminalResponse:
    403        """Send text to the emulator using the `String()` action.
    404
    405        Args:
    406            text: The string to type, including s3270 escape sequences.
    407
    408        Raises:
    409            ValueError: If *text* contains an invalid s3270 escape sequence.
    410        """
    411        validate_escape_sequences(text)
    412        return self.command(f"String({text})")
    413
    414    def send_text(self, text: str) -> TerminalResponse:
    415        """Alias for `string`."""
    416        return self.string(text)
    417
    418    def enter(self) -> TerminalResponse:
    419        """Press the Enter key and refresh the screen."""
    420        response = self.command("Enter")
    421        self.refresh()
    422        return response
    423
    424    def send_enter(self) -> TerminalResponse:
    425        """Alias for `enter`."""
    426        return self.enter()
    427
    428    def tab(self) -> TerminalResponse:
    429        """Press the Tab key to advance to the next input field."""
    430        return self.command("Tab")
    431
    432    def clear(self) -> TerminalResponse:
    433        """Press the Clear key and refresh the screen."""
    434        response = self.command("Clear")
    435        self.refresh()
    436        return response
    437
    438    def pf(self, n: int) -> TerminalResponse:
    439        """Press a PF function key and refresh the screen.
    440
    441        Args:
    442            n: PF key number, 1–24.
    443
    444        Raises:
    445            ValueError: If *n* is outside the range 1–24.
    446        """
    447        if not 1 <= n <= 24:
    448            raise ValueError(f"PF key must be 1–24, got {n}")
    449        response = self.command(f"PF({n})")
    450        self.refresh()
    451        return response
    452
    453    def send_pf(self, n: int) -> TerminalResponse:
    454        """Alias for `pf`."""
    455        return self.pf(n)
    456
    457    def pa(self, n: int) -> TerminalResponse:
    458        """Press a PA program-attention key and refresh the screen.
    459
    460        Args:
    461            n: PA key number, 1–3.
    462
    463        Raises:
    464            ValueError: If *n* is outside the range 1–3.
    465        """
    466        if not 1 <= n <= 3:
    467            raise ValueError(f"PA key must be 1–3, got {n}")
    468        response = self.command(f"PA({n})")
    469        self.refresh()
    470        return response
    471
    472    def move(self, row: int, col: int) -> TerminalResponse:
    473        """Move the cursor to the specified screen position.
    474
    475        Args:
    476            row: 1-based row number.
    477            col: 1-based column number.
    478        """
    479        emulator_row, emulator_col = self._to_emulator_coordinate(row, col)
    480        return self.command(f"MoveCursor({emulator_row},{emulator_col})")
    481
    482    def read_screen(self) -> str:
    483        """Refresh the screen buffer and return its full text content."""
    484        self.refresh()
    485        return self.screen()
    486
    487    def scrape(self, row: int, col: int, length: int, trim: bool = True) -> str:
    488        """Alias for `read`."""
    489        return self.read(row, col, length, trim=trim)
    490
    491    # ------------------------------------------------------------------
    492    # Wait helpers
    493    # ------------------------------------------------------------------
    494
    495    def wait(self, timeout: int | None = None) -> TerminalResponse:
    496        """Issue `Wait()` — blocks until the emulator is ready.
    497
    498        Args:
    499            timeout: Milliseconds to wait. Uses the session default if omitted.
    500        """
    501        return self.command("Wait()", timeout=timeout)
    502
    503    def wait_output(self, timeout: int | None = None) -> TerminalResponse:
    504        """Issue `Wait(Output)` — blocks until the host sends new screen data.
    505
    506        Args:
    507            timeout: Milliseconds to wait. Uses the session default if omitted.
    508        """
    509        return self.command("Wait(Output)", timeout=timeout)
    510
    511    def wait_unlock(self, timeout: int | None = None) -> TerminalResponse:
    512        """Issue `Wait(Unlock)` — blocks until the keyboard is unlocked.
    513
    514        Args:
    515            timeout: Milliseconds to wait. Uses the session default if omitted.
    516        """
    517        return self.command("Wait(Unlock)", timeout=timeout)
    518
    519    def wait_ready(self, timeout: int | None = None) -> None:
    520        """Wait for the keyboard to unlock, then wait for host output.
    521
    522        Convenience wrapper around `wait_unlock` + `wait_output`.
    523
    524        Args:
    525            timeout: Per-call timeout in milliseconds. Uses the session default if omitted.
    526        """
    527        self.wait_unlock(timeout)
    528        self.wait_output(timeout)
    529
    530    def wait_for(
    531        self,
    532        text: str,
    533        row: int | None = None,
    534        col: int | None = None,
    535        timeout: int = 30_000,
    536    ) -> bool:
    537        """Poll the screen until *text* appears, or *timeout* elapses.
    538
    539        Args:
    540            text: The string to watch for.
    541            row: 1-based row to check. Must be paired with *col*.
    542            col: 1-based column to check. Must be paired with *row*.
    543            timeout: Total wait time in milliseconds (capped at 300 000). Defaults to 30 000.
    544
    545        Returns:
    546            ``True`` if *text* was found before the deadline, ``False`` otherwise.
    547
    548        Raises:
    549            ValueError: If only one of *row* / *col* is provided.
    550        """
    551        if (row is None) != (col is None):
    552            raise ValueError("both row and col must be provided, or neither")
    553        timeout = max(0, min(timeout, 300_000))
    554        deadline = time.monotonic() + timeout / 1000
    555        while time.monotonic() < deadline:
    556            self.refresh()
    557            if row is not None:
    558                if self.read(row, col, len(text)) == text:  # type: ignore[arg-type]
    559                    return True
    560            else:
    561                if text in self.screen():
    562                    return True
    563            time.sleep(0.1)
    564        return False

High-level synchronous wrapper around a single s3270 process.

Manages the full lifecycle of a terminal session: start, connect, interact, disconnect, and stop. All operations are synchronous and blocking.

###### Arguments:

- options: Configuration for the s3270 process. Defaults to `TerminalOptions()`.
- session_id: Stable identifier for this session. Auto-generated if omitted.

Terminal( options: TerminalOptions | None = None, *, session_id: str | None = None) View Source

    48    def __init__(
    49        self,
    50        options: TerminalOptions | None = None,
    51        *,
    52        session_id: str | None = None,
    53    ) -> None:
    54        self._options = options or TerminalOptions()
    55        self._session_id = session_id or str(uuid4())
    56        self._transport = Transport(default_timeout_ms=self._options.timeout)
    57        self._connected: bool = False
    58        self._screen_buffer: list[str] = []
    59        self._last_status_info: StatusInfo | None = None
    60        self._state = SessionState.Initial

def available(self) -> bool: View Source

    106    def available(self) -> bool:
    107        """Return `True` if the underlying s3270 process is running."""
    108        return self._transport.available()

Return `True` if the underlying s3270 process is running.

state: SessionState View Source

    110    @property
    111    def state(self) -> SessionState:
    112        """Current lifecycle state of the session."""
    113        return self._state

Current lifecycle state of the session.

session_id: str View Source

    115    @property
    116    def session_id(self) -> str:
    117        """Unique identifier for this session."""
    118        return self._session_id

Unique identifier for this session.

def start(self) -> None: View Source

    120    def start(self) -> None:
    121        """Launch the s3270 process. No-op if already running."""
    122        if self.available():
    123            return
    124        self._transport.start(self._options.executable, self._resolved_start_args())
    125        self._state = SessionState.Started

Launch the s3270 process. No-op if already running.

def stop(self) -> None: View Source

    127    def stop(self) -> None:
    128        """Terminate the s3270 process and release resources."""
    129        self._transport.stop()
    130        self._connected = False
    131        self._state = SessionState.Stopped

Terminate the s3270 process and release resources.

def command( self, cmd: str, *, timeout: int | None = None) -> TerminalResponse: View Source

    137    def command(self, cmd: str, *, timeout: int | None = None) -> TerminalResponse:
    138        """Send a raw s3270 command and return the response.
    139
    140        Args:
    141            cmd: The s3270 command string, e.g. `"Enter"` or `"String(text)"`.
    142            timeout: Milliseconds to wait for a response. Uses the session default if omitted.
    143
    144        Returns:
    145            A `TerminalResponse` with `ok`, `data`, and `status`.
    146
    147        Raises:
    148            SessionDisconnectedError: If the s3270 process is not running.
    149            SessionTimeoutError: If the command does not complete within *timeout*.
    150        """
    151        self._assert_running()
    152        started_at = time.monotonic()
    153        try:
    154            response = self._transport.execute(cmd, timeout=timeout)
    155        except (SessionDisconnectedError, SessionProcessError):
    156            self._state = SessionState.Failed
    157            raise
    158
    159        if response.status:
    160            self._last_status_info = parse_status(response.status)
    161
    162        if response.ok:
    163            elapsed_ms = int((time.monotonic() - started_at) * 1000)
    164            _LOG.debug(
    165                "session=%s pid=%s cmd=%s elapsed_ms=%d ok=true",
    166                self._session_id,
    167                self._transport.pid(),
    168                cmd,
    169                elapsed_ms,
    170            )
    171        else:
    172            elapsed_ms = int((time.monotonic() - started_at) * 1000)
    173            _LOG.debug(
    174                "session=%s pid=%s cmd=%s elapsed_ms=%d ok=false data=%s",
    175                self._session_id,
    176                self._transport.pid(),
    177                cmd,
    178                elapsed_ms,
    179                response.data,
    180            )
    181        return response

Send a raw s3270 command and return the response.

###### Arguments:

- cmd: The s3270 command string, e.g. `"Enter"` or `"String(text)"`.
- timeout: Milliseconds to wait for a response. Uses the session default if omitted.

###### Returns:

> A `TerminalResponse` with `ok`, `data`, and `status`.

###### Raises:

- SessionDisconnectedError: If the s3270 process is not running.
- SessionTimeoutError: If the command does not complete within timeout.

def run_step( self, cmd: str, *, timeout: int | None = None) -> TerminalResponse: View Source

    183    def run_step(self, cmd: str, *, timeout: int | None = None) -> TerminalResponse:
    184        """Alias for `command`. Sends a single raw s3270 command.
    185
    186        Args:
    187            cmd: The s3270 command string.
    188            timeout: Milliseconds to wait. Uses the session default if omitted.
    189        """
    190        return self.command(cmd, timeout=timeout)

Alias for `command`. Sends a single raw s3270 command.

###### Arguments:

- cmd: The s3270 command string.
- timeout: Milliseconds to wait. Uses the session default if omitted.

def run_workflow( self, commands: list[str], *, timeout: int | None = None) -> list[TerminalResponse]: View Source

    192    def run_workflow(self, commands: list[str], *, timeout: int | None = None) -> list[TerminalResponse]:
    193        """Execute a sequence of raw s3270 commands in order.
    194
    195        Args:
    196            commands: List of s3270 command strings to execute sequentially.
    197            timeout: Per-command timeout in milliseconds. Uses the session default if omitted.
    198
    199        Returns:
    200            List of `TerminalResponse` objects, one per command.
    201        """
    202        return [self.command(cmd, timeout=timeout) for cmd in commands]

Execute a sequence of raw s3270 commands in order.

###### Arguments:

- commands: List of s3270 command strings to execute sequentially.
- timeout: Per-command timeout in milliseconds. Uses the session default if omitted.

###### Returns:

> List of `TerminalResponse` objects, one per command.

def connect( self, hostname: str, port: int, *, mode: TerminalMode | None = None, lu_name: str | None = None) -> TerminalResponse: View Source

    208    def connect(
    209        self,
    210        hostname: str,
    211        port: int,
    212        *,
    213        mode: TerminalMode | None = None,
    214        lu_name: str | None = None,
    215    ) -> TerminalResponse:
    216        """Connect to a TN3270 host.
    217
    218        Args:
    219            hostname: Hostname or IP address of the mainframe.
    220            port: TCP port (commonly 23 or 992 for TLS).
    221            mode: Optional `TerminalMode` prefix (e.g. `NoTN3270E`).
    222            lu_name: Optional LU name for LU-to-LU connections.
    223
    224        Returns:
    225            `TerminalResponse` — `ok` if the connection was established.
    226        """
    227        addr = f"{hostname.lower()}:{port}"
    228        if lu_name:
    229            addr = f"{lu_name}@{addr}"
    230        if mode:
    231            addr = f"{mode.value}:{addr}"
    232        resp = self.command(f"Connect({addr})")
    233        self._connected = resp.ok
    234        if self._connected:
    235            self._state = SessionState.Connected
    236        return resp

Connect to a TN3270 host.

###### Arguments:

- hostname: Hostname or IP address of the mainframe.
- port: TCP port (commonly 23 or 992 for TLS).
- mode: Optional `TerminalMode` prefix (e.g. `NoTN3270E`).
- lu_name: Optional LU name for LU-to-LU connections.

###### Returns:

> `TerminalResponse` — `ok` if the connection was established.

def disconnect(self) -> TerminalResponse: View Source

    238    def disconnect(self) -> TerminalResponse:
    239        """Disconnect from the host. No-op (returns ok) if not connected."""
    240        if not self._connected:
    241            return TerminalResponse(ok=True, data="", status="", raw=[])
    242        resp = self.command("Disconnect")
    243        self._connected = False
    244        self._state = SessionState.Disconnected
    245        return resp

Disconnect from the host. No-op (returns ok) if not connected.

def query( self, setting: TerminalSetting) -> TerminalResponse: View Source

    251    def query(self, setting: TerminalSetting) -> TerminalResponse:
    252        """Issue a `Query()` command for the given setting.
    253
    254        Args:
    255            setting: The `TerminalSetting` to query (e.g. `Host`, `Model`).
    256
    257        Returns:
    258            `TerminalResponse` with the value in `data`.
    259        """
    260        return self.command(f"Query({setting.value})")

Issue a `Query()` command for the given setting.

###### Arguments:

- setting: The `TerminalSetting` to query (e.g. `Host`, `Model`).

###### Returns:

> `TerminalResponse` with the value in `data`.

def get(self, setting: TerminalSetting) -> str: View Source

    262    def get(self, setting: TerminalSetting) -> str:
    263        """Return the string value of a terminal setting.
    264
    265        Args:
    266            setting: The `TerminalSetting` to retrieve.
    267
    268        Returns:
    269            The setting value as a plain string.
    270        """
    271        resp = self.query(setting)
    272        return resp.data

Return the string value of a terminal setting.

###### Arguments:

- setting: The `TerminalSetting` to retrieve.

###### Returns:

> The setting value as a plain string.

def cursor(self) -> ScreenPosition | None: View Source

    274    def cursor(self) -> ScreenPosition | None:
    275        """Return the current cursor position (1-based row/col), or `None` if unknown."""
    276        if self._last_status_info is None:
    277            return None
    278        s = self._last_status_info
    279        return self._to_display_position(s.cursor_row, s.cursor_col)

Return the current cursor position (1-based row/col), or `None` if unknown.

def screen_size(self) -> ScreenSize | None: View Source

    281    def screen_size(self) -> ScreenSize | None:
    282        """Return the current screen dimensions, or `None` if not yet known."""
    283        if self._last_status_info is None:
    284            return None
    285        return ScreenSize(self._last_status_info.rows, self._last_status_info.cols)

Return the current screen dimensions, or `None` if not yet known.

def is_(self, flag: StatusFlag) -> bool: View Source

    287    def is_(self, flag: StatusFlag) -> bool:
    288        """Test a boolean status flag from the last s3270 status line.
    289
    290        Args:
    291            flag: A `StatusFlag` such as `Formatted` or `KeyboardLock`.
    292
    293        Returns:
    294            `True` if the flag is set.
    295        """
    296        val = self._get_status_field(flag)
    297        if flag == StatusFlag.KeyboardLock:
    298            return val != "false"
    299        return val in ("true", "1")

Test a boolean status flag from the last s3270 status line.

###### Arguments:

- flag: A `StatusFlag` such as `Formatted` or `KeyboardLock`.

###### Returns:

> `True` if the flag is set.

def current_field(self) -> ScreenPosition | None: View Source

    301    def current_field(self) -> ScreenPosition | None:
    302        """Return the position of the current input field (same as `cursor`)."""
    303        return self.cursor()

Return the position of the current input field (same as `cursor`).

def refresh(self) -> TerminalResponse: View Source

    309    def refresh(self) -> TerminalResponse:
    310        """Wait for the host and update the internal screen buffer via `Ascii1()`.
    311
    312        Returns:
    313            `TerminalResponse` from the `Ascii1()` command.
    314        """
    315        self.wait_ready()
    316        resp = self.command("Ascii1()")
    317        self._screen_buffer = resp.data.splitlines()
    318        return resp

Wait for the host and update the internal screen buffer via `Ascii1()`.

###### Returns:

> `TerminalResponse` from the `Ascii1()` command.

def screen(self) -> str: View Source

    320    def screen(self) -> str:
    321        """Return the last refreshed screen as a single newline-joined string."""
    322        return "\n".join(self._screen_buffer)

Return the last refreshed screen as a single newline-joined string.

def get_screen_buffer(self) -> list[str]: View Source

    324    def get_screen_buffer(self) -> list[str]:
    325        """Return the last refreshed screen as a list of row strings."""
    326        return list(self._screen_buffer)

Return the last refreshed screen as a list of row strings.

def read(self, row: int, col: int, length: int, trim: bool = True) -> str: View Source

    332    def read(self, row: int, col: int, length: int, trim: bool = True) -> str:
    333        """Read a region from the screen buffer.
    334
    335        Args:
    336            row: 1-based row number.
    337            col: 1-based column number.
    338            length: Number of characters to read.
    339            trim: Strip trailing whitespace from the result. Defaults to `True`.
    340
    341        Returns:
    342            The extracted string, or `""` if the row is out of range.
    343        """
    344        if row < 1 or row > len(self._screen_buffer):
    345            return ""
    346        line = self._screen_buffer[row - 1]
    347        result = line[col - 1 : col - 1 + length]
    348        return result.rstrip() if trim else result

Read a region from the screen buffer.

###### Arguments:

- row: 1-based row number.
- col: 1-based column number.
- length: Number of characters to read.
- trim: Strip trailing whitespace from the result. Defaults to `True`.

###### Returns:

> The extracted string, or `""` if the row is out of range.

def write( self, text: str, row: int, col: int, length: int | None = None) -> TerminalResponse: View Source

    350    def write(self, text: str, row: int, col: int, length: int | None = None) -> TerminalResponse:
    351        """Move the cursor then type *text* with `String()`.
    352
    353        Args:
    354            text: Text to send.
    355            row: 1-based destination row.
    356            col: 1-based destination column.
    357            length: If provided, right-justify *text* in a field of this width.
    358        """
    359        if length is not None:
    360            text = text[:length].rjust(length)
    361        self.move(row, col)
    362        return self.string(text)

Move the cursor then type text with `String()`.

###### Arguments:

- text: Text to send.
- row: 1-based destination row.
- col: 1-based destination column.
- length: If provided, right-justify text in a field of this width.

def check(self, text: str, row: int, col: int) -> bool: View Source

    364    def check(self, text: str, row: int, col: int) -> bool:
    365        """Return `True` if *text* appears at the given screen position.
    366
    367        Args:
    368            text: Expected string.
    369            row: 1-based row.
    370            col: 1-based column.
    371        """
    372        return self.read(row, col, len(text)) == text

Return `True` if text appears at the given screen position.

###### Arguments:

- text: Expected string.
- row: 1-based row.
- col: 1-based column.

def read_many( self, fields: list[FieldDefinition]) -> dict[str, str | float | None]: View Source

    374    def read_many(self, fields: list[FieldDefinition]) -> FieldDefinitionRecord:
    375        """Refresh the screen and read multiple fields in one call.
    376
    377        Args:
    378            fields: List of `FieldDefinition` descriptors.
    379
    380        Returns:
    381            Dict mapping each field's `name` (or `"row,col"` key) to its value.
    382            Numeric fields are coerced to `float`; blank numeric fields become `None`.
    383        """
    384        self.refresh()
    385        result: FieldDefinitionRecord = {}
    386        for field in fields:
    387            raw = self.read(field.row, field.col, field.length, trim=field.trim)
    388            key = field.name or f"{field.row},{field.col}"
    389            if field.type == "number":
    390                try:
    391                    result[key] = float(raw) if raw else None
    392                except ValueError:
    393                    result[key] = raw
    394            else:
    395                result[key] = raw
    396        return result

Refresh the screen and read multiple fields in one call.

###### Arguments:

- fields: List of `FieldDefinition` descriptors.

###### Returns:

> Dict mapping each field's `name` (or `"row,col"` key) to its value. Numeric fields are coerced to `float`; blank numeric fields become `None`.

def string(self, text: str) -> TerminalResponse: View Source

    402    def string(self, text: str) -> TerminalResponse:
    403        """Send text to the emulator using the `String()` action.
    404
    405        Args:
    406            text: The string to type, including s3270 escape sequences.
    407
    408        Raises:
    409            ValueError: If *text* contains an invalid s3270 escape sequence.
    410        """
    411        validate_escape_sequences(text)
    412        return self.command(f"String({text})")

Send text to the emulator using the `String()` action.

###### Arguments:

- text: The string to type, including s3270 escape sequences.

###### Raises:

- ValueError: If text contains an invalid s3270 escape sequence.

def send_text(self, text: str) -> TerminalResponse: View Source

    414    def send_text(self, text: str) -> TerminalResponse:
    415        """Alias for `string`."""
    416        return self.string(text)

Alias for `string`.

def enter(self) -> TerminalResponse: View Source

    418    def enter(self) -> TerminalResponse:
    419        """Press the Enter key and refresh the screen."""
    420        response = self.command("Enter")
    421        self.refresh()
    422        return response

Press the Enter key and refresh the screen.

def send_enter(self) -> TerminalResponse: View Source

    424    def send_enter(self) -> TerminalResponse:
    425        """Alias for `enter`."""
    426        return self.enter()

Alias for `enter`.

def tab(self) -> TerminalResponse: View Source

    428    def tab(self) -> TerminalResponse:
    429        """Press the Tab key to advance to the next input field."""
    430        return self.command("Tab")

Press the Tab key to advance to the next input field.

def clear(self) -> TerminalResponse: View Source

    432    def clear(self) -> TerminalResponse:
    433        """Press the Clear key and refresh the screen."""
    434        response = self.command("Clear")
    435        self.refresh()
    436        return response

Press the Clear key and refresh the screen.

def pf(self, n: int) -> TerminalResponse: View Source

    438    def pf(self, n: int) -> TerminalResponse:
    439        """Press a PF function key and refresh the screen.
    440
    441        Args:
    442            n: PF key number, 1–24.
    443
    444        Raises:
    445            ValueError: If *n* is outside the range 1–24.
    446        """
    447        if not 1 <= n <= 24:
    448            raise ValueError(f"PF key must be 1–24, got {n}")
    449        response = self.command(f"PF({n})")
    450        self.refresh()
    451        return response

Press a PF function key and refresh the screen.

###### Arguments:

- n: PF key number, 1–24.

###### Raises:

- ValueError: If n is outside the range 1–24.

def send_pf(self, n: int) -> TerminalResponse: View Source

    453    def send_pf(self, n: int) -> TerminalResponse:
    454        """Alias for `pf`."""
    455        return self.pf(n)

Alias for `pf`.

def pa(self, n: int) -> TerminalResponse: View Source

    457    def pa(self, n: int) -> TerminalResponse:
    458        """Press a PA program-attention key and refresh the screen.
    459
    460        Args:
    461            n: PA key number, 1–3.
    462
    463        Raises:
    464            ValueError: If *n* is outside the range 1–3.
    465        """
    466        if not 1 <= n <= 3:
    467            raise ValueError(f"PA key must be 1–3, got {n}")
    468        response = self.command(f"PA({n})")
    469        self.refresh()
    470        return response

Press a PA program-attention key and refresh the screen.

###### Arguments:

- n: PA key number, 1–3.

###### Raises:

- ValueError: If n is outside the range 1–3.

def move(self, row: int, col: int) -> TerminalResponse: View Source

    472    def move(self, row: int, col: int) -> TerminalResponse:
    473        """Move the cursor to the specified screen position.
    474
    475        Args:
    476            row: 1-based row number.
    477            col: 1-based column number.
    478        """
    479        emulator_row, emulator_col = self._to_emulator_coordinate(row, col)
    480        return self.command(f"MoveCursor({emulator_row},{emulator_col})")

Move the cursor to the specified screen position.

###### Arguments:

- row: 1-based row number.
- col: 1-based column number.

def read_screen(self) -> str: View Source

    482    def read_screen(self) -> str:
    483        """Refresh the screen buffer and return its full text content."""
    484        self.refresh()
    485        return self.screen()

Refresh the screen buffer and return its full text content.

def scrape(self, row: int, col: int, length: int, trim: bool = True) -> str: View Source

    487    def scrape(self, row: int, col: int, length: int, trim: bool = True) -> str:
    488        """Alias for `read`."""
    489        return self.read(row, col, length, trim=trim)

Alias for `read`.

def wait(self, timeout: int | None = None) -> TerminalResponse: View Source

    495    def wait(self, timeout: int | None = None) -> TerminalResponse:
    496        """Issue `Wait()` — blocks until the emulator is ready.
    497
    498        Args:
    499            timeout: Milliseconds to wait. Uses the session default if omitted.
    500        """
    501        return self.command("Wait()", timeout=timeout)

Issue `Wait()` — blocks until the emulator is ready.

###### Arguments:

- timeout: Milliseconds to wait. Uses the session default if omitted.

def wait_output(self, timeout: int | None = None) -> TerminalResponse: View Source

    503    def wait_output(self, timeout: int | None = None) -> TerminalResponse:
    504        """Issue `Wait(Output)` — blocks until the host sends new screen data.
    505
    506        Args:
    507            timeout: Milliseconds to wait. Uses the session default if omitted.
    508        """
    509        return self.command("Wait(Output)", timeout=timeout)

Issue `Wait(Output)` — blocks until the host sends new screen data.

###### Arguments:

- timeout: Milliseconds to wait. Uses the session default if omitted.

def wait_unlock(self, timeout: int | None = None) -> TerminalResponse: View Source

    511    def wait_unlock(self, timeout: int | None = None) -> TerminalResponse:
    512        """Issue `Wait(Unlock)` — blocks until the keyboard is unlocked.
    513
    514        Args:
    515            timeout: Milliseconds to wait. Uses the session default if omitted.
    516        """
    517        return self.command("Wait(Unlock)", timeout=timeout)

Issue `Wait(Unlock)` — blocks until the keyboard is unlocked.

###### Arguments:

- timeout: Milliseconds to wait. Uses the session default if omitted.

def wait_ready(self, timeout: int | None = None) -> None: View Source

    519    def wait_ready(self, timeout: int | None = None) -> None:
    520        """Wait for the keyboard to unlock, then wait for host output.
    521
    522        Convenience wrapper around `wait_unlock` + `wait_output`.
    523
    524        Args:
    525            timeout: Per-call timeout in milliseconds. Uses the session default if omitted.
    526        """
    527        self.wait_unlock(timeout)
    528        self.wait_output(timeout)

Wait for the keyboard to unlock, then wait for host output.

Convenience wrapper around `wait_unlock` + `wait_output`.

###### Arguments:

- timeout: Per-call timeout in milliseconds. Uses the session default if omitted.

def wait_for( self, text: str, row: int | None = None, col: int | None = None, timeout: int = 30000) -> bool: View Source

    530    def wait_for(
    531        self,
    532        text: str,
    533        row: int | None = None,
    534        col: int | None = None,
    535        timeout: int = 30_000,
    536    ) -> bool:
    537        """Poll the screen until *text* appears, or *timeout* elapses.
    538
    539        Args:
    540            text: The string to watch for.
    541            row: 1-based row to check. Must be paired with *col*.
    542            col: 1-based column to check. Must be paired with *row*.
    543            timeout: Total wait time in milliseconds (capped at 300 000). Defaults to 30 000.
    544
    545        Returns:
    546            ``True`` if *text* was found before the deadline, ``False`` otherwise.
    547
    548        Raises:
    549            ValueError: If only one of *row* / *col* is provided.
    550        """
    551        if (row is None) != (col is None):
    552            raise ValueError("both row and col must be provided, or neither")
    553        timeout = max(0, min(timeout, 300_000))
    554        deadline = time.monotonic() + timeout / 1000
    555        while time.monotonic() < deadline:
    556            self.refresh()
    557            if row is not None:
    558                if self.read(row, col, len(text)) == text:  # type: ignore[arg-type]
    559                    return True
    560            else:
    561                if text in self.screen():
    562                    return True
    563            time.sleep(0.1)
    564        return False

Poll the screen until text appears, or timeout elapses.

###### Arguments:

- text: The string to watch for.
- row: 1-based row to check. Must be paired with col.
- col: 1-based column to check. Must be paired with row.
- timeout: Total wait time in milliseconds (capped at 300 000). Defaults to 30 000.

###### Returns:

> `True` if text was found before the deadline, `False` otherwise.

###### Raises:

- ValueError: If only one of row / col is provided.

class Transport: View Source

     59class Transport:
     60    """Low-level process transport for one s3270 session.
     61
     62    Manages the s3270 subprocess, its stdin/stdout pipe, and the lock that
     63    prevents concurrent in-flight commands.
     64
     65    Args:
     66        default_timeout_ms: Timeout in milliseconds used when callers do not
     67            supply an explicit timeout to `execute`.
     68    """
     69
     70    def __init__(self, *, default_timeout_ms: int) -> None:
     71        self._default_timeout_ms = default_timeout_ms
     72        self._process: subprocess.Popen[str] | None = None
     73        self._line_queue: queue.Queue[str | object] = queue.Queue()
     74        self._reader: _StdoutReader | None = None
     75        self._inflight_lock = threading.Lock()
     76        self._stopped = False
     77
     78    def available(self) -> bool:
     79        """Return ``True`` if the s3270 process is running."""
     80        return self._process is not None and self._process.poll() is None
     81
     82    def pid(self) -> int | None:
     83        """Return the PID of the s3270 process, or ``None`` if not started."""
     84        if self._process is None:
     85            return None
     86        return self._process.pid
     87
     88    def start(self, executable: str, args: list[str]) -> None:
     89        """Launch the s3270 process. No-op if already running.
     90
     91        Args:
     92            executable: Path or name of the s3270 binary.
     93            args: Extra command-line arguments (e.g. ``["-model", "3279-2"]``).
     94        """
     95        if self.available():
     96            return
     97
     98        cmd = [executable, "-script", *args]
     99        self._process = subprocess.Popen(
    100            cmd,
    101            stdin=subprocess.PIPE,
    102            stdout=subprocess.PIPE,
    103            stderr=subprocess.DEVNULL,
    104            text=True,
    105            encoding="utf-8",
    106            errors="replace",
    107            bufsize=1,
    108        )
    109        assert self._process.stdout is not None
    110        self._line_queue = queue.Queue()
    111        self._reader = _StdoutReader(self._process.stdout, self._line_queue)
    112        self._reader.start()
    113        self._stopped = False
    114
    115    def stop(self) -> None:
    116        """Terminate the s3270 process and clean up resources."""
    117        self._stopped = True
    118        if self._process is None:
    119            return
    120
    121        try:
    122            if self._process.stdin is not None:
    123                self._process.stdin.close()
    124        except Exception:
    125            pass
    126
    127        try:
    128            if self._process.poll() is None:
    129                self._process.terminate()
    130                self._process.wait(timeout=2)
    131        except Exception:
    132            try:
    133                self._process.kill()
    134                self._process.wait(timeout=2)
    135            except Exception:
    136                pass
    137
    138        if self._reader is not None:
    139            self._reader.join(timeout=1)
    140            self._reader = None
    141
    142        self._process = None
    143
    144    def execute(self, command: str, *, timeout: int | None = None) -> TerminalResponse:
    145        """Send *command* to s3270 and return the response.
    146
    147        Args:
    148            command: The raw s3270 command string.
    149            timeout: Timeout in milliseconds. Falls back to *default_timeout_ms* if ``None``.
    150
    151        Returns:
    152            A `TerminalResponse` with `ok`, `data`, and `status`.
    153
    154        Raises:
    155            SessionBusyError: If another command is already in flight.
    156            SessionTimeoutError: If no response arrives before the timeout.
    157            SessionProcessError: If the s3270 process terminates unexpectedly.
    158        """
    159        if self._stopped:
    160            raise RuntimeError("Transport is stopped")
    161        if not self._inflight_lock.acquire(blocking=False):
    162            raise SessionBusyError("Session already has an in-flight operation")
    163
    164        try:
    165            self._send_raw(command)
    166            effective_timeout = timeout if timeout is not None else self._default_timeout_ms
    167            return self._read_response(effective_timeout)
    168        finally:
    169            self._inflight_lock.release()
    170
    171    def _assert_running(self) -> None:
    172        if not self.available():
    173            raise SessionDisconnectedError("Terminal is not running")
    174
    175    def _send_raw(self, command: str) -> None:
    176        self._assert_running()
    177        assert self._process is not None
    178        assert self._process.stdin is not None
    179        try:
    180            self._process.stdin.write(command + "\n")
    181            self._process.stdin.flush()
    182        except OSError as exc:
    183            raise SessionProcessError(f"Failed to send command: {command}") from exc
    184
    185    def _read_response(self, timeout_ms: int) -> TerminalResponse:
    186        deadline = time.monotonic() + (timeout_ms / 1000)
    187        lines: list[str] = []
    188
    189        while True:
    190            remaining = deadline - time.monotonic()
    191            if remaining <= 0:
    192                raise SessionTimeoutError("Command timeout")
    193            try:
    194                item = self._line_queue.get(timeout=remaining)
    195            except queue.Empty as exc:
    196                raise SessionTimeoutError("Command timeout") from exc
    197
    198            if item is _EOF:
    199                self._stopped = True
    200                raise SessionProcessError("s3270 process terminated unexpectedly")
    201
    202            line = str(item)
    203            if line == "ok" or line.startswith("error"):
    204                return _build_response(lines, line)
    205            lines.append(line)

Low-level process transport for one s3270 session.

Manages the s3270 subprocess, its stdin/stdout pipe, and the lock that prevents concurrent in-flight commands.

###### Arguments:

- default_timeout_ms: Timeout in milliseconds used when callers do not supply an explicit timeout to `execute`.

Transport(*, default_timeout_ms: int) View Source

    70    def __init__(self, *, default_timeout_ms: int) -> None:
    71        self._default_timeout_ms = default_timeout_ms
    72        self._process: subprocess.Popen[str] | None = None
    73        self._line_queue: queue.Queue[str | object] = queue.Queue()
    74        self._reader: _StdoutReader | None = None
    75        self._inflight_lock = threading.Lock()
    76        self._stopped = False

def available(self) -> bool: View Source

    78    def available(self) -> bool:
    79        """Return ``True`` if the s3270 process is running."""
    80        return self._process is not None and self._process.poll() is None

Return `True` if the s3270 process is running.

def pid(self) -> int | None: View Source

    82    def pid(self) -> int | None:
    83        """Return the PID of the s3270 process, or ``None`` if not started."""
    84        if self._process is None:
    85            return None
    86        return self._process.pid

Return the PID of the s3270 process, or `None` if not started.

def start(self, executable: str, args: list[str]) -> None: View Source

     88    def start(self, executable: str, args: list[str]) -> None:
     89        """Launch the s3270 process. No-op if already running.
     90
     91        Args:
     92            executable: Path or name of the s3270 binary.
     93            args: Extra command-line arguments (e.g. ``["-model", "3279-2"]``).
     94        """
     95        if self.available():
     96            return
     97
     98        cmd = [executable, "-script", *args]
     99        self._process = subprocess.Popen(
    100            cmd,
    101            stdin=subprocess.PIPE,
    102            stdout=subprocess.PIPE,
    103            stderr=subprocess.DEVNULL,
    104            text=True,
    105            encoding="utf-8",
    106            errors="replace",
    107            bufsize=1,
    108        )
    109        assert self._process.stdout is not None
    110        self._line_queue = queue.Queue()
    111        self._reader = _StdoutReader(self._process.stdout, self._line_queue)
    112        self._reader.start()
    113        self._stopped = False

Launch the s3270 process. No-op if already running.

###### Arguments:

- executable: Path or name of the s3270 binary.
- args: Extra command-line arguments (e.g. `["-model", "3279-2"]`).

def stop(self) -> None: View Source

    115    def stop(self) -> None:
    116        """Terminate the s3270 process and clean up resources."""
    117        self._stopped = True
    118        if self._process is None:
    119            return
    120
    121        try:
    122            if self._process.stdin is not None:
    123                self._process.stdin.close()
    124        except Exception:
    125            pass
    126
    127        try:
    128            if self._process.poll() is None:
    129                self._process.terminate()
    130                self._process.wait(timeout=2)
    131        except Exception:
    132            try:
    133                self._process.kill()
    134                self._process.wait(timeout=2)
    135            except Exception:
    136                pass
    137
    138        if self._reader is not None:
    139            self._reader.join(timeout=1)
    140            self._reader = None
    141
    142        self._process = None

Terminate the s3270 process and clean up resources.

def execute( self, command: str, *, timeout: int | None = None) -> TerminalResponse: View Source

    144    def execute(self, command: str, *, timeout: int | None = None) -> TerminalResponse:
    145        """Send *command* to s3270 and return the response.
    146
    147        Args:
    148            command: The raw s3270 command string.
    149            timeout: Timeout in milliseconds. Falls back to *default_timeout_ms* if ``None``.
    150
    151        Returns:
    152            A `TerminalResponse` with `ok`, `data`, and `status`.
    153
    154        Raises:
    155            SessionBusyError: If another command is already in flight.
    156            SessionTimeoutError: If no response arrives before the timeout.
    157            SessionProcessError: If the s3270 process terminates unexpectedly.
    158        """
    159        if self._stopped:
    160            raise RuntimeError("Transport is stopped")
    161        if not self._inflight_lock.acquire(blocking=False):
    162            raise SessionBusyError("Session already has an in-flight operation")
    163
    164        try:
    165            self._send_raw(command)
    166            effective_timeout = timeout if timeout is not None else self._default_timeout_ms
    167            return self._read_response(effective_timeout)
    168        finally:
    169            self._inflight_lock.release()

Send command to s3270 and return the response.

###### Arguments:

- command: The raw s3270 command string.
- timeout: Timeout in milliseconds. Falls back to default_timeout_ms if `None`.

###### Returns:

> A `TerminalResponse` with `ok`, `data`, and `status`.

###### Raises:

- SessionBusyError: If another command is already in flight.
- SessionTimeoutError: If no response arrives before the timeout.
- SessionProcessError: If the s3270 process terminates unexpectedly.

class TerminalMode(enum.StrEnum): View Source

    11class TerminalMode(StrEnum):
    12    """Connection mode prefix passed to `Connect()`."""
    13
    14    Passthru = "P"
    15    SuppressExtendedDS = "S"
    16    NoTN3270E = "N"
    17    SSLTunnel = "L"
    18    BindStrict = "B"

Connection mode prefix passed to `Connect()`.

Passthru = <TerminalMode.Passthru: 'P'>
SuppressExtendedDS = <TerminalMode.SuppressExtendedDS: 'S'>
NoTN3270E = <TerminalMode.NoTN3270E: 'N'>
SSLTunnel = <TerminalMode.SSLTunnel: 'L'>
BindStrict = <TerminalMode.BindStrict: 'B'>
@dataclass
class TerminalOptions: View Source

    84@dataclass
    85class TerminalOptions:
    86    """Configuration for launching an s3270 process.
    87
    88    Attributes:
    89        executable: Path or name of the s3270 binary. Defaults to `"s3270"`.
    90        args: Extra command-line arguments forwarded to s3270.
    91        verbose: Reserved for future debug-logging use.
    92        timeout: Default command timeout in milliseconds. Defaults to `30_000`.
    93    """
    94
    95    executable: str = "s3270"
    96    args: list[str] = dataclass_field(default_factory=list)
    97    verbose: bool = False
    98    timeout: int = 30_000

Configuration for launching an s3270 process.

###### Attributes:

- executable: Path or name of the s3270 binary. Defaults to `"s3270"`.
- args: Extra command-line arguments forwarded to s3270.
- verbose: Reserved for future debug-logging use.
- timeout: Default command timeout in milliseconds. Defaults to `30_000`.

TerminalOptions( executable: str = 's3270', args: list[str] = <factory>, verbose: bool = False, timeout: int = 30000)
executable: str = 's3270'
args: list[str]
verbose: bool = False
timeout: int = 30000
@dataclass
class TerminalResponse: View Source

    101@dataclass
    102class TerminalResponse:
    103    """Result of a single s3270 command.
    104
    105    Attributes:
    106        ok: `True` if s3270 responded with `ok`.
    107        data: Payload text (lines after `data: ` prefix are stripped).
    108        status: The raw s3270 status line, if present.
    109        raw: All lines received from s3270 for this command.
    110    """
    111
    112    ok: bool
    113    data: str
    114    status: str
    115    raw: list[str]

Result of a single s3270 command.

###### Attributes:

- ok: `True` if s3270 responded with `ok`.
- data: Payload text (lines after `data:` prefix are stripped).
- status: The raw s3270 status line, if present.
- raw: All lines received from s3270 for this command.

TerminalResponse(ok: bool, data: str, status: str, raw: list[str])
ok: bool
data: str
status: str
raw: list[str]
class TerminalSetting(enum.StrEnum): View Source

    21class TerminalSetting(StrEnum):
    22    """Keys accepted by the s3270 `Query()` action."""
    23
    24    ConnectionState = "ConnectionState"
    25    Host = "Host"
    26    LuName = "LuName"
    27    Model = "Model"
    28    Encoding = "Encoding"
    29    CodePage = "CodePage"
    30    Aid = "Aid"
    31    BindPluName = "BindPluName"

Keys accepted by the s3270 `Query()` action.

ConnectionState = <TerminalSetting.ConnectionState: 'ConnectionState'>
Host = <TerminalSetting.Host: 'Host'>
LuName = <TerminalSetting.LuName: 'LuName'>
Model = <TerminalSetting.Model: 'Model'>
Encoding = <TerminalSetting.Encoding: 'Encoding'>
CodePage = <TerminalSetting.CodePage: 'CodePage'>
Aid = <TerminalSetting.Aid: 'Aid'>
BindPluName = <TerminalSetting.BindPluName: 'BindPluName'>
class UnexpectedScreenError(ibm3270.SessionError): View Source

    27class UnexpectedScreenError(SessionError):
    28    """Raised when a caller expects specific screen content but it is absent."""

Raised when a caller expects specific screen content but it is absent.

__version__ = '0.1.0'
def field( row: int, col: int, length: int, type: Literal['string', 'number'] = 'string', *, trim: bool = True, name: str | None = None) -> FieldDefinition: View Source

    189def field(
    190    row: int,
    191    col: int,
    192    length: int,
    193    type: Literal["string", "number"] = "string",
    194    *,
    195    trim: bool = True,
    196    name: str | None = None,
    197) -> FieldDefinition:
    198    """Convenience constructor for `FieldDefinition`.
    199
    200    Args:
    201        row: 1-based row.
    202        col: 1-based column.
    203        length: Number of characters to read.
    204        type: `"string"` or `"number"`.
    205        trim: Strip trailing whitespace. Defaults to `True`.
    206        name: Optional key in the result dict.
    207
    208    Returns:
    209        A `FieldDefinition` instance.
    210    """
    211    return FieldDefinition(
    212        row=row,
    213        col=col,
    214        length=length,
    215        type=type,
    216        trim=trim,
    217        name=name,
    218    )

Convenience constructor for `FieldDefinition`.

###### Arguments:

- row: 1-based row.
- col: 1-based column.
- length: Number of characters to read.
- type: `"string"` or `"number"`.
- trim: Strip trailing whitespace. Defaults to `True`.
- name: Optional key in the result dict.

###### Returns:

> A `FieldDefinition` instance.