Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Benchmarks
on:
push:
branches:
- "main"
- 'main'
pull_request:
workflow_dispatch:

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
timeout-minutes: 20
strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0-beta.1']
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0-beta.3']

steps:
- uses: actions/checkout@v4
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/tests_and_coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0-beta.1']
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', '3.14t', '3.15.0-beta.3']

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -44,7 +44,7 @@ jobs:
- name: Run tests and show the branch coverage on the command line
shell: bash
run: |
pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/suby_coverage_process_startup.pth"
pth_file="$(python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')/coverage_process_startup.pth"
printf "import os; os.getenv('COVERAGE_PROCESS_START') and __import__('coverage').process_startup()\n" > "$pth_file"
coverage erase
COVERAGE_PROCESS_START="$PWD/pyproject.toml" coverage run -m pytest -n auto --cache-clear --assert=plain
Expand Down
40 changes: 21 additions & 19 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
[build-system]
requires = ["flit_core==3.12.0"]
build-backend = "flit_core.buildapi"
requires = ['flit_core==3.12.0']
build-backend = 'flit_core.buildapi'

[project]
name = "suby"
version = "0.0.10"
name = 'suby'
version = '0.0.11'
authors = [
{ name="Evgeniy Blinov", email="zheni-b@yandex.ru" },
{ name='Evgeniy Blinov', email='zheni-b@yandex.ru' },
]
description = 'Slightly simplified subprocesses'
readme = "README.md"
requires-python = ">=3.8"
readme = 'README.md'
requires-python = '>=3.8'
dependencies = [
'emptylog>=0.0.12',
'cantok>=0.0.36',
'cantok>=0.0.40',
'microbenchmark>=0.0.3',
'sigmatch>=0.0.9',
'sigmatch>=0.0.10',
]
classifiers = [
"Operating System :: OS Independent",
'Operating System :: OS Independent',
'Operating System :: MacOS :: MacOS X',
'Operating System :: Microsoft :: Windows',
'Operating System :: POSIX',
Expand Down Expand Up @@ -46,26 +46,28 @@ keywords = [
]

[tool.mutmut]
paths_to_mutate=["suby"]
paths_to_mutate=['suby']

[tool.coverage.run]
branch = true
parallel = true
plugins = ["coverage_pyver_pragma"]
source = ["suby"]
plugins = ['coverage_pyver_pragma']
source = ['suby']

[tool.pytest.ini_options]
addopts = "-m 'not slow'"
addopts = '-m "not slow"'
markers = [
"slow: tests that create isolated environments, install dependencies, or otherwise take noticeably longer",
'slow: tests that create isolated environments, install dependencies, or otherwise take noticeably longer',
]
norecursedirs = ["build", "mutants"]
testpaths = ["tests/documentation", "tests/typing", "tests/units"]
norecursedirs = ['build', 'mutants']
testpaths = ['tests/documentation', 'tests/typing', 'tests/units']

[tool.ruff]
lint.ignore = ['E501', 'E712', 'PTH123', 'PTH118', 'PLR2004', 'PTH107', 'SIM105', 'SIM102', 'RET503', 'PLR0912', 'C901', 'E731', 'F821']
lint.select = ["ERA001", "YTT", "ASYNC", "BLE", "B", "A", "COM", "INP", "PIE", "T20", "PT", "RSE", "RET", "SIM", "SLOT", "TID252", "ARG", "PTH", "I", "C90", "N", "E", "W", "D201", "D202", "D419", "F", "PL", "PLE", "PLR", "PLW", "RUF", "TRY201", "TRY400", "TRY401"]
format.quote-style = "single"
lint.select = ['ERA001', 'YTT', 'ASYNC', 'BLE', 'B', 'A', 'COM', 'INP', 'PIE', 'T20', 'PT', 'RSE', 'RET', 'SIM', 'SLOT', 'TID252', 'ARG', 'PTH', 'I', 'C90', 'N', 'E', 'W', 'D201', 'D202', 'D419', 'F', 'PL', 'PLE', 'PLR', 'PLW', 'RUF', 'TRY201', 'TRY400', 'TRY401']
lint.isort.combine-as-imports = true
lint.isort.force-single-line = false
format.quote-style = 'single'

[project.urls]
'Source' = 'https://github.com/mutating/suby'
Expand Down
10 changes: 5 additions & 5 deletions suby/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from suby.errors import ConditionCancellationError as ConditionCancellationError
from suby.errors import (
ConditionCancellationError as ConditionCancellationError,
EnvironmentVariablesConflict as EnvironmentVariablesConflict,
RunningCommandError as RunningCommandError,
TimeoutCancellationError as TimeoutCancellationError,
WrongCommandError as WrongCommandError,
WrongDirectoryError as WrongDirectoryError,
)
from suby.errors import RunningCommandError as RunningCommandError
from suby.errors import TimeoutCancellationError as TimeoutCancellationError
from suby.errors import WrongCommandError as WrongCommandError
from suby.errors import WrongDirectoryError as WrongDirectoryError
from suby.run import run as run
from suby.subprocess_result import SubprocessResult as SubprocessResult
6 changes: 4 additions & 2 deletions suby/errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from cantok import ConditionCancellationError as CantokConditionCancellationError
from cantok import TimeoutCancellationError as CantokTimeoutCancellationError
from cantok import (
ConditionCancellationError as CantokConditionCancellationError,
TimeoutCancellationError as CantokTimeoutCancellationError,
)

from suby.subprocess_result import SubprocessResult

Expand Down
4 changes: 2 additions & 2 deletions suby/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@
from cantok import (
AbstractToken,
CancellationError,
ConditionCancellationError as CantokConditionCancellationError,
DefaultToken,
TimeoutCancellationError as CantokTimeoutCancellationError,
TimeoutToken,
)
from cantok import ConditionCancellationError as CantokConditionCancellationError
from cantok import TimeoutCancellationError as CantokTimeoutCancellationError
from emptylog import EmptyLogger, LoggerProtocol
from sigmatch import PossibleCallMatcher, SignatureMismatchError

Expand Down
2 changes: 0 additions & 2 deletions tests/typing/test_typing_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@
)
from suby.errors import (
EnvironmentVariablesConflict as ModuleEnvironmentVariablesConflict,
)
from suby.errors import (
WrongDirectoryError as ModuleWrongDirectoryError,
)
from suby.subprocess_result import SubprocessResult
Expand Down
5 changes: 1 addition & 4 deletions tests/units/test_process_waiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@
from cantok import ConditionToken, SimpleToken, TimeoutCancellationError

