Porting matrix

Note

This page is generated from src/textual_wasm/substitutions.py by textual-wasm matrix, and a test fails when the committed copy drifts from it. Every row was measured inside a real Pyodide. Pyodide’s own documentation lists four modules as removed that import fine in the version pinned here.

Porting matrix

What behaves differently under Pyodide 314.0.6. Pyodide’s own documentation lists termios, fcntl, pty and tty as removed; in this version all four import successfully. Every row below carries the snippet used to observe it, and a characterisation test re-runs those snippets inside a real Pyodide, so a runtime change fails the suite instead of quietly making this page wrong.

Run textual-wasm doctor <module:App> to find these in your own source, with a file:line for each.

Silent and wrong

No exception is raised and the behaviour is wrong. This is the class Pyodide’s own documentation does not cover, and the reason this project exists: every other kind of failure announces itself.

Capability

Detected as

What happens

What to do instead

asyncio.run_in_executor

run_in_executor(...)

Returns the correct result, but Pyodide’s WebLoop ignores the executor and runs the callable inline on the only thread. Code written to keep a UI responsive freezes the page instead.

There is no thread to offload to. Make the work a coroutine and await it, or break it into chunks that yield with await asyncio.sleep(0) between them.

time.sleep

time.sleep(...)

Blocks for the full duration on the only thread. In a browser the tab stops rendering, stops handling input and stops running timers for that period.

Use await asyncio.sleep(...), which yields to the event loop.

socket.connect

socket.socket(...)

socket() and connect() both succeed and return None. Emscripten backs sockets with WebSockets, so a send appears to work and the first recv hangs until the timeout - or forever, if none is set. Differs between Node and a browser.

Raw TCP does not exist in a browser. Use pyodide.http.pyfetch for async HTTP, or requests/httpx, which Pyodide patches to route through fetch.

os.kill.suspend

os.kill(...)

os.kill(pid, SIGTSTP) returns None and does nothing; the app is not suspended.

There is no job control. Textual gates this behind Driver.can_suspend, which the WASM driver reports as False.

termios.tcsetattr

tty.setcbreak(...), tty.setraw(...), termios.tcsetattr(...)

Succeeds and changes nothing. The call that follows it is the problem: putting a terminal into cbreak mode is what code does before writing a query escape sequence and reading the terminal’s reply, and no reply ever comes - so the read blocks forever on the only thread there is. Differs between Node and a browser.

There is no terminal to interrogate. Take the size from the driver, which already has it, and choose rendering modes from a configuration value; there is nothing to ask. textual_wasm.capabilities reports what this runtime can do without probing for it.

os.system

os.system(...)

In Node it really shells out, via child_process.spawnSync, and returns the exit status. In a browser there is no such branch: it returns 0 and does nothing. The most dangerous entry in this registry - it passes every test run under Node and silently does nothing in production. Differs between Node and a browser.

There is no shell in a browser. Whatever the command did has to move into Python, or behind a network call to a server that still has one.

Fatal

Tears down the interpreter. Not a Python exception, so nothing catches it and nothing runs afterwards - including whatever you would have used to report it.

Capability

Detected as

What happens

What to do instead

os.kill.terminate

os.kill(...)

os.kill(pid, SIGKILL) tears the runtime down with a JS exit object carrying pyodide_fatal_error: true. It is not a Python exception, nothing can catch it, and the interpreter is unusable afterwards.

Exit through App.exit() so Textual can shut down and restore the terminal.

Loud but misleading

Raises, and the message sends you somewhere else - a symptom, a private module, or a generic errno instead of the actual constraint. textual_wasm.diagnostics rewrites exactly these.

Capability

Detected as

What happens

What to do instead

threading.thread

import threading, threading.Thread(...)

Constructing a Thread succeeds; .start() raises. The message reads as a transient resource limit, but this build has no pthreads at all - sys._emscripten_info.pthreads is False and cannot be turned on by any header or flag.

Use asyncio. In Textual specifically, @work (async) works and @work(thread=True) cannot.

concurrent.thread_pool

ThreadPoolExecutor(...)

The constructor succeeds and the failure is deferred to the first submit(), buried under _adjust_thread_count, so the traceback points away from the caller.

Same as threading.thread: there are no threads. Use asyncio.

multiprocessing.process

import multiprocessing

import multiprocessing and Process(...) both succeed; .start() fails with a private C module name leaking through a six-frame import chain.

There are no processes and no threads. The work has to happen inline.

os.fork

os.fork(...)

A bare ENOSYS with no mention of fork, WebAssembly or Pyodide.

WebAssembly has no process model. There is nothing to substitute.

urllib.tls

urllib.request.urlopen(...)

Accurate but unhelpful, and buried under roughly forty traceback frames. Pyodide ships an ssl stub with no OpenSSL behind it.

Use pyodide.http.pyfetch (async), or requests/httpx - Pyodide patches both to route through fetch, so they work unmodified in a browser.

os.get_terminal_size

os.get_terminal_size(...)

Raises, because stdin is not a tty. Note the errno: Emscripten uses its own table, so this is 59, not the familiar 25.

Use shutil.get_terminal_size(), which falls back to COLUMNS/LINES and then to 80x24. textual-wasm’s host preset sets those from the terminal element.

stdin.read

input(...), getpass.getpass(...)

With no stdin handler installed, reads hit EOF immediately. In a browser the default handler is window.prompt, so getpass would echo the secret. Differs between Node and a browser.

A TUI should read keys through Textual, not stdin. If you genuinely need a prompt, install one with pyodide.setStdin({stdin, isatty}).

fcntl.ioctl

fcntl.ioctl(...)

Raises OSError: [Errno 59] Not a tty, identically under Node and in a browser. Errno 59 is not ENOTTY, which is 25, so code matching on the number sees an unrelated error. Match on the exception.

TIOCGWINSZ is the usual reason to reach for this, and the size is already available: the driver is told it by the host and os.environ['COLUMNS'] is set before the application starts.

pty.openpty

pty.openpty(...), pty.fork(...), pty.spawn(...)

Raises OSError: out of pty devices, which reads as exhaustion - as though waiting or closing something would help. There are none and there will be none.

A pseudo-terminal needs a kernel. Anything that would drive a child program through one needs a server; there is no in-page substitute.

socket.bind

socketserver.TCPServer(...), socketserver.ThreadingTCPServer(...), http.server.HTTPServer(...), web.run_app(...), aiohttp.web.run_app(...), uvicorn.run(...)

In a browser, OSError: [Errno 138] Not supported - which says that something is unsupported without saying that it is listening. Under Node the same code succeeds silently, binds, listens, and accepts nothing, so a check run only on the Node leg reports a server that works and a browser then refuses it. Differs between Node and a browser.

A page cannot listen for connections; nothing in the sandbox can. An application that wants to be reached from outside needs a server, which is the architecture this project replaces.

webbrowser.open

webbrowser.open(...)

Works in a browser - Pyodide’s stub calls window.open - and fails in Node with an import error naming js, which says nothing about what went wrong. Differs between Node and a browser.

Use App.open_url(), which the WASM driver implements with window.open. Expect popup blocking unless it happens during a user gesture.

Absent

The module is not in the build, so it fails as an ordinary ImportError where you imported it. Listed because knowing before you deploy is the point.

Capability

Detected as

What happens

What to do instead

curses

import curses

Absent from the build; the import fails at the import site.

Textual does not use curses, so this usually means a dependency does. There is no replacement.

Already clear

Raises with a message that says what is wrong and often how to fix it. Recorded so the runtime translator leaves them alone: replacing a good message with a generic one would be a regression.

Capability

Detected as

What happens

What to do instead

subprocess.run

import subprocess

Raises immediately with a message that names the constraint exactly.

Nothing to substitute; a browser has no processes. The message is already correct, so the runtime translator passes it through untouched.

zoneinfo.tzdata

zoneinfo.ZoneInfo(...)

Pyodide patches the error to name the exact fix. Left alone deliberately.

Load the tzdata package first, then zoneinfo resolves normally.

What is not here

Things that work, and are commonly assumed not to:

  • requests and httpx work in a browser. Pyodide patches httpx, and bundled urllib3 ships an Emscripten backend that routes through JSPI, a worker, or XHR. CORS applies, and timeouts, certificates and proxies are not controllable.

  • C-extension dependencies are no longer limited to what Pyodide bundles: a package can publish a pyemscripten wasm wheel to PyPI and micropip installs it. The inverse is the new constraint - a package that only exists as a Pyodide-bundled native wheel pins you to Pyodide’s version of it.

  • SharedArrayBuffer does not enable Python threads. It enables the interrupt buffer and urllib3’s streaming worker. @work(thread=True) is unavailable in any configuration of this build.