Compare commits

...
21 Commits
Author SHA1 Message Date
devx-ci-bot 4de11bfc18 release: v0.47.2 [skip ci] 2026-07-17 00:45:13 +00:00
emil a02bf6d70e DEVX-143: fix: add retry logic to TeaCLI for transient HTTP errors (502/503/504/429)
Post-merge / detect-and-configure (push) Waiting to run
Post-merge / release-and-maintain (push) Waiting to run
2026-07-17 00:44:27 +00:00
gitea-actions-bot 368c87aabf chore: update badge URLs to commit 4e6bada8 [skip ci] 2026-07-16 14:27:31 +00:00
devx-ci-bot 4f982dc3ba release: v0.47.1 [skip ci] 2026-07-16 14:26:58 +00:00
emil a7a8637244 DEVX-142: fix: tea CLI login failure handling, error messages, release retry
Post-merge / detect-and-configure (push) Successful in 12s
Post-merge / release-and-maintain (push) Successful in 1m2s
2026-07-16 14:26:15 +00:00
gitea-actions-bot cdf3408a35 chore: update badge URLs to commit e6827cec [skip ci] 2026-07-14 23:19:50 +00:00
devx-ci-bot 8fcac10286 release: v0.47.0 [skip ci] 2026-07-14 23:19:15 +00:00
emil c62c560c85 DEVX-141: feat: add promtool to install_tools for alert rule validation
Post-merge / detect-and-configure (push) Successful in 14s
Post-merge / release-and-maintain (push) Successful in 1m5s
2026-07-14 23:18:29 +00:00
gitea-actions-bot 08b781f978 chore: update badge URLs to commit 75024199 [skip ci] 2026-07-14 16:30:59 +00:00
devx-ci-bot ea7566fe6b release: v0.46.0 [skip ci] 2026-07-14 16:30:24 +00:00
emil d8ceb6c8a1 DEVX-140: feat: make check_test_isolation configurable via pyproject.toml
Post-merge / detect-and-configure (push) Successful in 17s
Post-merge / release-and-maintain (push) Successful in 1m9s
2026-07-14 16:29:31 +00:00
gitea-actions-bot 748baf17eb chore: update badge URLs to commit b6a7c5d7 [skip ci] 2026-07-14 12:36:06 +00:00
devx-ci-bot f339df3562 release: v0.45.1 [skip ci] 2026-07-14 12:35:27 +00:00
emil db38453a54 DEVX-139: fix: URL-encode package names and versions in clean_images API calls
Post-merge / detect-and-configure (push) Successful in 18s
Post-merge / release-and-maintain (push) Successful in 1m9s
2026-07-14 12:34:35 +00:00
gitea-actions-bot 5d78377152 chore: update badge URLs to commit 5a9243cc [skip ci] 2026-07-14 01:22:38 +00:00
devx-ci-bot b8b21cccd5 release: v0.45.0 [skip ci] 2026-07-14 01:21:59 +00:00
emil 326eccfd2f DEVX-138: feat: add IO_INTERNAL_CALLS to check_test_isolation
Post-merge / detect-and-configure (push) Successful in 13s
Post-merge / release-and-maintain (push) Successful in 1m8s
2026-07-14 01:21:15 +00:00
gitea-actions-bot 076b470344 chore: update badge URLs to commit 6ee532d4 [skip ci] 2026-07-14 00:55:29 +00:00
devx-ci-bot 53b49ec91c release: v0.44.2 [skip ci] 2026-07-14 00:54:56 +00:00
emil 2cfc0aca10 DEVX-137: fix: use legacy Docker builder to avoid Gitea registry 403
Post-merge / detect-and-configure (push) Successful in 13s
Post-merge / release-and-maintain (push) Successful in 1m1s
2026-07-14 00:54:12 +00:00
gitea-actions-bot 83ea4496e5 chore: update badge URLs to commit 52dfd18c [skip ci] 2026-07-14 00:48:24 +00:00
17 changed files with 701 additions and 61 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
- name: Check unit test speed
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
python3 -m devx.tools.check_test_speed --max-seconds 8 --max-single-seconds 0.5
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
env:
DEVX_DOC_COVERAGE_STRICT: "1"
+15 -1
View File
@@ -71,7 +71,7 @@ src/devx/
├── translations.json # Translation strings (en, bg, de, pl, ru, zh)
├── ci/ # CI/CD automation modules (run by workflows)
│ ├── release.py # Automated versioning, tagging, changelog
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
│ ├── publish.py # Build, publish to Gitea PyPI registry, create Gitea release (with retry)
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master)
│ ├── _shared.py # Shared utilities (get_latest_tag)
@@ -310,6 +310,20 @@ by `python -m devx.tools.install_tools` and configured by
- `create_pr()` / `merge_pr()` / `review_pr()` — Pull request operations
- `create_release()` / `list_releases()` — Release management
**`devx.gitea_cli.configure_tea_login()`** — Configures tea login in
containerized CI environments where `make setup` was not called. Used by
`publish.py` (`--auto-login`) and `notify_failure.py` (`--auto-login`).
Raises `TeaCLIError` if login configuration fails — this prevents cryptic
"no available login" errors from subsequent tea commands.
**Error handling**: `TeaCLI._run()` includes both stdout and stderr in
`TeaCLIError` messages, because `tea` writes some errors (for example,
"no available login") to stdout, not stderr.
**Release creation retry**: `publish.py` retries Gitea release creation
up to 3 times with exponential backoff (2s, 4s) on transient failures.
"Already exists" errors are treated as success (idempotent).
### git-cliff Commit Preprocessing
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
+42
View File
@@ -2,6 +2,48 @@
All notable changes to this project will be documented in this file.
## [0.47.2] - 2026-07-17
### Bug Fixes
- Add retry logic to TeaCLI for transient HTTP errors (502/503/504/429)
## [0.47.1] - 2026-07-16
### Bug Fixes
- Tea CLI login failure handling, error messages, release retry
## [0.47.0] - 2026-07-14
### Features
- Add promtool to install_tools for alert rule validation
## [0.46.0] - 2026-07-14
### Features
- Make check_test_isolation configurable via pyproject.toml
## [0.45.1] - 2026-07-14
### Bug Fixes
- URL-encode package names and versions in clean_images API calls
## [0.45.0] - 2026-07-14
### Features
- Add IO_INTERNAL_CALLS to check_test_isolation
## [0.44.2] - 2026-07-14
### Bug Fixes
- Use legacy Docker builder to avoid Gitea registry 403
## [0.44.1] - 2026-07-14
### Bug Fixes
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.44.1",
"devx>=0.47.2",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.44.1"`) or use a version constraint
> (for example, `"devx>=0.44.1,<0.45"`).
> `dependencies` (for example, `"devx==0.47.2"`) or use a version constraint
> (for example, `"devx>=0.47.2,<0.48"`).
### Optional extras
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a9cb1ef13f9a9380d2b66ee2c8244cae5ab6935c/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/4e6bada89275c693958c53ae8c5fd9fb562329b8/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.44.1",
"devx>=0.47.2",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.44.1"` or `"devx>=0.44.1,<0.45"`.
Pin a specific version if needed: `"devx==0.47.2"` or `"devx>=0.47.2,<0.48"`.
### Optional extras
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.44.1",
"devx>=0.47.2",
]
[project.optional-dependencies]
dev = [
"devx>=0.44.1",
"devx>=0.47.2",
]
```
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.44.1"
__version__ = "0.47.2"
+37 -7
View File
@@ -4,6 +4,10 @@
Uses git-cliff to generate the release notes from conventional commits.
Uses the ``tea`` Gitea CLI for release creation.
Gitea release creation is retried up to 3 times with exponential backoff
(2s, 4s) to handle transient failures (network timeouts, 5xx errors).
If the release already exists, it is treated as success (idempotent).
Publishing destinations (checked in order):
1. **Gitea PyPI registry** — if ``--registry-url`` is given (or
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
@@ -27,6 +31,7 @@ from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
@@ -312,13 +317,7 @@ def main(
release_body = generate_release_notes(tag)
try:
tea.create_release(repo, tag=tag, title=tag, body=release_body)
except TeaCLIError as e:
if "already" in str(e).lower() and "release" in str(e).lower():
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
return
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
_create_release_with_retry(tea, repo, tag, release_body)
click.echo(
_(
@@ -328,5 +327,36 @@ def main(
)
def _create_release_with_retry(tea: TeaCLI, repo: str, tag: str, release_body: str) -> None:
"""Create a Gitea release with retry for transient failures.
Retries up to 3 times with exponential backoff (2s, 4s) on TeaCLIError
unless the error indicates the release already exists (which is treated
as success). This handles transient issues like network timeouts, Gitea
rate limiting, or temporary 5xx errors that caused CI run #2822 to fail.
"""
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=2, min=2, max=10),
retry=retry_if_exception_type(TeaCLIError),
reraise=True,
)
def _attempt() -> None:
try:
tea.create_release(repo, tag=tag, title=tag, body=release_body)
except TeaCLIError as e:
error_str = str(e).lower()
if "already" in error_str and "release" in error_str:
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
return
raise
try:
_attempt()
except TeaCLIError as e:
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
if __name__ == "__main__": # pragma: no cover
main()
+78 -17
View File
@@ -40,27 +40,46 @@ Usage::
from __future__ import annotations
import json
import logging
import shutil
import subprocess # nosec B404
from typing import Any
import click
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from devx.config import GITEA_API_URL
from devx.config import GITEA_API_URL, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
from devx.i18n import _
from devx.tokens import get_ci_token
logger = logging.getLogger("gitea_cli")
class TeaCLIError(Exception):
"""Raised when a tea CLI command fails."""
class _TransientTeaError(TeaCLIError):
"""Tea CLI error caused by a transient HTTP status (502/503/504/429)."""
def configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from CI_GITEA_API_TOKEN and DEVX_GITEA_API_URL.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips silently if tea is not installed or no token is set.
Raises ``TeaCLIError`` if the login add or default command fails. This is
critical because subsequent tea commands (e.g. ``releases create``) will
fail with a cryptic "no available login" error if the login was not
configured successfully.
Used by CI scripts (publish, notify_failure) that need tea login but
run in containerized environments where ``make setup`` was not called.
"""
@@ -88,18 +107,31 @@ def configure_tea_login(login_name: str = "devx") -> None:
return
click.echo(_("Configuring tea login '{name}' for {url}...", name=login_name, url=gitea_url))
subprocess.run( # nosec B603
add_result = subprocess.run( # nosec B603
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
capture_output=True,
text=True,
check=False,
)
subprocess.run( # nosec B603
if add_result.returncode != 0:
raise TeaCLIError(
f"tea login add failed (rc={add_result.returncode})\n"
f"stdout: {add_result.stdout.strip()}\n"
f"stderr: {add_result.stderr.strip()}"
)
default_result = subprocess.run( # nosec B603
[tea_bin, "login", "default", login_name],
capture_output=True,
text=True,
check=False,
)
if default_result.returncode != 0:
raise TeaCLIError(
f"tea login default failed (rc={default_result.returncode})\n"
f"stdout: {default_result.stdout.strip()}\n"
f"stderr: {default_result.stderr.strip()}"
)
class TeaCLI:
@@ -122,6 +154,10 @@ class TeaCLI:
def _run(self, args: list[str], json_output: bool = True) -> str:
"""Run a tea command and return stdout.
Retries up to ``MAX_RETRIES`` times on transient HTTP errors
(502/503/504/429) detected in stderr/stdout, with exponential
backoff. Non-transient errors fail immediately.
Args:
args: Command arguments (without the leading ``tea``).
json_output: If True, append ``--output json`` to the command.
@@ -130,25 +166,50 @@ class TeaCLI:
stdout as a string.
Raises:
TeaCLIError: If the command fails.
TeaCLIError: If the command fails after retries are exhausted.
"""
cmd = [self._tea, *args]
if json_output:
cmd.extend(["--output", "json"])
def _execute() -> str:
try:
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError as e:
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
if result.returncode != 0:
parts = [
f"tea command failed (rc={result.returncode}): {' '.join(args)}",
f"stdout: {result.stdout.strip()}" if result.stdout.strip() else "",
f"stderr: {result.stderr.strip()}" if result.stderr.strip() else "",
]
msg = "\n".join(p for p in parts if p)
combined = f"{result.stdout} {result.stderr}".lower()
if any(str(code) in combined for code in RETRY_STATUS_CODES):
raise _TransientTeaError(msg)
raise TeaCLIError(msg)
return result.stdout.strip()
retry_decorator = retry(
stop=stop_after_attempt(MAX_RETRIES),
wait=wait_exponential(
multiplier=RETRY_BACKOFF_BASE,
min=RETRY_BACKOFF_BASE,
max=RETRY_BACKOFF_BASE**MAX_RETRIES,
),
retry=retry_if_exception_type(_TransientTeaError),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
try:
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
except FileNotFoundError as e:
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
if result.returncode != 0:
raise TeaCLIError(
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
)
return result.stdout.strip()
return retry_decorator(_execute)()
except _TransientTeaError as e:
raise TeaCLIError(str(e)) from e
def _run_raw(self, args: list[str]) -> str:
"""Run a tea command without JSON output and return stdout."""
+4 -1
View File
@@ -162,7 +162,7 @@ def build_image(
return False
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
cmd = ["docker", "build", "--provenance=false"]
cmd = ["docker", "build"]
if pull:
cmd.append("--pull")
for ft in full_tags:
@@ -174,9 +174,12 @@ def build_image(
return True
click.echo(f"Building {spec.name} ({len(full_tags)} tag(s))...")
# Use legacy builder (DOCKER_BUILDKIT=0) to avoid OCI-format manifest
# blobs (attestation, config) that the Gitea registry rejects with 403.
result = subprocess.run( # nosec B603
cmd,
check=False,
env={**os.environ, "DOCKER_BUILDKIT": "0"},
)
if result.returncode != 0:
click.echo(_("Build failed for {name}", name=spec.name), err=True)
+117 -4
View File
@@ -25,6 +25,25 @@ This module is used in two ways:
findings are reported as advisories (exit 0) since static analysis
can't predict early exits — the runtime audit is authoritative.
Project-Specific Configuration
-------------------------------
Projects can extend the built-in rule sets via ``[tool.devx.check_test_isolation]``
in ``pyproject.toml``. Entries are merged on top of the defaults they
add to (not replace) the built-in rules::
[tool.devx.check_test_isolation]
# Functions known to do filesystem or network I/O
io_functions = { "my_func" = "reads config from disk", ... }
# Functions known to spawn subprocesses
subprocess_helpers = { "my_helper" = "calls subprocess.run", ... }
# Transitive deps: if a helper calls these, patching any of them is safe
helper_internal_calls = { "my_helper" = ["subprocess", "run_cmd"], ... }
# I/O function internal deps: patching any of these makes the call safe
io_internal_calls = { "my_func" = ["open", "yaml"], ... }
# Heavy modules slow to import at module level in test files
heavy_module_imports = { "mymodule" = 150.0, ... }
Patterns detected:
1. **Unpatched subprocess calls** test functions that call
@@ -60,6 +79,7 @@ from pathlib import Path
import click
from devx.config import _load_pyproject_devx
from devx.i18n import _
# ── Configuration ─────────────────────────────────────────────────────────────
@@ -71,7 +91,7 @@ DEFAULT_MAX_LOOP_ITERATIONS = 100
# Maps module name → approximate import time in milliseconds.
# NOTE: ``requests`` is excluded because it's a core devx dependency —
# it's loaded during collection regardless of whether test files import it.
HEAVY_MODULE_IMPORTS: dict[str, float] = {
_DEFAULT_HEAVY_MODULE_IMPORTS: dict[str, float] = {
"httpx": 80.0,
"aiohttp": 120.0,
"docker": 90.0,
@@ -96,7 +116,7 @@ HEAVY_MODULE_IMPORTS: dict[str, float] = {
# Functions known to spawn subprocesses. When a test calls any of these
# without patching them, the real subprocess runs.
# Maps function name → human-readable description.
KNOWN_SUBPROCESS_HELPERS: dict[str, str] = {
_DEFAULT_SUBPROCESS_HELPERS: dict[str, str] = {
"update_doc_versions": "calls subprocess.run to run check_doc_versions --fix",
"run_tests": "calls run_cmd to run make lint-ruff and make pytest-cov",
"run_cmd": "calls subprocess.run for shell commands",
@@ -105,7 +125,7 @@ KNOWN_SUBPROCESS_HELPERS: dict[str, str] = {
# Functions known to do filesystem or network I/O that should be mocked in tests.
# Maps function name → description of what I/O it does.
# If a test calls one of these without a corresponding @patch, it's a violation.
KNOWN_IO_FUNCTIONS: dict[str, str] = { # nosec B105 — descriptions, not passwords
_DEFAULT_IO_FUNCTIONS: dict[str, str] = { # nosec B105 — descriptions, not passwords
"get_pat": "reads ZITADEL PAT from filesystem/env (ZitadelAuth._iter_sources)",
"load_secrets": "reads YAML config file from disk",
"get_customer_secret": "reads customer-specific config from disk",
@@ -124,12 +144,102 @@ KNOWN_IO_FUNCTIONS: dict[str, str] = { # nosec B105 — descriptions, not passw
# Transitive dependencies: if a helper calls another helper that is patched,
# the call is safe. Maps helper → set of function names it internally calls.
# If ANY of these are in the test's patches, the helper call is safe.
HELPER_INTERNAL_CALLS: dict[str, set[str]] = {
_DEFAULT_HELPER_INTERNAL_CALLS: dict[str, set[str]] = {
"run_tests": {"run_cmd", "subprocess"},
"update_doc_versions": {"subprocess"},
"run_cmd": {"subprocess"},
}
# I/O function internal dependencies: if a test patches one of these
# internal dependencies, the I/O function call is considered safe.
# Maps I/O function name → set of internal function/method names it calls.
_DEFAULT_IO_INTERNAL_CALLS: dict[str, set[str]] = {
"get_customer_vm_ip": {"get_tofu_output", "get_tofu_vm_ip", "subprocess"},
"get_observability_vm_ip": {"get_tofu_output", "get_tofu_vm_ip", "subprocess"},
"get_pat": {
"_iter_sources",
"_local_pat_path",
"_secrets_path",
"_read_secrets_pat",
"validate_pat",
"ZitadelAuth",
"load_secrets",
"os.environ",
},
"load_secrets": {"load_vault_yaml", "REPO_ROOT", "open", "yaml", "safe_load"},
"get_customer_secret": {"load_customer_secrets", "load_vault_yaml", "load_secrets", "REPO_ROOT", "open"},
}
def _load_test_isolation_config() -> None:
"""Merge project-specific rules from ``[tool.devx.check_test_isolation]``.
Reads from pyproject.toml and merges with defaults. Project-specific
entries are added on top of (not replacing) the built-in defaults.
Supported keys::
[tool.devx.check_test_isolation]
io_functions = { "my_func" = "does network I/O", ... }
subprocess_helpers = { "my_helper" = "calls subprocess.run", ... }
helper_internal_calls = { "my_helper" = ["subprocess", "run_cmd"], ... }
io_internal_calls = { "my_func" = ["open", "yaml"], ... }
heavy_module_imports = { "mymodule" = 150.0, ... }
"""
devx_cfg = _load_pyproject_devx()
cfg_raw = devx_cfg.get("check_test_isolation", {})
if not isinstance(cfg_raw, dict):
return
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
# io_functions: {name: description}
io_extra = cfg.get("io_functions", {})
if isinstance(io_extra, dict):
for name, desc in io_extra.items():
if isinstance(name, str) and isinstance(desc, str):
KNOWN_IO_FUNCTIONS[name] = desc
# subprocess_helpers: {name: description}
sp_extra = cfg.get("subprocess_helpers", {})
if isinstance(sp_extra, dict):
for name, desc in sp_extra.items():
if isinstance(name, str) and isinstance(desc, str):
KNOWN_SUBPROCESS_HELPERS[name] = desc
# helper_internal_calls: {name: [deps]}
hic_extra = cfg.get("helper_internal_calls", {})
if isinstance(hic_extra, dict):
for name, deps in hic_extra.items():
if isinstance(name, str) and isinstance(deps, list):
deps_set = {str(d) for d in deps if isinstance(d, str)}
HELPER_INTERNAL_CALLS.setdefault(name, set()).update(deps_set)
# io_internal_calls: {name: [deps]}
iic_extra = cfg.get("io_internal_calls", {})
if isinstance(iic_extra, dict):
for name, deps in iic_extra.items():
if isinstance(name, str) and isinstance(deps, list):
deps_set = {str(d) for d in deps if isinstance(d, str)}
IO_INTERNAL_CALLS.setdefault(name, set()).update(deps_set)
# heavy_module_imports: {name: ms}
hmi_extra = cfg.get("heavy_module_imports", {})
if isinstance(hmi_extra, dict):
for name, ms in hmi_extra.items():
if isinstance(name, str) and isinstance(ms, (int, float)):
HEAVY_MODULE_IMPORTS[name] = float(ms)
# Active rule sets — start with defaults, merged with project config at import.
HEAVY_MODULE_IMPORTS: dict[str, float] = dict(_DEFAULT_HEAVY_MODULE_IMPORTS)
KNOWN_SUBPROCESS_HELPERS: dict[str, str] = dict(_DEFAULT_SUBPROCESS_HELPERS)
KNOWN_IO_FUNCTIONS: dict[str, str] = dict(_DEFAULT_IO_FUNCTIONS)
HELPER_INTERNAL_CALLS: dict[str, set[str]] = {k: set(v) for k, v in _DEFAULT_HELPER_INTERNAL_CALLS.items()}
IO_INTERNAL_CALLS: dict[str, set[str]] = {k: set(v) for k, v in _DEFAULT_IO_INTERNAL_CALLS.items()}
# Merge project-specific configuration from pyproject.toml
_load_test_isolation_config()
# subprocess functions that the runtime audit wraps.
_SUBPROCESS_FUNCS = ("run", "call", "check_call", "check_output", "Popen")
@@ -817,6 +927,9 @@ class TestIsolationVisitor(ast.NodeVisitor):
or sn in all_patches
or any(io_key in p or sn in p for p in all_patches)
or any(p.endswith(f".{sn}") for p in all_patches)
or any(
dep in all_patches or any(dep in p for p in all_patches) for dep in IO_INTERNAL_CALLS.get(io_key, set())
)
):
self.violations.append(
Violation(
+16 -2
View File
@@ -5,6 +5,13 @@ Queries the Gitea API for all versions of a package (container type) and
deletes all but the most recent N versions. The ``latest`` tag is always
preserved if present.
.. note::
This tool only deletes package versions via the Gitea API. The underlying
blob files on the Gitea server's filesystem are NOT removed by this tool
(Gitea 1.26.x has no built-in garbage collection). The production VM's
daily cleanup script (``cleanup_gitea.py``) handles filesystem blob GC
by querying the database for referenced blobs and removing orphaned files.
Usage::
# Clean up ci-base images, keep last 2 versions
@@ -57,7 +64,10 @@ def list_package_versions(
Returns a list of version dicts, each containing at least ``version``
and ``created_at`` fields.
"""
url = f"{api_url}/packages/{owner}?type=container&name={name}"
from urllib.parse import quote
encoded_name = quote(name, safe="")
url = f"{api_url}/packages/{owner}?type=container&name={encoded_name}"
headers = {"Authorization": f"token {token}"}
all_versions: list[dict[str, Any]] = []
page = 1
@@ -96,7 +106,11 @@ def delete_package_version(
Returns True on success, False on failure.
"""
url = f"{api_url}/packages/{owner}/{package_type}/{name}/{version}"
from urllib.parse import quote
encoded_name = quote(name, safe="")
encoded_version = quote(version, safe="")
url = f"{api_url}/packages/{owner}/{package_type}/{encoded_name}/{encoded_version}"
headers = {"Authorization": f"token {token}"}
for attempt in range(max_retries):
try:
+25 -1
View File
@@ -8,6 +8,7 @@ Handles installation of:
- tea (Gitea CLI official command-line tool for Gitea API operations)
- hadolint (Dockerfile linter)
- vale (prose linter for documentation quality)
- promtool (Prometheus rule validator)
Each tool is installed to ``~/.local/bin`` if not already on PATH.
Idempotent: skips tools that are already available.
@@ -47,6 +48,8 @@ TOFU_VERSION = "1.12.3"
VALE_VERSION = "3.15.1"
PROMTOOL_VERSION = "3.5.5"
def _arch() -> str:
"""Return the architecture string used by release assets (delegates to shared utility)."""
@@ -212,7 +215,26 @@ def install_vale() -> bool:
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu", "vale"]
def install_promtool() -> bool:
"""Install promtool (Prometheus rule validator) if not already present.
Downloads the official Prometheus release tarball from GitHub and
extracts the ``promtool`` binary to ``~/.local/bin``.
"""
if _is_installed("promtool"):
click.echo("promtool: already installed")
return True
arch = _arch()
url = (
f"https://github.com/prometheus/prometheus/releases/download/"
f"v{PROMTOOL_VERSION}/prometheus-{PROMTOOL_VERSION}.linux-{arch}.tar.gz"
)
dest = _download_and_extract_tarball(url, "promtool")
click.echo(f"promtool: installed to {dest}")
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu", "vale", "promtool"]
def _install_tool(name: str) -> bool:
@@ -231,6 +253,8 @@ def _install_tool(name: str) -> bool:
return install_tofu()
if name == "vale":
return install_vale()
if name == "promtool":
return install_promtool()
raise click.ClickException(f"Unknown tool: {name}")
+112
View File
@@ -8,14 +8,19 @@ import textwrap
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from click.testing import CliRunner
from devx.tools.check_test_isolation import (
HEAVY_MODULE_IMPORTS,
HELPER_INTERNAL_CALLS,
IO_INTERNAL_CALLS,
KNOWN_IO_FUNCTIONS,
KNOWN_SUBPROCESS_HELPERS,
CallGraph,
_extract_patch_targets,
_is_integration_test,
_load_test_isolation_config,
_SubprocessAudit,
analyze_file,
analyze_test_files,
@@ -1667,3 +1672,110 @@ class TestIsIntegrationTest:
item.keywords = {}
item.fspath = "tests/unit/test_foo.py"
assert _is_integration_test(item) is False
class TestLoadTestIsolationConfig:
"""Tests for _load_test_isolation_config — project-specific rule merging."""
def test_merges_io_functions(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Project-specific io_functions are added to KNOWN_IO_FUNCTIONS."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
'[tool.devx.check_test_isolation]\nio_functions = { "my_custom_io" = "reads from disk" }\n'
)
monkeypatch.chdir(tmp_path)
_load_test_isolation_config()
assert "my_custom_io" in KNOWN_IO_FUNCTIONS
assert KNOWN_IO_FUNCTIONS["my_custom_io"] == "reads from disk"
def test_merges_subprocess_helpers(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Project-specific subprocess_helpers are added."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
'[tool.devx.check_test_isolation]\nsubprocess_helpers = { "my_sp_helper" = "calls subprocess.run" }\n'
)
monkeypatch.chdir(tmp_path)
_load_test_isolation_config()
assert "my_sp_helper" in KNOWN_SUBPROCESS_HELPERS
def test_merges_helper_internal_calls(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Project-specific helper_internal_calls are merged."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
'[tool.devx.check_test_isolation]\nhelper_internal_calls = { "my_helper" = ["subprocess", "run_cmd"] }\n'
)
monkeypatch.chdir(tmp_path)
_load_test_isolation_config()
assert "my_helper" in HELPER_INTERNAL_CALLS
assert HELPER_INTERNAL_CALLS["my_helper"] == {"subprocess", "run_cmd"}
def test_merges_io_internal_calls(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Project-specific io_internal_calls are merged."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
'[tool.devx.check_test_isolation]\nio_internal_calls = { "my_io_func" = ["open", "yaml"] }\n'
)
monkeypatch.chdir(tmp_path)
_load_test_isolation_config()
assert "my_io_func" in IO_INTERNAL_CALLS
assert IO_INTERNAL_CALLS["my_io_func"] == {"open", "yaml"}
def test_merges_heavy_module_imports(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Project-specific heavy_module_imports are merged."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.check_test_isolation]\nheavy_module_imports = { "mymodule" = 150.0 }\n')
monkeypatch.chdir(tmp_path)
_load_test_isolation_config()
assert "mymodule" in HEAVY_MODULE_IMPORTS
assert HEAVY_MODULE_IMPORTS["mymodule"] == 150.0
def test_no_config_section_is_noop(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Missing [tool.devx.check_test_isolation] section is a no-op."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx]\nother_key = "value"\n')
monkeypatch.chdir(tmp_path)
before_io = dict(KNOWN_IO_FUNCTIONS)
_load_test_isolation_config()
assert before_io == KNOWN_IO_FUNCTIONS
def test_no_pyproject_is_noop(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""No pyproject.toml at all is a no-op."""
monkeypatch.chdir(tmp_path)
before = dict(KNOWN_SUBPROCESS_HELPERS)
_load_test_isolation_config()
assert before == KNOWN_SUBPROCESS_HELPERS
def test_non_dict_config_is_noop(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""A non-dict check_test_isolation section is a no-op."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx]\ncheck_test_isolation = "not_a_dict"\n')
monkeypatch.chdir(tmp_path)
before = dict(HEAVY_MODULE_IMPORTS)
_load_test_isolation_config()
assert before == HEAVY_MODULE_IMPORTS
def test_invalid_entry_types_are_skipped(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Entries with wrong types (non-str values) are silently skipped."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"[tool.devx.check_test_isolation]\n"
'io_functions = { "good_func" = "desc", "bad_func" = 123 }\n'
'heavy_module_imports = { "good_mod" = 100.0, "bad_mod" = "fast" }\n'
)
monkeypatch.chdir(tmp_path)
_load_test_isolation_config()
assert "good_func" in KNOWN_IO_FUNCTIONS
assert "bad_func" not in KNOWN_IO_FUNCTIONS
assert "good_mod" in HEAVY_MODULE_IMPORTS
assert "bad_mod" not in HEAVY_MODULE_IMPORTS
def test_extends_without_replacing_defaults(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Project config adds to defaults without removing them."""
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.check_test_isolation]\nio_functions = { "project_func" = "project I/O" }\n')
monkeypatch.chdir(tmp_path)
_load_test_isolation_config()
# Default entries still present
assert "get_pat" in KNOWN_IO_FUNCTIONS
# Project entry added
assert "project_func" in KNOWN_IO_FUNCTIONS
+105 -4
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/gitea_cli.py."""
"""Unit tests for devx/gitea_cli.py."""
from __future__ import annotations
@@ -7,7 +7,13 @@ from unittest.mock import MagicMock, patch
import pytest
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number, configure_tea_login
from devx.gitea_cli import (
TeaCLI,
TeaCLIError,
_extract_issue_number,
_extract_pr_number,
configure_tea_login,
)
class TestExtractIssueNumber:
@@ -77,6 +83,25 @@ class TestTeaCLIRun:
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
def test_run_failure_includes_stdout(self) -> None:
"""tea writes some errors to stdout (e.g. 'no available login')."""
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=1, stdout="no available login", stderr="")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(TeaCLIError, match="no available login"):
cli._run(["releases", "create"])
def test_run_failure_includes_both_stdout_and_stderr(self) -> None:
"""When both stdout and stderr have content, both are included."""
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=1, stdout="partial error", stderr="auth error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(TeaCLIError, match="partial error"):
cli._run(["labels", "list"])
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
def test_run_tea_not_found_raises_tea_error(self) -> None:
cli = TeaCLI(tea_bin="tea")
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
@@ -100,6 +125,46 @@ class TestTeaCLIRun:
cmd = mock_run.call_args[0][0]
assert "--output" not in cmd
def test_run_retries_on_502(self) -> None:
"""Transient 502 errors should be retried, then succeed."""
cli = TeaCLI(tea_bin="/fake/tea")
fail_result = MagicMock(returncode=1, stdout="", stderr="502 Bad Gateway")
success_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
with patch("subprocess.run", side_effect=[fail_result, success_result]) as mock_run:
with patch("tenacity.nap.time.sleep"):
output = cli._run(["labels", "list"])
assert output == '[{"id": 1}]'
assert mock_run.call_count == 2
def test_run_retries_on_503_then_fails(self) -> None:
"""If all retries are exhausted on 503, raise TeaCLIError."""
cli = TeaCLI(tea_bin="/fake/tea")
fail_result = MagicMock(returncode=1, stdout="", stderr="503 Service Unavailable")
with patch("subprocess.run", return_value=fail_result):
with patch("tenacity.nap.time.sleep"):
with pytest.raises(TeaCLIError, match="503"):
cli._run(["issues", "create"])
# MAX_RETRIES=3, so 3 attempts total
def test_run_no_retry_on_non_transient_error(self) -> None:
"""Non-transient errors (e.g. auth) should fail immediately without retry."""
cli = TeaCLI(tea_bin="/fake/tea")
fail_result = MagicMock(returncode=1, stdout="", stderr="auth error")
with patch("subprocess.run", return_value=fail_result) as mock_run:
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
assert mock_run.call_count == 1
def test_run_retries_on_429_in_stdout(self) -> None:
"""429 rate limit in stdout should trigger retry."""
cli = TeaCLI(tea_bin="/fake/tea")
fail_result = MagicMock(returncode=1, stdout="429 Too Many Requests", stderr="")
success_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", side_effect=[fail_result, success_result]):
with patch("tenacity.nap.time.sleep"):
output = cli._run(["releases", "create"])
assert output == "ok"
class TestRepoArg:
def test_with_repo_arg(self) -> None:
@@ -380,9 +445,11 @@ class TestConfigureTeaLogin:
def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""configure_tea_login adds login when not already configured."""
mock_list = MagicMock(returncode=0, stdout="")
mock_subprocess.return_value = mock_list
mock_add = MagicMock(returncode=0, stdout="Login successful", stderr="")
mock_default = MagicMock(returncode=0, stdout="", stderr="")
mock_subprocess.side_effect = [mock_list, mock_add, mock_default]
configure_tea_login()
assert mock_subprocess.call_count >= 2 # login list + login add + login default
assert mock_subprocess.call_count == 3 # login list + login add + login default
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@@ -393,3 +460,37 @@ class TestConfigureTeaLogin:
mock_subprocess.return_value = mock_list
configure_tea_login()
assert mock_subprocess.call_count == 1 # only login list, no add
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
def test_raises_on_login_add_failure(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""configure_tea_login raises TeaCLIError if tea login add fails."""
mock_list = MagicMock(returncode=0, stdout="")
mock_add = MagicMock(returncode=1, stdout="", stderr="invalid token")
mock_subprocess.side_effect = [mock_list, mock_add]
with pytest.raises(TeaCLIError, match="login add failed"):
configure_tea_login()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
def test_raises_on_login_default_failure(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""configure_tea_login raises TeaCLIError if tea login default fails."""
mock_list = MagicMock(returncode=0, stdout="")
mock_add = MagicMock(returncode=0, stdout="Login successful", stderr="")
mock_default = MagicMock(returncode=1, stdout="", stderr="login not found")
mock_subprocess.side_effect = [mock_list, mock_add, mock_default]
with pytest.raises(TeaCLIError, match="login default failed"):
configure_tea_login()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
def test_login_add_failure_includes_stdout(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""Error message includes stdout when tea writes errors there."""
mock_list = MagicMock(returncode=0, stdout="")
mock_add = MagicMock(returncode=1, stdout="Error: invalid username", stderr="")
mock_subprocess.side_effect = [mock_list, mock_add]
with pytest.raises(TeaCLIError, match="invalid username"):
configure_tea_login()
+47 -1
View File
@@ -297,6 +297,47 @@ class TestInstallVale:
assert (tmp_path / "vale").exists()
class TestInstallPromtool:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_promtool() is True
def test_install(self, tmp_path: Path) -> None:
import io
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake promtool"
with tarfile.open(tarball_path, "w:gz") as tar:
info = tarfile.TarInfo(name="promtool")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(install_tools, "_arch", return_value="amd64"):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
assert install_tools.install_promtool() is True
assert (tmp_path / "promtool").exists()
def test_url_contains_version(self, tmp_path: Path) -> None:
"""Verify the download URL includes the correct promtool version."""
captured_url = []
def fake_extract(url: str, binary_name: str) -> Path:
captured_url.append(url)
return tmp_path / binary_name
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "_download_and_extract_tarball", side_effect=fake_extract):
install_tools.install_promtool()
assert any(f"v{install_tools.PROMTOOL_VERSION}" in url for url in captured_url)
class TestListTools:
def test_list(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
@@ -341,6 +382,11 @@ class TestInstallTool:
assert install_tools._install_tool("vale") is True
mock.assert_called_once()
def test_promtool(self) -> None:
with patch.object(install_tools, "install_promtool", return_value=True) as mock:
assert install_tools._install_tool("promtool") is True
mock.assert_called_once()
def test_unknown_tool(self) -> None:
with pytest.raises(ClickException, match="Unknown tool"):
install_tools._install_tool("unknown")
@@ -359,7 +405,7 @@ class TestMain:
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert mock_install.call_count == 7
assert mock_install.call_count == 8
def test_install_specific_tool(self) -> None:
runner = CliRunner()
+82 -2
View File
@@ -387,8 +387,10 @@ class TestMain:
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
@patch("time.sleep")
def test_release_failure_raises_click(
self,
mock_sleep: MagicMock,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_tea_cls: MagicMock,
@@ -397,6 +399,7 @@ class TestMain:
mock_tag: MagicMock,
mock_login: MagicMock,
) -> None:
"""Release creation failure after retries raises ClickException."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = TeaCLIError("server error")
@@ -405,6 +408,8 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "Release creation failed" in result.output
# Retried 3 times (stop_after_attempt(3))
assert mock_tea.create_release.call_count == 3
@patch("devx.ci.publish.subprocess.run")
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
@@ -496,8 +501,10 @@ class TestMain:
@patch("devx.ci.publish.publish_to_gitea_registry")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
@patch("time.sleep")
def test_create_release_already_exists_is_idempotent(
self,
mock_sleep: MagicMock,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_gitea_pub: MagicMock,
@@ -507,7 +514,7 @@ class TestMain:
mock_tag: MagicMock,
mock_login: MagicMock,
) -> None:
"""If create_release fails with 'already exists', treat as success."""
"""If create_release fails with 'already exists', treat as success (no retry)."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
@@ -516,6 +523,8 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "already exists" in result.output
# "already exists" is caught immediately — no retry
assert mock_tea.create_release.call_count == 1
@patch("devx.ci.publish.subprocess.run")
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
@@ -526,8 +535,10 @@ class TestMain:
@patch("devx.ci.publish.publish_to_gitea_registry")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
@patch("time.sleep")
def test_create_release_other_error_raises(
self,
mock_sleep: MagicMock,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_gitea_pub: MagicMock,
@@ -537,7 +548,7 @@ class TestMain:
mock_tag: MagicMock,
mock_login: MagicMock,
) -> None:
"""If create_release fails with a non-'already exists' error, raise."""
"""If create_release fails with a non-'already exists' error, raise after retries."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea.create_release.side_effect = TeaCLIError("network error")
@@ -546,6 +557,75 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code != 0
assert "Release creation failed" in result.output
# Retried 3 times before giving up
assert mock_tea.create_release.call_count == 3
class TestReleaseRetry:
"""Tests for retry logic on transient release creation failures."""
@patch("devx.ci.publish.subprocess.run")
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
@patch("devx.gitea_cli.configure_tea_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package")
@patch("time.sleep")
def test_transient_failure_retried_and_succeeds(
self,
mock_sleep: MagicMock,
mock_build: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
mock_run: MagicMock,
mock_tag: MagicMock,
mock_login: MagicMock,
) -> None:
"""Transient failure on first attempt succeeds on retry."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = [
TeaCLIError("connection timeout"),
None, # second attempt succeeds
]
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
assert result.exit_code == 0
assert "Gitea release v1.0.0 created" in result.output
assert mock_tea.create_release.call_count == 2
mock_sleep.assert_called() # slept between attempts
@patch("devx.ci.publish.subprocess.run")
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
@patch("devx.gitea_cli.configure_tea_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package")
@patch("time.sleep")
def test_all_retries_exhausted_raises(
self,
mock_sleep: MagicMock,
mock_build: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
mock_run: MagicMock,
mock_tag: MagicMock,
mock_login: MagicMock,
) -> None:
"""All 3 retry attempts fail — raises ClickException."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = TeaCLIError("503 service unavailable")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
assert result.exit_code == 1
assert "Release creation failed" in result.output
assert mock_tea.create_release.call_count == 3
assert mock_sleep.call_count == 2 # slept between 3 attempts (2 sleeps)
class TestFromTag: