diff --git a/README.md b/README.md index 94a2fcd..95324ec 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ depend on pytest-xdist or implement xdist's flags or fixtures. ```bash pip install throngtest pytest -pytest --throngtest-distribution=files -pytest --isolates=2 --throngtest-backend=local +pytest --distribution=files +pytest --isolates=2 --backend=local ``` Installing the plugin enables distribution into up to four isolates by default. @@ -38,7 +38,7 @@ Python 3.8+ and pytest 8.3.5–9.x are supported. All throngtest settings are loaded, converted and validated through [skelet](https://github.com/mutating/skelet). Sources have this precedence: -1. Explicit `--throngtest-*` arguments, including arguments supplied by pytest's +1. Explicit CLI options listed below, including arguments supplied by pytest's `addopts` or `PYTEST_ADDOPTS`. 2. Environment variables with the `THRONGTEST_` prefix. 3. `[tool.throngtest]` in `pyproject.toml` at pytest's `rootdir`. @@ -55,18 +55,18 @@ preparation = ["python scripts/prepare.py", "python scripts/seed_test_data.py"] ```bash THRONGTEST_WORKERS=4 pytest -pytest --isolates=2 --throngtest-exclude='[".git/", ".venv/", "large-data/"]' +pytest --isolates=2 --exclude='[".git/", ".venv/", "large-data/"]' ``` | Setting | CLI option | Default | Meaning | | --- | --- | --- | --- | | `workers` | `--isolates` | `4` | Maximum number of nonempty test subsets; a nonnegative integer. `0` disables distribution. | -| `check_fingerprints` | `--throngtest-check-fingerprints` | `false` | Require identical ordered collections in the controller and isolates. | -| `backend` | `--throngtest-backend` | `temporary_directory` | Name of an installed throng plugin. | -| `distribution` | `--throngtest-distribution` | `tests` | Split individual tests or keep each file together (`files`). | -| `python` | `--throngtest-python` | Controller's `sys.executable` | Python executable available inside each isolate. | -| `exclude` | `--throngtest-exclude` | See below | Throng snapshot exclusion patterns; a JSON array for CLI/environment sources and an array in TOML. | -| `preparation` | `--throngtest-preparation` | `[]` | Ordered list of nonempty commands run once in each isolate before pytest; JSON for CLI/environment sources and an array in TOML. | +| `check_fingerprints` | `--check-fingerprints` | `false` | Require identical ordered collections in the controller and isolates. | +| `backend` | `--backend` | `temporary_directory` | Name of an installed throng plugin. | +| `distribution` | `--distribution` | `tests` | Split individual tests or keep each file together (`files`). | +| `python` | `--python` | Controller's `sys.executable` | Python executable available inside each isolate. | +| `exclude` | `--exclude` | See below | Throng snapshot exclusion patterns; a JSON array for CLI/environment sources and an array in TOML. | +| `preparation` | `--preparation` | `[]` | Ordered list of nonempty commands run once in each isolate before pytest; JSON for CLI/environment sources and an array in TOML. | The default exclusions are `.git/`, `.venv/`, `venv/`, `__pycache__/`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`, `build/`, `dist/`, and `mutants/`. @@ -74,9 +74,9 @@ An explicit exclusion list replaces the defaults. Patterns are interpreted by throng. Configurations are read afresh for each pytest session. Fingerprint checks are disabled by default. Enable them with -`--throngtest-check-fingerprints`, `THRONGTEST_CHECK_FINGERPRINTS=true`, or +`--check-fingerprints`, `THRONGTEST_CHECK_FINGERPRINTS=true`, or `check_fingerprints = true` in `[tool.throngtest]`. Use -`--throngtest-no-check-fingerprints` to override an enabled setting from the +`--no-check-fingerprints` to override an enabled setting from the environment or TOML. Both CLI flags take no value; if both are supplied, the last flag wins. The environment accepts `true`/`false`; TOML uses booleans. @@ -86,7 +86,7 @@ Use `preparation` to generate files, install dependencies, or otherwise prepare each isolate before its pytest process starts: ```bash -pytest --throngtest-preparation='["python scripts/prepare.py"]' +pytest --preparation='["python scripts/prepare.py"]' THRONGTEST_PREPARATION='["python scripts/prepare.py"]' pytest ``` @@ -223,7 +223,7 @@ budget. Xdist's `worker_id` values, such as `gw0`, are local to each isolate. The supported schedulers are `load`, `loadfile`, `loadscope`, `loadgroup`, and `worksteal`. Their grouping guarantees apply within each assigned subset. -For example, use `--throngtest-distribution=files --dist=loadfile` to keep a +For example, use `--distribution=files --dist=loadfile` to keep a file together at both levels. Xdist groups do not combine tests from different isolates. `--dist=each` is rejected because it intentionally repeats tests. Explicit `--tx`/`--px` execution environments and `--looponfail` are unsupported; @@ -245,7 +245,7 @@ throng backends must provide stdout and a process return code. The selected interpreter must have throngtest, pytest, the project's dependencies, and required pytest plugins available by the end of preparation. Throngtest only installs packages when explicitly instructed through preparation commands. -For a remote backend, set `--throngtest-python` to an interpreter available in +For a remote backend, set `--python` to an interpreter available in the isolate, such as `python`; its default is the controller's absolute `sys.executable` path, which is usually absent on a remote machine. diff --git a/pyproject.toml b/pyproject.toml index fd2ca92..0ca8392 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "throngtest" -version = "0.0.1" +version = "0.0.2" authors = [ { name="Evgeniy Blinov", email="zheni-b@yandex.ru" }, ] diff --git a/tests/test_coverage_agents.py b/tests/test_coverage_agents.py index 7bcad4d..9dc3516 100644 --- a/tests/test_coverage_agents.py +++ b/tests/test_coverage_agents.py @@ -533,7 +533,7 @@ def test_other(): assert classify(0) == 'other' environment.pop('COVERAGE_PROCESS_START', None) environment.pop('COVERAGE_FILE', None) arguments = [sys.executable, '-m', 'coverage', 'run', '-m', 'pytest'] if provider == 'coverage' else [sys.executable, '-m', 'pytest', '--cov=app', '--cov-branch', '--cov-fail-under=100'] - result = subprocess.run([*arguments, '-q', '--isolates=2', f'--throngtest-backend={backend}'], cwd=pytester.path, env=environment, text=True, capture_output=True, check=False) + result = subprocess.run([*arguments, '-q', '--isolates=2', f'--backend={backend}'], cwd=pytester.path, env=environment, text=True, capture_output=True, check=False) assert result.returncode == 0, result.stdout + result.stderr if provider == 'coverage': combined = subprocess.run([sys.executable, '-m', 'coverage', 'combine', '-q'], cwd=pytester.path, env=environment, text=True, capture_output=True, check=False) @@ -568,7 +568,7 @@ def test_other(): assert classify(0) == 'other' environment.pop('COVERAGE_PROCESS_START', None) environment.pop('COVERAGE_FILE', None) result = subprocess.run([ - sys.executable, '-m', 'pytest', '-q', '--isolates=2', '--throngtest-backend=temporary_directory', + sys.executable, '-m', 'pytest', '-q', '--isolates=2', '--backend=temporary_directory', '-n', '2', '--cov=app', '--cov-branch', '--cov-fail-under=100', ], cwd=pytester.path, env=environment, text=True, capture_output=True, check=False) assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/test_fingerprints.py b/tests/test_fingerprints.py index e415035..18802e6 100644 --- a/tests/test_fingerprints.py +++ b/tests/test_fingerprints.py @@ -33,9 +33,9 @@ def test_item(index): assert not marker.exists() marker.touch() ''') - arguments = ['--isolates=2', f'--throngtest-backend={backend}', '--junitxml=results.xml'] + arguments = ['--isolates=2', f'--backend={backend}', '--junitxml=results.xml'] if source == 'cli': - arguments.append('--throngtest-check-fingerprints' if checked else '--throngtest-no-check-fingerprints') + arguments.append('--check-fingerprints' if checked else '--no-check-fingerprints') elif source == 'environment': monkeypatch.setenv('THRONGTEST_CHECK_FINGERPRINTS', str(checked).lower()) elif source == 'toml': @@ -70,7 +70,7 @@ def test_fingerprint_flag_before_an_absolute_test_path(pytester: pytest.Pytester child.mkdir() (child / 'keep.txt').write_text('Keep the invocation directory in the isolate snapshot.') monkeypatch.chdir(child) - flag = '--throngtest-check-fingerprints' if checked else '--throngtest-no-check-fingerprints' + flag = '--check-fingerprints' if checked else '--no-check-fingerprints' pytester.runpytest_subprocess(flag, str(test), timeout=30).assert_outcomes(passed=1) @@ -114,7 +114,7 @@ def pytest_collection_modifyitems(config, items): items[:] = items[:count] ''') pytester.makepyfile(**{f'test_{index}': 'def test_ok(): pass' for index in range(4)}) - result = pytester.runpytest_subprocess(f'--throngtest-backend={backend}', f'--throngtest-distribution={distribution}', timeout=30) + result = pytester.runpytest_subprocess(f'--backend={backend}', f'--distribution={distribution}', timeout=30) result.assert_outcomes(passed=worker_count) assert result.ret == (pytest.ExitCode.OK if worker_count else pytest.ExitCode.NO_TESTS_COLLECTED) @@ -123,5 +123,5 @@ def pytest_collection_modifyitems(config, items): def test_duplicate_identifiers_remain_valid_in_both_modes(pytester: pytest.Pytester, backend: str, checked: bool) -> None: """Preserve duplicate test occurrences with fingerprint checking enabled or disabled.""" test = pytester.makepyfile('def test_ok(): pass') - flag = '--throngtest-check-fingerprints' if checked else '--throngtest-no-check-fingerprints' - pytester.runpytest_subprocess(f'--throngtest-backend={backend}', flag, '--keep-duplicates', str(test), str(test), timeout=30).assert_outcomes(passed=2) + flag = '--check-fingerprints' if checked else '--no-check-fingerprints' + pytester.runpytest_subprocess(f'--backend={backend}', flag, '--keep-duplicates', str(test), str(test), timeout=30).assert_outcomes(passed=2) diff --git a/tests/test_integration.py b/tests/test_integration.py index 934c7de..f175add 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,10 +7,10 @@ def run(pytester: pytest.Pytester, backend: str, *arguments: str) -> pytest.RunResult: - return pytester.runpytest_subprocess('--isolates=2', f'--throngtest-backend={backend}', *arguments, timeout=30) + return pytester.runpytest_subprocess('--isolates=2', f'--backend={backend}', *arguments, timeout=30) -@pytest.mark.parametrize(('arguments', 'workers'), [((), 4), (('--isolates=2',), 2), (('--isolates', '2'), 2), (('--throngtest-check-fingerprints',), 4)]) +@pytest.mark.parametrize(('arguments', 'workers'), [((), 4), (('--isolates=2',), 2), (('--isolates', '2'), 2), (('--check-fingerprints',), 4)]) def test_real_isolates_execute_every_test_once(pytester: pytest.Pytester, backend: str, tmp_path: Path, arguments: tuple, workers: int) -> None: """Execute every test once through the selected throng backend and isolate count. @@ -46,7 +46,7 @@ def test_item(index): target.write_text(json.dumps({{'pid': os.getpid(), 'cwd': str(Path.cwd())}})) Path('isolate-marker').write_text('written') ''') - result = pytester.runpytest_subprocess(f'--throngtest-backend={backend}', *arguments, timeout=30) + result = pytester.runpytest_subprocess(f'--backend={backend}', *arguments, timeout=30) result.assert_outcomes(passed=8) records = [json.loads((tmp_path / str(index)).read_text()) for index in range(8)] dispatched = [path.read_text() for path in tmp_path.glob('dispatch-*')] @@ -109,7 +109,7 @@ def test_item(resource, index): assert resource == 42 ''' pytester.makepyfile(test_a=source, test_b=source) - run(pytester, backend, '--throngtest-distribution=files').assert_outcomes(passed=8) + run(pytester, backend, '--distribution=files').assert_outcomes(passed=8) assert (tmp_path / 'test_a.fixture').read_text() != (tmp_path / 'test_b.fixture').read_text() assert len(list(tmp_path.glob('*.done'))) == 2 @@ -246,7 +246,7 @@ def test_collection_mismatch(pytester: pytest.Pytester, backend: str, tmp_path: @pytest.mark.parametrize('value', values) def test_item(value): raise AssertionError('test must not execute') ''') - result = run(pytester, backend, '--throngtest-check-fingerprints') + result = run(pytester, backend, '--check-fingerprints') assert result.ret == pytest.ExitCode.INTERNAL_ERROR output = result.stdout.str() + result.stderr.str() assert 'collection differs' in output @@ -277,7 +277,7 @@ def test_collection_diagnostic_for_absolute_parameter_path(pytester: pytest.Pyte @pytest.mark.parametrize('value', [{path_expression}]) def test_item(value): raise AssertionError('test must not execute') ''') - result = run(pytester, 'temporary_directory', '--throngtest-check-fingerprints') + result = run(pytester, 'temporary_directory', '--check-fingerprints') assert result.ret == pytest.ExitCode.INTERNAL_ERROR output = result.stdout.str() + result.stderr.str() assert 'controller: 1 selected tests\nisolate: 1 selected tests' in output @@ -353,7 +353,7 @@ def test_invocation_directory_and_absolute_nodeid(pytester: pytest.Pytester, bac def test_missing_interpreter(pytester: pytest.Pytester, backend: str) -> None: """Report a missing configured Python executable as a worker startup failure.""" pytester.makepyfile('def test_ok(): pass') - result = run(pytester, backend, '--throngtest-python=missing-throngtest-python') + result = run(pytester, backend, '--python=missing-throngtest-python') assert result.ret == pytest.ExitCode.INTERNAL_ERROR assert 'worker terminated without a result' in result.stdout.str() + result.stderr.str() @@ -384,7 +384,7 @@ def test_excluded_file_is_not_copied(pytester: pytest.Pytester) -> None: """Omit explicitly excluded project files from temporary isolate copies.""" (pytester.path / 'private.txt').write_text('not for the snapshot') pytester.makepyfile('from pathlib import Path\ndef test_excluded(): assert not Path("private.txt").exists()') - run(pytester, 'temporary_directory', '--throngtest-exclude=["private.txt"]').assert_outcomes(passed=1) + run(pytester, 'temporary_directory', '--exclude=["private.txt"]').assert_outcomes(passed=1) def test_strict_xpass_is_failure(pytester: pytest.Pytester, backend: str) -> None: diff --git a/tests/test_preparation.py b/tests/test_preparation.py index ccf1aea..56c00c5 100644 --- a/tests/test_preparation.py +++ b/tests/test_preparation.py @@ -65,11 +65,11 @@ def pytest_sessionstart(session): def test_prepared(index): assert (Path.cwd().parent / 'second marker').read_text() == 'prepared' ''') - arguments = ['--isolates=2', f'--throngtest-backend={backend}'] + arguments = ['--isolates=2', f'--backend={backend}'] if source == 'cli': - arguments.append('--throngtest-preparation=' + json.dumps(commands)) + arguments.append('--preparation=' + json.dumps(commands)) elif source == 'cli_separate': - arguments.extend(['--throngtest-preparation', json.dumps(commands)]) + arguments.extend(['--preparation', json.dumps(commands)]) elif source == 'environment': monkeypatch.setenv('THRONGTEST_PREPARATION', json.dumps(commands)) else: @@ -106,7 +106,7 @@ def test_preparation_failure_stops_commands_and_tests(pytester: pytest.Pytester, python_command(f'from pathlib import Path; Path({str(forbidden)!r}).touch()'), ] pytester.makepyfile(f'from pathlib import Path\ndef test_never(): Path({str(forbidden)!r}).touch()') - result = pytester.runpytest_subprocess('--isolates=1', f'--throngtest-backend={backend}', '--throngtest-preparation=' + json.dumps(commands), timeout=30) + result = pytester.runpytest_subprocess('--isolates=1', f'--backend={backend}', '--preparation=' + json.dumps(commands), timeout=30) assert result.ret == pytest.ExitCode.INTERNAL_ERROR output = result.stdout.str() + result.stderr.str() assert 'preparation command 2 failed with exit code 7' in output @@ -124,7 +124,7 @@ def test_preparation_is_not_run_without_isolates(pytester: pytest.Pytester, tmp_ marker = tmp_path / 'must-not-run' commands = [python_command(f'from pathlib import Path; Path({str(marker)!r}).touch()')] pytester.makepyfile('def test_ok(): pass') - result = pytester.runpytest_subprocess(*arguments, '--throngtest-preparation=' + json.dumps(commands), timeout=30) + result = pytester.runpytest_subprocess(*arguments, '--preparation=' + json.dumps(commands), timeout=30) assert result.ret == exitcode assert not marker.exists() @@ -157,7 +157,7 @@ def test_preparation_failure_cancels_other_preparation(pytester: pytest.Pytester sys.exit(9) ''') commands = [shlex.join([sys.executable, script.name])] - result = pytester.runpytest_subprocess('--isolates=2', '--throngtest-preparation=' + json.dumps(commands), timeout=30) + result = pytester.runpytest_subprocess('--isolates=2', '--preparation=' + json.dumps(commands), timeout=30) assert result.ret == pytest.ExitCode.INTERNAL_ERROR assert 'preparation command 1 failed with exit code 9' in result.stdout.str() + result.stderr.str() assert not (tmp_path / 'not-cancelled').exists() diff --git a/tests/test_runner.py b/tests/test_runner.py index a671375..04d2bbf 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -8,7 +8,7 @@ from throng import AbstractManager from throngtest.protocol import Request, WorkerError, encode -from throngtest.runner import Runner, execute, relocate, replay +from throngtest.runner import Runner, execute, relocate, replay, worker_arguments from throngtest.settings import ARGUMENTS, Settings @@ -33,10 +33,27 @@ def test_relocate_paths(tmp_path: Path) -> None: child.mkdir() (child / '2').touch() assert relocate('--isolates=2', root, child, root) == '--isolates=2' - preparation = '--throngtest-preparation=["echo ' + 'long command ' * 100 + '"]' + preparation = '--preparation=["echo ' + 'long command ' * 100 + '"]' assert relocate(preparation, root, tmp_path, root) == preparation +@pytest.mark.parametrize('option', ['--isolates', '--backend', '--distribution', '--python', '--exclude', '--preparation']) +def test_worker_arguments_preserve_unprefixed_option_values(pytester: pytest.Pytester, tmp_path: Path, option: str) -> None: + """Keep isolate option values intact with both CLI argument spellings. + + A same-named file in the invocation directory would trigger path rewriting + if a value were mistaken for a test path during worker dispatch. + """ + root = tmp_path / 'project' + invocation = root / 'subdirectory' + invocation.mkdir(parents=True) + (invocation / 'value').touch() + config = pytester.parseconfig('--isolates=0') + for arguments in ([option, 'value'], [f'{option}=value']): + config.stash[ARGUMENTS] = arguments + assert worker_arguments(config, root, invocation, Path())[:len(arguments)] == arguments + + @pytest.mark.parametrize('stage', ['enter', 'run', 'exit']) def test_isolate_errors_are_reported_and_cleanup_attempted(stage: str) -> None: """Name the failing backend phase and attempt cleanup after scope entry. diff --git a/tests/test_settings.py b/tests/test_settings.py index 9c2be3d..5b7f8cc 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -34,7 +34,7 @@ def test_precedence_and_fresh_environment(pytester: pytest.Pytester, monkeypatch assert read_settings(config).workers == 3 monkeypatch.setenv('THRONGTEST_WORKERS', '4') assert read_settings(config).workers == 4 - config = pytester.parseconfig('--isolates=0', '--throngtest-distribution=tests', '--throngtest-python=custom python', '--throngtest-exclude=["custom/"]') + config = pytester.parseconfig('--isolates=0', '--distribution=tests', '--python=custom python', '--exclude=["custom/"]') settings = read_settings(config) assert settings.workers == 0 assert settings.distribution == 'tests' @@ -45,7 +45,7 @@ def test_precedence_and_fresh_environment(pytester: pytest.Pytester, monkeypatch @pytest.mark.parametrize(('name', 'value'), [('workers', '-1'), ('workers', 'nope'), ('backend', ' '), ('distribution', 'other'), ('python', ''), ('exclude', 'oops'), ('preparation', 'oops')]) def test_invalid_cli(pytester: pytest.Pytester, name: str, value: str) -> None: """Report invalid CLI values as plugin-specific pytest usage errors.""" - option = '--isolates' if name == 'workers' else f'--throngtest-{name}' + option = '--isolates' if name == 'workers' else f'--{name}' with pytest.raises(pytest.UsageError, match='throngtest:'): read_settings(pytester.parseconfig(f'{option}={value}')) @@ -60,6 +60,18 @@ def test_workers_cli_was_renamed(pytester: pytest.Pytester) -> None: pytester.parseconfig('--throngtest-workers=2') +@pytest.mark.parametrize('name', ['backend', 'distribution', 'python', 'exclude', 'preparation', 'check-fingerprints', 'no-check-fingerprints']) +def test_unprefixed_options_replace_old_names(pytester: pytest.Pytester, name: str) -> None: + """Expose the shorter CLI spelling and reject the removed prefixed name.""" + result = pytester.runpytest_subprocess('--help') + assert result.ret == pytest.ExitCode.OK + option = f'--{name}' if name.endswith('check-fingerprints') else f'--{name}={name.upper()}' + assert option in result.stdout.str() + assert f'--throngtest-{name}' not in result.stdout.str() + with pytest.raises(pytest.UsageError, match='unrecognized arguments: --throngtest-'): + pytester.parseconfig(f'--throngtest-{name}') + + def test_invalid_toml_type(pytester: pytest.Pytester) -> None: """Reject a TOML worker count whose type does not match the setting.""" pytester.makepyprojecttoml('[tool.throngtest]\nworkers = "two"') @@ -74,9 +86,9 @@ def test_preparation_precedence_and_empty_override(pytester: pytest.Pytester, mo assert read_settings(config).preparation == ['echo first', 'echo second'] monkeypatch.setenv('THRONGTEST_PREPARATION', '["echo environment"]') assert read_settings(config).preparation == ['echo environment'] - config = pytester.parseconfig('--throngtest-preparation=["echo CLI"]') + config = pytester.parseconfig('--preparation=["echo CLI"]') assert read_settings(config).preparation == ['echo CLI'] - config = pytester.parseconfig('--throngtest-preparation=[]') + config = pytester.parseconfig('--preparation=[]') assert read_settings(config).preparation == [] monkeypatch.setenv('THRONGTEST_PREPARATION', '[]') assert read_settings(pytester.parseconfig()).preparation == [] @@ -89,7 +101,7 @@ def test_invalid_preparation(pytester: pytest.Pytester, monkeypatch: pytest.Monk encoded = json.dumps(value) arguments = [] if source == 'cli': - arguments = ['--throngtest-preparation=' + encoded] + arguments = ['--preparation=' + encoded] elif source == 'environment': monkeypatch.setenv('THRONGTEST_PREPARATION', encoded) else: @@ -105,12 +117,12 @@ def test_fingerprint_flag_precedence(pytester: pytest.Pytester, monkeypatch: pyt assert read_settings(config).check_fingerprints is True monkeypatch.setenv('THRONGTEST_CHECK_FINGERPRINTS', 'false') assert read_settings(config).check_fingerprints is False - assert read_settings(pytester.parseconfig('--throngtest-check-fingerprints')).check_fingerprints is True + assert read_settings(pytester.parseconfig('--check-fingerprints')).check_fingerprints is True monkeypatch.setenv('THRONGTEST_CHECK_FINGERPRINTS', 'true') assert read_settings(config).check_fingerprints is True - assert read_settings(pytester.parseconfig('--throngtest-no-check-fingerprints')).check_fingerprints is False - assert read_settings(pytester.parseconfig('--throngtest-check-fingerprints', '--throngtest-no-check-fingerprints')).check_fingerprints is False - assert read_settings(pytester.parseconfig('--throngtest-no-check-fingerprints', '--throngtest-check-fingerprints')).check_fingerprints is True + assert read_settings(pytester.parseconfig('--no-check-fingerprints')).check_fingerprints is False + assert read_settings(pytester.parseconfig('--check-fingerprints', '--no-check-fingerprints')).check_fingerprints is False + assert read_settings(pytester.parseconfig('--no-check-fingerprints', '--check-fingerprints')).check_fingerprints is True @pytest.mark.parametrize(('source', 'value'), [('environment', 'maybe'), ('environment', '[]'), ('toml', '1'), ('toml', '"true"'), ('toml', '[]')]) diff --git a/tests/test_xdist.py b/tests/test_xdist.py index 6568a2a..4657429 100644 --- a/tests/test_xdist.py +++ b/tests/test_xdist.py @@ -50,8 +50,8 @@ def test_item(index, worker_id, request): pytester.makepyfile(**{f'test_{index}': source for index in range(4)}) preparation = shlex.join([sys.executable, '-c', "from pathlib import Path; Path('prepared').write_text('ready')"]) result = pytester.runpytest_subprocess( - '--isolates=2', '-n', '2', f'--dist={mode}', '--throngtest-distribution=files', f'--throngtest-backend={backend}', - '--throngtest-preparation=' + json.dumps([preparation]), *(['--throngtest-check-fingerprints'] if checked else []), timeout=60, + '--isolates=2', '-n', '2', f'--dist={mode}', '--distribution=files', f'--backend={backend}', + '--preparation=' + json.dumps([preparation]), *(['--check-fingerprints'] if checked else []), timeout=60, ) result.assert_outcomes(passed=16) records = [json.loads(record.read_text()) for record in tmp_path.glob('*.json')] @@ -131,7 +131,7 @@ def test_nested_absolute_parameter_paths(pytester: pytest.Pytester, checked: boo @pytest.mark.parametrize('path', [str(Path.cwd() / 'missing')]) def test_path(path): assert Path(path).parent == Path.cwd() ''') - args = ['--throngtest-check-fingerprints'] if checked else [] + args = ['--check-fingerprints'] if checked else [] result = pytester.runpytest_subprocess('-n2', *args, timeout=45) if checked: assert result.ret == pytest.ExitCode.INTERNAL_ERROR @@ -144,8 +144,8 @@ def test_path(path): assert Path(path).parent == Path.cwd() def test_nested_duplicate_nodeids(pytester: pytest.Pytester, backend: str, checked: bool) -> None: """Preserve duplicate collected occurrences when xdist schedules an isolate's tests.""" test = pytester.makepyfile('def test_ok(): pass') - args = ['--throngtest-check-fingerprints'] if checked else [] - pytester.runpytest_subprocess('--isolates=1', '-n2', f'--throngtest-backend={backend}', *args, str(test), str(test), timeout=45).assert_outcomes(passed=2) + args = ['--check-fingerprints'] if checked else [] + pytester.runpytest_subprocess('--isolates=1', '-n2', f'--backend={backend}', *args, str(test), str(test), timeout=45).assert_outcomes(passed=2) @pytest.mark.parametrize('source', ['cli', 'environment', 'ini']) @@ -195,7 +195,7 @@ def test_xdist_loadgroup_marks_and_file_distribution(pytester: pytest.Pytester, def test_item(index): (Path({str(tmp_path)!r}) / str(index)).write_text(str(os.getpid())) ''') - result = pytester.runpytest_subprocess('--isolates=2', '-n2', '--dist=loadgroup', '--throngtest-check-fingerprints', f'--throngtest-backend={backend}', '--throngtest-distribution=files', timeout=45) + result = pytester.runpytest_subprocess('--isolates=2', '-n2', '--dist=loadgroup', '--check-fingerprints', f'--backend={backend}', '--distribution=files', timeout=45) result.assert_outcomes(passed=8) assert len({path.read_text() for path in tmp_path.iterdir()}) == 1 @@ -211,7 +211,7 @@ def test_nested_failfast(pytester: pytest.Pytester, backend: str) -> None: @pytest.mark.parametrize('index', range(20)) def test_fail(index): assert False ''') - result = pytester.runpytest_subprocess('--isolates=1', '-n2', '-x', f'--throngtest-backend={backend}', timeout=45) + result = pytester.runpytest_subprocess('--isolates=1', '-n2', '-x', f'--backend={backend}', timeout=45) assert result.ret == pytest.ExitCode.TESTS_FAILED assert 1 <= result.parseoutcomes()['failed'] <= 2 assert 'exactly once' not in result.stdout.str() @@ -231,7 +231,7 @@ def test_crash(): os._exit(17) def test_ok(): pass def test_also_ok(): pass ''') - result = pytester.runpytest_subprocess('--isolates=1', '-n1', '--max-worker-restart=' + restart, f'--throngtest-backend={backend}', '--junitxml=results.xml', timeout=45) + result = pytester.runpytest_subprocess('--isolates=1', '-n1', '--max-worker-restart=' + restart, f'--backend={backend}', '--junitxml=results.xml', timeout=45) assert result.ret == pytest.ExitCode.TESTS_FAILED assert result.parseoutcomes()['failed'] == 1 assert 'crashed while running' in result.stdout.str() @@ -302,7 +302,7 @@ def pytest_collection_modifyitems(config, items): @pytest.mark.parametrize('index', range(4)) def test_item(index): pass ''') - result = pytester.runpytest_subprocess('--isolates=2', '-n2', f'--throngtest-backend={backend}', timeout=45) + result = pytester.runpytest_subprocess('--isolates=2', '-n2', f'--backend={backend}', timeout=45) result.assert_outcomes(passed=count) assert result.ret == (pytest.ExitCode.OK if count else pytest.ExitCode.NO_TESTS_COLLECTED) @@ -313,7 +313,7 @@ def test_nested_warnings(pytester: pytest.Pytester, backend: str) -> None: import warnings def test_warning(): warnings.warn('nested warning', UserWarning) ''') - result = pytester.runpytest_subprocess('--isolates=1', '-n2', f'--throngtest-backend={backend}', timeout=45) + result = pytester.runpytest_subprocess('--isolates=1', '-n2', f'--backend={backend}', timeout=45) result.assert_outcomes(passed=1, warnings=1) assert 'nested warning' in result.stdout.str() @@ -338,7 +338,7 @@ def resource(): def test_crash(resource): if {stage!r} == 'call': os._exit(17) ''') - result = pytester.runpytest_subprocess('--isolates=1', '-n1', '--dist=loadgroup', '--max-worker-restart=0', '--throngtest-check-fingerprints', timeout=45) + result = pytester.runpytest_subprocess('--isolates=1', '-n1', '--dist=loadgroup', '--max-worker-restart=0', '--check-fingerprints', timeout=45) assert result.ret == pytest.ExitCode.TESTS_FAILED assert result.parseoutcomes()['failed'] == 1 assert 'crashed while running' in result.stdout.str() @@ -357,7 +357,7 @@ def pytest_configure_node(node): ''') pytester.makepyfile('def test_never(): assert False') commands = [shlex.join([sys.executable, '-c', 'raise SystemExit(7)'])] - result = pytester.runpytest_subprocess('--isolates=2', '-n2', '--throngtest-preparation=' + json.dumps(commands), timeout=45) + result = pytester.runpytest_subprocess('--isolates=2', '-n2', '--preparation=' + json.dumps(commands), timeout=45) assert result.ret == pytest.ExitCode.INTERNAL_ERROR assert 'preparation command 1 failed with exit code 7' in result.stdout.str() assert not (tmp_path / 'started').exists() @@ -395,7 +395,7 @@ def pytest_collection_modifyitems(items): @pytest.mark.xdist_group('group') def test_ok(): pass ''') - args = ['--throngtest-check-fingerprints'] if checked else [] + args = ['--check-fingerprints'] if checked else [] result = pytester.runpytest_subprocess('--isolates=1', '-n2', '--dist=loadgroup', '--junitxml=results.xml', *args, timeout=45) result.assert_outcomes(passed=1) case = ElementTree.parse(pytester.path / 'results.xml').find('.//testcase') diff --git a/throngtest/plugin.py b/throngtest/plugin.py index 71ea891..b764bba 100644 --- a/throngtest/plugin.py +++ b/throngtest/plugin.py @@ -21,10 +21,10 @@ def pytest_addoption(parser: pytest.Parser) -> None: ('exclude', 'JSON list of patterns excluded from the isolate snapshot'), ('preparation', 'JSON list of commands run in each isolate before pytest (default: [])'), ): - group.addoption(f'--throngtest-{name}', default=None, help=description) - group.addoption('--throngtest-check-fingerprints', action='store_const', const=True, default=None, + group.addoption(f'--{name}', dest=f'throngtest_{name}', metavar=name.upper(), default=None, help=description) + group.addoption('--check-fingerprints', action='store_const', const=True, default=None, dest='throngtest_check_fingerprints', help='Require identical ordered test collections in all isolates (default: disabled)') - group.addoption('--throngtest-no-check-fingerprints', action='store_const', const=False, default=None, + group.addoption('--no-check-fingerprints', action='store_const', const=False, default=None, dest='throngtest_check_fingerprints', help='Disable collection fingerprint checks, overriding environment and TOML settings') diff --git a/throngtest/runner.py b/throngtest/runner.py index 3859669..c06b63a 100644 --- a/throngtest/runner.py +++ b/throngtest/runner.py @@ -21,10 +21,12 @@ from throngtest.settings import ARGUMENTS, Settings from throngtest.xdist import NESTED +VALUE_OPTIONS = frozenset(('--isolates', '--backend', '--distribution', '--python', '--exclude', '--preparation')) + def relocate(argument: str, root: Path, invocation: Path, directory: Path) -> str: """Relocate project paths without interpreting pytest's other arguments.""" - if not argument or argument.startswith(('--throngtest-', '--isolates=')) or (argument.startswith('-') and '=' not in argument): + if not argument or argument.partition('=')[0] in VALUE_OPTIONS or (argument.startswith('-') and '=' not in argument): return argument prefix, separator, value = argument.partition('=') if argument.startswith('-') else ('', '', argument) path, *selectors = value.split('::') @@ -82,11 +84,7 @@ def worker_arguments(config: pytest.Config, root: Path, invocation: Path, direct preserve_value = False else: arguments.append(relocate(argument, root, invocation, root / directory)) - preserve_value = argument == '--isolates' or ( - argument.startswith('--throngtest-') and '=' not in argument and argument not in ( - '--throngtest-check-fingerprints', '--throngtest-no-check-fingerprints', - ) - ) + preserve_value = argument in VALUE_OPTIONS arguments.extend(['--rootdir', os.path.relpath(root, root / directory)]) return arguments