ibm3270

 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):
60class ConnectionState(StrEnum):
61    Connected = "C"
62    NotConnected = "N"
Connected = <ConnectionState.Connected: 'C'>
NotConnected = <ConnectionState.NotConnected: 'N'>
class EmulatorMode(enum.StrEnum):
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:
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):
55class FieldProtection(StrEnum):
56    Protected = "P"
57    Unprotected = "U"
Protected = <FieldProtection.Protected: 'P'>
Unprotected = <FieldProtection.Unprotected: 'U'>
class KeyboardState(enum.StrEnum):
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):
50class ScreenFormatting(StrEnum):
51    Formatted = "F"
52    Unformatted = "U"
Formatted = <ScreenFormatting.Formatted: 'F'>
Unformatted = <ScreenFormatting.Unformatted: 'U'>
@dataclass
class ScreenPosition:
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:
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):
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):
15class SessionDisconnectedError(SessionError):
16    """Raised when an operation requires a running session."""

Raised when an operation requires a running session.

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

Base exception for session-level failures.

class SessionManager:
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:
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:
Returns:

The new session ID (UUID string).

def get_session(self, session_id: str) -> Terminal:
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:
Returns:

The Terminal for that session.

Raises:
  • KeyError: If session_id is not registered.
def close_session(self, session_id: str) -> None:
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:
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]:
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):
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):
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):
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):
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:
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:
 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)
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:
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
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
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:
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:
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:
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:
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]:
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:
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:
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:
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:
Returns:

TerminalResponse with the value in data.

def get(self, setting: TerminalSetting) -> str:
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:
Returns:

The setting value as a plain string.

def cursor(self) -> ScreenPosition | None:
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:
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:
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:
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:
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:
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]:
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:
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:
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:
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]:
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:
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:
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:
414    def send_text(self, text: str) -> TerminalResponse:
415        """Alias for `string`."""
416        return self.string(text)

Alias for string.

def enter(self) -> TerminalResponse:
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:
424    def send_enter(self) -> TerminalResponse:
425        """Alias for `enter`."""
426        return self.enter()

Alias for enter.

def tab(self) -> TerminalResponse:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
 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)
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:
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:
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:
 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:
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:
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):
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:
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:
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):
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):
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:
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.