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.
This commit is contained in:
Dash committed 2026-10-17 19:53:53 +02:00
1 parent d1d7c2d9df
commit 9689372e25
3 files changed
+66 -17

No files matched your search

+11 -1
View File
@@ -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.
+31 -16
View File
@@ -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(
Generated
+24
View File
@@ -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"