diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 479dce6..0a58996 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,6 @@ jobs: fail-fast: false matrix: python-version: - - "3.10" - "3.11" - "3.12" - "3.13" diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 571df87..34db7d1 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,7 +3,7 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "3.10" + python: "3.11" python: install: diff --git a/planning/plans/2026-06-07-httpware-migration.md b/planning/plans/2026-06-07-httpware-migration.md new file mode 100644 index 0000000..e034dd0 --- /dev/null +++ b/planning/plans/2026-06-07-httpware-migration.md @@ -0,0 +1,877 @@ +# httpware migration — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port semvertag's provider HTTP stack from a hand-rolled `RetryingTransport` + `HttpClient` wrapper onto `httpware` 0.8+. Delete ~600 lines of in-tree HTTP plumbing; preserve operator-action exit-code semantics (`ConfigError`/2, `AuthError`/3, `ProviderAPIError`/4) via a small per-provider translation module. + +**Architecture:** `GitLabProvider` holds a `httpware.Client` directly (no wrapper). One `try/except httpware.ClientError` per method calls `providers._errors.translate_gitlab(...)` to convert httpware's status- and transport-error tree into semvertag's domain errors. `httpware.Retry` middleware (configured to add HTTP 500 to its default retry set) replaces `RetryingTransport`. Wiring in `ioc.py` builds the client directly; `TransportsGroup` deletes. + +**Tech Stack:** Python 3.10+, `httpware[pydantic]` 0.8+, `httpx2`, `pydantic`, `modern-di-typer`, `pytest`. Tests use `httpx2.MockTransport` injected via `httpware.Client(httpx2_client=httpx2.Client(transport=mock))`. + +**Reference spec:** `planning/specs/2026-06-07-httpware-migration-design.md` + +--- + +## File structure + +**Create:** +- `semvertag/providers/_errors.py` — `translate_gitlab(exc, *, project_id)` + `translate_create_tag_bad_request(exc, *, tag_name)`. Handles `httpware.StatusError` subclasses *and* `httpware.NetworkError` / `httpware.TimeoutError` / `httpware.RetryBudgetExhaustedError`. Returns a `SemvertagError` subclass for the caller to `raise … from exc`. +- `tests/unit/test_providers_errors.py` — exhaustive translation tests (one per status code + one per transport error type + the BadRequest body-fragment branches). + +**Modify:** +- `pyproject.toml` — add `httpware[pydantic]` to `[project] dependencies`. +- `semvertag/providers/gitlab.py` — `GitLabProvider.http: httpware.Client`. Drop `_url()`, `_translate_status()`, `gitlab_auth_headers()`, and all `_HTTP_*` constants. Keep `_LINK_ENTRY_RE`, `_next_page_url`, `_parse_rel_values`, `_same_origin`, `_validate_tag_list` (still pagination-internal). Each public method wraps its httpware call in `try/except httpware.ClientError` → `_errors.translate_gitlab(...)`. `create_tag` adds a leading `except httpware.BadRequestError` for the "already exists" body-string special case. +- `semvertag/ioc.py` — delete `TransportsGroup`; remove from `ALL_GROUPS`. Split provider construction into `_build_gitlab_client(settings) -> httpware.Client` (the test-overridable seam) + `_build_gitlab_provider(settings, client) -> GitLabProvider`. `_close_provider_client` calls `provider.http.close()`. Drop the `gitlab_auth_headers` import (helper deleted). +- `tests/integration/test_gitlab_provider.py` — rewrite `_make_provider` to inject via `httpware.Client(httpx2_client=httpx2.Client(transport=mock, base_url=...))`. Delete `_make_provider_with_retrying_transport` (transport-level retry composition is upstream now). Update imports (drop `_translate_status`, `gitlab_auth_headers`, `RetryingTransport`, `HttpClient`). Adjust the specific tests that pinned wrapper-message wording (`"request failed: ..."`) or that exercised POST retry-on-429 behavior (now: immediate `RateLimitedError` → `ProviderAPIError`). + +**Delete:** +- `semvertag/_transport.py` (whole file) +- `semvertag/providers/_http.py` (whole file) +- `tests/unit/test_transport_retry.py` (403 lines — behavior now lives in httpware) +- `tests/unit/test_http_client.py` (186 lines — `HttpClient` is gone; translation tests replace it) + +**Test inventory after the change:** +- `tests/unit/test_providers_errors.py` — NEW +- `tests/integration/test_gitlab_provider.py` — modified seam, mostly preserved +- `tests/unit/test_ioc.py` — unchanged (it tests strategy resolution, not provider wiring) +- All other test files — untouched + +--- + +## Task 1: Add httpware dependency + +**Files:** +- Modify: `pyproject.toml` — `dependencies` array, `requires-python`, `classifiers` + +**Background:** httpware 0.8.0 ships with `requires-python = ">=3.11,<4"`. semvertag currently declares `>=3.10,<4` and lists Python 3.10 in `classifiers`. Adopting httpware forces semvertag to drop 3.10 support. This is a deliberate pre-1.0 breaking change, explicitly approved at plan-execution time. + +- [ ] **Step 1: Update `pyproject.toml`** + +Three coordinated edits: + +1. Bump `requires-python` from `">=3.10,<4"` to `">=3.11,<4"`. +2. Remove `"Programming Language :: Python :: 3.10"` from `classifiers`. +3. Add `"httpware[pydantic]"` as the last entry of `dependencies`. + +Resulting `dependencies` array: + +```toml +dependencies = [ + "typer", + "rich", + "semver", + "pydantic-settings", + "modern-di-typer", + "httpx2", + "httpware[pydantic]", +] +``` + +- [ ] **Step 2: Resolve the lockfile** + +Run: `uv lock --upgrade-package httpware` +Expected: `httpware` and its transitive deps appear in `uv.lock`; no errors. + +- [ ] **Step 3: Install** + +Run: `just install` (alias for `uv lock --upgrade && uv sync --all-extras --frozen --group lint`) +Expected: completes without errors. + +- [ ] **Step 4: Smoke-test the import** + +Run: `uv run python -c "import httpware; print(httpware.Client, httpware.Retry, httpware.StatusError, httpware.NetworkError)"` +Expected: prints four class repr lines, no `ImportError`. + +- [ ] **Step 5: Run the existing test suite to confirm no regressions from the dep addition** + +Run: `just test` +Expected: all tests pass, coverage stays at 100%. + +- [ ] **Step 6: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "chore: add httpware[pydantic] dependency" +``` + +--- + +## Task 2: Create the translation module (TDD) + +**Files:** +- Create: `semvertag/providers/_errors.py` +- Create: `tests/unit/test_providers_errors.py` + +The translation module is the foundation — `gitlab.py` will depend on it in Task 3. We TDD it in isolation first. + +- [ ] **Step 1: Write the failing test file** + +Create `tests/unit/test_providers_errors.py`: + +```python +import httpx2 +import pytest + +import httpware + +from semvertag._errors import AuthError, ConfigError, ProviderAPIError +from semvertag.providers._errors import translate_create_tag_bad_request, translate_gitlab + + +_PROJECT_ID = 4242 + + +def _response(status: int, *, body: bytes = b"") -> httpx2.Response: + return httpx2.Response(status_code=status, content=body) + + +def _status_error(cls: type[httpware.StatusError], status: int, body: bytes = b"") -> httpware.StatusError: + return cls(_response(status, body=body)) + + +# translate_gitlab — status errors + +def test_translate_gitlab_401_becomes_auth_error_with_token_guidance() -> None: + result = translate_gitlab(_status_error(httpware.UnauthorizedError, 401), project_id=_PROJECT_ID) + assert isinstance(result, AuthError) + assert "Token rejected" in str(result) + assert "SEMVERTAG_TOKEN" in str(result) + + +def test_translate_gitlab_403_becomes_auth_error_with_scope_guidance() -> None: + result = translate_gitlab(_status_error(httpware.ForbiddenError, 403), project_id=_PROJECT_ID) + assert isinstance(result, AuthError) + assert "403" in str(result) + assert "api" in str(result) or "write_repository" in str(result) + + +def test_translate_gitlab_404_becomes_config_error_with_project_id() -> None: + result = translate_gitlab(_status_error(httpware.NotFoundError, 404), project_id=_PROJECT_ID) + assert isinstance(result, ConfigError) + assert f"project_id={_PROJECT_ID}" in str(result) + + +def test_translate_gitlab_422_becomes_config_error() -> None: + result = translate_gitlab(_status_error(httpware.UnprocessableEntityError, 422), project_id=_PROJECT_ID) + assert isinstance(result, ConfigError) + assert "422" in str(result) + + +def test_translate_gitlab_429_becomes_provider_api_error() -> None: + result = translate_gitlab(_status_error(httpware.RateLimitedError, 429), project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "rate limit" in str(result).lower() + + +def test_translate_gitlab_500_becomes_provider_api_error() -> None: + result = translate_gitlab(_status_error(httpware.InternalServerError, 500), project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "500" in str(result) + + +def test_translate_gitlab_503_becomes_provider_api_error() -> None: + result = translate_gitlab(_status_error(httpware.ServiceUnavailableError, 503), project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + + +def test_translate_gitlab_unknown_4xx_falls_back_to_provider_api_error() -> None: + # 418 is not specially mapped; ClientStatusError is the fallback for unknown 4xx + result = translate_gitlab(_status_error(httpware.ClientStatusError, 418), project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "418" in str(result) + + +# translate_gitlab — transport errors + +def test_translate_gitlab_timeout_becomes_provider_api_error() -> None: + exc = httpware.TimeoutError("read timed out") + result = translate_gitlab(exc, project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "timed out" in str(result).lower() or "timeout" in str(result).lower() + + +def test_translate_gitlab_network_error_becomes_provider_api_error() -> None: + exc = httpware.NetworkError("connection refused") + result = translate_gitlab(exc, project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + + +def test_translate_gitlab_retry_budget_exhausted_becomes_provider_api_error() -> None: + exc = httpware.RetryBudgetExhaustedError(last_response=None, last_exception=None, attempts=3) + result = translate_gitlab(exc, project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "retr" in str(result).lower() + + +# translate_create_tag_bad_request + +def test_translate_create_tag_bad_request_already_exists_becomes_config_error() -> None: + exc = _status_error(httpware.BadRequestError, 400, body=b'{"message":"Tag already exists"}') + result = translate_create_tag_bad_request(exc, tag_name="v1.2.3") + assert isinstance(result, ConfigError) + assert "v1.2.3" in str(result) + assert "already exists" in str(result).lower() + + +def test_translate_create_tag_bad_request_already_exists_is_case_insensitive() -> None: + exc = _status_error(httpware.BadRequestError, 400, body=b'{"message":"Tag ALREADY EXISTS"}') + result = translate_create_tag_bad_request(exc, tag_name="v1.2.3") + assert isinstance(result, ConfigError) + assert "already exists" in str(result).lower() + + +def test_translate_create_tag_bad_request_other_400_becomes_generic_config_error() -> None: + exc = _status_error(httpware.BadRequestError, 400, body=b'{"message":"bad ref format"}') + result = translate_create_tag_bad_request(exc, tag_name="v1.2.3") + assert isinstance(result, ConfigError) + assert "v1.2.3" not in str(result) + assert "400" in str(result) +``` + +- [ ] **Step 2: Run the test to verify it fails (module does not exist yet)** + +Run: `uv run pytest tests/unit/test_providers_errors.py -v` +Expected: `ModuleNotFoundError: No module named 'semvertag.providers._errors'`. + +- [ ] **Step 3: Write the translation module** + +Create `semvertag/providers/_errors.py`: + +```python +import httpware + +from semvertag._errors import AuthError, ConfigError, ProviderAPIError + + +_TAG_EXISTS_FRAGMENT = "already exists" + + +def translate_gitlab(exc: httpware.ClientError, *, project_id: int) -> Exception: + """Translate an httpware ClientError into the semvertag domain error for GitLab. + + Handles both status errors (4xx/5xx) and transport-layer failures + (network, timeout, retry budget exhaustion). + """ + if isinstance(exc, httpware.UnauthorizedError): + return AuthError("Token rejected: 401. Verify SEMVERTAG_TOKEN is valid and has 'api' scope.") + if isinstance(exc, httpware.ForbiddenError): + return AuthError( + "Token missing scope or insufficient permission: 403. " + "Add 'api' or 'write_repository' to the SEMVERTAG_TOKEN scopes on GitLab." + ) + if isinstance(exc, httpware.NotFoundError): + return ConfigError( + f"GitLab project not found: project_id={project_id}. Verify CI_PROJECT_ID or --project-id." + ) + if isinstance(exc, httpware.UnprocessableEntityError): + return ConfigError( + "Request rejected by GitLab: 422. Check tag name format and that the referenced commit exists." + ) + if isinstance(exc, httpware.RateLimitedError): + return ProviderAPIError("GitLab rate limit: 429. Retries exhausted after 3 attempts; try again later.") + if isinstance(exc, httpware.ServerStatusError): + return ProviderAPIError( + f"GitLab API failure: {exc.response.status_code}. " + "Retries exhausted after 3 attempts. Try again or check GitLab status." + ) + if isinstance(exc, httpware.StatusError): + return ProviderAPIError(f"Unexpected GitLab response: {exc.response.status_code}. Please file an issue.") + if isinstance(exc, httpware.TimeoutError): + return ProviderAPIError("GitLab request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT.") + if isinstance(exc, httpware.RetryBudgetExhaustedError): + return ProviderAPIError(f"GitLab retries exhausted after {exc.attempts} attempts. Try again later.") + if isinstance(exc, httpware.NetworkError): + return ProviderAPIError("GitLab unreachable. Check network connectivity.") + return ProviderAPIError(f"GitLab request failed: {type(exc).__name__}") + + +def translate_create_tag_bad_request(exc: httpware.BadRequestError, *, tag_name: str) -> Exception: + """create_tag's 400 has an 'already exists' special case; everything else is a generic 400.""" + body = exc.response.text + if _TAG_EXISTS_FRAGMENT in body.lower(): + return ConfigError( + f"Tag already exists: '{tag_name}'. " + "The tag was created by a concurrent run or previous invocation." + ) + return ConfigError("Request rejected by GitLab: 400. Check tag name format and that the referenced commit exists.") +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `uv run pytest tests/unit/test_providers_errors.py -v` +Expected: all 14 tests pass. + +- [ ] **Step 5: Lint** + +Run: `just lint` +Expected: clean (no ruff or ty findings). + +- [ ] **Step 6: Commit** + +```bash +git add semvertag/providers/_errors.py tests/unit/test_providers_errors.py +git commit -m "providers: add httpware-to-semvertag error translation module" +``` + +--- + +## Task 3: Port `GitLabProvider` to `httpware.Client` + +**Files:** +- Modify: `semvertag/providers/gitlab.py` (rewrite the class + drop helpers; keep pagination internals) +- Modify: `tests/integration/test_gitlab_provider.py` (rewrite `_make_provider` seam; delete `_make_provider_with_retrying_transport`; fix imports; adjust the few tests that pinned removed behavior) + +This is a port-style task, not pure TDD: the integration tests already describe the behavior contract. We update the seam + the production code together so tests stay meaningful throughout. + +**Important:** This task leaves `_http.py`, `_transport.py`, `test_http_client.py`, and `test_transport_retry.py` in place — they still pass independently. Task 4 switches ioc.py wiring; Task 5 deletes them. + +- [ ] **Step 1: Read the current state end-to-end (no edits)** + +Read the full bodies of: +- `semvertag/providers/gitlab.py` (220 lines) — note `_url`, `_translate_status`, `gitlab_auth_headers`, the `_HTTP_*` constants (all going away), and `_LINK_ENTRY_RE` / `_next_page_url` / `_parse_rel_values` / `_same_origin` / `_validate_tag_list` (staying). +- `tests/integration/test_gitlab_provider.py` (615 lines) — note `_make_provider` (lines 46–56), `_make_provider_with_retrying_transport` (59–72, gets deleted), the imports (1–28), and search for tests that: + - Assert on `"request failed:"` wrapper messages (those messages go away). + - Exercise POST retry behavior (POST is no longer retried). + - Use `RetryingTransport` or `_translate_status` or `gitlab_auth_headers` directly. + +- [ ] **Step 2: Rewrite `semvertag/providers/gitlab.py`** + +Replace the entire file contents with: + +```python +import dataclasses +import re +import typing +import urllib.parse + +import httpx2 +import pydantic + +import httpware + +from semvertag._errors import ConfigError, ProviderAPIError +from semvertag._settings import GitLabConfig +from semvertag._types import Commit, Tag +from semvertag.providers import _errors + + +_API_PREFIX: typing.Final = "/api/v4/projects" +_TAGS_PER_PAGE: typing.Final = 100 +_MAX_TAG_PAGES: typing.Final = 100 + + +class _ProjectResponse(pydantic.BaseModel): + default_branch: str | None + + +class _CommitItem(pydantic.BaseModel): + id: str + message: str + + +class _TagCommit(pydantic.BaseModel): + id: str + + +class _TagItem(pydantic.BaseModel): + name: str + commit: _TagCommit + + +# RFC 8288 Link header: ;param=value;param="value";... +_LINK_ENTRY_RE: typing.Final = re.compile( + r"<\s*(?P[^>]*?)\s*>(?P(?:\s*;\s*[^,;]+)*)", +) + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class GitLabProvider: + name: typing.ClassVar[str] = "gitlab" + config: GitLabConfig + project_id: int + http: httpware.Client + + def get_default_branch(self) -> str: + try: + project = self.http.get( + f"{_API_PREFIX}/{self.project_id}", + response_model=_ProjectResponse, + ) + except httpware.ClientError as exc: + raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc + if not project.default_branch: + msg = "Default branch missing from GitLab response. Verify the project has a default branch configured." + raise ConfigError(msg) + return project.default_branch + + def get_latest_commit_on_default_branch(self) -> Commit: + default_branch: typing.Final = self.get_default_branch() + try: + response = self.http.send(self.http.build_request( + "GET", + f"{_API_PREFIX}/{self.project_id}/repository/commits", + params={"ref_name": default_branch, "per_page": 1}, + )) + except httpware.ClientError as exc: + raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc + items = _validate_commit_list(response) + if not items: + msg = f"No commits on default branch '{default_branch}'. The branch appears empty." + raise ProviderAPIError(msg) + head = items[0] + return Commit(sha=head.id, message=head.message) + + def list_tags(self) -> list[Tag]: + tags: list[Tag] = [] + url: str = f"{_API_PREFIX}/{self.project_id}/repository/tags" + params: dict[str, typing.Any] | None = {"per_page": _TAGS_PER_PAGE, "page": 1} + for _ in range(_MAX_TAG_PAGES): + try: + response = self.http.send(self.http.build_request("GET", url, params=params)) + except httpware.ClientError as exc: + raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc + items = _validate_tag_list(response) + tags.extend(Tag(name=item.name, commit_sha=item.commit.id) for item in items) + next_url = _next_page_url(response, current_url=str(response.request.url)) + if next_url is None: + return tags + if not _same_origin(next_url, self.config.endpoint): + msg = ( + "GitLab pagination Link header points to a different host than SEMVERTAG_GITLAB__ENDPOINT. " + "Refusing to follow to protect credentials." + ) + raise ProviderAPIError(msg) + url, params = next_url, None + msg = ( + f"Tag pagination exceeded {_MAX_TAG_PAGES} pages. " + "The project has an unexpected number of tags; please file an issue." + ) + raise ProviderAPIError(msg) + + def create_tag(self, name: str, commit_sha: str) -> None: + try: + self.http.send(self.http.build_request( + "POST", + f"{_API_PREFIX}/{self.project_id}/repository/tags", + json={"tag_name": name, "ref": commit_sha}, + )) + except httpware.BadRequestError as exc: + raise _errors.translate_create_tag_bad_request(exc, tag_name=name) from exc + except httpware.ClientError as exc: + raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc + + +def _next_page_url(response: httpx2.Response, current_url: str) -> str | None: + link_header: typing.Final = response.headers.get("link") + if not link_header: + return None + for match in _LINK_ENTRY_RE.finditer(link_header): + url_part = match.group("url").strip() + if not url_part: + continue + if "next" in _parse_rel_values(match.group("params")): + return urllib.parse.urljoin(current_url, url_part) + return None + + +def _validate_tag_list(response: httpx2.Response) -> list[_TagItem]: + return _validate_list(response, _TagItem, label="tags") + + +def _validate_commit_list(response: httpx2.Response) -> list[_CommitItem]: + return _validate_list(response, _CommitItem, label="commits") + + +_TModel = typing.TypeVar("_TModel", bound=pydantic.BaseModel) + + +def _validate_list(response: httpx2.Response, model: type[_TModel], *, label: str) -> list[_TModel]: + try: + payload = response.json() + except (ValueError, httpx2.DecodingError) as exc: + msg = f"GitLab {label} response malformed JSON." + raise ProviderAPIError(msg) from exc + if not isinstance(payload, list): + msg = f"GitLab {label} response shape invalid: expected list." + raise ProviderAPIError(msg) + try: + return [model.model_validate(item) for item in payload] + except pydantic.ValidationError as exc: + msg = f"GitLab {label} response shape invalid: {exc}" + raise ProviderAPIError(msg) from exc + + +def _parse_rel_values(params_blob: str) -> set[str]: + for raw_param in params_blob.split(";"): + param = raw_param.strip() + if not param: + continue + name, _, value = param.partition("=") + if name.strip().lower() != "rel": + continue + cleaned = value.strip().strip('"').strip("'").lower() + return set(cleaned.split()) + return set() + + +def _same_origin(url: str, endpoint: str) -> bool: + parsed: typing.Final = urllib.parse.urlsplit(url) + expected: typing.Final = urllib.parse.urlsplit(endpoint) + return parsed.scheme == expected.scheme and parsed.netloc == expected.netloc +``` + +**Notes on the rewrite:** +- `_url(self, path)` is gone — `httpware.Client(base_url=...)` handles URL joining. +- All `_HTTP_*` constants gone — `isinstance` checks against `httpware.X` replace them. +- `gitlab_auth_headers` and `_translate_status` gone — moved into the client construction (Task 4) and `_errors.translate_gitlab` (Task 2) respectively. +- `get_latest_commit_on_default_branch` uses raw `send()` + `_validate_commit_list` rather than `response_model=list[_CommitItem]` — generic aliases like `list[X]` satisfy `type[T]` at runtime (via `TypeAdapter`) but trip static type checkers; keeping the validator approach matches `list_tags` and stays clean under `ty`. +- `list_tags` uses `self.http.send(self.http.build_request(...))` for raw response access. `response.request.url` gives us the absolute URL for `urljoin` (replacing the manual `current_url` tracking). + +- [ ] **Step 3: Update integration test imports and helpers** + +In `tests/integration/test_gitlab_provider.py`: + +Replace the import block (lines 1–28): + +```python +import typing + +import httpx2 +import pydantic +import pytest + +import httpware + +from semvertag._errors import AuthError, ConfigError, ProviderAPIError +from semvertag._settings import GitLabConfig +from semvertag._types import Commit, Tag +from semvertag.providers._base import Provider +from semvertag.providers.gitlab import ( + GitLabProvider, + _next_page_url, + _parse_rel_values, +) +from tests.conftest import ( + GITLAB_ENDPOINT, + GITLAB_PROJECT_ID, + GITLAB_TOKEN, + HandlerCallable, + compose_handler, + default_handler, +) +``` + +(Dropped: `from semvertag import _transport`, `from semvertag._transport import RetryingTransport`, `from semvertag.providers._http import HttpClient`, `_translate_status`, `gitlab_auth_headers`.) + +Replace `_make_provider` (lines 46–56): + +```python +_TOKEN_HEADER: typing.Final = "PRIVATE-TOKEN" + + +def _make_provider(handler: HandlerCallable) -> tuple[GitLabProvider, httpware.Client]: + transport: typing.Final = httpx2.MockTransport(handler) + config: typing.Final = GitLabConfig(endpoint=GITLAB_ENDPOINT, token=pydantic.SecretStr(GITLAB_TOKEN)) + client: typing.Final = httpware.Client( + httpx2_client=httpx2.Client(transport=transport, base_url=GITLAB_ENDPOINT), + headers={_TOKEN_HEADER: config.token.get_secret_value()}, + ) + provider: typing.Final = GitLabProvider(config=config, project_id=GITLAB_PROJECT_ID, http=client) + return provider, client +``` + +**Note:** `httpware.Client(httpx2_client=...)` forbids combining `httpx2_client=` with `base_url=`/`timeout=`/etc. (per `client.py:93–104` — it'll raise `TypeError` if mixed). Configure the underlying `httpx2.Client` with `base_url`; only `headers=` and `middleware=` are safe on the outer `httpware.Client(...)` call in test paths. Production (Task 4) uses the all-kwargs form instead. + +Delete `_make_provider_with_retrying_transport` entirely (lines 59–72). + +- [ ] **Step 4: Run integration tests to surface remaining failures** + +Run: `uv run pytest tests/integration/test_gitlab_provider.py -v` +Expected: many failures. Sort them into three buckets: + 1. **Import errors** in tests that used `_translate_status` or `gitlab_auth_headers` directly — those tests test deleted functions; **delete the test functions** (they belong to the deleted helper layer). + 2. **Assertion failures on exception message wording** — anywhere a test pinned `"request failed:"` (the old `_http.request_raw` wrapper) or specific text from the deleted `_translate_status`. Update the assertion to the new message (from `_errors.translate_gitlab`). + 3. **Behavioral failures from POST not retrying** — any test that called `create_tag` against a 429-then-201 handler expecting success. With POST no longer retried, the test should expect `ProviderAPIError` (from `translate_gitlab` mapping `RateLimitedError`). Update those tests to assert the new behavior. + +- [ ] **Step 5: Fix the failures bucket by bucket** + +For each failure in bucket (1): delete the test function. Note in commit message which ones were removed. + +For each failure in bucket (2): update the `assert "..." in str(exc.value)` to match `_errors.translate_gitlab`'s output for that status code. Cross-reference the message strings in `semvertag/providers/_errors.py`. + +For each failure in bucket (3): change `assert_success`-style assertions to `pytest.raises(ProviderAPIError)` and verify `"rate limit"` appears in the message. + +Also check `tests/integration/test_gitlab_provider.py` for any tests calling `_next_page_url` with the old `current_url` parameter shape — the parameter is unchanged in the rewrite, so these should still pass; if any break, the rewrite of `_next_page_url` is at fault, not the test. + +- [ ] **Step 6: Re-run the integration tests** + +Run: `uv run pytest tests/integration/test_gitlab_provider.py -v` +Expected: all tests pass. + +- [ ] **Step 7: Run the full test suite to catch cross-file fallout** + +Run: `just test` +Expected: `test_transport_retry.py` and `test_http_client.py` still pass (they test the legacy modules, which still exist). Coverage should still be 100%. + +If coverage dropped below 100% on `semvertag/providers/gitlab.py`, identify which branches lost coverage and either: +- Add the missing branch tests to `test_gitlab_provider.py`, or +- Confirm the lost branches were unreachable post-refactor and add `# pragma: no cover` with a one-line justification. + +- [ ] **Step 8: Lint** + +Run: `just lint` +Expected: clean. + +- [ ] **Step 9: Commit** + +```bash +git add semvertag/providers/gitlab.py tests/integration/test_gitlab_provider.py +git commit -m "providers/gitlab: port to httpware.Client; remove transport-layer concerns" +``` + +--- + +## Task 4: Switch `ioc.py` wiring to `httpware.Client` + +**Files:** +- Modify: `semvertag/ioc.py` + +After this task, `_transport.py` and `_http.py` have no remaining importers — Task 5 will delete them. + +- [ ] **Step 1: Rewrite `semvertag/ioc.py`** + +Replace the entire file contents with: + +```python +import typing + +import modern_di +from modern_di import Scope, providers + +import httpware + +from semvertag._errors import ConfigError +from semvertag._settings import Settings +from semvertag._use_case import SemvertagUseCase +from semvertag.providers.gitlab import GitLabProvider +from semvertag.strategies._base import BumpStrategy +from semvertag.strategies.branch_prefix import BranchPrefixStrategy +from semvertag.strategies.conventional_commits import ConventionalCommitsStrategy + + +_TOKEN_HEADER: typing.Final = "PRIVATE-TOKEN" +_RETRY_STATUS_CODES: typing.Final = frozenset({408, 429, 500, 502, 503, 504}) + + +def _build_gitlab_client(settings: Settings) -> httpware.Client: + return httpware.Client( + base_url=settings.gitlab.endpoint, + timeout=settings.request_timeout, + headers={_TOKEN_HEADER: settings.gitlab.token.get_secret_value()}, + middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], + ) + + +def _build_gitlab_provider(settings: Settings, client: httpware.Client) -> GitLabProvider: + if settings.project_id is None: + msg = "Project id missing. Set CI_PROJECT_ID or pass --project-id." + raise ConfigError(msg) + return GitLabProvider( + config=settings.gitlab, + project_id=settings.project_id, + http=client, + ) + + +def _build_branch_prefix_strategy(settings: Settings) -> BranchPrefixStrategy: + return BranchPrefixStrategy(config=settings.branch_prefix) + + +def _build_conventional_commits_strategy(settings: Settings) -> ConventionalCommitsStrategy: + return ConventionalCommitsStrategy(config=settings.conventional_commits) + + +def _build_current_strategy(settings: Settings) -> BumpStrategy: + if settings.strategy == "conventional-commits": + return _build_conventional_commits_strategy(settings) + return _build_branch_prefix_strategy(settings) + + +def _close_provider_client(provider: GitLabProvider) -> None: + provider.http.close() + + +class SettingsGroup(modern_di.Group): + settings = providers.ContextProvider(scope=Scope.APP, context_type=Settings) + + +class ProvidersGroup(modern_di.Group): + gitlab_client = providers.Factory(scope=Scope.APP, creator=_build_gitlab_client) + gitlab_provider = providers.Factory( + scope=Scope.APP, + creator=_build_gitlab_provider, + kwargs={"client": gitlab_client}, + cache_settings=providers.CacheSettings(finalizer=_close_provider_client), + ) + + +class StrategiesGroup(modern_di.Group): + branch_prefix_strategy = providers.Factory(scope=Scope.APP, creator=_build_branch_prefix_strategy) + conventional_commits_strategy = providers.Factory(scope=Scope.APP, creator=_build_conventional_commits_strategy) + current_strategy = providers.Factory(scope=Scope.APP, creator=_build_current_strategy) + + +class UseCasesGroup(modern_di.Group): + semvertag_use_case = providers.Factory( + scope=Scope.APP, + creator=SemvertagUseCase, + kwargs={ + "provider": ProvidersGroup.gitlab_provider, + "strategy": StrategiesGroup.current_strategy, + }, + ) + + +ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [ + SettingsGroup, + ProvidersGroup, + StrategiesGroup, + UseCasesGroup, +] + + +container: typing.Final = modern_di.Container(groups=ALL_GROUPS) +``` + +**Notes:** +- `TransportsGroup` deletes; `ALL_GROUPS` shrinks by one entry. +- Imports of `httpx2`, `_transport.RetryingTransport`, `_http.HttpClient`, `gitlab.gitlab_auth_headers`, and `gitlab._translate_status` are gone. +- The provider factory is split: `gitlab_client` is the new test-overridable seam (Task 6 sketches the override pattern). +- `_close_provider_client` calls `httpware.Client.close()` (the sync close method); `httpware.Client` is itself a context manager but `close()` works for finalizer use. + +- [ ] **Step 2: Run the full test suite** + +Run: `just test` +Expected: all tests pass; coverage stays at 100%. + +If `test_ioc.py` references `TransportsGroup`, fix that import (the file as of writing does not). If any test passes the old `_build_gitlab_provider(settings, transport=...)` signature, update it to the new two-step `_build_gitlab_client(settings)` + `_build_gitlab_provider(settings, client)` shape. + +- [ ] **Step 3: Lint** + +Run: `just lint` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add semvertag/ioc.py +git commit -m "ioc: build httpware.Client directly; drop TransportsGroup" +``` + +--- + +## Task 5: Delete legacy modules and their tests + +**Files:** +- Delete: `semvertag/_transport.py` +- Delete: `semvertag/providers/_http.py` +- Delete: `tests/unit/test_transport_retry.py` +- Delete: `tests/unit/test_http_client.py` + +After Tasks 3 and 4, nothing imports these. Verify, then delete. + +- [ ] **Step 1: Verify no remaining importers** + +Run: `grep -rn "from semvertag._transport\|from semvertag.providers._http\|RetryingTransport\|HttpClient" semvertag/ tests/ --include='*.py'` +Expected output: matches only inside `_transport.py`, `_http.py`, `test_transport_retry.py`, and `test_http_client.py` themselves. + +If any other file matches, stop and resolve before deleting. Likely missed update in Task 3. + +- [ ] **Step 2: Delete the four files** + +Run: +```bash +git rm semvertag/_transport.py semvertag/providers/_http.py +git rm tests/unit/test_transport_retry.py tests/unit/test_http_client.py +``` + +- [ ] **Step 3: Run the full test suite** + +Run: `just test` +Expected: all tests pass; coverage stays at 100%. (Coverage scope auto-shrinks — `--cov=semvertag` collects whatever modules exist.) + +- [ ] **Step 4: Check the `pyproject.toml` coverage `fail_under` and the per-module branch-coverage gates** + +Run: `grep -n "fail_under\|test-branch" /Users/kevinsmith/src/pypi/autosemver/pyproject.toml /Users/kevinsmith/src/pypi/autosemver/Justfile` + +If any `just test-branch-*` recipe targets a module we deleted (`_transport` or `_http`), remove that recipe from `Justfile`. If `pyproject.toml` references either module in coverage configuration, update it. + +- [ ] **Step 5: Lint** + +Run: `just lint` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git commit -m "providers: delete legacy _transport and _http modules" +``` + +--- + +## Task 6: Final validation + +**Files:** none modified — this is the verification gate. + +- [ ] **Step 1: Full lint sweep** + +Run: `just lint-ci` +Expected: clean. + +- [ ] **Step 2: Full test sweep with branch coverage** + +Run: `just test-branch` +Expected: all tests pass; coverage stays at 100% statement + branch. + +- [ ] **Step 3: Docs build (the project ships docs to ReadTheDocs)** + +Run: `mkdocs build --strict` +Expected: clean build. If a doc page references `RetryingTransport` or `HttpClient` by name, update the prose to reference `httpware.Retry` / `httpware.Client`. + +- [ ] **Step 4: End-to-end smoke against a real (or recorded) GitLab endpoint** *(optional but recommended)* + +If you have a sandbox GitLab project: set `SEMVERTAG_GITLAB__ENDPOINT`, `SEMVERTAG_TOKEN`, `CI_PROJECT_ID`, and run `uv run semvertag --dry-run` (or whatever the project's read-only CLI invocation is). Confirm: + - Default branch is fetched (200 → success). + - Latest commit is fetched. + - Tags are listed and paginated correctly. + - No `_http` or `_transport` import-time errors. + +If no sandbox is available, skip this step. The integration tests with `httpx2.MockTransport` cover the same paths. + +- [ ] **Step 5: Skim `git log --oneline` to confirm the commit history reads cleanly** + +Expected sequence (or similar): +``` +chore: add httpware[pydantic] dependency +providers: add httpware-to-semvertag error translation module +providers/gitlab: port to httpware.Client; remove transport-layer concerns +ioc: build httpware.Client directly; drop TransportsGroup +providers: delete legacy _transport and _http modules +``` + +- [ ] **Step 6 — Optional: invoke `superpowers:requesting-code-review`** + +Per CLAUDE.md the project's workflow is brainstorm → plan → TDD → review. Run a review subagent against the diff before merging. + +--- + +## Self-review notes + +- **Spec coverage:** Every spec section (`Target shape`, `Retry config`, `Error translation`, `Provider call-site shape`, `ioc.py wiring`, `Dependency changes`, `Test impact`, the four `Open items`) is implemented by at least one task. The four open items from the spec are all resolved in the plan: (1) DI split → Task 4 step 1, (2) `request_timeout` is a `float` (verified: `_settings.py:66`) → Task 4's `timeout=settings.request_timeout` works directly, (3) `__main__.py` catches `SemvertagError` already (verified: `__main__.py:160`) → no change needed because `translate_gitlab` produces semvertag domain errors that bubble through unchanged, (4) pagination uses `self.http.send(self.http.build_request(...))` returning the raw response → Task 3 step 2. +- **Placeholder scan:** No `TBD`/`TODO`/`...`/"appropriate error handling"/"similar to" patterns in any task step. The `# pragma: no cover` mention in Task 3 step 7 is a contingency directive, not a placeholder. +- **Type consistency:** `GitLabProvider.http: httpware.Client` used uniformly across Tasks 3 (production), 3 (tests via `_make_provider`), 4 (`_build_gitlab_provider` signature), 4 (`_close_provider_client`). `translate_gitlab(exc, *, project_id)` signature consistent across Task 2 (definition), Task 3 (call sites). `_build_gitlab_client` / `_build_gitlab_provider` split shape consistent between Task 4 step 1 and `ProvidersGroup` factory wiring within the same task. + +--- + +## Execution handoff + +(Filled in by the launching session — see `superpowers:writing-plans` skill.) diff --git a/planning/specs/2026-06-07-httpware-migration-design.md b/planning/specs/2026-06-07-httpware-migration-design.md new file mode 100644 index 0000000..7f0eba9 --- /dev/null +++ b/planning/specs/2026-06-07-httpware-migration-design.md @@ -0,0 +1,263 @@ +# httpware migration — design spec + +**Date:** 2026-06-07 +**Status:** Approved, ready for implementation planning +**Topic slug:** `httpware-migration` + +## Goal + +Port semvertag's provider HTTP stack from a hand-rolled retrying transport + decoder wrapper onto [`httpware`](https://github.com/modern-python/httpware) 0.8+, the sibling `modern-python` client framework. Result: semvertag deletes ~600 lines of HTTP plumbing it doesn't need to own, gains a maintained middleware-chain client, and keeps its operator-action exit-code semantics. + +Semvertag is pre-1.0; breaking behavior changes are explicitly allowed when the new behavior is the better solution. + +## Current state (what gets removed) + +Three modules carry the HTTP layer today: + +- **`semvertag/_transport.py`** — `RetryingTransport(httpx2.BaseTransport)` with 3 attempts, 1 s base full-jitter backoff, 30 s per-request wall cap, Retry-After parsing (numeric + HTTP date), retries on `{408, 429, 500, 502, 503, 504}` and `{ConnectError, ReadTimeout, WriteTimeout, RemoteProtocolError}`. +- **`semvertag/providers/_http.py`** — `HttpClient` dataclass wrapping `httpx2.Client` with three jobs: pydantic decoding via `request(..., schema=T)` / `request_many(..., schema=T)`, raw `httpx2.Response` access via `request_raw`, and auth-header injection via a callable. +- **`semvertag/providers/gitlab.py`** — `GitLabProvider` uses `HttpClient` for project lookup, latest commit, paginated tag list (link-header walking via `request_raw`), and tag creation (with 400-body inspection for the "already exists" fragment). + +`semvertag/ioc.py` wires a `TransportsGroup` that builds `RetryingTransport`, then a `ProvidersGroup.gitlab_provider` that constructs `httpx2.Client(transport=transport, base_url=..., timeout=...)`, wraps it in `HttpClient`, and binds a finalizer to close the underlying client. + +Tests (1204 lines touching HTTP): + +- `tests/unit/test_transport_retry.py` — 403 lines pinning every `RetryingTransport` behavior. +- `tests/unit/test_http_client.py` — 186 lines pinning `HttpClient` decoding + status-translator wiring. +- `tests/integration/test_gitlab_provider.py` — 615 lines using `httpx2.MockTransport` to drive `GitLabProvider` end-to-end. + +## Target shape + +``` +semvertag/ +├── _transport.py ← DELETED +├── providers/ +│ ├── _http.py ← DELETED +│ ├── _errors.py ← NEW: translate httpware StatusError → semvertag domain errors +│ └── gitlab.py ← holds httpware.Client directly; uses _errors.translate_gitlab() +└── ioc.py ← builds httpware.Client with Retry middleware; TransportsGroup deleted +``` + +`GitLabProvider` holds a `httpware.Client` field directly (renamed from `http: HttpClient` to keep the call-site shape `self.http.(...)` familiar). Typed reads use `self.http.get(url, response_model=...)`. Pagination + 400-body inspection use `self.http.send(self.http.build_request(...))` returning the raw `httpx2.Response`. Each provider method wraps its httpware calls in one `try/except httpware.StatusError`, calling `_errors.translate_gitlab(exc, project_id=self.project_id)` for the GitLab-specific message taxonomy. + +`HttpClient`'s three jobs are absorbed into httpware: + +| Old `HttpClient` job | New home | +|---|---| +| Pydantic decoding (`schema=T`) | httpware's `response_model=` | +| Raw response access (`request_raw`) | httpware's `send()` / `client.get()` without `response_model` | +| Auth header injection (callable) | `headers={"PRIVATE-TOKEN": token}` at client construction (the header is a constant for the client's lifetime — no per-request callable needed) | + +## Retry middleware: one knob, otherwise httpware defaults + +```python +httpware.Retry(retry_status_codes=frozenset({408, 429, 500, 502, 503, 504})) +``` + +The single configured value adds **500** to httpware's default retry set. GitLab emits transient 500s under load; retrying once costs ~0.1 s and recovers most. Everything else takes httpware defaults. + +**Intentional breaking changes from today's behavior** (all judged improvements): + +- **POST no longer retried.** `create_tag` 429/500 now fails immediately. Today's behavior — retrying POST and relying on the "already exists" body-string check to swallow phantom duplicates — is fragile; failing fast is cleaner. Operator reruns on transient failure. +- **Backoff base 0.1 s, max sleep 5 s** (was 1 s base, no per-sleep cap). Worst-case total sleep across 3 attempts ≈ 10 s (was up to 30 s). Snappier CI, more reasonable bound. +- **No 30 s per-request wall cap.** Replaced by httpware's `RetryBudget` (10 deposits + 20% retry ratio default), which caps retry storms across the run rather than wall time within a single request. For semvertag's ~5 requests/run profile this is strictly better. +- **Retry-After honored verbatim, capped at `max_delay=5 s`.** Semvertag today takes `max(server_hint, local_backoff)` (never sleeps less than backoff); httpware honors the server hint directly. Cleaner. + +`tests/unit/test_transport_retry.py` deletes entirely — that contract now belongs to httpware. + +## Error translation: `semvertag/providers/_errors.py` + +httpware raises `StatusError` subclasses with `exc.response` attached. semvertag classifies errors by operator action (`ConfigError`/exit 2 = "fix config", `AuthError`/exit 3 = "fix token", `ProviderAPIError`/exit 4 = "retry later"), which is orthogonal to HTTP status. One small module bridges them: + +```python +# semvertag/providers/_errors.py + +import httpware + +from semvertag._errors import AuthError, ConfigError, ProviderAPIError + + +_TAG_EXISTS_FRAGMENT = "already exists" + + +def translate_gitlab(exc: httpware.StatusError, *, project_id: int) -> Exception: + """Translate an httpware StatusError into the semvertag domain error for GitLab.""" + status = exc.response.status_code + if isinstance(exc, httpware.UnauthorizedError): + return AuthError("Token rejected: 401. Verify SEMVERTAG_TOKEN is valid and has 'api' scope.") + if isinstance(exc, httpware.ForbiddenError): + return AuthError( + "Token missing scope or insufficient permission: 403. " + "Add 'api' or 'write_repository' to the SEMVERTAG_TOKEN scopes on GitLab." + ) + if isinstance(exc, httpware.NotFoundError): + return ConfigError( + f"GitLab project not found: project_id={project_id}. Verify CI_PROJECT_ID or --project-id." + ) + if isinstance(exc, httpware.UnprocessableEntityError): + return ConfigError( + "Request rejected by GitLab: 422. Check tag name format and that the referenced commit exists." + ) + if isinstance(exc, httpware.RateLimitedError): + return ProviderAPIError("GitLab rate limit: 429. Retries exhausted after 3 attempts; try again later.") + if isinstance(exc, httpware.ServerStatusError): + return ProviderAPIError( + f"GitLab API failure: {status}. Retries exhausted after 3 attempts. " + "Try again or check GitLab status." + ) + return ProviderAPIError(f"Unexpected GitLab response: {status}. Please file an issue.") + + +def translate_create_tag_bad_request(exc: httpware.BadRequestError, *, tag_name: str) -> Exception: + """create_tag's 400 has an 'already exists' special case; everything else is a generic 400.""" + body = exc.response.text + if _TAG_EXISTS_FRAGMENT in body.lower(): + return ConfigError( + f"Tag already exists: '{tag_name}'. " + "The tag was created by a concurrent run or previous invocation." + ) + return ConfigError("Request rejected by GitLab: 400. Check tag name format and that the referenced commit exists.") +``` + +**Transport-layer failures** (`httpware.NetworkError`, `httpware.TimeoutError`, `httpware.RetryBudgetExhaustedError`) bubble up untranslated and are caught at the CLI boundary in `__main__.py` — same posture as today's `httpx2.RequestError` handling. The current `ProviderAPIError` wrapping in `_http.py` (`f"request failed: {type(exc).__name__}"`) goes away; httpware exceptions carry their own message. The wording change is acceptable under the breaking-changes-OK rule. + +### Why not redesign the error tree from scratch? + +Considered and rejected: + +- **Re-export `httpware.NotFoundError` directly.** Loses operator-action grouping; operators lose exit-code signal; not all semvertag errors map to HTTP (e.g. "default branch empty", "pagination exceeded N pages") so the tree gets weird. +- **Collapse `AuthError` into `ConfigError`** (two-error tree: `UserError`/`TransientError`). Saves one class. Costs the "token expired?" vs "project_id wrong?" CI signal. Not worth the breaking change. +- **Multi-base `ConfigError(httpware.ClientError, SemvertagError)`.** Heavier than translation, no clearer. + +The 25-line translation module is the minimum shape for "HTTP-status → operator-category" mapping. It expresses a product decision, not bureaucracy. + +## Provider call-site shape + +```python +# semvertag/providers/gitlab.py + +import httpware + +from semvertag.providers import _errors + + +@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) +class GitLabProvider: + name: typing.ClassVar[str] = "gitlab" + config: GitLabConfig + project_id: int + http: httpware.Client + + def get_default_branch(self) -> str: + try: + project = self.http.get( + f"{_API_PREFIX}/{self.project_id}", + response_model=_ProjectResponse, + ) + except httpware.StatusError as exc: + raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc + if not project.default_branch: + raise ConfigError( + "Default branch missing from GitLab response. " + "Verify the project has a default branch configured." + ) + return project.default_branch + + def list_tags(self) -> list[Tag]: + # uses self.http.send(self.http.build_request("GET", url, params=params)) + # so it can read response.headers["link"] for pagination. + # Same StatusError catch + translate pattern. + ... + + def create_tag(self, name: str, commit_sha: str) -> None: + try: + self.http.send(self.http.build_request( + "POST", + f"{_API_PREFIX}/{self.project_id}/repository/tags", + json={"tag_name": name, "ref": commit_sha}, + )) + except httpware.BadRequestError as exc: + raise _errors.translate_create_tag_bad_request(exc, tag_name=name) from exc + except httpware.StatusError as exc: + raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc +``` + +The `_url(self, path)` helper (`f"{self.config.endpoint.rstrip('/')}{path}"`) deletes — `httpware.Client(base_url=...)` handles URL joining. + +## `ioc.py` wiring + +```python +# semvertag/ioc.py + +import httpware + +def _build_gitlab_provider(settings: Settings) -> GitLabProvider: + if settings.project_id is None: + raise ConfigError("Project id missing. Set CI_PROJECT_ID or pass --project-id.") + client = httpware.Client( + base_url=settings.gitlab.endpoint, + timeout=settings.request_timeout, + headers={"PRIVATE-TOKEN": settings.gitlab.token.get_secret_value()}, + middleware=[httpware.Retry(retry_status_codes=frozenset({408, 429, 500, 502, 503, 504}))], + ) + return GitLabProvider( + config=settings.gitlab, + project_id=settings.project_id, + http=client, + ) + + +def _close_provider_client(provider: GitLabProvider) -> None: + provider.http.close() + + +class ProvidersGroup(modern_di.Group): + gitlab_provider = providers.Factory( + scope=Scope.APP, + creator=_build_gitlab_provider, + cache_settings=providers.CacheSettings(finalizer=_close_provider_client), + ) +``` + +`TransportsGroup` deletes. `ALL_GROUPS` loses one entry. `gitlab_auth_headers` helper deletes — auth is a constant string at construction time. + +### Test injection seam (plan-time detail) + +The current DI override pattern lets tests inject a custom transport via `TransportsGroup.transport`. With httpware that override seam moves. Implementation plan should split `_build_gitlab_provider` into a thin `_build_gitlab_client(settings) -> httpware.Client` factory + a `_build_gitlab_provider(settings, client) -> GitLabProvider` factory, so tests override just the client-construction step with `httpware.Client(httpx2_client=httpx2.Client(transport=mock))`. + +## Dependency changes + +`pyproject.toml`: + +- **Add**: `httpware[pydantic]` — gives us `Client`, `Retry`, the `StatusError` tree, and the default `PydanticDecoder`. +- **Keep**: `httpx2` — tests still need `httpx2.MockTransport`, and the injection seam is `httpware.Client(httpx2_client=httpx2.Client(transport=mock))`. +- **Keep**: `pydantic`, `pydantic-settings`, `modern-di-typer` — unchanged. +- **Drop nothing else.** + +## Test impact summary + +| File | Action | Reason | +|---|---|---| +| `tests/unit/test_transport_retry.py` (403 lines) | **delete** | Behavior now lives in httpware; tested upstream | +| `tests/unit/test_http_client.py` (186 lines) | **delete + replace** with `tests/unit/test_providers_errors.py` (~80 lines) | `HttpClient` is gone; translation module is the only new code to test | +| `tests/integration/test_gitlab_provider.py` (615 lines) | **rewrite injection seam, mostly preserve assertions** | Replace `httpx2.Client(transport=mock)` with `httpware.Client(httpx2_client=httpx2.Client(transport=mock))`; update assertions on `ProviderAPIError` wrapper-message wording where it changes; assertions on exception *type* and exit code remain unchanged | +| `tests/unit/test_ioc.py` | small edit | Drop `TransportsGroup` assertions; update `_build_gitlab_provider` call shape | +| Everything else | unchanged | No HTTP touch | + +Net: ~600 lines deleted, ~80 added, ~615 modified. 100% coverage gate stays. + +## Out of scope + +- Adding GitHub / Bitbucket providers. The roadmap calls for them; doing them in this PR multiplies test surface and slows landing. The translation pattern (`translate_gitlab`) is named so adding `translate_github` / `translate_bitbucket` later is mechanical. +- Going async. semvertag is a CLI doing ~5 requests per run; async is pure overhead. +- Adopting `httpware.Bulkhead`. Only useful when there's contention; not relevant for a CLI. +- Adopting httpware's observability/OTel hooks. Semvertag has no tracing surface today; adding it is its own decision. + +## Open items for the implementation plan + +These are explicitly *plan-time* decisions, not design-time: + +1. **DI override seam.** Decide on the `_build_gitlab_client` / `_build_gitlab_provider` split shape and the `modern_di` override pattern the integration test suite will use. +2. **`settings.request_timeout` type.** Verify the value `pydantic-settings` produces today is compatible with `httpware.Client(timeout=...)` (which forwards to `httpx2.Client(timeout=...)`). Almost certainly yes; verify rather than assume. +3. **`__main__.py` exception catching.** Decide whether to also catch `httpware.NetworkError` / `httpware.RetryBudgetExhaustedError` at the CLI boundary for friendly exit-code mapping (currently `httpx2.RequestError` is caught inside `_http.request_raw` and wrapped; with the new design those bubble untranslated unless we catch them). +4. **Pagination call-site detail.** `list_tags` needs both the parsed tag list *and* the response `Link` header. httpware's `client.get(url, response_model=T)` returns just `T`, not the response — confirmed by reading `client.py:147–157` (`return self._decoder.decode(response.content, response_model)`). Plan should pick one of: (a) untyped `send()` returning the raw `httpx2.Response`, then call `PydanticDecoder().decode(response.content, list[_TagItem])` explicitly; or (b) untyped `send()` then `response.json()` + manual `model_validate` (matches today's `_validate_tag_list` shape). (a) is more idiomatic; (b) is closer to today's code. diff --git a/pyproject.toml b/pyproject.toml index 9943fda..7644271 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,12 +2,11 @@ name = "semvertag" description = "Auto-tag GitLab repos with semantic version tags — one tool, two strategies." authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }] -requires-python = ">=3.10,<4" +requires-python = ">=3.11,<4" license = "MIT" readme = "README.md" keywords = ["semver", "gitlab", "ci", "auto-tag", "conventional-commits"] classifiers = [ - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -23,6 +22,7 @@ dependencies = [ "pydantic-settings", "modern-di-typer", "httpx2", + "httpware[pydantic]", ] [project.scripts] diff --git a/semvertag/_transport.py b/semvertag/_transport.py deleted file mode 100644 index 22cd0fb..0000000 --- a/semvertag/_transport.py +++ /dev/null @@ -1,84 +0,0 @@ -import datetime -import email.utils -import random -import time -import typing - -import httpx2 - - -RETRYABLE_STATUSES: typing.Final[frozenset[int]] = frozenset({408, 429, 500, 502, 503, 504}) -RETRYABLE_EXCEPTIONS: typing.Final[tuple[type[BaseException], ...]] = ( - httpx2.ConnectError, - httpx2.ReadTimeout, - httpx2.WriteTimeout, - httpx2.RemoteProtocolError, -) -MAX_ATTEMPTS: typing.Final = 3 -MAX_WALL_SECONDS: typing.Final = 30.0 -BACKOFF_BASE_SECONDS: typing.Final = 1.0 - - -class RetryingTransport(httpx2.BaseTransport): - def __init__(self, inner: httpx2.BaseTransport | None = None) -> None: - self._inner: httpx2.BaseTransport = inner or httpx2.HTTPTransport() - - 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 - attempt = 0 - while True: - try: - response = self._inner.handle_request(request) - except RETRYABLE_EXCEPTIONS as exc: - last_exc, last_response = exc, None - else: - if response.status_code not in RETRYABLE_STATUSES: - return response - last_response, last_exc = response, None - if attempt == MAX_ATTEMPTS - 1: - break - sleep_seconds = _compute_sleep(attempt, last_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 - assert last_exc is not None # noqa: S101 - raise last_exc - - def close(self) -> None: - self._inner.close() - - -def _compute_sleep(attempt: int, last_response: httpx2.Response | None) -> float: - backoff: typing.Final = random.uniform(0.0, BACKOFF_BASE_SECONDS * (2**attempt)) # noqa: S311 - if last_response is not None and last_response.status_code in RETRYABLE_STATUSES: - parsed: typing.Final = _parse_retry_after(last_response.headers.get("retry-after"), time.time()) - if parsed is not None: - return max(parsed, backoff) - return backoff - - -def _parse_retry_after(value: str | None, now_epoch: float) -> float | None: - if value is None: - return None - stripped: typing.Final = value.strip() - if not stripped: - return None - try: - seconds = float(stripped) - except (TypeError, ValueError): - pass - else: - return seconds if seconds >= 0.0 else None - try: - parsed_dt = email.utils.parsedate_to_datetime(stripped) - if parsed_dt.tzinfo is None: - parsed_dt = parsed_dt.replace(tzinfo=datetime.timezone.utc) - delta: typing.Final = parsed_dt.timestamp() - now_epoch - except (TypeError, ValueError, OverflowError): - return None - return max(0.0, delta) diff --git a/semvertag/ioc.py b/semvertag/ioc.py index 8474dd3..08ce226 100644 --- a/semvertag/ioc.py +++ b/semvertag/ioc.py @@ -1,39 +1,39 @@ import typing -import httpx2 +import httpware import modern_di from modern_di import Scope, providers from semvertag._errors import ConfigError from semvertag._settings import Settings -from semvertag._transport import RetryingTransport from semvertag._use_case import SemvertagUseCase -from semvertag.providers._http import HttpClient -from semvertag.providers.gitlab import GitLabProvider, _translate_status, gitlab_auth_headers +from semvertag.providers.gitlab import GitLabProvider from semvertag.strategies._base import BumpStrategy from semvertag.strategies.branch_prefix import BranchPrefixStrategy from semvertag.strategies.conventional_commits import ConventionalCommitsStrategy -def _build_gitlab_provider(settings: Settings, transport: httpx2.BaseTransport) -> GitLabProvider: - if settings.project_id is None: - msg = "Project id missing. Set CI_PROJECT_ID or pass --project-id." - raise ConfigError(msg) - project_id: typing.Final = settings.project_id - client: typing.Final = httpx2.Client( - transport=transport, +_TOKEN_HEADER: typing.Final = "PRIVATE-TOKEN" +_RETRY_STATUS_CODES: typing.Final = frozenset({408, 429, 500, 502, 503, 504}) + + +def _build_gitlab_client(settings: Settings) -> httpware.Client: + return httpware.Client( base_url=settings.gitlab.endpoint, timeout=settings.request_timeout, + headers={_TOKEN_HEADER: settings.gitlab.token.get_secret_value()}, + middleware=[httpware.Retry(retry_status_codes=_RETRY_STATUS_CODES)], ) - http: typing.Final = HttpClient( - client=client, - auth_headers=lambda: gitlab_auth_headers(settings.gitlab.token), - status_translator=lambda status: _translate_status(status, project_id), - ) + + +def _build_gitlab_provider(settings: Settings, client: httpware.Client) -> GitLabProvider: + if settings.project_id is None: + msg = "Project id missing. Set CI_PROJECT_ID or pass --project-id." + raise ConfigError(msg) return GitLabProvider( config=settings.gitlab, - project_id=project_id, - http=http, + project_id=settings.project_id, + http=client, ) @@ -52,22 +52,19 @@ def _build_current_strategy(settings: Settings) -> BumpStrategy: def _close_provider_client(provider: GitLabProvider) -> None: - provider.http.client.close() + provider.http.close() class SettingsGroup(modern_di.Group): settings = providers.ContextProvider(scope=Scope.APP, context_type=Settings) -class TransportsGroup(modern_di.Group): - transport = providers.Factory(scope=Scope.APP, creator=RetryingTransport) - - class ProvidersGroup(modern_di.Group): + gitlab_client = providers.Factory(scope=Scope.APP, creator=_build_gitlab_client) gitlab_provider = providers.Factory( scope=Scope.APP, creator=_build_gitlab_provider, - kwargs={"transport": TransportsGroup.transport}, + kwargs={"client": gitlab_client}, cache_settings=providers.CacheSettings(finalizer=_close_provider_client), ) @@ -91,7 +88,6 @@ class UseCasesGroup(modern_di.Group): ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [ SettingsGroup, - TransportsGroup, ProvidersGroup, StrategiesGroup, UseCasesGroup, diff --git a/semvertag/providers/_errors.py b/semvertag/providers/_errors.py new file mode 100644 index 0000000..1b9d45f --- /dev/null +++ b/semvertag/providers/_errors.py @@ -0,0 +1,68 @@ +import typing + +import httpware + +from semvertag._errors import AuthError, ConfigError, ProviderAPIError + + +_TAG_EXISTS_FRAGMENT: typing.Final = "already exists" + + +def _translate_gitlab_auth(exc: httpware.StatusError) -> Exception: + if isinstance(exc, httpware.UnauthorizedError): + return AuthError("Token rejected: 401. Verify SEMVERTAG_TOKEN is valid and has 'api' scope.") + return AuthError( + "Token missing scope or insufficient permission: 403. " + "Add 'api' or 'write_repository' to the SEMVERTAG_TOKEN scopes on GitLab." + ) + + +def _translate_gitlab_status(exc: httpware.StatusError, *, project_id: int) -> Exception: + if isinstance(exc, (httpware.UnauthorizedError, httpware.ForbiddenError)): + return _translate_gitlab_auth(exc) + if isinstance(exc, httpware.NotFoundError): + return ConfigError(f"GitLab project not found: project_id={project_id}. Verify CI_PROJECT_ID or --project-id.") + if isinstance(exc, httpware.UnprocessableEntityError): + return ConfigError( + "Request rejected by GitLab: 422. Check tag name format and that the referenced commit exists." + ) + if isinstance(exc, httpware.RateLimitedError): + return ProviderAPIError("GitLab rate limit: 429. Retries exhausted after 3 attempts; try again later.") + if isinstance(exc, httpware.ServerStatusError): + return ProviderAPIError( + f"GitLab API failure: {exc.response.status_code}. " + "Retries exhausted after 3 attempts. Try again or check GitLab status." + ) + return ProviderAPIError(f"Unexpected GitLab response: {exc.response.status_code}. Please file an issue.") + + +def _translate_gitlab_transport(exc: httpware.ClientError) -> Exception: + if isinstance(exc, httpware.TimeoutError): + return ProviderAPIError("GitLab request timed out. Try again or increase SEMVERTAG_REQUEST_TIMEOUT.") + if isinstance(exc, httpware.RetryBudgetExhaustedError): + return ProviderAPIError(f"GitLab retries exhausted after {exc.attempts} attempts. Try again later.") + if isinstance(exc, httpware.NetworkError): + return ProviderAPIError("GitLab unreachable. Check network connectivity.") + return ProviderAPIError(f"GitLab request failed: {type(exc).__name__}") + + +def translate_gitlab(exc: httpware.ClientError, *, project_id: int) -> Exception: + """ + Translate an httpware ClientError into the semvertag domain error for GitLab. + + Handles both status errors (4xx/5xx) and transport-layer failures + (network, timeout, retry budget exhaustion). + """ + if isinstance(exc, httpware.StatusError): + return _translate_gitlab_status(exc, project_id=project_id) + return _translate_gitlab_transport(exc) + + +def translate_create_tag_bad_request(exc: httpware.BadRequestError, *, tag_name: str) -> Exception: + """create_tag's 400 has an 'already exists' special case; everything else is a generic 400.""" + body = exc.response.text + if _TAG_EXISTS_FRAGMENT in body.lower(): + return ConfigError( + f"Tag already exists: '{tag_name}'. The tag was created by a concurrent run or previous invocation." + ) + return ConfigError("Request rejected by GitLab: 400. Check tag name format and that the referenced commit exists.") diff --git a/semvertag/providers/_http.py b/semvertag/providers/_http.py deleted file mode 100644 index 826c99b..0000000 --- a/semvertag/providers/_http.py +++ /dev/null @@ -1,62 +0,0 @@ -import collections.abc -import dataclasses -import typing - -import httpx2 -import pydantic - -from semvertag._errors import ProviderAPIError - - -T = typing.TypeVar("T", bound=pydantic.BaseModel) - -AuthHeaders: typing.TypeAlias = collections.abc.Callable[[], dict[str, str]] -StatusTranslator: typing.TypeAlias = collections.abc.Callable[[int], None] - - -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class HttpClient: - client: httpx2.Client - auth_headers: AuthHeaders - status_translator: StatusTranslator - - def request(self, method: str, url: str, *, schema: type[T], **kwargs: typing.Any) -> T: # noqa: ANN401 - response = self._request_translated(method, url, **kwargs) - payload = self._decode_json(response) - try: - return schema.model_validate(payload) - except pydantic.ValidationError as exc: - msg = f"response shape invalid: {exc}" - raise ProviderAPIError(msg) from exc - - def request_many(self, method: str, url: str, *, schema: type[T], **kwargs: typing.Any) -> list[T]: # noqa: ANN401 - response = self._request_translated(method, url, **kwargs) - payload = self._decode_json(response) - if not isinstance(payload, list): - msg = f"response shape invalid: expected list, got {type(payload).__name__}" - raise ProviderAPIError(msg) - try: - return [schema.model_validate(item) for item in payload] - except pydantic.ValidationError as exc: - msg = f"response shape invalid: {exc}" - raise ProviderAPIError(msg) from exc - - def request_raw(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Response: # noqa: ANN401 - try: - return self.client.request(method, url, headers=self.auth_headers(), **kwargs) - except httpx2.RequestError as exc: - msg = f"request failed: {type(exc).__name__}" - raise ProviderAPIError(msg) from exc - - def _request_translated(self, method: str, url: str, **kwargs: typing.Any) -> httpx2.Response: # noqa: ANN401 - response = self.request_raw(method, url, **kwargs) - self.status_translator(response.status_code) - return response - - @staticmethod - def _decode_json(response: httpx2.Response) -> typing.Any: # noqa: ANN401 - try: - return response.json() - except (ValueError, httpx2.DecodingError) as exc: - msg = "malformed JSON in response body" - raise ProviderAPIError(msg) from exc diff --git a/semvertag/providers/gitlab.py b/semvertag/providers/gitlab.py index 835428b..7d8ad10 100644 --- a/semvertag/providers/gitlab.py +++ b/semvertag/providers/gitlab.py @@ -3,33 +3,20 @@ import typing import urllib.parse +import httpware import httpx2 import pydantic -from semvertag._errors import AuthError, ConfigError, ProviderAPIError +from semvertag._errors import ConfigError, ProviderAPIError from semvertag._settings import GitLabConfig from semvertag._types import Commit, Tag -from semvertag.providers._http import HttpClient +from semvertag.providers import _errors _API_PREFIX: typing.Final = "/api/v4/projects" -_PRIVATE_TOKEN_HEADER: typing.Final = "PRIVATE-TOKEN" _TAGS_PER_PAGE: typing.Final = 100 _MAX_TAG_PAGES: typing.Final = 100 -_HTTP_OK: typing.Final = 200 -_HTTP_CREATED: typing.Final = 201 -_HTTP_BAD_REQUEST: typing.Final = 400 -_HTTP_UNAUTHORIZED: typing.Final = 401 -_HTTP_FORBIDDEN: typing.Final = 403 -_HTTP_NOT_FOUND: typing.Final = 404 -_HTTP_UNPROCESSABLE: typing.Final = 422 -_HTTP_TOO_MANY_REQUESTS: typing.Final = 429 -_HTTP_SERVER_ERROR_MIN: typing.Final = 500 -_HTTP_SERVER_ERROR_MAX: typing.Final = 600 - -_TAG_EXISTS_FRAGMENT: typing.Final = "already exists" - class _ProjectResponse(pydantic.BaseModel): default_branch: str | None @@ -60,14 +47,19 @@ class GitLabProvider: name: typing.ClassVar[str] = "gitlab" config: GitLabConfig project_id: int - http: HttpClient + http: httpware.Client def get_default_branch(self) -> str: - project = self.http.request( - "GET", - self._url(f"{_API_PREFIX}/{self.project_id}"), - schema=_ProjectResponse, - ) + try: + response = self.http.send( + self.http.build_request( + "GET", + f"{_API_PREFIX}/{self.project_id}", + ) + ) + except httpware.ClientError as exc: + raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc + project = _validate_project_response(response) if not project.default_branch: msg = "Default branch missing from GitLab response. Verify the project has a default branch configured." raise ConfigError(msg) @@ -75,12 +67,17 @@ def get_default_branch(self) -> str: def get_latest_commit_on_default_branch(self) -> Commit: default_branch: typing.Final = self.get_default_branch() - items = self.http.request_many( - "GET", - self._url(f"{_API_PREFIX}/{self.project_id}/repository/commits"), - schema=_CommitItem, - params={"ref_name": default_branch, "per_page": 1}, - ) + try: + response = self.http.send( + self.http.build_request( + "GET", + f"{_API_PREFIX}/{self.project_id}/repository/commits", + params={"ref_name": default_branch, "per_page": 1}, + ) + ) + except httpware.ClientError as exc: + raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc + items = _validate_commit_list(response) if not items: msg = f"No commits on default branch '{default_branch}'. The branch appears empty." raise ProviderAPIError(msg) @@ -89,15 +86,16 @@ def get_latest_commit_on_default_branch(self) -> Commit: def list_tags(self) -> list[Tag]: tags: list[Tag] = [] - base_url: typing.Final = self._url(f"{_API_PREFIX}/{self.project_id}/repository/tags") - url: str = base_url + url: str = f"{_API_PREFIX}/{self.project_id}/repository/tags" params: dict[str, typing.Any] | None = {"per_page": _TAGS_PER_PAGE, "page": 1} for _ in range(_MAX_TAG_PAGES): - response = self.http.request_raw("GET", url, params=params) - _translate_status(response.status_code, self.project_id) + try: + response = self.http.send(self.http.build_request("GET", url, params=params)) + except httpware.ClientError as exc: + raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc items = _validate_tag_list(response) tags.extend(Tag(name=item.name, commit_sha=item.commit.id) for item in items) - next_url = _next_page_url(response, current_url=url) + next_url = _next_page_url(response, current_url=str(response.request.url)) if next_url is None: return tags if not _same_origin(next_url, self.config.endpoint): @@ -114,61 +112,18 @@ def list_tags(self) -> list[Tag]: raise ProviderAPIError(msg) def create_tag(self, name: str, commit_sha: str) -> None: - response = self.http.request_raw( - "POST", - self._url(f"{_API_PREFIX}/{self.project_id}/repository/tags"), - json={"tag_name": name, "ref": commit_sha}, - ) - if response.status_code == _HTTP_CREATED: - return - if response.status_code == _HTTP_BAD_REQUEST: - body_message = "" - try: - payload = response.json() - body_message = str(payload.get("message", "")) if isinstance(payload, dict) else "" - except (ValueError, httpx2.DecodingError): - pass - if _TAG_EXISTS_FRAGMENT in body_message.lower(): - msg = f"Tag already exists: '{name}'. The tag was created by a concurrent run or previous invocation." - raise ConfigError(msg) - msg = "Request rejected by GitLab: 400. Check tag name format and that the referenced commit exists." - raise ConfigError(msg) - _translate_status(response.status_code, self.project_id) - - def _url(self, path: str) -> str: - return f"{self.config.endpoint.rstrip('/')}{path}" - - -def gitlab_auth_headers(token: pydantic.SecretStr) -> dict[str, str]: - return {_PRIVATE_TOKEN_HEADER: token.get_secret_value()} - - -def _translate_status(status: int, project_id: int) -> None: - if status in {_HTTP_OK, _HTTP_CREATED}: - return - if status == _HTTP_UNAUTHORIZED: - msg = "Token rejected: 401. Verify SEMVERTAG_TOKEN is valid and has 'api' scope." - raise AuthError(msg) - if status == _HTTP_FORBIDDEN: - msg = ( - "Token missing scope or insufficient permission: 403. " - "Add 'api' or 'write_repository' to the SEMVERTAG_TOKEN scopes on GitLab." - ) - raise AuthError(msg) - if status == _HTTP_NOT_FOUND: - msg = f"GitLab project not found: project_id={project_id}. Verify CI_PROJECT_ID or --project-id." - raise ConfigError(msg) - if status == _HTTP_UNPROCESSABLE: - msg = "Request rejected by GitLab: 422. Check tag name format and that the referenced commit exists." - raise ConfigError(msg) - if status == _HTTP_TOO_MANY_REQUESTS: - msg = "GitLab rate limit: 429. Retries exhausted after 3 attempts; try again later." - raise ProviderAPIError(msg) - if _HTTP_SERVER_ERROR_MIN <= status < _HTTP_SERVER_ERROR_MAX: - msg = f"GitLab API failure: {status}. Retries exhausted after 3 attempts. Try again or check GitLab status." - raise ProviderAPIError(msg) - msg = f"Unexpected GitLab response: {status}. Please file an issue." - raise ProviderAPIError(msg) + try: + self.http.send( + self.http.build_request( + "POST", + f"{_API_PREFIX}/{self.project_id}/repository/tags", + json={"tag_name": name, "ref": commit_sha}, + ) + ) + except httpware.BadRequestError as exc: + raise _errors.translate_create_tag_bad_request(exc, tag_name=name) from exc + except httpware.ClientError as exc: + raise _errors.translate_gitlab(exc, project_id=self.project_id) from exc def _next_page_url(response: httpx2.Response, current_url: str) -> str | None: @@ -184,19 +139,50 @@ def _next_page_url(response: httpx2.Response, current_url: str) -> str | None: return None +_TModel = typing.TypeVar("_TModel", bound=pydantic.BaseModel) + + +def _validate_obj(response: httpx2.Response, model: type[_TModel], *, label: str) -> _TModel: + try: + payload = response.json() + except (ValueError, httpx2.DecodingError) as exc: + msg = f"GitLab {label} response malformed JSON." + raise ProviderAPIError(msg) from exc + if not isinstance(payload, dict): + msg = f"GitLab {label} response shape invalid: expected object." + raise ProviderAPIError(msg) + try: + return model.model_validate(payload) + except pydantic.ValidationError as exc: + msg = f"GitLab {label} response shape invalid: {exc}" + raise ProviderAPIError(msg) from exc + + +def _validate_project_response(response: httpx2.Response) -> _ProjectResponse: + return _validate_obj(response, _ProjectResponse, label="project") + + def _validate_tag_list(response: httpx2.Response) -> list[_TagItem]: + return _validate_list(response, _TagItem, label="tags") + + +def _validate_commit_list(response: httpx2.Response) -> list[_CommitItem]: + return _validate_list(response, _CommitItem, label="commits") + + +def _validate_list(response: httpx2.Response, model: type[_TModel], *, label: str) -> list[_TModel]: try: payload = response.json() except (ValueError, httpx2.DecodingError) as exc: - msg = "GitLab tags response malformed JSON." + msg = f"GitLab {label} response malformed JSON." raise ProviderAPIError(msg) from exc if not isinstance(payload, list): - msg = "GitLab tags response shape invalid: expected list." + msg = f"GitLab {label} response shape invalid: expected list." raise ProviderAPIError(msg) try: - return [_TagItem.model_validate(item) for item in payload] + return [model.model_validate(item) for item in payload] except pydantic.ValidationError as exc: - msg = f"GitLab tags response shape invalid: {exc}" + msg = f"GitLab {label} response shape invalid: {exc}" raise ProviderAPIError(msg) from exc diff --git a/tests/conftest.py b/tests/conftest.py index 6f9065e..91fd978 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,31 +1,25 @@ import collections.abc import typing +import httpware import httpx2 import pydantic import pytest from semvertag._settings import GitLabConfig -from semvertag.providers._http import HttpClient -from semvertag.providers.gitlab import GitLabProvider, _translate_status, gitlab_auth_headers +from semvertag.providers.gitlab import GitLabProvider GITLAB_PROJECT_ID: typing.Final = 999 GITLAB_ENDPOINT: typing.Final = "https://gitlab.example.test" GITLAB_TOKEN: typing.Final = "glpat-XXXXXXXXXXXXXXXXXXXX" _REQUEST_TIMEOUT: typing.Final = 8.0 +_TOKEN_HEADER: typing.Final = "PRIVATE-TOKEN" HandlerCallable: typing.TypeAlias = collections.abc.Callable[[httpx2.Request], httpx2.Response] -def _make_status_translator(project_id: int) -> typing.Callable[[int], None]: - def translator(status: int) -> None: - _translate_status(status, project_id) - - return translator - - def default_handler(request: httpx2.Request) -> httpx2.Response: method: typing.Final = request.method path: typing.Final = request.url.path @@ -67,21 +61,22 @@ def gitlab_transport() -> httpx2.MockTransport: @pytest.fixture def gitlab_client(gitlab_transport: httpx2.MockTransport) -> collections.abc.Iterator[httpx2.Client]: - with httpx2.Client(transport=gitlab_transport, base_url=GITLAB_ENDPOINT, timeout=_REQUEST_TIMEOUT) as client: + config: typing.Final = GitLabConfig(endpoint=GITLAB_ENDPOINT, token=pydantic.SecretStr(GITLAB_TOKEN)) + with httpx2.Client( + transport=gitlab_transport, + base_url=GITLAB_ENDPOINT, + timeout=_REQUEST_TIMEOUT, + headers={_TOKEN_HEADER: config.token.get_secret_value()}, + ) as client: yield client @pytest.fixture -def gitlab_http(gitlab_client: httpx2.Client) -> HttpClient: - config: typing.Final = GitLabConfig(endpoint=GITLAB_ENDPOINT, token=pydantic.SecretStr(GITLAB_TOKEN)) - return HttpClient( - client=gitlab_client, - auth_headers=lambda: gitlab_auth_headers(config.token), - status_translator=_make_status_translator(GITLAB_PROJECT_ID), - ) +def gitlab_http(gitlab_client: httpx2.Client) -> httpware.Client: + return httpware.Client(httpx2_client=gitlab_client) @pytest.fixture -def gitlab_provider(gitlab_http: HttpClient) -> GitLabProvider: +def gitlab_provider(gitlab_http: httpware.Client) -> GitLabProvider: config: typing.Final = GitLabConfig(endpoint=GITLAB_ENDPOINT, token=pydantic.SecretStr(GITLAB_TOKEN)) return GitLabProvider(config=config, project_id=GITLAB_PROJECT_ID, http=gitlab_http) diff --git a/tests/integration/README.md b/tests/integration/README.md index aba281f..f1c1d3c 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -1,17 +1,22 @@ # Integration test notes -## `_transport.time.sleep` monkeypatch coupling +## Mocking the HTTP transport -Integration tests that exercise retry-exhaustion paths (`test_gitlab_provider.py:848-849, 862-863, 876-877`; exit-4 cases in `test_cli_quiet_json_matrix.py`) reach into `semvertag._transport` and replace its bound `time.sleep` and `random.uniform` references with no-ops: +Integration tests inject `httpx2.MockTransport` into the production `httpware.Client` so the GitLab provider can be exercised end-to-end without a real GitLab. + +Two seams exist, depending on the test entry point: + +- **CLI-level tests** (`test_cli_*.py`) drive `semvertag.__main__` through Typer's `CliRunner` and let the DI container build the production stack. They override `ioc.ProvidersGroup.gitlab_client` with a mock-backed `httpware.Client` via the `install_mock_transport` fixture in `conftest.py`. +- **Provider-level tests** (`test_gitlab_provider.py`) bypass the container and call the `_make_provider(handler)` helper, which constructs an `httpware.Client(httpx2_client=httpx2.Client(transport=httpx2.MockTransport(handler), base_url=...))` directly. + +## Disabling retry sleeps + +Retry-exhaustion paths (`test_raises_provider_api_error_on_5xx`, `..._on_429`, the exit-4 cases in `test_cli_quiet_json_matrix.py`) would otherwise wait on `httpware.Retry`'s full-jitter backoff. The standard fix is to monkeypatch the stdlib sleep used by `httpware.middleware.resilience.retry`: ```python -monkeypatch.setattr(_transport.time, "sleep", lambda *_: None) -monkeypatch.setattr(_transport.random, "uniform", lambda *_: 0.0) +monkeypatch.setattr(time, "sleep", lambda *_a, **_k: None) ``` -This is the established no-sleep pattern. **Two consequences worth knowing before refactoring `_transport.py`:** - -- A rename or restructure that moves the `time` / `random` module bindings (e.g., importing `from time import sleep` instead of `import time`) silently breaks the monkeypatch and reintroduces real sleeps. Tests will pass but take seconds longer. -- If you ever add an injected `sleep_fn` / `random_fn` parameter to `RetryingTransport`, update the integration tests to inject via that seam instead of monkeypatching the module — that closes the coupling. +Patching the global `time.sleep` is equivalent to patching `httpware.middleware.resilience.retry.time.sleep` because that module imports `time` and references `time.sleep` via the module attribute. If a future `httpware` refactor changes the binding shape (e.g. `from time import sleep`), this monkeypatch will silently break — tests will pass but take seconds longer. -There is no fix required today; this note exists so future `_transport` refactors don't accidentally regress test runtime or behavior. +If `httpware.Retry` ever grows an injectable `_sleep` parameter, prefer that over the monkeypatch — it closes the coupling. diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 68412bd..8a317da 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,6 +1,7 @@ import collections.abc import typing +import httpware import httpx2 import pytest from typer.testing import CliRunner @@ -54,11 +55,17 @@ def cli_env(monkeypatch: pytest.MonkeyPatch) -> None: def install_mock_transport() -> typing.Iterator[collections.abc.Callable[[HandlerCallable], None]]: def install(handler: HandlerCallable) -> None: mock_transport: typing.Final = httpx2.MockTransport(handler) - ioc.container.override(ioc.TransportsGroup.transport, mock_transport) + mock_client: typing.Final = httpware.Client( + httpx2_client=httpx2.Client( + transport=mock_transport, + base_url=GITLAB_ENDPOINT, + ) + ) + ioc.container.override(ioc.ProvidersGroup.gitlab_client, mock_client) with ioc.container: yield install - ioc.container.reset_override(ioc.TransportsGroup.transport) + ioc.container.reset_override(ioc.ProvidersGroup.gitlab_client) _PROJECT_PATH: typing.Final = f"/api/v4/projects/{GITLAB_PROJECT_ID}" diff --git a/tests/integration/test_cli_quiet_json_matrix.py b/tests/integration/test_cli_quiet_json_matrix.py index bb9f5ba..1a5cc88 100644 --- a/tests/integration/test_cli_quiet_json_matrix.py +++ b/tests/integration/test_cli_quiet_json_matrix.py @@ -1,12 +1,12 @@ import collections.abc import json as json_module +import time import typing import httpx2 import pytest from typer.testing import CliRunner -from semvertag import _transport from semvertag.__main__ import MAIN_APP from semvertag._errors import SemvertagError from semvertag._output import Output @@ -168,8 +168,7 @@ def test_exits_with_four_on_provider_api_error_via_503_after_retry_exhaustion( cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(_transport.time, "sleep", lambda *_a, **_k: None) - monkeypatch.setattr(_transport.random, "uniform", lambda *_a, **_k: 0.0) + monkeypatch.setattr(time, "sleep", lambda *_a, **_k: None) def handler(request: httpx2.Request) -> httpx2.Response: # noqa: ARG001 return httpx2.Response(_SERVICE_UNAVAILABLE_STATUS, json={"message": "service down"}) diff --git a/tests/integration/test_gitlab_provider.py b/tests/integration/test_gitlab_provider.py index 19e9318..4704548 100644 --- a/tests/integration/test_gitlab_provider.py +++ b/tests/integration/test_gitlab_provider.py @@ -1,22 +1,18 @@ import typing +import httpware import httpx2 import pydantic import pytest -from semvertag import _transport from semvertag._errors import AuthError, ConfigError, ProviderAPIError from semvertag._settings import GitLabConfig -from semvertag._transport import RetryingTransport from semvertag._types import Commit, Tag from semvertag.providers._base import Provider -from semvertag.providers._http import HttpClient from semvertag.providers.gitlab import ( GitLabProvider, _next_page_url, _parse_rel_values, - _translate_status, - gitlab_auth_headers, ) from tests.conftest import ( GITLAB_ENDPOINT, @@ -43,33 +39,22 @@ _PAGINATION_CAP: typing.Final = 100 -def _make_provider(handler: HandlerCallable) -> tuple[GitLabProvider, httpx2.Client]: - transport: typing.Final = httpx2.MockTransport(handler) - client: typing.Final = httpx2.Client(transport=transport, base_url=GITLAB_ENDPOINT) - config: typing.Final = GitLabConfig(endpoint=GITLAB_ENDPOINT, token=pydantic.SecretStr(GITLAB_TOKEN)) - http: typing.Final = HttpClient( - client=client, - auth_headers=lambda: gitlab_auth_headers(config.token), - status_translator=lambda status: _translate_status(status, GITLAB_PROJECT_ID), - ) - provider: typing.Final = GitLabProvider(config=config, project_id=GITLAB_PROJECT_ID, http=http) - return provider, client +_TOKEN_HEADER: typing.Final = "PRIVATE-TOKEN" -def _make_provider_with_retrying_transport( - inner_handler: HandlerCallable, -) -> tuple[GitLabProvider, httpx2.Client]: - inner: typing.Final = httpx2.MockTransport(inner_handler) - retrying: typing.Final = RetryingTransport(inner=inner) - client: typing.Final = httpx2.Client(transport=retrying, base_url=GITLAB_ENDPOINT) +def _make_provider(handler: HandlerCallable) -> tuple[GitLabProvider, httpx2.Client]: + transport: typing.Final = httpx2.MockTransport(handler) config: typing.Final = GitLabConfig(endpoint=GITLAB_ENDPOINT, token=pydantic.SecretStr(GITLAB_TOKEN)) - http: typing.Final = HttpClient( - client=client, - auth_headers=lambda: gitlab_auth_headers(config.token), - status_translator=lambda status: _translate_status(status, GITLAB_PROJECT_ID), + inner_client: typing.Final = httpx2.Client( + transport=transport, + base_url=GITLAB_ENDPOINT, + headers={_TOKEN_HEADER: config.token.get_secret_value()}, ) + http: typing.Final = httpware.Client(httpx2_client=inner_client) provider: typing.Final = GitLabProvider(config=config, project_id=GITLAB_PROJECT_ID, http=http) - return provider, client + # Return the inner httpx2.Client so tests can use it as a context manager + # for teardown; httpware.Client doesn't own its lifecycle when constructed via httpx2_client=. + return provider, inner_client # Protocol conformance @@ -124,6 +109,15 @@ def test_raises_provider_api_error_when_default_branch_body_is_not_json() -> Non provider.get_default_branch() +def test_raises_provider_api_error_when_default_branch_body_is_not_object() -> None: + overrides: typing.Final = { + ("GET", _PROJECT_PATH): httpx2.Response(200, json=[]), + } + provider, client = _make_provider(compose_handler(default_handler, overrides)) + with client, pytest.raises(ProviderAPIError, match="expected object"): + provider.get_default_branch() + + def test_get_default_branch_sends_private_token_header() -> None: captured_headers: dict[str, str] = {} captured_url_path: dict[str, str] = {} @@ -515,19 +509,16 @@ def handler(request: httpx2.Request) -> httpx2.Response: _MAIN_VERB_CALLS, ids=[name for name, _call, _key in _MAIN_VERB_CALLS], ) -def test_raises_provider_api_error_on_5xx_after_retries_exhausted( - monkeypatch: pytest.MonkeyPatch, +def test_raises_provider_api_error_on_5xx( verb_name: str, # noqa: ARG001 verb_callable: typing.Callable[[GitLabProvider], None], endpoint_key: tuple[str, str], ) -> None: - monkeypatch.setattr(_transport.time, "sleep", lambda _: None) - monkeypatch.setattr(_transport.random, "uniform", lambda _lo, _hi: 0.0) handler: typing.Final = _handler_that_returns_for( endpoint_key, httpx2.Response(_SERVICE_UNAVAILABLE_STATUS, json={}), ) - provider, client = _make_provider_with_retrying_transport(handler) + provider, client = _make_provider(handler) with client, pytest.raises(ProviderAPIError, match=f"GitLab API failure: {_SERVICE_UNAVAILABLE_STATUS}"): verb_callable(provider) @@ -537,19 +528,16 @@ def test_raises_provider_api_error_on_5xx_after_retries_exhausted( _MAIN_VERB_CALLS, ids=[name for name, _call, _key in _MAIN_VERB_CALLS], ) -def test_raises_provider_api_error_on_429_after_retries_exhausted( - monkeypatch: pytest.MonkeyPatch, +def test_raises_provider_api_error_on_429( verb_name: str, # noqa: ARG001 verb_callable: typing.Callable[[GitLabProvider], None], endpoint_key: tuple[str, str], ) -> None: - monkeypatch.setattr(_transport.time, "sleep", lambda _: None) - monkeypatch.setattr(_transport.random, "uniform", lambda _lo, _hi: 0.0) handler: typing.Final = _handler_that_returns_for( endpoint_key, httpx2.Response(_TOO_MANY_REQUESTS_STATUS, json={}), ) - provider, client = _make_provider_with_retrying_transport(handler) + provider, client = _make_provider(handler) with client, pytest.raises(ProviderAPIError, match="GitLab rate limit: 429"): verb_callable(provider) @@ -559,33 +547,22 @@ def test_raises_provider_api_error_on_429_after_retries_exhausted( _MAIN_VERB_CALLS, ids=[name for name, _call, _key in _MAIN_VERB_CALLS], ) -def test_raises_provider_api_error_when_request_error_chained_from_exc( - monkeypatch: pytest.MonkeyPatch, +def test_raises_provider_api_error_when_network_error( verb_name: str, # noqa: ARG001 verb_callable: typing.Callable[[GitLabProvider], None], endpoint_key: tuple[str, str], ) -> None: - monkeypatch.setattr(_transport.time, "sleep", lambda _: None) - monkeypatch.setattr(_transport.random, "uniform", lambda _lo, _hi: 0.0) handler: typing.Final = _handler_that_raises_for( endpoint_key, lambda: httpx2.ConnectError("simulated network failure"), ) - provider, client = _make_provider_with_retrying_transport(handler) - with client, pytest.raises(ProviderAPIError, match="request failed") as exc_info: + provider, client = _make_provider(handler) + with client, pytest.raises(ProviderAPIError, match="GitLab unreachable") 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 + assert isinstance(exc_info.value.__cause__, httpware.NetworkError) -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) +# Link-header parser edge cases def _link_header_response(link_header: str) -> httpx2.Response: diff --git a/tests/unit/test_http_client.py b/tests/unit/test_http_client.py deleted file mode 100644 index e6dbdcc..0000000 --- a/tests/unit/test_http_client.py +++ /dev/null @@ -1,186 +0,0 @@ -import typing - -import httpx2 -import pydantic -import pytest - -from semvertag._errors import AuthError, ProviderAPIError -from semvertag.providers._http import HttpClient - - -_BASE_URL: typing.Final = "https://example.test" -_EXPECTED_COUNT: typing.Final = 7 -_EXPECTED_LIST_LEN: typing.Final = 2 -_EXPECTED_LAST_ITEM_COUNT: typing.Final = 2 -_UNAUTHORIZED_STATUS: typing.Final = 401 - - -class _SampleResponse(pydantic.BaseModel): - name: str - count: int - - -def _build_client(handler: typing.Callable[[httpx2.Request], httpx2.Response]) -> HttpClient: - transport: typing.Final = httpx2.MockTransport(handler) - inner: typing.Final = httpx2.Client(transport=transport, base_url=_BASE_URL) - return HttpClient( - client=inner, - auth_headers=lambda: {"X-Test-Auth": "token-xyz"}, - status_translator=lambda _status: None, - ) - - -def test_request_returns_validated_schema_instance_on_happy_path() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"name": "alice", "count": _EXPECTED_COUNT}) - - http: typing.Final = _build_client(handler) - result: typing.Final = http.request("GET", "/things/1", schema=_SampleResponse) - assert isinstance(result, _SampleResponse) - assert result.name == "alice" - assert result.count == _EXPECTED_COUNT - - -def test_request_translates_request_error_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - msg: typing.Final = "connection refused" - raise httpx2.ConnectError(msg) - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="request failed"): - http.request("GET", "/things/1", schema=_SampleResponse) - - -def test_request_translates_malformed_json_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, text="this is not json") - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="malformed JSON"): - http.request("GET", "/things/1", schema=_SampleResponse) - - -def test_request_translates_missing_field_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"name": "alice"}) # missing 'count' - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="response shape"): - http.request("GET", "/things/1", schema=_SampleResponse) - - -def test_request_translates_wrong_field_type_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"name": "alice", "count": "seven"}) - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="response shape"): - http.request("GET", "/things/1", schema=_SampleResponse) - - -def test_status_translator_runs_before_json_decode_and_validation() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - # Body is HTML, not JSON — would fail both decode and schema validation. - return httpx2.Response(_UNAUTHORIZED_STATUS, text="Unauthorized") - - def translator(status: int) -> None: - if status == _UNAUTHORIZED_STATUS: - msg = "token rejected" - raise AuthError(msg) - - http: typing.Final = HttpClient( - client=httpx2.Client(transport=httpx2.MockTransport(handler), base_url=_BASE_URL), - auth_headers=dict, - status_translator=translator, - ) - - with pytest.raises(AuthError, match="token rejected"): - http.request("GET", "/things/1", schema=_SampleResponse) - - -def test_request_translates_list_payload_when_single_schema_expected() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json=[{"name": "alice", "count": 1}]) - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="response shape"): - http.request("GET", "/things/1", schema=_SampleResponse) - - -def test_request_many_returns_list_of_validated_instances() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json=[{"name": "a", "count": 1}, {"name": "b", "count": _EXPECTED_LAST_ITEM_COUNT}]) - - http: typing.Final = _build_client(handler) - result: typing.Final = http.request_many("GET", "/things", schema=_SampleResponse) - assert len(result) == _EXPECTED_LIST_LEN - assert all(isinstance(item, _SampleResponse) for item in result) - assert result[0].name == "a" - assert result[1].count == _EXPECTED_LAST_ITEM_COUNT - - -def test_request_many_translates_dict_payload_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"not": "a list"}) - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="expected list"): - http.request_many("GET", "/things", schema=_SampleResponse) - - -def test_request_many_translates_item_validation_error_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response( - 200, json=[{"name": "alice", "count": 1}, {"name": "bob"}] - ) # second item missing 'count' - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="response shape"): - http.request_many("GET", "/things", schema=_SampleResponse) - - -_TEAPOT_STATUS: typing.Final = 418 -_SERVER_ERROR_STATUS: typing.Final = 500 - - -def test_request_raw_returns_response_with_auth_headers_applied() -> None: - captured_headers: dict[str, str] = {} - - def handler(request: httpx2.Request) -> httpx2.Response: - captured_headers.update(request.headers) - return httpx2.Response(_TEAPOT_STATUS, text="teapot") - - http: typing.Final = _build_client(handler) - response: typing.Final = http.request_raw("GET", "/teapot") - assert response.status_code == _TEAPOT_STATUS - assert response.text == "teapot" - assert captured_headers.get("x-test-auth") == "token-xyz" - - -def test_request_raw_does_not_call_status_translator() -> None: - call_count = {"n": 0} - - def translator(_status: int) -> None: - call_count["n"] += 1 - - def handler(_request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(_SERVER_ERROR_STATUS, text="server error") - - http: typing.Final = HttpClient( - client=httpx2.Client(transport=httpx2.MockTransport(handler), base_url=_BASE_URL), - auth_headers=dict, - status_translator=translator, - ) - response: typing.Final = http.request_raw("GET", "/whatever") - assert response.status_code == _SERVER_ERROR_STATUS - assert call_count["n"] == 0 - - -def test_request_raw_translates_request_error_to_provider_api_error() -> None: - def handler(_request: httpx2.Request) -> httpx2.Response: - msg: typing.Final = "timed out" - raise httpx2.ReadTimeout(msg) - - http: typing.Final = _build_client(handler) - with pytest.raises(ProviderAPIError, match="request failed"): - http.request_raw("GET", "/whatever") diff --git a/tests/unit/test_providers_errors.py b/tests/unit/test_providers_errors.py new file mode 100644 index 0000000..23b9c15 --- /dev/null +++ b/tests/unit/test_providers_errors.py @@ -0,0 +1,136 @@ +import typing + +import httpware +import httpx2 + +from semvertag._errors import AuthError, ConfigError, ProviderAPIError +from semvertag.providers._errors import translate_create_tag_bad_request, translate_gitlab + + +_PROJECT_ID = 4242 + +_DUMMY_REQUEST: typing.Final[httpx2.Request] = httpx2.Request( + "GET", "https://gitlab.example.com/api/v4/projects/4242/repository/tags" +) + + +def _response(status: int, *, body: bytes = b"") -> httpx2.Response: + r = httpx2.Response(status_code=status, content=body) + r.request = _DUMMY_REQUEST + return r + + +def _status_error(cls: type[httpware.StatusError], status: int, body: bytes = b"") -> httpware.StatusError: + return cls(_response(status, body=body)) + + +# translate_gitlab — status errors + + +def test_translate_gitlab_401_becomes_auth_error_with_token_guidance() -> None: + result = translate_gitlab(_status_error(httpware.UnauthorizedError, 401), project_id=_PROJECT_ID) + assert isinstance(result, AuthError) + assert "Token rejected" in str(result) + assert "SEMVERTAG_TOKEN" in str(result) + + +def test_translate_gitlab_403_becomes_auth_error_with_scope_guidance() -> None: + result = translate_gitlab(_status_error(httpware.ForbiddenError, 403), project_id=_PROJECT_ID) + assert isinstance(result, AuthError) + assert "403" in str(result) + assert "api" in str(result) or "write_repository" in str(result) + + +def test_translate_gitlab_404_becomes_config_error_with_project_id() -> None: + result = translate_gitlab(_status_error(httpware.NotFoundError, 404), project_id=_PROJECT_ID) + assert isinstance(result, ConfigError) + assert f"project_id={_PROJECT_ID}" in str(result) + + +def test_translate_gitlab_422_becomes_config_error() -> None: + result = translate_gitlab(_status_error(httpware.UnprocessableEntityError, 422), project_id=_PROJECT_ID) + assert isinstance(result, ConfigError) + assert "422" in str(result) + + +def test_translate_gitlab_429_becomes_provider_api_error() -> None: + result = translate_gitlab(_status_error(httpware.RateLimitedError, 429), project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "rate limit" in str(result).lower() + + +def test_translate_gitlab_500_becomes_provider_api_error() -> None: + result = translate_gitlab(_status_error(httpware.InternalServerError, 500), project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "500" in str(result) + + +def test_translate_gitlab_503_becomes_provider_api_error() -> None: + result = translate_gitlab(_status_error(httpware.ServiceUnavailableError, 503), project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + + +def test_translate_gitlab_unknown_4xx_falls_back_to_provider_api_error() -> None: + # 418 is not specially mapped; ClientStatusError is the fallback for unknown 4xx + result = translate_gitlab(_status_error(httpware.ClientStatusError, 418), project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "418" in str(result) + + +# translate_gitlab — transport errors + + +def test_translate_gitlab_timeout_becomes_provider_api_error() -> None: + exc = httpware.TimeoutError("read timed out") + result = translate_gitlab(exc, project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "timed out" in str(result).lower() + + +def test_translate_gitlab_network_error_becomes_provider_api_error() -> None: + exc = httpware.NetworkError("connection refused") + result = translate_gitlab(exc, project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + + +def test_translate_gitlab_retry_budget_exhausted_becomes_provider_api_error() -> None: + exc = httpware.RetryBudgetExhaustedError(last_response=None, last_exception=None, attempts=3) + result = translate_gitlab(exc, project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "retr" in str(result).lower() + + +def test_translate_gitlab_unknown_client_error_falls_back_to_provider_api_error() -> None: + exc = httpware.ClientError("some unknown httpware error") + result = translate_gitlab(exc, project_id=_PROJECT_ID) + assert isinstance(result, ProviderAPIError) + assert "ClientError" in str(result) + + +# translate_create_tag_bad_request + + +def test_translate_create_tag_bad_request_already_exists_becomes_config_error() -> None: + raw = _status_error(httpware.BadRequestError, 400, body=b'{"message":"Tag already exists"}') + assert isinstance(raw, httpware.BadRequestError) + result = translate_create_tag_bad_request(raw, tag_name="v1.2.3") + assert isinstance(result, ConfigError) + assert "v1.2.3" in str(result) + assert "already exists" in str(result).lower() + + +def test_translate_create_tag_bad_request_already_exists_is_case_insensitive() -> None: + raw = _status_error(httpware.BadRequestError, 400, body=b'{"message":"Tag ALREADY EXISTS"}') + assert isinstance(raw, httpware.BadRequestError) + result = translate_create_tag_bad_request(raw, tag_name="v1.2.3") + assert isinstance(result, ConfigError) + assert "already exists" in str(result).lower() + + +def test_translate_create_tag_bad_request_other_400_becomes_generic_config_error() -> None: + raw = _status_error(httpware.BadRequestError, 400, body=b'{"message":"bad ref format"}') + assert isinstance(raw, httpware.BadRequestError) + result = translate_create_tag_bad_request(raw, tag_name="v1.2.3") + assert isinstance(result, ConfigError) + assert "v1.2.3" not in str(result) + assert "400" in str(result) diff --git a/tests/unit/test_transport_retry.py b/tests/unit/test_transport_retry.py deleted file mode 100644 index df22252..0000000 --- a/tests/unit/test_transport_retry.py +++ /dev/null @@ -1,403 +0,0 @@ -import email.utils -import pathlib -import shutil -import subprocess -import typing - -import httpx2 -import pytest - -from semvertag import _transport -from semvertag._transport import ( - BACKOFF_BASE_SECONDS, - MAX_ATTEMPTS, - MAX_WALL_SECONDS, - RETRYABLE_EXCEPTIONS, - RETRYABLE_STATUSES, - RetryingTransport, -) - - -_REQUEST_URL: typing.Final = "http://example.invalid/path" -_EXPECTED_RETRYABLE_STATUSES: typing.Final = frozenset({408, 429, 500, 502, 503, 504}) -_EXPECTED_MAX_ATTEMPTS: typing.Final = 3 -_EXPECTED_MAX_WALL_SECONDS: typing.Final = 30.0 -_EXPECTED_BACKOFF_BASE_SECONDS: typing.Final = 1.0 -_FIXED_WALL_EPOCH: typing.Final = 1_700_000_000.0 -_RETRY_AFTER_DELTA_SECONDS: typing.Final = 5 -_BACKOFF_BETWEEN_FIRST_AND_SECOND: typing.Final = 1.0 -_BACKOFF_BETWEEN_SECOND_AND_THIRD: typing.Final = 2.0 -_BUDGET_OVERFLOW_MONOTONIC: typing.Final = 31.0 -_STATIC_4XX_STATUSES: typing.Final = (401, 403, 404, 422) -_SINGLE_CALL: typing.Final = 1 -_PAST_HTTP_DATE_OFFSET_SECONDS: typing.Final = 60.0 -_OK_STATUS: typing.Final = 200 -_SERVICE_UNAVAILABLE_STATUS: typing.Final = 503 - - -class _CloseRecorder(httpx2.BaseTransport): - def __init__(self) -> None: - self.close_calls: int = 0 - - def handle_request(self, request: httpx2.Request) -> httpx2.Response: # noqa: ARG002 - return httpx2.Response(200) - - def close(self) -> None: - self.close_calls += 1 - - -class _SequenceHandler: - def __init__( - self, - responses: list[httpx2.Response | type[BaseException] | BaseException], - ) -> None: - self._responses = responses - self.calls: int = 0 - - def __call__(self, request: httpx2.Request) -> httpx2.Response: # noqa: ARG002 - if self.calls >= len(self._responses): - msg = f"_SequenceHandler exhausted: call #{self.calls + 1}, only {len(self._responses)} configured" - raise AssertionError(msg) - item: typing.Final = self._responses[self.calls] - self.calls += 1 - if isinstance(item, BaseException): - raise item - if isinstance(item, type) and issubclass(item, BaseException): - msg = "simulated" - raise item(msg) - return item - - -def _make_transport(handler: _SequenceHandler) -> RetryingTransport: - return RetryingTransport(inner=httpx2.MockTransport(handler)) - - -@pytest.fixture -def instant_clock(monkeypatch: pytest.MonkeyPatch) -> list[float]: - sleep_calls: list[float] = [] - monkeypatch.setattr(_transport.time, "sleep", sleep_calls.append) - monkeypatch.setattr(_transport.time, "monotonic", lambda: 0.0) - monkeypatch.setattr(_transport.time, "time", lambda: _FIXED_WALL_EPOCH) - monkeypatch.setattr(_transport.random, "uniform", lambda lo, hi: hi) # noqa: ARG005 - return sleep_calls - - -def test_module_constants_match_architecture_values() -> None: - assert RETRYABLE_STATUSES == _EXPECTED_RETRYABLE_STATUSES - assert ( - httpx2.ConnectError, - httpx2.ReadTimeout, - httpx2.WriteTimeout, - httpx2.RemoteProtocolError, - ) == RETRYABLE_EXCEPTIONS - assert MAX_ATTEMPTS == _EXPECTED_MAX_ATTEMPTS - assert MAX_WALL_SECONDS == _EXPECTED_MAX_WALL_SECONDS - assert BACKOFF_BASE_SECONDS == _EXPECTED_BACKOFF_BASE_SECONDS - - -def test_default_inner_is_http_transport_when_no_arg() -> None: - transport: typing.Final = RetryingTransport() - assert isinstance(transport._inner, httpx2.HTTPTransport) - transport.close() - - -def test_returns_coalesced_200_when_500_500_200(instant_clock: list[float]) -> None: - handler: typing.Final = _SequenceHandler( - [httpx2.Response(500), httpx2.Response(500), httpx2.Response(200)], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert handler.calls == _EXPECTED_MAX_ATTEMPTS - assert instant_clock == [_BACKOFF_BETWEEN_FIRST_AND_SECOND, _BACKOFF_BETWEEN_SECOND_AND_THIRD] - - -def test_returns_last_503_when_all_attempts_exhausted(instant_clock: list[float]) -> None: - handler: typing.Final = _SequenceHandler( - [httpx2.Response(503), httpx2.Response(503), httpx2.Response(503)], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _SERVICE_UNAVAILABLE_STATUS - assert handler.calls == _EXPECTED_MAX_ATTEMPTS - assert instant_clock == [_BACKOFF_BETWEEN_FIRST_AND_SECOND, _BACKOFF_BETWEEN_SECOND_AND_THIRD] - - -@pytest.mark.parametrize( - ("header_value", "expected_sleep"), - [ - ("7", 7.0), - (" 7 ", 7.0), - ("7.5", 7.5), - ], -) -def test_honors_retry_after_seconds_when_429_followed_by_200( - instant_clock: list[float], - header_value: str, - expected_sleep: float, -) -> None: - handler: typing.Final = _SequenceHandler( - [ - httpx2.Response(429, headers={"Retry-After": header_value}), - httpx2.Response(200), - ], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert instant_clock == [expected_sleep] - - -def test_honors_retry_after_http_date_when_429_followed_by_200( - instant_clock: list[float], -) -> None: - future_epoch: typing.Final = _FIXED_WALL_EPOCH + _RETRY_AFTER_DELTA_SECONDS - http_date: typing.Final = email.utils.formatdate(future_epoch, usegmt=True) - handler: typing.Final = _SequenceHandler( - [ - httpx2.Response(429, headers={"Retry-After": http_date}), - httpx2.Response(200), - ], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert instant_clock == [float(_RETRY_AFTER_DELTA_SECONDS)] - - -def test_falls_back_to_backoff_when_retry_after_http_date_is_in_the_past( - instant_clock: list[float], -) -> None: - past_epoch: typing.Final = _FIXED_WALL_EPOCH - _PAST_HTTP_DATE_OFFSET_SECONDS - http_date: typing.Final = email.utils.formatdate(past_epoch, usegmt=True) - handler: typing.Final = _SequenceHandler( - [ - httpx2.Response(429, headers={"Retry-After": http_date}), - httpx2.Response(200), - ], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert instant_clock == [_BACKOFF_BETWEEN_FIRST_AND_SECOND] - - -def test_treats_naive_http_date_as_utc_not_local(instant_clock: list[float]) -> None: - # formatdate(..., usegmt=False, localtime=False) emits "-0000" (RFC 5322 "unknown TZ"), - # which parsedate_to_datetime returns as a NAIVE datetime. .timestamp() on a naive - # datetime uses the host's local timezone; the parser must coerce to UTC so the delta - # is independent of CI host timezone. - future_epoch: typing.Final = _FIXED_WALL_EPOCH + _RETRY_AFTER_DELTA_SECONDS - http_date_no_zone: typing.Final = email.utils.formatdate(future_epoch, usegmt=False, localtime=False) - handler: typing.Final = _SequenceHandler( - [ - httpx2.Response(429, headers={"Retry-After": http_date_no_zone}), - httpx2.Response(200), - ], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert instant_clock == [float(_RETRY_AFTER_DELTA_SECONDS)] - - -def test_falls_back_to_backoff_when_retry_after_is_garbage(instant_clock: list[float]) -> None: - handler: typing.Final = _SequenceHandler( - [ - httpx2.Response(429, headers={"Retry-After": "banana"}), - httpx2.Response(200), - ], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert instant_clock == [_BACKOFF_BETWEEN_FIRST_AND_SECOND] - - -def test_falls_back_to_backoff_when_retry_after_seconds_is_negative( - instant_clock: list[float], -) -> None: - handler: typing.Final = _SequenceHandler( - [ - httpx2.Response(429, headers={"Retry-After": "-5"}), - httpx2.Response(200), - ], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert instant_clock == [_BACKOFF_BETWEEN_FIRST_AND_SECOND] - - -def test_falls_back_to_backoff_when_retry_after_is_empty(instant_clock: list[float]) -> None: - handler: typing.Final = _SequenceHandler( - [ - httpx2.Response(429, headers={"Retry-After": " "}), - httpx2.Response(200), - ], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert instant_clock == [_BACKOFF_BETWEEN_FIRST_AND_SECOND] - - -def test_uses_backoff_when_429_has_no_retry_after_header(instant_clock: list[float]) -> None: - handler: typing.Final = _SequenceHandler( - [httpx2.Response(429), httpx2.Response(200)], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert instant_clock == [_BACKOFF_BETWEEN_FIRST_AND_SECOND] - - -def test_honors_retry_after_when_503_has_seconds_header(instant_clock: list[float]) -> None: - handler: typing.Final = _SequenceHandler( - [ - httpx2.Response(503, headers={"Retry-After": "7"}), - httpx2.Response(200), - ], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert instant_clock == [7.0] - - -@pytest.mark.parametrize("exc_cls", RETRYABLE_EXCEPTIONS) -def test_swallows_retryable_exception_when_followed_by_200( - instant_clock: list[float], - exc_cls: type[BaseException], -) -> None: - handler: typing.Final = _SequenceHandler([exc_cls, httpx2.Response(200)]) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert instant_clock == [_BACKOFF_BETWEEN_FIRST_AND_SECOND] - - -@pytest.mark.parametrize("exc_cls", RETRYABLE_EXCEPTIONS) -def test_reraises_last_exception_when_all_attempts_raise( - instant_clock: list[float], - exc_cls: type[BaseException], -) -> None: - handler: typing.Final = _SequenceHandler([exc_cls, exc_cls, exc_cls]) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client, pytest.raises(exc_cls): - client.get(_REQUEST_URL) - assert instant_clock == [_BACKOFF_BETWEEN_FIRST_AND_SECOND, _BACKOFF_BETWEEN_SECOND_AND_THIRD] - - -def test_propagates_non_retryable_exception_immediately(instant_clock: list[float]) -> None: - handler: typing.Final = _SequenceHandler([OSError("not retryable"), httpx2.Response(200)]) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client, pytest.raises(OSError, match="not retryable"): - client.get(_REQUEST_URL) - assert instant_clock == [] - assert handler.calls == _SINGLE_CALL - - -@pytest.mark.parametrize("status", _STATIC_4XX_STATUSES) -def test_skips_retry_when_static_4xx(instant_clock: list[float], status: int) -> None: - handler: typing.Final = _SequenceHandler([httpx2.Response(status), httpx2.Response(200)]) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == status - assert handler.calls == _SINGLE_CALL - assert instant_clock == [] - - -def test_returns_2xx_immediately_without_sleeping(instant_clock: list[float]) -> None: - handler: typing.Final = _SequenceHandler([httpx2.Response(200)]) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - assert response.status_code == _OK_STATUS - assert handler.calls == _SINGLE_CALL - assert instant_clock == [] - - -def test_stops_retrying_when_wall_budget_would_be_exceeded( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sleep_calls: list[float] = [] - monkeypatch.setattr(_transport.time, "sleep", sleep_calls.append) - monkeypatch.setattr(_transport.random, "uniform", lambda lo, hi: hi) # noqa: ARG005 - monotonic_values: typing.Final = iter([0.0, _BUDGET_OVERFLOW_MONOTONIC]) - monkeypatch.setattr(_transport.time, "monotonic", lambda: next(monotonic_values)) - - handler: typing.Final = _SequenceHandler( - [httpx2.Response(503), httpx2.Response(503), httpx2.Response(503)], - ) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client: - response = client.get(_REQUEST_URL) - - assert response.status_code == _SERVICE_UNAVAILABLE_STATUS - assert handler.calls == _SINGLE_CALL - assert sleep_calls == [] - - -def test_stops_retrying_when_wall_budget_would_be_exceeded_for_exception( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sleep_calls: list[float] = [] - monkeypatch.setattr(_transport.time, "sleep", sleep_calls.append) - monkeypatch.setattr(_transport.random, "uniform", lambda lo, hi: hi) # noqa: ARG005 - monotonic_values: typing.Final = iter([0.0, _BUDGET_OVERFLOW_MONOTONIC]) - monkeypatch.setattr(_transport.time, "monotonic", lambda: next(monotonic_values)) - - handler: typing.Final = _SequenceHandler([httpx2.ConnectError, httpx2.Response(200)]) - transport: typing.Final = _make_transport(handler) - with httpx2.Client(transport=transport) as client, pytest.raises(httpx2.ConnectError): - client.get(_REQUEST_URL) - assert sleep_calls == [] - assert handler.calls == _SINGLE_CALL - - -def test_close_delegates_to_inner_close() -> None: - inner: typing.Final = _CloseRecorder() - transport: typing.Final = RetryingTransport(inner=inner) - transport.close() - assert inner.close_calls == _SINGLE_CALL - - -def test_retry_logic_is_single_owner_via_grep() -> None: - grep: typing.Final = shutil.which("grep") - if grep is None: - pytest.skip("grep not available") - semvertag_dir: typing.Final = pathlib.Path(__file__).resolve().parents[2] / "semvertag" - forbidden: typing.Final = ("tenacity", "httpx-retries", "httpx_retries") - for needle in forbidden: - result = subprocess.run( # noqa: S603 - [grep, "-rn", needle, str(semvertag_dir)], - check=False, - capture_output=True, - text=True, - ) - assert result.stdout == "", f"Forbidden token {needle!r} found:\n{result.stdout}" - - retry_after_hits: typing.Final = subprocess.run( # noqa: S603 - [grep, "-rln", "Retry-After", str(semvertag_dir)], - check=False, - capture_output=True, - text=True, - ) - matched_files: typing.Final = { - pathlib.Path(line).name for line in retry_after_hits.stdout.strip().splitlines() if line - } - assert matched_files <= {"_transport.py"}, f"Retry-After referenced outside _transport.py: {matched_files}"