Skip to content

Commit efb1bdb

Browse files
committed
Update Testing
- Fix flakey tests in parallel with fixtures - Generate stubs via nox - Use nox in definitively - Add more session types to nox - add generate-stubs.sh for convinenence
1 parent fd302b5 commit efb1bdb

File tree

10 files changed

+200
-130
lines changed

10 files changed

+200
-130
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ jobs:
5353
node-version: '24'
5454

5555
- name: Generate type stubs
56-
run: script/check.sh stubs
56+
run: uv tool run nox -s stubs
5757

5858
- name: Check for stub changes
5959
run: |

noxfile.py

Lines changed: 123 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,174 @@
1-
import os
2-
31
import nox
42

53
# Python versions from pyproject.toml
6-
PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13"]
4+
PYTHON_VERSIONS = ['3.9', '3.10', '3.11', '3.12', '3.13']
5+
6+
7+
def _generate_stubs():
8+
"""Helper function to generate pyright stubs."""
9+
import os
10+
import shutil
11+
import subprocess
12+
13+
# Check if npx is available
14+
try:
15+
subprocess.run(['npx', '--version'], capture_output=True, check=True)
16+
except (subprocess.CalledProcessError, FileNotFoundError):
17+
raise RuntimeError(
18+
'npx not found. Please install Node.js to generate type stubs.\n'
19+
'Visit: https://nodejs.org/ or use your package manager.'
20+
)
21+
22+
# Remove existing stubs to avoid duplication
23+
subprocess.run(['find', 'python', '-name', '*.pyi', '-type', 'f', '-delete'])
24+
25+
# Generate stubs using npx pyright
26+
env = os.environ.copy()
27+
env['PYTHONPATH'] = 'python'
28+
29+
try:
30+
subprocess.run(
31+
['npx', '-y', 'pyright', '--createstub', 'coglet'], env=env, check=True
32+
)
33+
except subprocess.CalledProcessError:
34+
print('Warning: coglet stub creation may have failed')
35+
36+
try:
37+
subprocess.run(
38+
['npx', '-y', 'pyright', '--createstub', 'cog'], env=env, check=True
39+
)
40+
except subprocess.CalledProcessError:
41+
print('Warning: cog stub creation may have failed')
42+
43+
# Move stubs from typings/ to alongside source
44+
if os.path.exists('typings'):
45+
shutil.copytree('typings', 'python', dirs_exist_ok=True)
46+
shutil.rmtree('typings')
47+
748

849
# Use uv for faster package installs
9-
nox.options.default_venv_backend = "uv"
50+
nox.options.default_venv_backend = 'uv'
1051

1152

1253
@nox.session(python=PYTHON_VERSIONS)
1354
def tests(session):
1455
"""Run tests with pytest-xdist parallelization."""
15-
session.install(".[test]")
16-
56+
session.install('.[test]')
57+
1758
# Pass through any arguments to pytest
18-
pytest_args = ["-vv", "-n", "auto"] + list(session.posargs)
19-
59+
pytest_args = ['-vv', '-n', 'auto'] + list(session.posargs)
60+
2061
# Only add -n auto if -n isn't already specified
21-
if any(arg.startswith("-n") for arg in session.posargs):
22-
pytest_args = ["-vv"] + list(session.posargs)
23-
24-
session.run("pytest", *pytest_args)
62+
if any(arg.startswith('-n') for arg in session.posargs):
63+
pytest_args = ['-vv'] + list(session.posargs)
2564

65+
session.run('pytest', *pytest_args)
2666

27-
@nox.session(python="3.11") # Single version for regular testing
67+
68+
@nox.session(python='3.11') # Single version for regular testing
2869
def test(session):
2970
"""Run tests on single Python version (faster for development)."""
30-
session.install(".[test]")
31-
71+
session.install('.[test]')
72+
3273
# Pass through any arguments to pytest
33-
pytest_args = ["-vv", "-n", "auto"] + list(session.posargs)
34-
74+
pytest_args = ['-vv', '-n', 'auto'] + list(session.posargs)
75+
3576
# Only add -n auto if -n isn't already specified
36-
if any(arg.startswith("-n") for arg in session.posargs):
37-
pytest_args = ["-vv"] + list(session.posargs)
38-
39-
session.run("pytest", *pytest_args)
77+
if any(arg.startswith('-n') for arg in session.posargs):
78+
pytest_args = ['-vv'] + list(session.posargs)
79+
80+
session.run('pytest', *pytest_args)
4081

4182

