Claude Code for pty: Python Pseudo-Terminal I/O — Claude Skills 360 Blog
Blog / AI / Claude Code for pty: Python Pseudo-Terminal I/O
AI

Claude Code for pty: Python Pseudo-Terminal I/O

Published: October 18, 2028
Read time: 5 min read
By: Claude Skills 360

Python’s pty module (Unix/macOS only) allocates pseudo-terminal pairs and forks child processes attached to them, enabling interactive-terminal simulation, ANSI escape capture, and password-aware process automation. import pty. openpty: master_fd, slave_fd = pty.openpty() — allocate a pty pair; child uses slave_fd, parent uses master_fd. fork: pid, fd = pty.fork() — fork; in the child (pid=0), stdin/stdout/stderr are the slave pty; in the parent, fd is the master. spawn: pty.spawn(argv) or pty.spawn(argv, master_read=fn, stdin_read=fn) — runs a command attached to the current terminal’s pty or a new one; blocks until completion. Master fd behavior: reads return bytes from child’s stdout; writes send bytes to child’s stdin; the kernel echoes input by default. Use os.read(master_fd, 1024) — non-blocking with select; os.write(master_fd, b"input\n"). tty.setraw(sys.stdin.fileno()) — disable line buffering for raw key capture. termios — read/write terminal attributes. select.select([master_fd], [], [], timeout) — poll for output. Windows: not available; use winpty (PyPI) or pywinpty. Claude Code generates interactive process wrappers, terminal recorders, pexpect-style automators, and SSH tunnel handlers.

CLAUDE.md for pty

## pty Stack
- Stdlib: import pty, os, select, tty, termios  # Unix/macOS only
- Pair:   master_fd, slave_fd = pty.openpty()
- Fork:   pid, master_fd = pty.fork()
          if pid == 0: os.execvp(cmd, args)   # child
          else: os.read(master_fd, 1024)       # parent reads child output
- Spawn:  pty.spawn(["ssh", "host"])           # interactive pass-through
- Read:   select.select([master_fd], [], [], 0.1); data = os.read(master_fd, 1024)
- Write:  os.write(master_fd, b"command\n")

pty Pseudo-Terminal Pipeline

# app/ptyutil.py — spawn, capture, interact, record, expect-style automation
from __future__ import annotations

import errno
import io
import os
import platform
import select
import signal
import sys
import time
from dataclasses import dataclass, field
from typing import Callable

# pty is Unix only
_PTY_AVAILABLE = platform.system() != "Windows"
if _PTY_AVAILABLE:
    import pty
    import termios
    import tty


# ─────────────────────────────────────────────────────────────────────────────
# 1. Basic low-level helpers
# ─────────────────────────────────────────────────────────────────────────────

def allocate_pty() -> tuple[int, int]:
    """
    Allocate a pseudo-terminal pair.
    Returns (master_fd, slave_fd).
    The child process should use slave_fd; the parent uses master_fd.

    Example:
        master_fd, slave_fd = allocate_pty()
    """
    if not _PTY_AVAILABLE:
        raise OSError("pty is not available on Windows")
    return pty.openpty()


def set_nonblocking(fd: int) -> None:
    """Set a file descriptor to non-blocking mode."""
    import fcntl
    flags = fcntl.fcntl(fd, fcntl.F_GETFL)
    fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)


def read_available(fd: int, max_bytes: int = 4096, timeout: float = 0.1) -> bytes:
    """
    Read available bytes from fd with a timeout.
    Returns empty bytes if nothing available.

    Example:
        data = read_available(master_fd)
    """
    r, _, _ = select.select([fd], [], [], timeout)
    if not r:
        return b""
    try:
        return os.read(fd, max_bytes)
    except OSError as e:
        if e.errno in (errno.EIO, errno.EBADF):
            return b""
        raise


def read_until_eof(fd: int, timeout: float = 30.0, chunk: int = 4096) -> bytes:
    """
    Read from fd until EIO (child closed) or timeout.
    Returns all accumulated bytes.

    Example:
        output = read_until_eof(master_fd, timeout=10)
    """
    buf = io.BytesIO()
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        r, _, _ = select.select([fd], [], [], 0.1)
        if r:
            try:
                data = os.read(fd, chunk)
                if not data:
                    break
                buf.write(data)
            except OSError as e:
                if e.errno == errno.EIO:
                    break
                raise
    return buf.getvalue()


