Compare commits

...
32 Commits
Author SHA1 Message Date
devx-ci-bot e3a37c95c1 release: v0.47.7 [skip ci] 2026-08-03 21:41:37 +00:00
emilandemo 9bb461e12f DEVX-146: fix: scale check_test_speed limits on CI runners
Post-merge / detect-and-configure (push) Successful in 20s
Post-merge / release-and-maintain (push) Successful in 2m2s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-08-03 21:40:17 +00:00
gitea-actions-bot 32193a0e6d chore: update badge URLs to commit d49dc712 [skip ci] 2026-08-03 15:34:38 +00:00
devx-ci-bot 155c4a204a release: v0.47.6 [skip ci] 2026-08-03 15:34:03 +00:00
emo 491137f944 DEVX-3: fix: configure git auth in setup_image for git+https deps 2026-08-03 15:33:16 +00:00
gitea-actions-bot 48cd33be22 chore: update badge URLs to commit cd7648fd [skip ci] 2026-08-03 14:56:33 +00:00
devx-ci-bot 2fae9bc723 release: v0.47.5 [skip ci] 2026-08-03 14:55:50 +00:00
emo bfc2ebec81 DEVX-2: fix: push wiki to main branch instead of master 2026-08-03 14:55:10 +00:00
devx-ci-bot e01c39b4b8 release: v0.47.4 [skip ci] 2026-08-03 14:41:33 +00:00
emo aa93e894a6 DEVX-1: fix: add User-Agent header to _download in install_tools 2026-08-03 14:40:51 +00:00
gitea-actions-bot 004b890463 chore: update badge URLs to commit 82b4caf3 [skip ci] 2026-07-17 02:11:54 +00:00
devx-ci-bot 587906f518 release: v0.47.3 [skip ci] 2026-07-17 02:11:15 +00:00
emil d743ba93eb DEVX-144: fix: bake promtool into ci-full image, add download timeout, speed up tests
Post-merge / release-and-maintain (push) Waiting to run
Post-merge / detect-and-configure (push) Waiting to run
2026-07-17 02:10:17 +00:00
gitea-actions-bot c7351a495a chore: update badge URLs to commit eeaec1e7 [skip ci] 2026-07-17 00:45:48 +00:00
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
23 changed files with 879 additions and 78 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
+66
View File
@@ -2,6 +2,72 @@
All notable changes to this project will be documented in this file.
## [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
- 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
+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/52dfd18c3e37f43d60412314932c3b77eb859e7f/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/52dfd18c3e37f43d60412314932c3b77eb859e7f/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/52dfd18c3e37f43d60412314932c3b77eb859e7f/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/52dfd18c3e37f43d60412314932c3b77eb859e7f/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/52dfd18c3e37f43d60412314932c3b77eb859e7f/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/52dfd18c3e37f43d60412314932c3b77eb859e7f/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/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.2",
"devx>=0.47.7",
]
[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.2"`) or use a version constraint
> (for example, `"devx>=0.44.2,<0.45"`).
> `dependencies` (for example, `"devx==0.47.7"`) or use a version constraint
> (for example, `"devx>=0.47.7,<0.48"`).
### Optional extras
+3 -2
View File
@@ -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
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/52dfd18c3e37f43d60412314932c3b77eb859e7f/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/52dfd18c3e37f43d60412314932c3b77eb859e7f/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/52dfd18c3e37f43d60412314932c3b77eb859e7f/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/52dfd18c3e37f43d60412314932c3b77eb859e7f/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/52dfd18c3e37f43d60412314932c3b77eb859e7f/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/52dfd18c3e37f43d60412314932c3b77eb859e7f/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d49dc712ba721a4c7bdc376a2453113e76e56477/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.2",
"devx>=0.47.7",
]
[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.2"` or `"devx>=0.44.2,<0.45"`.
Pin a specific version if needed: `"devx==0.47.7"` or `"devx>=0.47.7,<0.48"`.
### Optional extras
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.44.2",
"devx>=0.47.7",
]
[project.optional-dependencies]
dev = [
"devx>=0.44.2",
"devx>=0.47.7",
]
```
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.44.2"
__version__ = "0.47.7"
+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()
+1 -1
View File
@@ -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
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."""
+1 -1
View File
@@ -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
+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(
+48 -6
View File
@@ -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 3-4x 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 4) 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 3-4x 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", "4"))
_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,
)
)
+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:
+33 -3
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)."""
@@ -62,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:
@@ -212,7 +221,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 +259,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}")
+11
View File
@@ -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
+9 -1
View File
@@ -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)"
}
}
}
+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
+55
View File
@@ -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)
+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()
+69 -6
View File
@@ -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"
@@ -297,6 +314,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 +399,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 +422,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: