Skip to content
Merged
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
3 changes: 0 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ dev = [
"pytest-cov",
"pytest-xdist",
"pytest-randomly",
"pyyaml",
]
lint = [
"ruff",
Expand Down Expand Up @@ -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-branch --cov-report term-missing"
testpaths = ["tests"]

[tool.coverage.report]
fail_under = 100
exclude_also = [
"if typing.TYPE_CHECKING:",
]
5 changes: 0 additions & 5 deletions semvertag/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import errno
import importlib.metadata
import typing

Expand Down Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions semvertag/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand Down
16 changes: 8 additions & 8 deletions semvertag/_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
4 changes: 2 additions & 2 deletions tests/_descriptor_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
Expand Down Expand Up @@ -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)
168 changes: 168 additions & 0 deletions tests/integration/test_cli_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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 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) -> typing.NoReturn: # 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(
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(
ctx,
project_id=None,
strategy=None,
token=None,
default_branch=None,
gitlab_endpoint=None,
request_timeout=None,
_version=None,
)


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
46 changes: 45 additions & 1 deletion tests/integration/test_gitlab_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"}
17 changes: 13 additions & 4 deletions tests/test_ci_descriptor_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
9 changes: 8 additions & 1 deletion tests/unit/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"})
Loading