# ─────────────────────────────────────────────────────────────────────────────
# 2. Process spawner with output capture
# ─────────────────────────────────────────────────────────────────────────────

@dataclass
class PtyResult:
    output:    bytes
    exit_code: int
    duration:  float

    def text(self, encoding: str = "utf-8", errors: str = "replace") -> str:
        return self.output.decode(encoding, errors=errors)

    def __str__(self) -> str:
        return (f"exit={self.exit_code}  "
                f"bytes={len(self.output)}  "
                f"time={self.duration:.2f}s")


def run_in_pty(
    args: list[str],
    stdin_data: bytes | None = None,
    timeout: float = 30.0,
    env: dict | None = None,
    cwd: str | None = None,
) -> PtyResult:
    """
    Run a command in a pseudo-terminal and capture all output.
    Useful for commands that behave differently without a tty (e.g. color output,
    interactive prompts, terminal-aware pagers).

    Example:
        result = run_in_pty(["python3", "-c", "import sys; print(sys.stdout.isatty())"])
        print(result.text())   # "True"
    """
    if not _PTY_AVAILABLE:
        raise OSError("pty not available on Windows")

    t0 = time.monotonic()
    master_fd, slave_fd = pty.openpty()

    pid = os.fork()
    if pid == 0:
        # Child: replace stdio with slave pty
        os.setsid()
        import fcntl
        fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
        for fd in (0, 1, 2):
            os.dup2(slave_fd, fd)
        if slave_fd > 2:
            os.close(slave_fd)
        os.close(master_fd)
        if cwd:
            os.chdir(cwd)
        if env is not None:
            os.execvpe(args[0], args, env)
        else:
            os.execvp(args[0], args)
        os._exit(127)

    # Parent
    os.close(slave_fd)
    set_nonblocking(master_fd)

    buf = io.BytesIO()
    deadline = time.monotonic() + timeout

    if stdin_data:
        try:
            os.write(master_fd, stdin_data)
        except OSError:
            pass

    while True:
        if time.monotonic() > deadline:
            try:
                os.kill(pid, signal.SIGKILL)
            except ProcessLookupError:
                pass
            break
        r, _, _ = select.select([master_fd], [], [], 0.1)
        if r:
            try:
                data = os.read(master_fd, 4096)
                if data:
                    buf.write(data)
                    continue
            except OSError as e:
                if e.errno == errno.EIO:
                    break
                raise
        # Check if child exited
        result_pid, status = os.waitpid(pid, os.WNOHANG)
        if result_pid == pid:
            # Drain any remaining output
            while True:
                r2, _, _ = select.select([master_fd], [], [], 0.05)
                if not r2:
                    break
                try:
                    data = os.read(master_fd, 4096)
                    if data:
                        buf.write(data)
                    else:
                        break
                except OSError:
                    break
            os.close(master_fd)
            exit_code = os.waitstatus_to_exitcode(status)
            return PtyResult(output=buf.getvalue(), exit_code=exit_code,
                             duration=time.monotonic() - t0)

    os.close(master_fd)
    _, status = os.waitpid(pid, 0)
    exit_code = os.waitstatus_to_exitcode(status)
    return PtyResult(output=buf.getvalue(), exit_code=exit_code,
                     duration=time.monotonic() - t0)


# ─────────────────────────────────────────────────────────────────────────────
# 3. Terminal recorder (output capture with timestamps)
# ─────────────────────────────────────────────────────────────────────────────

@dataclass
class TerminalEvent:
    ts:    float   # seconds since start
    kind:  str     # "output" or "input"
    data:  bytes


@dataclass
class TerminalRecording:
    events:    list[TerminalEvent] = field(default_factory=list)
    duration:  float = 0.0
    exit_code: int = -1

    def full_output(self) -> bytes:
        return b"".join(e.data for e in self.events if e.kind == "output")

    def text(self, encoding: str = "utf-8") -> str:
        return self.full_output().decode(encoding, errors="replace")

    def __str__(self) -> str:
        out_bytes = sum(len(e.data) for e in self.events if e.kind == "output")
        return (f"events={len(self.events)}  "
                f"output_bytes={out_bytes}  "
                f"duration={self.duration:.2f}s  "
                f"exit={self.exit_code}")


