Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddb0f17886 | ||
|
|
a8a8b743f3 | ||
|
|
a487bddb09 | ||
|
|
e3a37c95c1 | ||
|
|
9bb461e12f | ||
|
|
32193a0e6d | ||
|
|
155c4a204a | ||
|
|
491137f944 | ||
|
|
48cd33be22 | ||
|
|
2fae9bc723 | ||
|
|
bfc2ebec81 | ||
|
|
e01c39b4b8 | ||
|
|
aa93e894a6 | ||
|
|
004b890463 | ||
|
|
587906f518 | ||
|
|
d743ba93eb | ||
|
|
c7351a495a | ||
|
|
4de11bfc18 | ||
|
|
a02bf6d70e | ||
|
|
368c87aabf | ||
|
|
4f982dc3ba | ||
|
|
a7a8637244 | ||
|
|
cdf3408a35 |
@@ -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
|
||||
|
||||
@@ -2,6 +2,54 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.47.8] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Increase CI_SCALE_FACTOR default from 4 to 6
|
||||
|
||||
## [0.47.7] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Scale check_test_speed limits on CI runners
|
||||
|
||||
## [0.47.6] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Configure git auth in setup_image for git+https deps
|
||||
|
||||
## [0.47.5] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Push wiki to main branch instead of master
|
||||
|
||||
## [0.47.4] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add User-Agent header to _download in install_tools
|
||||
|
||||
## [0.47.3] - 2026-07-17
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Bake promtool into ci-full image, add download timeout, speed up tests
|
||||
|
||||
## [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
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.47.0",
|
||||
"devx>=0.47.8",
|
||||
]
|
||||
|
||||
[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.47.0"`) or use a version constraint
|
||||
> (for example, `"devx>=0.47.0,<0.48"`).
|
||||
> `dependencies` (for example, `"devx==0.47.8"`) or use a version constraint
|
||||
> (for example, `"devx>=0.47.8,<0.48"`).
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
@@ -20,5 +20,6 @@ COPY . /tmp/devx
|
||||
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
|
||||
&& rm -rf /tmp/devx
|
||||
|
||||
# Install git-cliff (changelog generator for release job) and OpenTofu (for infra deploy jobs)
|
||||
RUN python3 -m devx.tools.install_tools --tool git-cliff --tool tofu
|
||||
# Install git-cliff (changelog generator for release job), OpenTofu (for infra deploy jobs),
|
||||
# and promtool (Prometheus rule validator — used by every infra CI run for alert validation)
|
||||
RUN python3 -m devx.tools.install_tools --tool git-cliff --tool tofu --tool promtool
|
||||
|
||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](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.47.0",
|
||||
"devx>=0.47.8",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||
```
|
||||
|
||||
Pin a specific version if needed: `"devx==0.47.0"` or `"devx>=0.47.0,<0.48"`.
|
||||
Pin a specific version if needed: `"devx==0.47.8"` or `"devx>=0.47.8,<0.48"`.
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.47.0",
|
||||
"devx>=0.47.8",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx>=0.47.0",
|
||||
"devx>=0.47.8",
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.47.0"
|
||||
__version__ = "0.47.8"
|
||||
|
||||
+37
-7
@@ -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()
|
||||
|
||||
@@ -252,7 +252,7 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
|
||||
|
||||
# Push
|
||||
result = subprocess.run( # nosec
|
||||
["git", "push", "--force", wiki_url, "HEAD:master"],
|
||||
["git", "push", "--force", wiki_url, "HEAD:main"],
|
||||
cwd=wiki_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
+78
-17
@@ -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."""
|
||||
|
||||
@@ -309,7 +309,7 @@ devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-
|
||||
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
devx-test-unit:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov -n 8
|
||||
|
||||
devx-pytest-cov:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -n auto --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
||||
|
||||
@@ -11,6 +11,18 @@ Usage:
|
||||
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
|
||||
that pytest emits per-test timing lines alongside the summary. Both the
|
||||
total wall-clock time and individual test durations are parsed and validated.
|
||||
|
||||
CI runner scaling
|
||||
-----------------
|
||||
CI runners (Gitea Actions Docker containers) are typically 5-8x slower than
|
||||
local development machines due to shared CPU, fewer cores, and container
|
||||
overhead. When the ``CI`` environment variable is set (standard CI
|
||||
convention), both the total and per-test limits are multiplied by
|
||||
``CI_SCALE_FACTOR`` (default 6) to account for this. This keeps the local
|
||||
budget strict while preventing false failures on slower CI runners.
|
||||
|
||||
The scale factor can be overridden via the ``DEVX_CI_SCALE_FACTOR``
|
||||
environment variable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +39,12 @@ DEFAULT_MAX_SECONDS = 10.0
|
||||
DEFAULT_MAX_SINGLE_SECONDS = 0.5
|
||||
TEST_COMMAND = ["make", "test-unit"]
|
||||
|
||||
# CI runners are typically 5-8x slower than local machines (shared CPU,
|
||||
# fewer cores, container overhead). Scale limits up when running on CI
|
||||
# so the gate catches real regressions, not infrastructure slowness.
|
||||
CI_SCALE_FACTOR = float(os.environ.get("DEVX_CI_SCALE_FACTOR", "6"))
|
||||
_IS_CI = bool(os.environ.get("CI") or os.environ.get("GITEA_ACTIONS"))
|
||||
|
||||
# Matches pytest summary line: "234 passed in 0.70s"
|
||||
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
||||
|
||||
@@ -38,6 +56,13 @@ _TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
||||
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+call\s+(.+)$")
|
||||
|
||||
|
||||
def _ci_scale_limit(limit: float) -> float:
|
||||
"""Scale a time limit by the CI factor when running on CI."""
|
||||
if _IS_CI:
|
||||
return limit * CI_SCALE_FACTOR
|
||||
return limit
|
||||
|
||||
|
||||
def run_tests() -> tuple[str, str]:
|
||||
"""Execute the unit-test suite and return (stdout, stderr).
|
||||
|
||||
@@ -123,21 +148,38 @@ def check_per_test_speed(
|
||||
|
||||
def main(max_seconds: float, max_single_seconds: float) -> None:
|
||||
"""Run tests, parse timings, and enforce both budgets."""
|
||||
# Scale limits for CI runners (slower CPU, fewer workers).
|
||||
effective_max = _ci_scale_limit(max_seconds)
|
||||
effective_single = _ci_scale_limit(max_single_seconds)
|
||||
|
||||
if _IS_CI:
|
||||
click.echo(
|
||||
_(
|
||||
"[check-test-speed] CI environment detected — scaling limits by {factor}x "
|
||||
"(total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
factor=CI_SCALE_FACTOR,
|
||||
orig=max_seconds,
|
||||
eff=effective_max,
|
||||
orig_s=max_single_seconds,
|
||||
eff_s=effective_single,
|
||||
)
|
||||
)
|
||||
|
||||
stdout, stderr = run_tests()
|
||||
combined = stdout + "\n" + stderr
|
||||
click.echo(combined, err=False)
|
||||
|
||||
duration = parse_duration(combined)
|
||||
check_speed(duration, max_seconds)
|
||||
check_speed(duration, effective_max)
|
||||
|
||||
if max_single_seconds > 0:
|
||||
if effective_single > 0:
|
||||
per_test = parse_per_test_durations(combined)
|
||||
violations = check_per_test_speed(per_test, max_single_seconds)
|
||||
violations = check_per_test_speed(per_test, effective_single)
|
||||
if violations:
|
||||
msg = _(
|
||||
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
count=len(violations),
|
||||
limit=max_single_seconds,
|
||||
limit=effective_single,
|
||||
)
|
||||
click.echo(f"\n{msg}", err=True)
|
||||
for v in violations:
|
||||
@@ -148,8 +190,8 @@ def main(max_seconds: float, max_single_seconds: float) -> None:
|
||||
_(
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||
duration=duration,
|
||||
max=max_seconds,
|
||||
single=max_single_seconds,
|
||||
max=effective_max,
|
||||
single=effective_single,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -65,8 +65,14 @@ def _ensure_target_dir() -> Path:
|
||||
|
||||
|
||||
def _download(url: str, dest: Path) -> None:
|
||||
"""Download a file from ``url`` to ``dest``."""
|
||||
urllib.request.urlretrieve(url, dest) # nosec B310
|
||||
"""Download a file from ``url`` to ``dest`` with a 60s timeout.
|
||||
|
||||
A User-Agent header is set because some CDNs (e.g. dl.gitea.com)
|
||||
return 403 to requests with Python's default User-Agent.
|
||||
"""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "devx/install-tools"})
|
||||
with urllib.request.urlopen(req, timeout=60) as resp, open(dest, "wb") as f: # nosec B310
|
||||
shutil.copyfileobj(resp, f)
|
||||
|
||||
|
||||
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
||||
|
||||
@@ -81,6 +81,17 @@ def _install_in_image(
|
||||
username,
|
||||
token,
|
||||
)
|
||||
# Configure git URL rewrite so git+https dependencies can authenticate
|
||||
subprocess.run( # nosec B603, B607
|
||||
[
|
||||
"git",
|
||||
"config",
|
||||
"--global",
|
||||
f"url.https://{username}:{token}@{gitea_host}/.insteadOf",
|
||||
f"https://{gitea_host}/",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
click.echo(f"[setup-image] Linked {opt_venv}" + (f" with [{extras}]" if extras else "") + ".")
|
||||
subprocess.run(cmd, check=True, env=env) # nosec B603
|
||||
|
||||
@@ -3822,5 +3822,13 @@
|
||||
"pl": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||
"ru": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally.",
|
||||
"zh": "importlib.reload({mod}) called {n} time(s) in test '{test}' — odd count leaves module in modified state. Add a final reload to restore defaults or wrap in try/finally."
|
||||
},
|
||||
"[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)": {
|
||||
"en": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
"bg": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
"de": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
"pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
"ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||
"zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ from devx.tools.check_test_speed import (
|
||||
DEFAULT_MAX_SECONDS,
|
||||
DEFAULT_MAX_SINGLE_SECONDS,
|
||||
TEST_COMMAND,
|
||||
_ci_scale_limit,
|
||||
check_per_test_speed,
|
||||
check_speed,
|
||||
cli,
|
||||
@@ -144,7 +145,26 @@ def test_main_module_block() -> None:
|
||||
mock_cli.assert_called_once_with([])
|
||||
|
||||
|
||||
class TestCiScaleLimit:
|
||||
def test_no_scaling_when_not_ci(self) -> None:
|
||||
with patch("devx.tools.check_test_speed._IS_CI", False):
|
||||
assert _ci_scale_limit(10.0) == 10.0
|
||||
assert _ci_scale_limit(0.5) == 0.5
|
||||
|
||||
def test_scales_when_ci(self) -> None:
|
||||
with patch("devx.tools.check_test_speed._IS_CI", True):
|
||||
with patch("devx.tools.check_test_speed.CI_SCALE_FACTOR", 4.0):
|
||||
assert _ci_scale_limit(10.0) == 40.0
|
||||
assert _ci_scale_limit(0.5) == 2.0
|
||||
|
||||
def test_custom_scale_factor(self) -> None:
|
||||
with patch("devx.tools.check_test_speed._IS_CI", True):
|
||||
with patch("devx.tools.check_test_speed.CI_SCALE_FACTOR", 2.5):
|
||||
assert _ci_scale_limit(10.0) == 25.0
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@@ -174,6 +194,7 @@ class TestMain:
|
||||
mock_parse_per.assert_called_once()
|
||||
mock_check_per.assert_called_once_with([], DEFAULT_MAX_SINGLE_SECONDS)
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
def test_slow_total_exits(
|
||||
@@ -189,6 +210,7 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
assert "too slow" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@@ -213,6 +235,7 @@ class TestMain:
|
||||
assert "Per-test speed check FAILED" in result.output
|
||||
assert "test_slow" in result.output
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
def test_parse_failure_exits(
|
||||
self,
|
||||
@@ -225,6 +248,7 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
assert "Could not parse" in result.output
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@@ -248,6 +272,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
mock_check.assert_called_once_with(0.5, 1.5)
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@@ -270,6 +295,7 @@ class TestMain:
|
||||
mock_parse_per.assert_not_called()
|
||||
mock_check_per.assert_not_called()
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", False)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@@ -292,3 +318,32 @@ class TestMain:
|
||||
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
|
||||
assert result.exit_code == 0
|
||||
mock_check_per.assert_called_once_with([], 1.0)
|
||||
|
||||
@patch("devx.tools.check_test_speed._IS_CI", True)
|
||||
@patch("devx.tools.check_test_speed.CI_SCALE_FACTOR", 4.0)
|
||||
@patch("devx.tools.check_test_speed.run_tests")
|
||||
@patch("devx.tools.check_test_speed.parse_duration")
|
||||
@patch("devx.tools.check_test_speed.check_speed")
|
||||
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||
def test_ci_scales_limits(
|
||||
self,
|
||||
mock_check_per: MagicMock,
|
||||
mock_parse_per: MagicMock,
|
||||
mock_check: MagicMock,
|
||||
mock_parse: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
) -> None:
|
||||
mock_run.return_value = ("out\n", "err\n")
|
||||
mock_parse.return_value = 30.0 # would fail local (10s) but pass CI (40s)
|
||||
mock_parse_per.return_value = []
|
||||
mock_check_per.return_value = []
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "CI environment detected" in result.output
|
||||
assert "scaling limits by 4.0x" in result.output
|
||||
# check_speed called with scaled limit
|
||||
mock_check.assert_called_once_with(30.0, 40.0)
|
||||
mock_check_per.assert_called_once_with([], 2.0)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -47,13 +48,29 @@ class TestDownload:
|
||||
def test_download(self, tmp_path: Path) -> None:
|
||||
dest = tmp_path / "file.bin"
|
||||
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"data")
|
||||
return str(path), None
|
||||
class _FakeResponse:
|
||||
def __init__(self) -> None:
|
||||
self._sent = False
|
||||
|
||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||
def __enter__(self) -> _FakeResponse:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
pass
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
if self._sent:
|
||||
return b""
|
||||
self._sent = True
|
||||
return b"data"
|
||||
|
||||
with patch("urllib.request.urlopen", return_value=_FakeResponse()) as mock_urlopen:
|
||||
install_tools._download("https://example.com/file", dest)
|
||||
mock_retrieve.assert_called_once()
|
||||
mock_urlopen.assert_called_once()
|
||||
call_args = mock_urlopen.call_args
|
||||
req = call_args.args[0]
|
||||
assert isinstance(req, urllib.request.Request)
|
||||
assert req.get_header("User-agent") == "devx/install-tools"
|
||||
assert dest.read_bytes() == b"data"
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user