From 9689372e251d106d4c0b83dd65a3d2494682c3c8 Mon Sep 17 00:00:00 2001 From: Dash Date: Sat, 17 Oct 2026 19:53:53 +0200 Subject: [PATCH] tests: run in parallel with pytest-xdist pytest now runs with -n auto; pass -n0 to run tests one by one. The pty harness starts pqcli through subprocess instead of pty.fork(), which is unsafe in the threaded xdist workers, and the child claims the pty as its controlling terminal so ^C still raises SIGINT. --- pyproject.toml | 12 +++++++++++- tests/pty_app.py | 47 +++++++++++++++++++++++++++++++---------------- uv.lock | 24 ++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5dacd4a..dc4b598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,13 @@ requires = ["hatchling"] build-backend = "hatchling.build" [dependency-groups] -dev = ["prek>=0.2", "pyte>=0.8", "pytest>=8.0", "ty>=0.0.89"] +dev = [ + "prek>=0.2", + "pyte>=0.8", + "pytest>=8.0", + "pytest-xdist>=3.0", + "ty>=0.0.89", +] [tool.ty.src] # Only the package and its tests; agent worktrees under .claude/ would @@ -53,6 +59,10 @@ select = ["E", "F", "W", "I", "UP"] [tool.ruff.lint.isort] known-first-party = ["pqcli"] +[tool.pytest.ini_options] +# Run tests in parallel; pass -n0 to run them one by one. +addopts = "-n auto" + [tool.coverage.run] # Also measure the pqcli processes the tests start, such as the curses # UI driven through a pseudo-terminal. diff --git a/tests/pty_app.py b/tests/pty_app.py index 5766c92..1fbe42c 100644 --- a/tests/pty_app.py +++ b/tests/pty_app.py @@ -1,14 +1,14 @@ """Run pqcli in a pseudo-terminal and read its screen through pyte. -Only works on POSIX: it needs pty.fork and the termios ioctls. +Only works on POSIX: it needs a pty and the termios ioctls. """ import fcntl import os -import pty import select import signal import struct +import subprocess import sys import termios import time @@ -75,18 +75,33 @@ class PtyApp: # LINES/COLUMNS would override the pty size seen by curses. env.pop("LINES", None) env.pop("COLUMNS", None) - argv = [sys.executable, "-m", "pqcli", *args] + # The child makes the pty its controlling terminal, so that ^C + # raises SIGINT as in a real terminal, then becomes pqcli. + argv = [ + sys.executable, + "-c", + "import fcntl, sys, termios, runpy; " + "fcntl.ioctl(0, termios.TIOCSCTTY, 0); " + "sys.argv[0] = 'pqcli'; " + "runpy.run_module('pqcli', run_name='__main__')", + *args, + ] - pid, fd = pty.fork() - if pid == 0: # pragma: no cover - runs in the child - try: - _set_size(0, rows, cols) - if cwd is not None: - os.chdir(cwd) - os.execve(sys.executable, argv, env) - finally: - os._exit(127) - self.pid = pid + # Popen forks and execs in C, which is safe in the threaded + # pytest-xdist workers, unlike pty.fork(). + fd, child_fd = os.openpty() + _set_size(child_fd, rows, cols) + self._process = subprocess.Popen( + argv, + stdin=child_fd, + stdout=child_fd, + stderr=child_fd, + env=env, + cwd=cwd, + start_new_session=True, + ) + os.close(child_fd) + self.pid = self._process.pid self.fd = fd # reading --------------------------------------------------------- @@ -202,9 +217,9 @@ class PtyApp: while self.exit_status is None: # keep draining so the child never blocks on a full pty self._pump(0.02) - pid, status = os.waitpid(self.pid, os.WNOHANG) - if pid: - self.exit_status = os.waitstatus_to_exitcode(status) + status = self._process.poll() + if status is not None: + self.exit_status = status break if time.monotonic() > deadline: raise AssertionError( diff --git a/uv.lock b/uv.lock index 37545c7..d700264 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "iniconfig" version = "2.3.1" @@ -68,6 +77,7 @@ dev = [ { name = "prek" }, { name = "pyte" }, { name = "pytest" }, + { name = "pytest-xdist" }, { name = "ty" }, ] @@ -83,6 +93,7 @@ dev = [ { name = "prek", specifier = ">=0.2" }, { name = "pyte", specifier = ">=0.8" }, { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-xdist", specifier = ">=3.0" }, { name = "ty", specifier = ">=0.0.89" }, ] @@ -142,6 +153,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "tomli" version = "2.5.0"