4283
@nox.session(python=None) # Use current system Python, but create venv
4384
def test_current(session):
4485
"""Run tests using current system Python with isolated venv (for CI)."""
45-
session.install(".[test]")
46-
86+
session.install('.[test]')
87+
4788
# Pass through any arguments to pytest
48-
pytest_args = ["-vv", "-n", "auto"] + list(session.posargs)
49-
89+
pytest_args = ['-vv', '-n', 'auto'] + list(session.posargs)
90+
5091
# Only add -n auto if -n isn't already specified
51-
if any(arg.startswith("-n") for arg in session.posargs):
52-
pytest_args = ["-vv"] + list(session.posargs)
53-
54-
session.run("pytest", *pytest_args)
92+
if any(arg.startswith('-n') for arg in session.posargs):
93+
pytest_args = ['-vv'] + list(session.posargs)
94+
95+
session.run('pytest', *pytest_args)
5596

5697

5798
@nox.session
5899
def lint(session):
59100
"""Run ruff linting (check mode)."""
60-
session.install(".[dev]")
101+
session.install('.[dev]')
61102
# Check if --fix is in posargs for local dev
62-
if "--fix" in session.posargs:
63-
session.run("ruff", "check", "--fix", ".")
103+
check_only = '--fix' not in session.posargs
104+
session.log(f'running lint... {"[check_only]" if check_only else "[auto_fix]"}')
105+
if '--fix' in session.posargs:
106+
session.run('ruff', 'check', '--fix', '.')
64107
else:
65-
session.run("ruff", "check", ".")
108+
session.run('ruff', 'check', '.')
66109

67110

68111
@nox.session
69112
def format(session):
70113
"""Format code with ruff."""
71-
session.install(".[dev]")
114+
session.install('.[dev]')
72115
# Check mode for CI, format mode for local
73-
if "--check" in session.posargs:
74-
session.run("ruff", "format", "--check", ".")
116+
if '--check' in session.posargs:
117+
session.run('ruff', 'format', '--check', '.')
75118
else:
76-
session.run("ruff", "format", ".")
119+
session.run('ruff', 'format', '.')
120+
121+
122+
@nox.session
123+
def stubs(session):
124+
"""Generate pyright stubs."""
125+
session.log('stubbing python modules...')
126+
try:
127+
_generate_stubs()
128+
session.log('python module stubs generated successfully')
129+
except RuntimeError as e:
130+
session.error(str(e))
77131

78132

79133
@nox.session
80134
def typecheck(session):
81135
"""Run mypy type checking."""
82-
session.install(".[dev]")
136+
import os
137+
138+
session.log('running typecheck...')
139+
# Only generate stubs if not in CI (CI validates existing stubs separately)
140+
if not os.environ.get('CI'):
141+
session.log('auto-generating python module stubs...')
142+
# Run stubs generation inline instead of separate session
143+
_generate_stubs()
144+
session.log('done auto-generating python module stubs...')
145+
146+
# Install dev deps (mypy) + test deps (pytest) + provided deps (pydantic)
147+
session.install('.[dev,test,provided]')
83148
session.run(
84-
"mypy", ".",
85-
"--exclude", "build",
86-
"--exclude", "python/tests/cases",
87-
"--exclude", "python/tests/bad_inputs",
88-
"--exclude", "python/tests/bad_predictors",
89-
"--exclude", "python/tests/runners",
90-
"--exclude", "python/tests/schemas",
91-
"--exclude", "python/.*\\.pyi",
92-
*session.posargs
149+
'mypy',
150+
'.',
151+
'--exclude',
152+
'build',
153+
'--exclude',
154+
'python/tests/cases',
155+
'--exclude',
156+
'python/tests/bad_inputs',
157+
'--exclude',
158+
'python/tests/bad_predictors',
159+
'--exclude',
160+
'python/tests/runners',
161+
'--exclude',
162+
'python/tests/schemas',
163+
'--exclude',
164+
'python/.*\\.pyi',
165+
*session.posargs,
93166
)
94167

95168

96169
@nox.session
97170
def check_all(session):
98171
"""Run all checks (lint, format check, typecheck)."""
99-
session.notify("lint")
100-
session.notify("format", ["--check"])
101-
session.notify("typecheck")
172+
session.notify('lint')
173+
session.notify('format', ['--check'])
174+
session.notify('typecheck')

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dev = [
1919
'ipython',
2020
'mypy==1.16.0', # pinned to fix CI
2121
'nox',
22+
'ruff',
2223
'setuptools',
2324
]
2425

python/tests/test_file_runner.py

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@
1111
from pathlib import Path
1212
from typing import Dict, List, Optional
1313

14-
from coglet import file_runner
14+
import pytest
1515

16-
ipc_port: Optional[int] = None
17-
ipc_popen: Optional[subprocess.Popen[bytes]] = None
16+
from coglet import file_runner
1817

1918

2019
def find_free_port() -> int:
@@ -24,26 +23,42 @@ def find_free_port() -> int:
2423
return s.getsockname()[1]
2524

2625