from suby import process_waiting, run
from suby.process_waiting import (
has_event_driven_wait,
wait_for_process_exit,
)
from suby.process_waiting import has_event_driven_wait, wait_for_process_exit
from suby.subprocess_result import SubprocessResult

_run_module = importlib.import_module('suby.run')
Expand Down
53 changes: 42 additions & 11 deletions tests/units/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from io import StringIO
from os import environ
from pathlib import Path, PurePath
from threading import Event, Thread
from threading import Event, Lock, Thread
from time import perf_counter
from types import MappingProxyType, SimpleNamespace
from typing import Any, List, cast
Expand Down Expand Up @@ -385,28 +385,55 @@ def test_condition_token_cancellation_returns_or_raises_killed_result_according_


@pytest.mark.parametrize(
('command', 'run_timeout', 'expected_exception', 'expected_token_identity'),
('command', 'run_timeout', 'condition_timeout', 'observed_time', 'expected_exception', 'expected_token_identity'),
[
((sys.executable, '-c "import time; time.sleep({sleep_time})"'), 3, ConditionCancellationError, True),
(('python -c "import time; time.sleep({sleep_time})"',), 3, ConditionCancellationError, True),
((sys.executable, '-c "import time; time.sleep({sleep_time})"'), 0.05, TimeoutCancellationError, False),
(('python -c "import time; time.sleep({sleep_time})"',), 0.05, TimeoutCancellationError, False),
((sys.executable, '-c "import time; time.sleep({sleep_time})"'), 3, 0.1, 0.2, ConditionCancellationError, True),
(('python -c "import time; time.sleep({sleep_time})"',), 3, 0.1, 0.2, ConditionCancellationError, True),
((sys.executable, '-c "import time; time.sleep({sleep_time})"'), 0.05, 0.1, 0.06, TimeoutCancellationError, False),
(('python -c "import time; time.sleep({sleep_time})"',), 0.05, 0.1, 0.06, TimeoutCancellationError, False),
],
)
def test_token_plus_timeout_without_catching_raises_expected_cancellation(
def test_token_plus_timeout_without_catching_raises_expected_cancellation( # noqa: PLR0913
command,
run_timeout,
condition_timeout,
observed_time,
expected_exception,
expected_token_identity,
monkeypatch,
assert_no_suby_thread_leaks,
):
"""When token and timeout are both configured, the earlier cancellation source determines which exception is raised."""
"""When token and timeout are both configured, the earlier cancellation source determines which exception is raised.

This intentionally patches time.perf_counter because the unpatched version would depend on a narrow real-time
window: timeout must become active after 0.05 seconds while ConditionToken must still be inactive until 0.1 seconds.
Slow or differently scheduled runtimes, especially free-threaded Python under xdist and coverage, can miss that
window and make both tokens active before the first observed cancellation.

Patching global clocks should be justified because it can affect unrelated code in the same test process. Here the
patch is scoped by monkeypatch, the test keeps its own elapsed-time measurement on the already imported real
perf_counter, and cantok's TimeoutToken intentionally reads time.perf_counter through the time module. This keeps the
production path intact: run() still creates a real TimeoutToken, but the token sees a deterministic start time and
then a deterministic observation time. That lets the test assert the cancellation source selected by the token
composition without making CI timing part of the contract.
"""
sleep_time = 100000
timeout = 0.1
command = [subcommand.format(sleep_time=sleep_time) if isinstance(subcommand, str) else subcommand for subcommand in command]

timer_state = SimpleNamespace(calls=0)
timer_lock = Lock()

def controlled_perf_counter():
with timer_lock:
timer_state.calls += 1
if timer_state.calls == 1:
return 0.0
return observed_time

monkeypatch.setattr(time, 'perf_counter', controlled_perf_counter)

start_time = perf_counter()
token = ConditionToken(lambda: perf_counter() - start_time > timeout)
token = ConditionToken(lambda: observed_time > condition_timeout)

with assert_no_suby_thread_leaks(), pytest.raises(expected_exception) as exc_info:
run(*command, token=token, timeout=run_timeout)
Expand All @@ -418,13 +445,17 @@ def test_token_plus_timeout_without_catching_raises_expected_cancellation(
result = exc_info.value.result

end_time = perf_counter()
assert timer_state.calls >= 1

assert result.returncode != 0
assert result.stdout == ''
assert result.stderr == ''
assert result.killed_by_token == True

assert end_time - start_time >= min(timeout, run_timeout)
if expected_exception is ConditionCancellationError:
assert condition_timeout < observed_time < run_timeout
else:
assert run_timeout < observed_time < condition_timeout
assert end_time - start_time < sleep_time


Expand Down
Loading