From d24d306579f1bc33a05f0575013a6add62091a3f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 3 Jun 2026 00:16:46 +0300 Subject: [PATCH 1/2] tests: gate semvertag at 100% statement + branch coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover every reachable path in the semvertag package and the shared CI descriptor gate, then lock the level in with fail_under = 100 so it can't regress. - _use_case: dict-dispatch in _compute_new_version drops the dead ValueError on the Bump.NONE-already-filtered branch - _transport: while-True + explicit counter eliminates the unreachable "for loop completed naturally" exit branch - __main__: drop the OSError(EPIPE) handler — Python 3.3+ auto-dispatches EPIPE to BrokenPipeError, so the OSError arm is unreachable - new tests/integration/test_cli_errors.py covers --version (incl. PackageNotFoundError fallback), every CLI override flag, the ValidationError / ValueError→ConfigError paths, ImportError / BrokenPipeError / SemvertagError from the use case, resilient-parsing early return, and the main() entry point - gitlab provider tests cover unknown HTTP status, Link headers with empty URI / non-next rel / missing rel - apply_cli_overlay nesting-depth>2 covered in unit tests - pyyaml moved into dev deps; the importorskip workaround and `ty: ignore` go away; main() of the descriptor gate gets two new tests - pyproject narrows --cov to semvertag + the two descriptor gate modules, makes --cov-branch the default, and sets fail_under = 100; redundant `just test-branch` removed Co-Authored-By: Claude Opus 4.7 (1M context) --- Justfile | 3 - pyproject.toml | 4 +- semvertag/__main__.py | 5 - semvertag/_transport.py | 10 +- semvertag/_use_case.py | 16 +-- tests/_descriptor_gate.py | 4 +- tests/integration/test_cli_errors.py | 164 ++++++++++++++++++++++ tests/integration/test_gitlab_provider.py | 46 +++++- tests/test_ci_descriptor_gate.py | 17 ++- tests/unit/test_settings.py | 9 +- 10 files changed, 248 insertions(+), 30 deletions(-) create mode 100644 tests/integration/test_cli_errors.py diff --git a/Justfile b/Justfile index 0853917..476f540 100644 --- a/Justfile +++ b/Justfile @@ -19,9 +19,6 @@ lint-ci: test *args: uv run --no-sync pytest {{ args }} -test-branch: - @just test --cov-branch - publish: rm -rf dist uv version $GITHUB_REF_NAME diff --git a/pyproject.toml b/pyproject.toml index 69d74e2..a0a8e06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dev = [ "pytest-cov", "pytest-xdist", "pytest-randomly", + "pyyaml", ] lint = [ "ruff", @@ -79,10 +80,11 @@ isort.no-lines-before = ["standard-library", "local-folder"] "tests/**/*.py" = ["S101", "SLF001"] [tool.pytest.ini_options] -addopts = "--cov=. --cov-report term-missing" +addopts = "--cov=semvertag --cov=tests._descriptor_gate --cov=tests.test_ci_descriptor_gate --cov-branch --cov-report term-missing" testpaths = ["tests"] [tool.coverage.report] +fail_under = 100 exclude_also = [ "if typing.TYPE_CHECKING:", ] diff --git a/semvertag/__main__.py b/semvertag/__main__.py index 7cebde5..73aa0d1 100644 --- a/semvertag/__main__.py +++ b/semvertag/__main__.py @@ -1,4 +1,3 @@ -import errno import importlib.metadata import typing @@ -163,10 +162,6 @@ def _tag_command( raise typer.Exit(code=err.exit_code) from err except BrokenPipeError as exc: raise typer.Exit(code=0) from exc - except OSError as exc: - if exc.errno == errno.EPIPE: - raise typer.Exit(code=0) from exc - raise def main() -> None: diff --git a/semvertag/_transport.py b/semvertag/_transport.py index d1a4218..22cd0fb 100644 --- a/semvertag/_transport.py +++ b/semvertag/_transport.py @@ -27,7 +27,8 @@ def handle_request(self, request: httpx2.Request) -> httpx2.Response: start: typing.Final = time.monotonic() last_response: httpx2.Response | None = None last_exc: BaseException | None = None - for attempt in range(MAX_ATTEMPTS): + attempt = 0 + while True: try: response = self._inner.handle_request(request) except RETRYABLE_EXCEPTIONS as exc: @@ -42,12 +43,11 @@ def handle_request(self, request: httpx2.Request) -> httpx2.Response: if time.monotonic() - start + sleep_seconds > MAX_WALL_SECONDS: break time.sleep(sleep_seconds) + attempt += 1 if last_response is not None: return last_response - if last_exc is not None: - raise last_exc - msg = "RetryingTransport loop invariant violated" # pragma: no cover - raise RuntimeError(msg) # pragma: no cover + assert last_exc is not None # noqa: S101 + raise last_exc def close(self) -> None: self._inner.close() diff --git a/semvertag/_use_case.py b/semvertag/_use_case.py index a80c66f..01e9150 100644 --- a/semvertag/_use_case.py +++ b/semvertag/_use_case.py @@ -115,13 +115,13 @@ def _try_parse_semver(name: str) -> semver.Version | None: return None +_BUMP_FUNCTIONS: typing.Final[dict[Bump, typing.Callable[[semver.Version], semver.Version]]] = { + Bump.MAJOR: semver.Version.bump_major, + Bump.MINOR: semver.Version.bump_minor, + Bump.PATCH: semver.Version.bump_patch, +} + + def _compute_new_version(last_tag: Tag, bump: Bump) -> str: version: typing.Final = semver.Version.parse(last_tag.name) - if bump is Bump.MAJOR: - return str(version.bump_major()) - if bump is Bump.MINOR: - return str(version.bump_minor()) - if bump is Bump.PATCH: - return str(version.bump_patch()) - msg = f"Cannot compute new version for bump={bump!r}." - raise ValueError(msg) + return str(_BUMP_FUNCTIONS[bump](version)) diff --git a/tests/_descriptor_gate.py b/tests/_descriptor_gate.py index 832f57b..3ca31f9 100644 --- a/tests/_descriptor_gate.py +++ b/tests/_descriptor_gate.py @@ -10,7 +10,7 @@ import sys import typing -import yaml # ty: ignore[unresolved-import] # provided at runtime via `uv run --with pyyaml` +import yaml _DIGEST_MARKER: typing.Final = "@sha256:" @@ -116,5 +116,5 @@ def main(argv: list[str]) -> None: sys.stdout.write(f"{argv[1]} shape OK\n") -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover main(sys.argv) diff --git a/tests/integration/test_cli_errors.py b/tests/integration/test_cli_errors.py new file mode 100644 index 0000000..046d34f --- /dev/null +++ b/tests/integration/test_cli_errors.py @@ -0,0 +1,164 @@ +import collections.abc +import importlib.metadata +import sys +import typing +import unittest.mock + +import pytest +from typer.testing import CliRunner + +from semvertag import __main__ as cli_main +from semvertag import ioc +from semvertag.__main__ import MAIN_APP, _main_callback +from semvertag._errors import ProviderAPIError +from semvertag._output import Output +from semvertag._types import RunResult +from tests.conftest import HandlerCallable +from tests.integration.conftest import merge_commit_handler + + +_EXIT_CONFIG_ERROR: typing.Final = 2 +_EXIT_PROVIDER_API_ERROR: typing.Final = 4 + + +class _RaisingUseCase: + def __init__(self, exc: BaseException) -> None: + self._exc = exc + + def __call__(self, *, output: Output) -> RunResult: # noqa: ARG002 + raise self._exc + + +@pytest.fixture +def install_raising_use_case() -> typing.Iterator[collections.abc.Callable[[BaseException], None]]: + def install(exc: BaseException) -> None: + ioc.container.override(ioc.UseCasesGroup.semvertag_use_case, _RaisingUseCase(exc)) + + with ioc.container: + yield install + ioc.container.reset_override(ioc.UseCasesGroup.semvertag_use_case) + + +def test_version_flag_prints_installed_package_version(cli_runner: CliRunner) -> None: + result: typing.Final = cli_runner.invoke(MAIN_APP, ["--version"]) + assert result.exit_code == 0, result.output + assert result.stdout.strip() + + +def test_version_flag_falls_back_to_zero_when_package_metadata_missing( + cli_runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_not_found(_name: str) -> str: + raise importlib.metadata.PackageNotFoundError + + monkeypatch.setattr("semvertag.__main__.importlib.metadata.version", raise_not_found) + result: typing.Final = cli_runner.invoke(MAIN_APP, ["--version"]) + assert result.exit_code == 0, result.output + assert result.stdout.strip() == "0" + + +@pytest.mark.parametrize( + ("flag", "value"), + [ + ("--project-id", "1234"), + ("--strategy", "branch-prefix"), + ("--token", "glpat-override"), + ("--default-branch", "main"), + ("--gitlab-endpoint", "https://gitlab.example.test"), + ("--request-timeout", "5.0"), + ], +) +def test_each_cli_override_flag_drives_tag_command_to_success( + cli_env: None, # noqa: ARG001 + install_mock_transport: collections.abc.Callable[[HandlerCallable], None], + cli_runner: CliRunner, + flag: str, + value: str, +) -> None: + install_mock_transport(merge_commit_handler()) + result: typing.Final = cli_runner.invoke(MAIN_APP, [flag, value, "tag"]) + assert result.exit_code == 0, result.output + (result.stderr or "") + + +def test_invalid_env_var_triggers_pydantic_validation_error_path( + cli_env: None, # noqa: ARG001 + cli_runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("SEMVERTAG_REQUEST_TIMEOUT", "-1") + result: typing.Final = cli_runner.invoke(MAIN_APP, ["tag"]) + assert result.exit_code == _EXIT_CONFIG_ERROR, result.output + assert "Configuration error at 'request_timeout'" in result.stderr + + +def test_overlay_value_error_is_rewrapped_as_config_error( + cli_env: None, # noqa: ARG001 + cli_runner: CliRunner, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_value_error(*_args: object, **_kwargs: object) -> typing.NoReturn: + msg = "forced overlay failure" + raise ValueError(msg) + + monkeypatch.setattr("semvertag.__main__.apply_cli_overlay", raise_value_error) + result: typing.Final = cli_runner.invoke(MAIN_APP, ["tag"]) + assert result.exit_code == _EXIT_CONFIG_ERROR, result.output + assert "forced overlay failure" in result.stderr + + +def test_import_error_from_use_case_exits_with_config_error_code( + cli_env: None, # noqa: ARG001 + install_raising_use_case: collections.abc.Callable[[BaseException], None], + cli_runner: CliRunner, +) -> None: + install_raising_use_case(ImportError("optional dep missing")) + result: typing.Final = cli_runner.invoke(MAIN_APP, ["tag"]) + assert result.exit_code == _EXIT_CONFIG_ERROR, result.output + assert "Required module unavailable" in result.stderr + + +def test_broken_pipe_error_from_use_case_exits_clean( + cli_env: None, # noqa: ARG001 + install_raising_use_case: collections.abc.Callable[[BaseException], None], + cli_runner: CliRunner, +) -> None: + install_raising_use_case(BrokenPipeError("broken")) + result: typing.Final = cli_runner.invoke(MAIN_APP, ["tag"]) + assert result.exit_code == 0, result.output + + +def test_semvertag_error_from_use_case_exits_with_its_exit_code( + cli_env: None, # noqa: ARG001 + install_raising_use_case: collections.abc.Callable[[BaseException], None], + cli_runner: CliRunner, +) -> None: + install_raising_use_case(ProviderAPIError("upstream blew up")) + result: typing.Final = cli_runner.invoke(MAIN_APP, ["tag"]) + assert result.exit_code == _EXIT_PROVIDER_API_ERROR, result.output + assert "upstream blew up" in (result.stderr or "") + result.output + + +def test_main_callback_returns_early_when_resilient_parsing_active() -> None: + ctx = unittest.mock.MagicMock() + ctx.resilient_parsing = True + _main_callback( + ctx, + project_id=None, + strategy=None, + token=None, + default_branch=None, + gitlab_endpoint=None, + request_timeout=None, + _version=None, + ) + ctx.modern_di_container.assert_not_called() + + +def test_main_entry_point_runs_typer_app_inside_ioc_container( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sys, "argv", ["semvertag", "--version"]) + with pytest.raises(SystemExit) as exc_info: + cli_main.main() + assert exc_info.value.code == 0 diff --git a/tests/integration/test_gitlab_provider.py b/tests/integration/test_gitlab_provider.py index 11ffa46..19e9318 100644 --- a/tests/integration/test_gitlab_provider.py +++ b/tests/integration/test_gitlab_provider.py @@ -11,7 +11,13 @@ from semvertag._types import Commit, Tag from semvertag.providers._base import Provider from semvertag.providers._http import HttpClient -from semvertag.providers.gitlab import GitLabProvider, _translate_status, gitlab_auth_headers +from semvertag.providers.gitlab import ( + GitLabProvider, + _next_page_url, + _parse_rel_values, + _translate_status, + gitlab_auth_headers, +) from tests.conftest import ( GITLAB_ENDPOINT, GITLAB_PROJECT_ID, @@ -569,3 +575,41 @@ def test_raises_provider_api_error_when_request_error_chained_from_exc( with client, pytest.raises(ProviderAPIError, match="request failed") as exc_info: verb_callable(provider) assert isinstance(exc_info.value.__cause__, httpx2.ConnectError) + + +# Status translator + Link-header parser edge cases + + +_TEAPOT_STATUS: typing.Final = 418 + + +def test_translate_status_raises_provider_api_error_for_unknown_status() -> None: + with pytest.raises(ProviderAPIError, match="Unexpected GitLab response: 418"): + _translate_status(_TEAPOT_STATUS, GITLAB_PROJECT_ID) + + +def _link_header_response(link_header: str) -> httpx2.Response: + return httpx2.Response(200, headers={"link": link_header}, json=[]) + + +def test_next_page_url_skips_entries_with_empty_uri_reference() -> None: + response: typing.Final = _link_header_response('<>; rel="next"') + assert _next_page_url(response, current_url=f"{GITLAB_ENDPOINT}{_TAGS_PATH}") is None + + +def test_next_page_url_returns_none_when_link_header_absent() -> None: + response: typing.Final = httpx2.Response(200, json=[]) + assert _next_page_url(response, current_url=f"{GITLAB_ENDPOINT}{_TAGS_PATH}") is None + + +def test_next_page_url_returns_none_when_only_non_next_rel_present() -> None: + response: typing.Final = _link_header_response(f'<{GITLAB_ENDPOINT}{_TAGS_PATH}?page=1>; rel="prev"') + assert _next_page_url(response, current_url=f"{GITLAB_ENDPOINT}{_TAGS_PATH}") is None + + +def test_parse_rel_values_returns_empty_set_when_no_rel_param_present() -> None: + assert _parse_rel_values("; foo=bar; baz=qux") == set() + + +def test_parse_rel_values_skips_non_rel_params_before_finding_rel() -> None: + assert _parse_rel_values('; foo="bar"; rel="next"') == {"next"} diff --git a/tests/test_ci_descriptor_gate.py b/tests/test_ci_descriptor_gate.py index 1297a8c..e83d37a 100644 --- a/tests/test_ci_descriptor_gate.py +++ b/tests/test_ci_descriptor_gate.py @@ -11,11 +11,9 @@ import typing import pytest +import yaml - -yaml = pytest.importorskip("yaml", reason="pyyaml not installed; install via `uv run --with pyyaml pytest`") - -from tests._descriptor_gate import DescriptorGateError, validate # noqa: E402 +from tests._descriptor_gate import DescriptorGateError, main, validate _REPO_ROOT: typing.Final = pathlib.Path(__file__).parent.parent @@ -124,3 +122,14 @@ def test_unpinned_semvertag_fails(tmp_path: pathlib.Path, shipped_descriptor_doc bad = _write_descriptor(tmp_path, [spec, body]) with pytest.raises(DescriptorGateError, match=r"script.* must pin the semvertag version"): validate(str(bad)) + + +def test_main_prints_shape_ok_for_valid_descriptor(capsys: pytest.CaptureFixture[str]) -> None: + main(["_descriptor_gate", str(_DESCRIPTOR_PATH)]) + captured: typing.Final = capsys.readouterr() + assert captured.out == f"{_DESCRIPTOR_PATH} shape OK\n" + + +def test_main_raises_when_argv_length_wrong() -> None: + with pytest.raises(DescriptorGateError, match=r"usage: python -m tests\._descriptor_gate"): + main(["_descriptor_gate"]) diff --git a/tests/unit/test_settings.py b/tests/unit/test_settings.py index 00072a4..31a7d0b 100644 --- a/tests/unit/test_settings.py +++ b/tests/unit/test_settings.py @@ -3,7 +3,7 @@ import pydantic import pytest -from semvertag._settings import GitLabConfig, Settings +from semvertag._settings import GitLabConfig, Settings, apply_cli_overlay _NESTED_TOKEN: typing.Final = "tok-nested" @@ -144,3 +144,10 @@ def test_prefers_semvertag_project_id_over_ci_project_id( monkeypatch.setenv("CI_PROJECT_ID", _PROJECT_ID_CI) settings: typing.Final = Settings() assert settings.project_id == _PROJECT_ID_INT_SEMVERTAG + + +@pytest.mark.usefixtures("clean_settings_env") +def test_apply_cli_overlay_rejects_keys_deeper_than_two_levels() -> None: + base: typing.Final = Settings() + with pytest.raises(ValueError, match="exceeds nesting depth 2"): + apply_cli_overlay(base, {"gitlab.foo.bar": "x"}) From e8d403725683fb89faef620aa39ef4bb2a0ae1a7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Wed, 3 Jun 2026 00:38:55 +0300 Subject: [PATCH 2/2] tests: tighten resilient_parsing assertion, drop test module from coverage scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on the 100% coverage gate PR: - test_cli_errors: replace MagicMock.assert_not_called() on a never-touched attribute with a monkeypatch that fails the test if `Settings` is constructed, giving the resilient_parsing branch a real assertion instead of a vacuous one. - test_cli_errors: type `_RaisingUseCase.__call__` as `typing.NoReturn` (it always raises) and drop the now-unused `RunResult` import. - pyproject: remove `--cov=tests.test_ci_descriptor_gate` from addopts — the file is plain test code with no pragmas/skips, so measuring its coverage produces no signal. `--cov=tests._descriptor_gate` (the script) stays. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- tests/integration/test_cli_errors.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a0a8e06..9943fda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ isort.no-lines-before = ["standard-library", "local-folder"] "tests/**/*.py" = ["S101", "SLF001"] [tool.pytest.ini_options] -addopts = "--cov=semvertag --cov=tests._descriptor_gate --cov=tests.test_ci_descriptor_gate --cov-branch --cov-report term-missing" +addopts = "--cov=semvertag --cov=tests._descriptor_gate --cov-branch --cov-report term-missing" testpaths = ["tests"] [tool.coverage.report] diff --git a/tests/integration/test_cli_errors.py b/tests/integration/test_cli_errors.py index 046d34f..61b36c4 100644 --- a/tests/integration/test_cli_errors.py +++ b/tests/integration/test_cli_errors.py @@ -12,7 +12,6 @@ from semvertag.__main__ import MAIN_APP, _main_callback from semvertag._errors import ProviderAPIError from semvertag._output import Output -from semvertag._types import RunResult from tests.conftest import HandlerCallable from tests.integration.conftest import merge_commit_handler @@ -25,7 +24,7 @@ class _RaisingUseCase: def __init__(self, exc: BaseException) -> None: self._exc = exc - def __call__(self, *, output: Output) -> RunResult: # noqa: ARG002 + def __call__(self, *, output: Output) -> typing.NoReturn: # noqa: ARG002 raise self._exc @@ -139,7 +138,13 @@ def test_semvertag_error_from_use_case_exits_with_its_exit_code( assert "upstream blew up" in (result.stderr or "") + result.output -def test_main_callback_returns_early_when_resilient_parsing_active() -> None: +def test_main_callback_returns_early_when_resilient_parsing_active( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fail_if_constructed() -> typing.NoReturn: + pytest.fail("Settings must not be constructed during resilient_parsing") + + monkeypatch.setattr("semvertag.__main__.Settings", fail_if_constructed) ctx = unittest.mock.MagicMock() ctx.resilient_parsing = True _main_callback( @@ -152,7 +157,6 @@ def test_main_callback_returns_early_when_resilient_parsing_active() -> None: request_timeout=None, _version=None, ) - ctx.modern_di_container.assert_not_called() def test_main_entry_point_runs_typer_app_inside_ioc_container(