27-
def setup_module():
26+
@pytest.fixture(scope='module')
27+
def ipc_server():
28+
"""Start webhook server for IPC communication, one per test module."""
29+
import urllib.error
30+
import urllib.request
31+
2832
# Webhook simulates /_ipc endpoint of Go server for receiving Python runner status updates
2933
cwd = str(Path(__file__).absolute().parent)
3034
env = os.environ.copy()
31-
global ipc_port, ipc_popen
32-
ipc_port = find_free_port()
33-
env['PORT'] = str(ipc_port)
34-
ipc_popen = subprocess.Popen(['python3', 'webhook.py'], cwd=cwd, env=env)
35+
port = find_free_port()
36+
env['PORT'] = str(port)
37+
popen = subprocess.Popen(['python3', 'webhook.py'], cwd=cwd, env=env)
3538

39+
# Wait for server to actually start and be ready (more robust than fixed sleep)
40+
for _ in range(50): # Try for up to 5 seconds
41+
try:
42+
urllib.request.urlopen(f'http://localhost:{port}/_requests', timeout=0.1)
43+
break
44+
except (urllib.error.URLError, ConnectionRefusedError):
45+
time.sleep(0.1)
46+
else:
47+
popen.terminate()
48+
raise RuntimeError(f'Webhook server on port {port} failed to start')
3649

37-
def teardown_module():
38-
global ipc_popen
39-
ipc_popen.terminate()
50+
yield port
51+
52+
popen.terminate()
53+
popen.wait()
4054

4155

4256
class FileRunnerTest:
4357
def __init__(
4458
self,
4559
tmp_path: Path,
4660
predictor: str,
61+
ipc_port: int,
4762
env: Optional[Dict[str, str]] = None,
4863
max_concurrency: int = 1,
4964
predictor_class: str = 'Predictor',
@@ -53,7 +68,7 @@ def __init__(
5368
if env is not None:
5469
runner_env.update(env)
5570
runner_env['PYTHONPATH'] = str(Path(__file__).absolute().parent.parent)
56-
global ipc_port
71+
self.ipc_port = ipc_port
5772
self.name = f'runner-{uuid.uuid4()}'
5873
cmd = [
5974
sys.executable,
@@ -79,8 +94,7 @@ def __init__(
7994
)
8095

8196
def statuses(self) -> List[str]:
82-
global ipc_port
83-
resp = urllib.request.urlopen(f'http://localhost:{ipc_port}/_requests')
97+
resp = urllib.request.urlopen(f'http://localhost:{self.ipc_port}/_requests')
8498
requests = json.loads(resp.read()) or []
8599
statuses = []
86100
for r in requests:
@@ -105,8 +119,8 @@ def wait_for_file(path, exists: bool = True) -> None:
105119
return
106120

107121

108-
def test_file_runner(tmp_path):
109-
rt = FileRunnerTest(tmp_path, 'sleep', env={'SETUP_SLEEP': '1'})
122+
def test_file_runner(tmp_path, ipc_server):
123+
rt = FileRunnerTest(tmp_path, 'sleep', ipc_server, env={'SETUP_SLEEP': '1'})
110124

111125
openapi_file = os.path.join(tmp_path, 'openapi.json')
112126
wait_for_file(openapi_file)
@@ -145,8 +159,10 @@ def test_file_runner(tmp_path):
145159
rt.stop()
146160

147161

148-
def test_file_runner_setup_failed(tmp_path):
149-
rt = FileRunnerTest(tmp_path, 'sleep', predictor_class='SetupFailingPredictor')
162+
def test_file_runner_setup_failed(tmp_path, ipc_server):
163+
rt = FileRunnerTest(
164+
tmp_path, 'sleep', ipc_server, predictor_class='SetupFailingPredictor'
165+
)
150166

151167
openapi_file = os.path.join(tmp_path, 'openapi.json')
152168
wait_for_file(openapi_file)
@@ -160,9 +176,12 @@ def test_file_runner_setup_failed(tmp_path):
160176
rt.stop(1)
161177

162178

163-
def test_file_runner_predict_failed(tmp_path):
179+
def test_file_runner_predict_failed(tmp_path, ipc_server):
164180
rt = FileRunnerTest(
165-
tmp_path, 'sleep', predictor_class='PredictionFailingPredictorWithTiming'
181+
tmp_path,
182+
'sleep',
183+
ipc_server,
184+
predictor_class='PredictionFailingPredictorWithTiming',
166185
)
167186

168187
openapi_file = os.path.join(tmp_path, 'openapi.json')
@@ -202,8 +221,8 @@ def test_file_runner_predict_failed(tmp_path):
202221
rt.stop()
203222

204223

205-
def test_file_runner_predict_canceled(tmp_path):
206-
rt = FileRunnerTest(tmp_path, 'sleep')
224+
def test_file_runner_predict_canceled(tmp_path, ipc_server):
225+
rt = FileRunnerTest(tmp_path, 'sleep', ipc_server)
207226

208227
openapi_file = os.path.join(tmp_path, 'openapi.json')
209228
wait_for_file(openapi_file)

0 commit comments

Comments
 (0)