def record_pty(
    args: list[str],
    inputs: list[tuple[float, bytes]] | None = None,
    timeout: float = 30.0,
) -> TerminalRecording:
    """
    Run a command in a pty, recording all output events with timestamps.
    inputs: list of (delay_seconds, bytes_to_send) for scripted input.

    Example:
        rec = record_pty(["bash", "-i"], inputs=[(0.5, b"ls -1\n"), (1.0, b"exit\n")])
        print(rec.text())
    """
    if not _PTY_AVAILABLE:
        raise OSError("pty not available on Windows")

    recording = TerminalRecording()
    t0 = time.monotonic()

    master_fd, slave_fd = pty.openpty()
    pid = os.fork()

    if pid == 0:
        os.setsid()
        import fcntl
        fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
        for fd in (0, 1, 2):
            os.dup2(slave_fd, fd)
        if slave_fd > 2:
            os.close(slave_fd)
        os.close(master_fd)
        os.execvp(args[0], args)
        os._exit(127)

    os.close(slave_fd)
    set_nonblocking(master_fd)

    pending_inputs = list(inputs or [])
    deadline = time.monotonic() + timeout

    while time.monotonic() < deadline:
        now = time.monotonic() - t0
        # Send scheduled inputs
        while pending_inputs and now >= pending_inputs[0][0]:
            delay, data = pending_inputs.pop(0)
            try:
                os.write(master_fd, data)
                recording.events.append(TerminalEvent(ts=now, kind="input", data=data))
            except OSError:
                pass

        r, _, _ = select.select([master_fd], [], [], 0.05)
        if r:
            try:
                chunk = os.read(master_fd, 4096)
                if chunk:
                    recording.events.append(
                        TerminalEvent(ts=time.monotonic() - t0, kind="output", data=chunk)
                    )
                    continue
            except OSError as e:
                if e.errno == errno.EIO:
                    break
                raise

        result_pid, status = os.waitpid(pid, os.WNOHANG)
        if result_pid == pid:
            recording.exit_code = os.waitstatus_to_exitcode(status)
            break

    os.close(master_fd)
    try:
        _, status = os.waitpid(pid, os.WNOHANG)
        if status:
            recording.exit_code = os.waitstatus_to_exitcode(status)
    except ChildProcessError:
        pass

    recording.duration = time.monotonic() - t0
    return recording


# ─────────────────────────────────────────────────────────────────────────────
# 4. Expect-style pattern waiter
# ─────────────────────────────────────────────────────────────────────────────

class PtySession:
    """
    Interactive pty session with send/expect API.

    Example:
        with PtySession(["python3", "-i"]) as sess:
            sess.expect(">>> ", timeout=3)
            sess.send("1 + 1\n")
            output = sess.expect(">>> ", timeout=3)
            print(output)   # "2\\n>>> "
    """

    def __init__(self, args: list[str], timeout: float = 10.0) -> None:
        if not _PTY_AVAILABLE:
            raise OSError("pty not available on Windows")
        self._args = args
        self._timeout = timeout
        self._master_fd: int = -1
        self._pid: int = -1
        self._buf = b""

    def __enter__(self) -> "PtySession":
        master_fd, slave_fd = pty.openpty()
        pid = os.fork()
        if pid == 0:
            os.setsid()
            import fcntl
            fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0)
            for fd in (0, 1, 2):
                os.dup2(slave_fd, fd)
            if slave_fd > 2:
                os.close(slave_fd)
            os.close(master_fd)
            os.execvp(self._args[0], self._args)
            os._exit(127)
        os.close(slave_fd)
        set_nonblocking(master_fd)
        self._master_fd = master_fd
        self._pid = pid
        return self

    def __exit__(self, *args) -> None:
        try:
            os.kill(self._pid, signal.SIGTERM)
        except ProcessLookupError:
            pass
        try:
            os.close(self._master_fd)
        except OSError:
            pass
        try:
            os.waitpid(self._pid, 0)
        except ChildProcessError:
            pass

    def send(self, data: str | bytes) -> None:
        """Send data to the process stdin."""
        if isinstance(data, str):
            data = data.encode()
        os.write(self._master_fd, data)

    def expect(self, pattern: str | bytes, timeout: float | None = None) -> bytes:
        """
        Wait until pattern appears in output. Returns accumulated output up to
        and including the pattern. Raises TimeoutError on timeout.
        """
        if isinstance(pattern, str):
            pattern = pattern.encode()
        deadline = time.monotonic() + (timeout or self._timeout)

        while True:
            if pattern in self._buf:
                idx = self._buf.index(pattern) + len(pattern)
                result = self._buf[:idx]
                self._buf = self._buf[idx:]
                return result
            if time.monotonic() > deadline:
                raise TimeoutError(f"Pattern {pattern!r} not found within timeout")
            r, _, _ = select.select([self._master_fd], [], [], 0.05)
            if r:
                try:
                    chunk = os.read(self._master_fd, 4096)
                    self._buf += chunk
                except OSError as e:
                    if e.errno == errno.EIO:
                        raise EOFError("Process closed pty") from e
                    raise


