Skip to content

Commit 046ea89

Browse files
authored
Merge pull request #7 from mutating/develop
0.0.6
2 parents 68c3a82 + 4fc3f4d commit 046ea89

10 files changed

Lines changed: 454 additions & 6 deletions

File tree

.github/workflows/benchmarks.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
name: Benchmarks
2+
3+
on:
4+
push:
5+
branches:
6+
- "main"
7+
pull_request:
8+
# `workflow_dispatch` allows CodSpeed to trigger backtest
9+
# performance analysis in order to generate initial data.
10+
workflow_dispatch:
11+
12+
permissions:
13+
contents: read
14+
id-token: write
15+
16+
concurrency:
17+
group: ${{ github.workflow }}-${{ github.ref }}
18+
cancel-in-progress: true
19+
20+
jobs:
21+
benchmarks:
22+
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 30
25+
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- name: Set up Python 3.13
30+
uses: actions/setup-python@v5
31+
with:
32+
python-version: '3.13'
33+
34+
- name: Set up uv
35+
uses: astral-sh/setup-uv@v7
36+
with:
37+
enable-cache: true
38+
39+
- name: Install dependencies
40+
shell: bash
41+
run: uv pip install --system -r requirements_dev.txt
42+
43+
- name: Install the library
44+
shell: bash
45+
run: uv pip install --system .
46+
47+
- name: Install pytest-codspeed
48+
shell: bash
49+
run: uv pip install --system pytest-codspeed
50+
51+
- name: Run benchmarks
52+
uses: CodSpeedHQ/action@v4
53+
with:
54+
mode: simulation
55+
run: pytest tests/benchmarks/test_benchmarks.py --codspeed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ planning_features.md
2020
coverage.xml
2121
.qwen
2222
uv.lock
23+
.codspeed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
[![Checked with mypy](http://www.mypy-lang.org/static/mypy_badge.svg)](http://mypy-lang.org/)
1313
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
1414
[![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/mutating/suby)
15+
[![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/mutating/suby?utm_source=badge)
1516

1617
</details>
1718

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "suby"
7-
version = "0.0.5"
7+
version = "0.0.6"
88
authors = [
99
{ name="Evgeniy Blinov", email="zheni-b@yandex.ru" },
1010
]
@@ -14,6 +14,7 @@ requires-python = ">=3.8"
1414
dependencies = [
1515
'emptylog>=0.0.12',
1616
'cantok>=0.0.36',
17+
'microbenchmark>=0.0.2',
1718
]
1819
classifiers = [
1920
"Operating System :: OS Independent",
@@ -58,6 +59,7 @@ source = ["suby"]
5859

5960
[tool.pytest.ini_options]
6061
norecursedirs = ["build", "mutants"]
62+
testpaths = ["tests/documentation", "tests/typing", "tests/units"]
6163

6264
[tool.ruff]
6365
lint.ignore = ['E501', 'E712', 'PTH123', 'PTH118', 'PLR2004', 'PTH107', 'SIM105', 'SIM102', 'RET503', 'PLR0912', 'C901', 'E731', 'F821']

requirements_dev.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
pytest==8.3.5
2+
pytest-codspeed==2.2.1; python_version < '3.9'
3+
pytest-codspeed==4.3.0; python_version >= '3.9'
24
pytest-xdist==3.6.1; python_version < '3.9'
35
pytest-xdist==3.8.0; python_version >= '3.9'
46
coverage==7.6.1

suby/benchmarks.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from pathlib import Path
5+
from tempfile import TemporaryDirectory
6+
from time import time_ns
7+
8+
from cantok import ConditionToken, SimpleToken
9+
from microbenchmark import Scenario, a
10+
11+
from suby import run
12+
13+
ITERATIONS = 100
14+
SHORT_ITERATIONS = 20
15+
PYTHON = Path(sys.executable)
16+
17+
18+
def run_with_delayed_condition_token_cancellation() -> None:
19+
with TemporaryDirectory() as temporary_directory:
20+
marker_file = Path(temporary_directory) / 'subprocess-started'
21+
subprocess_started_at_ns = None
22+
23+
def should_cancel() -> bool:
24+
nonlocal subprocess_started_at_ns
25+
26+
if not marker_file.exists():
27+
return False
28+
if subprocess_started_at_ns is None:
29+
subprocess_started_at_ns = marker_file.stat().st_mtime_ns
30+
return time_ns() - subprocess_started_at_ns >= 10_000_000
31+
32+
run(
33+
PYTHON,
34+
'-c',
35+
(
36+
'import sys\n'
37+
'import time\n'
38+
'from pathlib import Path\n'
39+
'Path(sys.argv[1]).touch()\n'
40+
'time.sleep(1)'
41+
),
42+
marker_file,
43+
split=False,
44+
token=ConditionToken(should_cancel),
45+
catch_exceptions=True,
46+
catch_output=True,
47+
)
48+
49+
50+
simple_success = Scenario(
51+
run,
52+
a(PYTHON, '-c', 'pass'),
53+
name='simple_success',
54+
doc='Runs a minimal successful Python subprocess.',
55+
number=ITERATIONS,
56+
)
57+
58+
python_version_output = Scenario(
59+
run,
60+
a(PYTHON, '-VV', catch_output=True),
61+
name='python_version_output',
62+
doc='Runs the current Python executable as a pathlib.Path and prints its detailed version.',
63+
number=ITERATIONS,
64+
)
65+
66+
string_executable = Scenario(
67+
run,
68+
a(sys.executable, '-c', 'pass'),
69+
name='string_executable',
70+
doc='Runs a minimal command where the executable is supplied as a string.',
71+
number=ITERATIONS,
72+
)
73+
74+
path_argument = Scenario(
75+
run,
76+
a(PYTHON, '-c "import sys; print(sys.argv[1])"', Path(__file__), catch_output=True),
77+
name='path_argument',
78+
doc='Runs a command with a pathlib.Path supplied as one of the subprocess arguments.',
79+
number=ITERATIONS,
80+
)
81+
82+
multi_line_stdout = Scenario(
83+
run,
84+
a(PYTHON, '-c "for i in range(10): print(i)"', catch_output=True),
85+
name='multi_line_stdout',
86+
doc='Runs a successful command that writes several short stdout lines.',
87+
number=ITERATIONS,
88+
)
89+
90+
large_stdout = Scenario(
91+
run,
92+
a(PYTHON, '-c "print(\'x\' * 10000)"', catch_output=True),
93+
name='large_stdout',
94+
doc='Runs a successful command that writes one larger stdout payload.',
95+
number=ITERATIONS,
96+
)
97+
98+
stderr_output = Scenario(
99+
run,
100+
a(PYTHON, '-c "import sys; sys.stderr.write(\'error line\\\\n\')"', catch_output=True),
101+
name='stderr_output',
102+
doc='Runs a successful command that writes to stderr.',
103+
number=ITERATIONS,
104+
)
105+
106+
mixed_stdout_stderr = Scenario(
107+
run,
108+
a(PYTHON, '-c "import sys; print(\'out\'); sys.stderr.write(\'err\\\\n\')"', catch_output=True),
109+
name='mixed_stdout_stderr',
110+
doc='Runs a successful command that writes to both stdout and stderr.',
111+
number=ITERATIONS,
112+
)
113+
114+
many_short_lines = Scenario(
115+
run,
116+
a(PYTHON, '-c "for i in range(1000): print(i)"', catch_output=True),
117+
name='many_short_lines',
118+
doc='Runs a command that emits many small stdout lines for stream-reading overhead.',
119+
number=ITERATIONS,
120+
)
121+
122+
moderate_python_work = Scenario(
123+
run,
124+
a(PYTHON, '-c "sum(range(100000))"'),
125+
name='moderate_python_work',
126+
doc='Runs a subprocess that performs a small amount of CPU work before exiting.',
127+
number=ITERATIONS,
128+
)
129+
130+
short_sleep = Scenario(
131+
run,
132+
a(PYTHON, '-c "import time; time.sleep(0.01)"'),
133+
name='short_sleep',
134+
doc='Runs a subprocess that stays alive briefly without producing output.',
135+
number=SHORT_ITERATIONS,
136+
)
137+
138+
simple_token_success = Scenario(
139+
run,
140+
a(PYTHON, '-c', 'pass', token=SimpleToken()),
141+
name='simple_token_success',
142+
doc='Runs a minimal subprocess while checking a non-cancelled SimpleToken.',
143+
number=ITERATIONS,
144+
)
145+
146+
condition_token_success = Scenario(
147+
run,
148+
a(PYTHON, '-c', 'pass', token=ConditionToken(lambda: False)),
149+
name='condition_token_success',
150+
doc='Runs a minimal subprocess while polling a ConditionToken that remains active.',
151+
number=ITERATIONS,
152+
)
153+
154+
cancelled_token_before_start = Scenario(
155+
run,
156+
a(
157+
PYTHON,
158+
'-c "import time; time.sleep(1)"',
159+
token=SimpleToken().cancel(),
160+
catch_exceptions=True,
161+
catch_output=True,
162+
),
163+
name='cancelled_token_before_start',
164+
doc='Runs a subprocess with an already-cancelled token and catches the cancellation result.',
165+
number=SHORT_ITERATIONS,
166+
)
167+
168+
condition_token_cancel_after_start = Scenario(
169+
run_with_delayed_condition_token_cancellation,
170+
name='condition_token_cancel_after_start',
171+
doc='Starts a subprocess and cancels it with a ConditionToken shortly after the subprocess reports startup.',
172+
number=SHORT_ITERATIONS,
173+
)
174+
175+
all = ( # noqa: A001
176+
simple_success
177+
+ python_version_output
178+
+ string_executable
179+
+ path_argument
180+
+ multi_line_stdout
181+
+ large_stdout
182+
+ stderr_output
183+
+ mixed_stdout_stderr
184+
+ many_short_lines
185+
+ moderate_python_work
186+
+ short_sleep
187+
+ simple_token_success
188+
+ condition_token_success
189+
+ cancelled_token_before_start
190+
+ condition_token_cancel_after_start
191+
)

tests/benchmarks/__init__.py

Whitespace-only changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import pytest
2+
3+
from suby import benchmarks
4+
5+
SCENARIOS = [
6+
benchmarks.simple_success,
7+
benchmarks.python_version_output,
8+
benchmarks.string_executable,
9+
benchmarks.path_argument,
10+
benchmarks.multi_line_stdout,
11+
benchmarks.large_stdout,
12+
benchmarks.stderr_output,
13+
benchmarks.mixed_stdout_stderr,
14+
benchmarks.many_short_lines,
15+
benchmarks.moderate_python_work,
16+
benchmarks.short_sleep,
17+
benchmarks.simple_token_success,
18+
benchmarks.condition_token_success,
19+
benchmarks.cancelled_token_before_start,
20+
benchmarks.condition_token_cancel_after_start,
21+
]
22+
23+
24+
@pytest.mark.benchmark
25+
@pytest.mark.parametrize('scenario', SCENARIOS, ids=[scenario.name for scenario in SCENARIOS])
26+
def test_benchmark_scenario(benchmark, scenario):
27+
benchmark(scenario._call_once)

0 commit comments

Comments
 (0)