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"