# ─────────────────────────────────────────────────────────────────────────────
# Demo
# ─────────────────────────────────────────────────────────────────────────────

if __name__ == "__main__":
    if not _PTY_AVAILABLE:
        print("pty not available on Windows — skipping demo")
        raise SystemExit(0)

    print("=== pty demo ===")

    # ── run_in_pty: verify isatty() ────────────────────────────────────────────
    print("\n--- run_in_pty: isatty() check ---")
    result = run_in_pty(
        ["python3", "-c", "import sys; print(sys.stdout.isatty())"],
        timeout=10
    )
    print(f"  {result}")
    print(f"  isatty output: {result.text().strip()!r}")   # True when in pty

    # ── run_in_pty: color output ───────────────────────────────────────────────
    print("\n--- run_in_pty: ANSI color check (ls --color) ---")
    result2 = run_in_pty(["ls", "--color=always", "/"], timeout=5)
    print(f"  {result2}")
    has_ansi = b"\x1b[" in result2.output
    print(f"  ANSI escapes present: {has_ansi}")

    # ── run_in_pty: echo command ───────────────────────────────────────────────
    print("\n--- run_in_pty: echo ---")
    r3 = run_in_pty(["echo", "Hello from pty!"], timeout=5)
    print(f"  output: {r3.text().strip()!r}")

    # ── record_pty ────────────────────────────────────────────────────────────
    print("\n--- record_pty: python3 -c ---")
    rec = record_pty(
        ["python3", "-c", "print('recorded output'); import time; time.sleep(0.1)"],
        timeout=5,
    )
    print(f"  {rec}")
    print(f"  text: {rec.text().strip()!r}")

    # ── PtySession: python REPL interaction ───────────────────────────────────
    print("\n--- PtySession: python3 -i ---")
    try:
        with PtySession(["python3", "-i"], timeout=5) as sess:
            # Wait for initial prompt
            sess.expect(">>> ", timeout=5)
            sess.send("2 + 3\n")
            out = sess.expect(">>> ", timeout=5)
            val = out.decode("utf-8", errors="replace").strip().split("\n")[0]
            print(f"  2 + 3 = {val!r}")
            sess.send("exit()\n")
    except Exception as e:
        print(f"  PtySession demo: {e}")

    print("\n=== done ===")

For the subprocess alternative — subprocess.Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE) captures subprocess I/O through pipes without a terminal — use subprocess for the vast majority of process spawning needs where the child doesn’t check isatty() and where you don’t need ANSI escapes, interactive prompts, or a controlling terminal; use pty when the child process behaves differently without a tty (e.g. disables color output, buffers aggressively, or requires terminal-style echo), when automating interactive TUI programs, or when capturing terminal-aware output including ANSI escape sequences. For the pexpect alternative — pexpect (PyPI) provides a higher-level spawn/expect/sendline API built on top of pty with pattern-matching, timeout handling, and cross-platform Windows support via pexpect.popen_spawn — use pexpect when building robust interactive automation (SSH, FTP, passwd, database shells) where you need reliable pattern matching, \r\n handling, and tested edge cases; use pty directly when you need low-level control over the master fd, want to implement a terminal recorder or multiplexer, or want zero dependencies. The Claude Skills 360 bundle includes pty skill sets covering allocate_pty()/set_nonblocking()/read_available()/read_until_eof() low-level helpers, PtyResult with run_in_pty() capture-with-timeout runner, TerminalEvent/TerminalRecording with record_pty() timestamped recorder, and PtySession context-manager with send()/expect() pattern waiter. Start with the free tier to try pseudo-terminal patterns and pty pipeline code generation.

Keep Reading

AI

Claude Code for email.contentmanager: Python Email Content Accessors

Read and write EmailMessage body content with Python's email.contentmanager module and Claude Code — email contentmanager ContentManager for the class that maps content types to get and set handler functions allowing EmailMessage to support get_content and set_content with type-specific behaviour, email contentmanager raw_data_manager for the ContentManager instance that handles raw bytes and str payloads without any conversion, email contentmanager content_manager for the standard ContentManager instance used by email.policy.default that intelligently handles text plain text html multipart and binary content types, email contentmanager get_content_text for the handler that returns the decoded text payload of a text-star message part as a str, email contentmanager get_content_binary for the handler that returns the raw decoded bytes payload of a non-text message part, email contentmanager get_data_manager for the get-handler lookup used by EmailMessage get_content to find the right reader function for the content type, email contentmanager set_content text for the handler that creates and sets a text part correctly choosing charset and transfer encoding, email contentmanager set_content bytes for the handler that creates and sets a binary part with base64 encoding and optional filename Content-Disposition, email contentmanager EmailMessage get_content for the method that reads the message body using the registered content manager handlers, email contentmanager EmailMessage set_content for the method that sets the message body and MIME headers in one call, email contentmanager EmailMessage make_alternative make_mixed make_related for the methods that convert a simple message into a multipart container, email contentmanager EmailMessage add_attachment for the method that attaches a file or bytes to a multipart message, and email contentmanager integration with email.message and email.policy and email.mime and io for building high-level email readers attachment extractors text body accessors HTML readers and policy-aware MIME construction pipelines.

5 min read Feb 12, 2029
AI

Claude Code for email.charset: Python Email Charset Encoding

Control header and body encoding for international email with Python's email.charset module and Claude Code — email charset Charset for the class that wraps a character set name with the encoding rules for header encoding and body encoding describing how to encode text for that charset in email messages, email charset Charset header_encoding for the attribute specifying whether headers using this charset should use QP quoted-printable encoding BASE64 encoding or no encoding, email charset Charset body_encoding for the attribute specifying the Content-Transfer-Encoding to use for message bodies in this charset such as QP or BASE64, email charset Charset output_codec for the attribute giving the Python codec name used to encode the string to bytes for the wire format, email charset Charset input_codec for the attribute giving the Python codec name used to decode incoming bytes to str, email charset Charset get_output_charset for returning the output charset name, email charset Charset header_encode for encoding a header string using the charset's header_encoding method, email charset Charset body_encode for encoding body content using the charset's body_encoding, email charset Charset convert for converting a string from the input_codec to the output_codec, email charset add_charset for registering a new charset with custom encoding rules in the global charset registry, email charset add_alias for adding an alias name that maps to an existing registered charset, email charset add_codec for registering a codec name mapping for use by the charset machinery, and email charset integration with email.message and email.mime and email.policy and email.encoders for building international email senders non-ASCII header encoders Content-Transfer-Encoding selectors charset-aware message constructors and MIME encoding pipelines.

5 min read Feb 11, 2029
AI

Claude Code for email.utils: Python Email Address and Header Utilities

Parse and format RFC 2822 email addresses and dates with Python's email.utils module and Claude Code — email utils parseaddr for splitting a display-name plus angle-bracket address string into a realname and email address tuple, email utils formataddr for combining a realname and address string into a properly quoted RFC 2822 address with angle brackets, email utils getaddresses for parsing a list of raw address header strings each potentially containing multiple comma-separated addresses into a list of realname address tuples, email utils parsedate for parsing an RFC 2822 date string into a nine-tuple compatible with time.mktime, email utils parsedate_tz for parsing an RFC 2822 date string into a ten-tuple that includes the UTC offset timezone in seconds, email utils parsedate_to_datetime for parsing an RFC 2822 date string into an aware datetime object with timezone, email utils formatdate for formatting a POSIX timestamp or the current time as an RFC 2822 date string with optional usegmt and localtime flags, email utils format_datetime for formatting a datetime object as an RFC 2822 date string, email utils make_msgid for generating a globally unique Message-ID string with optional idstring and domain components, email utils decode_rfc2231 for decoding an RFC 2231 encoded parameter value into a tuple of charset language and value, email utils encode_rfc2231 for encoding a string as an RFC 2231 encoded parameter value, email utils collapse_rfc2231_value for collapsing a decoded RFC 2231 tuple to a Unicode string, and email utils integration with email.message and email.headerregistry and datetime and time for building address parsers date formatters message-id generators header extractors and RFC-compliant email construction utilities.

5 min read Feb 10, 2029

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free