Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3a37c95c1 | ||
|
|
9bb461e12f | ||
|
|
32193a0e6d | ||
|
|
155c4a204a | ||
|
|
491137f944 | ||
|
|
48cd33be22 | ||
|
|
2fae9bc723 | ||
|
|
bfc2ebec81 | ||
|
|
e01c39b4b8 | ||
|
|
aa93e894a6 | ||
|
|
004b890463 | ||
|
|
587906f518 | ||
|
|
d743ba93eb | ||
|
|
c7351a495a | ||
|
|
4de11bfc18 | ||
|
|
a02bf6d70e | ||
|
|
368c87aabf | ||
|
|
4f982dc3ba | ||
|
|
a7a8637244 | ||
|
|
cdf3408a35 | ||
|
|
8fcac10286 | ||
|
|
c62c560c85 | ||
|
|
08b781f978 | ||
|
|
ea7566fe6b | ||
|
|
d8ceb6c8a1 | ||
|
|
748baf17eb | ||
|
|
f339df3562 | ||
|
|
db38453a54 | ||
|
|
5d78377152 | ||
|
|
b8b21cccd5 | ||
|
|
326eccfd2f | ||
|
|
076b470344 |
@@ -46,7 +46,7 @@ jobs:
|
|||||||
- name: Check unit test speed
|
- name: Check unit test speed
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate 2>/dev/null || true
|
. .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)
|
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
|
||||||
env:
|
env:
|
||||||
DEVX_DOC_COVERAGE_STRICT: "1"
|
DEVX_DOC_COVERAGE_STRICT: "1"
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ src/devx/
|
|||||||
├── translations.json # Translation strings (en, bg, de, pl, ru, zh)
|
├── translations.json # Translation strings (en, bg, de, pl, ru, zh)
|
||||||
├── ci/ # CI/CD automation modules (run by workflows)
|
├── ci/ # CI/CD automation modules (run by workflows)
|
||||||
│ ├── release.py # Automated versioning, tagging, changelog
|
│ ├── 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
|
│ ├── 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)
|
│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master)
|
||||||
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
│ ├── _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_pr()` / `merge_pr()` / `review_pr()` — Pull request operations
|
||||||
- `create_release()` / `list_releases()` — Release management
|
- `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
|
### git-cliff Commit Preprocessing
|
||||||
|
|
||||||
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
|
Merge commits on master have the format `DEVX-N: <conventional commit>`. The
|
||||||
|
|||||||
@@ -2,6 +2,72 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## [0.44.2] - 2026-07-14
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](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/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.44.2",
|
"devx>=0.47.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
@@ -101,8 +101,8 @@ pip install -e .
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** If your project requires a specific devx version, pin it in
|
> **Note:** If your project requires a specific devx version, pin it in
|
||||||
> `dependencies` (for example, `"devx==0.44.2"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.47.7"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.44.2,<0.45"`).
|
> (for example, `"devx>=0.47.7,<0.48"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ COPY . /tmp/devx
|
|||||||
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
|
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
|
||||||
&& rm -rf /tmp/devx
|
&& rm -rf /tmp/devx
|
||||||
|
|
||||||
# Install git-cliff (changelog generator for release job) and OpenTofu (for infra deploy jobs)
|
# Install git-cliff (changelog generator for release job), OpenTofu (for infra deploy jobs),
|
||||||
RUN python3 -m devx.tools.install_tools --tool git-cliff --tool tofu
|
# 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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.44.2",
|
"devx>=0.47.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
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
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.44.2",
|
"devx>=0.47.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.44.2",
|
"devx>=0.47.7",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.44.2"
|
__version__ = "0.47.7"
|
||||||
|
|||||||
+37
-7
@@ -4,6 +4,10 @@
|
|||||||
Uses git-cliff to generate the release notes from conventional commits.
|
Uses git-cliff to generate the release notes from conventional commits.
|
||||||
Uses the ``tea`` Gitea CLI for release creation.
|
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):
|
Publishing destinations (checked in order):
|
||||||
1. **Gitea PyPI registry** — if ``--registry-url`` is given (or
|
1. **Gitea PyPI registry** — if ``--registry-url`` is given (or
|
||||||
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
||||||
@@ -27,6 +31,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
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.config import GITEA_API_URL, REPO_OWNER
|
||||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||||
@@ -312,13 +317,7 @@ def main(
|
|||||||
|
|
||||||
release_body = generate_release_notes(tag)
|
release_body = generate_release_notes(tag)
|
||||||
|
|
||||||
try:
|
_create_release_with_retry(tea, repo, tag, release_body)
|
||||||
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
|
|
||||||
|
|
||||||
click.echo(
|
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
|
if __name__ == "__main__": # pragma: no cover
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
|
|||||||
|
|
||||||
# Push
|
# Push
|
||||||
result = subprocess.run( # nosec
|
result = subprocess.run( # nosec
|
||||||
["git", "push", "--force", wiki_url, "HEAD:master"],
|
["git", "push", "--force", wiki_url, "HEAD:main"],
|
||||||
cwd=wiki_dir,
|
cwd=wiki_dir,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|||||||
+78
-17
@@ -40,27 +40,46 @@ Usage::
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import click
|
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.i18n import _
|
||||||
from devx.tokens import get_ci_token
|
from devx.tokens import get_ci_token
|
||||||
|
|
||||||
|
logger = logging.getLogger("gitea_cli")
|
||||||
|
|
||||||
|
|
||||||
class TeaCLIError(Exception):
|
class TeaCLIError(Exception):
|
||||||
"""Raised when a tea CLI command fails."""
|
"""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:
|
def configure_tea_login(login_name: str = "devx") -> None:
|
||||||
"""Configure tea CLI login from CI_GITEA_API_TOKEN and DEVX_GITEA_API_URL.
|
"""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.
|
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.
|
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
|
Used by CI scripts (publish, notify_failure) that need tea login but
|
||||||
run in containerized environments where ``make setup`` was not called.
|
run in containerized environments where ``make setup`` was not called.
|
||||||
"""
|
"""
|
||||||
@@ -88,18 +107,31 @@ def configure_tea_login(login_name: str = "devx") -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
click.echo(_("Configuring tea login '{name}' for {url}...", name=login_name, url=gitea_url))
|
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],
|
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
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],
|
[tea_bin, "login", "default", login_name],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
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:
|
class TeaCLI:
|
||||||
@@ -122,6 +154,10 @@ class TeaCLI:
|
|||||||
def _run(self, args: list[str], json_output: bool = True) -> str:
|
def _run(self, args: list[str], json_output: bool = True) -> str:
|
||||||
"""Run a tea command and return stdout.
|
"""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:
|
||||||
args: Command arguments (without the leading ``tea``).
|
args: Command arguments (without the leading ``tea``).
|
||||||
json_output: If True, append ``--output json`` to the command.
|
json_output: If True, append ``--output json`` to the command.
|
||||||
@@ -130,25 +166,50 @@ class TeaCLI:
|
|||||||
stdout as a string.
|
stdout as a string.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TeaCLIError: If the command fails.
|
TeaCLIError: If the command fails after retries are exhausted.
|
||||||
"""
|
"""
|
||||||
cmd = [self._tea, *args]
|
cmd = [self._tea, *args]
|
||||||
if json_output:
|
if json_output:
|
||||||
cmd.extend(["--output", "json"])
|
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:
|
try:
|
||||||
result = subprocess.run( # nosec B603
|
return retry_decorator(_execute)()
|
||||||
cmd,
|
except _TransientTeaError as e:
|
||||||
capture_output=True,
|
raise TeaCLIError(str(e)) from e
|
||||||
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()
|
|
||||||
|
|
||||||
def _run_raw(self, args: list[str]) -> str:
|
def _run_raw(self, args: list[str]) -> str:
|
||||||
"""Run a tea command without JSON output and return stdout."""
|
"""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 ───────────────────────────────────────────────────────────────────
|
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
devx-test-unit:
|
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-pytest-cov:
|
||||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -n auto --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -n auto --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ This module is used in two ways:
|
|||||||
findings are reported as advisories (exit 0) since static analysis
|
findings are reported as advisories (exit 0) since static analysis
|
||||||
can't predict early exits — the runtime audit is authoritative.
|
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:
|
Patterns detected:
|
||||||
|
|
||||||
1. **Unpatched subprocess calls** — test functions that call
|
1. **Unpatched subprocess calls** — test functions that call
|
||||||
@@ -60,6 +79,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from devx.config import _load_pyproject_devx
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
# ── Configuration ─────────────────────────────────────────────────────────────
|
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||||
@@ -71,7 +91,7 @@ DEFAULT_MAX_LOOP_ITERATIONS = 100
|
|||||||
# Maps module name → approximate import time in milliseconds.
|
# Maps module name → approximate import time in milliseconds.
|
||||||
# NOTE: ``requests`` is excluded because it's a core devx dependency —
|
# NOTE: ``requests`` is excluded because it's a core devx dependency —
|
||||||
# it's loaded during collection regardless of whether test files import it.
|
# 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,
|
"httpx": 80.0,
|
||||||
"aiohttp": 120.0,
|
"aiohttp": 120.0,
|
||||||
"docker": 90.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
|
# Functions known to spawn subprocesses. When a test calls any of these
|
||||||
# without patching them, the real subprocess runs.
|
# without patching them, the real subprocess runs.
|
||||||
# Maps function name → human-readable description.
|
# 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",
|
"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_tests": "calls run_cmd to run make lint-ruff and make pytest-cov",
|
||||||
"run_cmd": "calls subprocess.run for shell commands",
|
"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.
|
# 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.
|
# 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.
|
# 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)",
|
"get_pat": "reads ZITADEL PAT from filesystem/env (ZitadelAuth._iter_sources)",
|
||||||
"load_secrets": "reads YAML config file from disk",
|
"load_secrets": "reads YAML config file from disk",
|
||||||
"get_customer_secret": "reads customer-specific config 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,
|
# Transitive dependencies: if a helper calls another helper that is patched,
|
||||||
# the call is safe. Maps helper → set of function names it internally calls.
|
# 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.
|
# 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"},
|
"run_tests": {"run_cmd", "subprocess"},
|
||||||
"update_doc_versions": {"subprocess"},
|
"update_doc_versions": {"subprocess"},
|
||||||
"run_cmd": {"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 functions that the runtime audit wraps.
|
||||||
_SUBPROCESS_FUNCS = ("run", "call", "check_call", "check_output", "Popen")
|
_SUBPROCESS_FUNCS = ("run", "call", "check_call", "check_output", "Popen")
|
||||||
|
|
||||||
@@ -817,6 +927,9 @@ class TestIsolationVisitor(ast.NodeVisitor):
|
|||||||
or sn in all_patches
|
or sn in all_patches
|
||||||
or any(io_key in p or sn in p for p 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(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(
|
self.violations.append(
|
||||||
Violation(
|
Violation(
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ Usage:
|
|||||||
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
|
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
|
||||||
that pytest emits per-test timing lines alongside the summary. Both the
|
that pytest emits per-test timing lines alongside the summary. Both the
|
||||||
total wall-clock time and individual test durations are parsed and validated.
|
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
|
from __future__ import annotations
|
||||||
@@ -27,6 +39,12 @@ DEFAULT_MAX_SECONDS = 10.0
|
|||||||
DEFAULT_MAX_SINGLE_SECONDS = 0.5
|
DEFAULT_MAX_SINGLE_SECONDS = 0.5
|
||||||
TEST_COMMAND = ["make", "test-unit"]
|
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"
|
# Matches pytest summary line: "234 passed in 0.70s"
|
||||||
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
_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+(.+)$")
|
_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]:
|
def run_tests() -> tuple[str, str]:
|
||||||
"""Execute the unit-test suite and return (stdout, stderr).
|
"""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:
|
def main(max_seconds: float, max_single_seconds: float) -> None:
|
||||||
"""Run tests, parse timings, and enforce both budgets."""
|
"""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()
|
stdout, stderr = run_tests()
|
||||||
combined = stdout + "\n" + stderr
|
combined = stdout + "\n" + stderr
|
||||||
click.echo(combined, err=False)
|
click.echo(combined, err=False)
|
||||||
|
|
||||||
duration = parse_duration(combined)
|
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)
|
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:
|
if violations:
|
||||||
msg = _(
|
msg = _(
|
||||||
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
count=len(violations),
|
count=len(violations),
|
||||||
limit=max_single_seconds,
|
limit=effective_single,
|
||||||
)
|
)
|
||||||
click.echo(f"\n{msg}", err=True)
|
click.echo(f"\n{msg}", err=True)
|
||||||
for v in violations:
|
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).",
|
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||||
duration=duration,
|
duration=duration,
|
||||||
max=max_seconds,
|
max=effective_max,
|
||||||
single=max_single_seconds,
|
single=effective_single,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
deletes all but the most recent N versions. The ``latest`` tag is always
|
||||||
preserved if present.
|
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::
|
Usage::
|
||||||
|
|
||||||
# Clean up ci-base images, keep last 2 versions
|
# 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``
|
Returns a list of version dicts, each containing at least ``version``
|
||||||
and ``created_at`` fields.
|
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}"}
|
headers = {"Authorization": f"token {token}"}
|
||||||
all_versions: list[dict[str, Any]] = []
|
all_versions: list[dict[str, Any]] = []
|
||||||
page = 1
|
page = 1
|
||||||
@@ -96,7 +106,11 @@ def delete_package_version(
|
|||||||
|
|
||||||
Returns True on success, False on failure.
|
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}"}
|
headers = {"Authorization": f"token {token}"}
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Handles installation of:
|
|||||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
||||||
- hadolint (Dockerfile linter)
|
- hadolint (Dockerfile linter)
|
||||||
- vale (prose linter for documentation quality)
|
- vale (prose linter for documentation quality)
|
||||||
|
- promtool (Prometheus rule validator)
|
||||||
|
|
||||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||||
Idempotent: skips tools that are already available.
|
Idempotent: skips tools that are already available.
|
||||||
@@ -47,6 +48,8 @@ TOFU_VERSION = "1.12.3"
|
|||||||
|
|
||||||
VALE_VERSION = "3.15.1"
|
VALE_VERSION = "3.15.1"
|
||||||
|
|
||||||
|
PROMTOOL_VERSION = "3.5.5"
|
||||||
|
|
||||||
|
|
||||||
def _arch() -> str:
|
def _arch() -> str:
|
||||||
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
"""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:
|
def _download(url: str, dest: Path) -> None:
|
||||||
"""Download a file from ``url`` to ``dest``."""
|
"""Download a file from ``url`` to ``dest`` with a 60s timeout.
|
||||||
urllib.request.urlretrieve(url, dest) # nosec B310
|
|
||||||
|
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:
|
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
||||||
@@ -212,7 +221,26 @@ def install_vale() -> bool:
|
|||||||
return True
|
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:
|
def _install_tool(name: str) -> bool:
|
||||||
@@ -231,6 +259,8 @@ def _install_tool(name: str) -> bool:
|
|||||||
return install_tofu()
|
return install_tofu()
|
||||||
if name == "vale":
|
if name == "vale":
|
||||||
return install_vale()
|
return install_vale()
|
||||||
|
if name == "promtool":
|
||||||
|
return install_promtool()
|
||||||
raise click.ClickException(f"Unknown tool: {name}")
|
raise click.ClickException(f"Unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,17 @@ def _install_in_image(
|
|||||||
username,
|
username,
|
||||||
token,
|
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 "") + ".")
|
click.echo(f"[setup-image] Linked {opt_venv}" + (f" with [{extras}]" if extras else "") + ".")
|
||||||
subprocess.run(cmd, check=True, env=env) # nosec B603
|
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.",
|
"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.",
|
"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."
|
"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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,14 +8,19 @@ import textwrap
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from devx.tools.check_test_isolation import (
|
from devx.tools.check_test_isolation import (
|
||||||
|
HEAVY_MODULE_IMPORTS,
|
||||||
HELPER_INTERNAL_CALLS,
|
HELPER_INTERNAL_CALLS,
|
||||||
|
IO_INTERNAL_CALLS,
|
||||||
|
KNOWN_IO_FUNCTIONS,
|
||||||
KNOWN_SUBPROCESS_HELPERS,
|
KNOWN_SUBPROCESS_HELPERS,
|
||||||
CallGraph,
|
CallGraph,
|
||||||
_extract_patch_targets,
|
_extract_patch_targets,
|
||||||
_is_integration_test,
|
_is_integration_test,
|
||||||
|
_load_test_isolation_config,
|
||||||
_SubprocessAudit,
|
_SubprocessAudit,
|
||||||
analyze_file,
|
analyze_file,
|
||||||
analyze_test_files,
|
analyze_test_files,
|
||||||
@@ -1667,3 +1672,110 @@ class TestIsIntegrationTest:
|
|||||||
item.keywords = {}
|
item.keywords = {}
|
||||||
item.fspath = "tests/unit/test_foo.py"
|
item.fspath = "tests/unit/test_foo.py"
|
||||||
assert _is_integration_test(item) is False
|
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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from devx.tools.check_test_speed import (
|
|||||||
DEFAULT_MAX_SECONDS,
|
DEFAULT_MAX_SECONDS,
|
||||||
DEFAULT_MAX_SINGLE_SECONDS,
|
DEFAULT_MAX_SINGLE_SECONDS,
|
||||||
TEST_COMMAND,
|
TEST_COMMAND,
|
||||||
|
_ci_scale_limit,
|
||||||
check_per_test_speed,
|
check_per_test_speed,
|
||||||
check_speed,
|
check_speed,
|
||||||
cli,
|
cli,
|
||||||
@@ -144,7 +145,26 @@ def test_main_module_block() -> None:
|
|||||||
mock_cli.assert_called_once_with([])
|
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:
|
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.run_tests")
|
||||||
@patch("devx.tools.check_test_speed.parse_duration")
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
@patch("devx.tools.check_test_speed.check_speed")
|
@patch("devx.tools.check_test_speed.check_speed")
|
||||||
@@ -174,6 +194,7 @@ class TestMain:
|
|||||||
mock_parse_per.assert_called_once()
|
mock_parse_per.assert_called_once()
|
||||||
mock_check_per.assert_called_once_with([], DEFAULT_MAX_SINGLE_SECONDS)
|
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.run_tests")
|
||||||
@patch("devx.tools.check_test_speed.parse_duration")
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
def test_slow_total_exits(
|
def test_slow_total_exits(
|
||||||
@@ -189,6 +210,7 @@ class TestMain:
|
|||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "too slow" in result.output.lower()
|
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.run_tests")
|
||||||
@patch("devx.tools.check_test_speed.parse_duration")
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
@patch("devx.tools.check_test_speed.check_speed")
|
@patch("devx.tools.check_test_speed.check_speed")
|
||||||
@@ -213,6 +235,7 @@ class TestMain:
|
|||||||
assert "Per-test speed check FAILED" in result.output
|
assert "Per-test speed check FAILED" in result.output
|
||||||
assert "test_slow" 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")
|
@patch("devx.tools.check_test_speed.run_tests")
|
||||||
def test_parse_failure_exits(
|
def test_parse_failure_exits(
|
||||||
self,
|
self,
|
||||||
@@ -225,6 +248,7 @@ class TestMain:
|
|||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "Could not parse" in result.output
|
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.run_tests")
|
||||||
@patch("devx.tools.check_test_speed.parse_duration")
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
@patch("devx.tools.check_test_speed.check_speed")
|
@patch("devx.tools.check_test_speed.check_speed")
|
||||||
@@ -248,6 +272,7 @@ class TestMain:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_check.assert_called_once_with(0.5, 1.5)
|
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.run_tests")
|
||||||
@patch("devx.tools.check_test_speed.parse_duration")
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
@patch("devx.tools.check_test_speed.check_speed")
|
@patch("devx.tools.check_test_speed.check_speed")
|
||||||
@@ -270,6 +295,7 @@ class TestMain:
|
|||||||
mock_parse_per.assert_not_called()
|
mock_parse_per.assert_not_called()
|
||||||
mock_check_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.run_tests")
|
||||||
@patch("devx.tools.check_test_speed.parse_duration")
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
@patch("devx.tools.check_test_speed.check_speed")
|
@patch("devx.tools.check_test_speed.check_speed")
|
||||||
@@ -292,3 +318,32 @@ class TestMain:
|
|||||||
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
|
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_check_per.assert_called_once_with([], 1.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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -7,7 +7,13 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
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:
|
class TestExtractIssueNumber:
|
||||||
@@ -77,6 +83,25 @@ class TestTeaCLIRun:
|
|||||||
with pytest.raises(TeaCLIError, match="auth error"):
|
with pytest.raises(TeaCLIError, match="auth error"):
|
||||||
cli._run(["labels", "list"])
|
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:
|
def test_run_tea_not_found_raises_tea_error(self) -> None:
|
||||||
cli = TeaCLI(tea_bin="tea")
|
cli = TeaCLI(tea_bin="tea")
|
||||||
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
|
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
|
||||||
@@ -100,6 +125,46 @@ class TestTeaCLIRun:
|
|||||||
cmd = mock_run.call_args[0][0]
|
cmd = mock_run.call_args[0][0]
|
||||||
assert "--output" not in cmd
|
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:
|
class TestRepoArg:
|
||||||
def test_with_repo_arg(self) -> None:
|
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:
|
def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||||
"""configure_tea_login adds login when not already configured."""
|
"""configure_tea_login adds login when not already configured."""
|
||||||
mock_list = MagicMock(returncode=0, stdout="")
|
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()
|
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.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||||
@@ -393,3 +460,37 @@ class TestConfigureTeaLogin:
|
|||||||
mock_subprocess.return_value = mock_list
|
mock_subprocess.return_value = mock_list
|
||||||
configure_tea_login()
|
configure_tea_login()
|
||||||
assert mock_subprocess.call_count == 1 # only login list, no add
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import platform
|
import platform
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
@@ -47,13 +48,29 @@ class TestDownload:
|
|||||||
def test_download(self, tmp_path: Path) -> None:
|
def test_download(self, tmp_path: Path) -> None:
|
||||||
dest = tmp_path / "file.bin"
|
dest = tmp_path / "file.bin"
|
||||||
|
|
||||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
class _FakeResponse:
|
||||||
Path(path).write_bytes(b"data")
|
def __init__(self) -> None:
|
||||||
return str(path), 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)
|
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"
|
assert dest.read_bytes() == b"data"
|
||||||
|
|
||||||
|
|
||||||
@@ -297,6 +314,47 @@ class TestInstallVale:
|
|||||||
assert (tmp_path / "vale").exists()
|
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:
|
class TestListTools:
|
||||||
def test_list(self, tmp_path: Path) -> None:
|
def test_list(self, tmp_path: Path) -> None:
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||||
@@ -341,6 +399,11 @@ class TestInstallTool:
|
|||||||
assert install_tools._install_tool("vale") is True
|
assert install_tools._install_tool("vale") is True
|
||||||
mock.assert_called_once()
|
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:
|
def test_unknown_tool(self) -> None:
|
||||||
with pytest.raises(ClickException, match="Unknown tool"):
|
with pytest.raises(ClickException, match="Unknown tool"):
|
||||||
install_tools._install_tool("unknown")
|
install_tools._install_tool("unknown")
|
||||||
@@ -359,7 +422,7 @@ class TestMain:
|
|||||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||||
result = runner.invoke(install_tools.main, [])
|
result = runner.invoke(install_tools.main, [])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert mock_install.call_count == 7
|
assert mock_install.call_count == 8
|
||||||
|
|
||||||
def test_install_specific_tool(self) -> None:
|
def test_install_specific_tool(self) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
@@ -387,8 +387,10 @@ class TestMain:
|
|||||||
@patch("devx.ci.publish.TeaCLI")
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@patch("devx.ci.publish.build_package")
|
||||||
|
@patch("time.sleep")
|
||||||
def test_release_failure_raises_click(
|
def test_release_failure_raises_click(
|
||||||
self,
|
self,
|
||||||
|
mock_sleep: MagicMock,
|
||||||
mock_build: MagicMock,
|
mock_build: MagicMock,
|
||||||
mock_publish: MagicMock,
|
mock_publish: MagicMock,
|
||||||
mock_tea_cls: MagicMock,
|
mock_tea_cls: MagicMock,
|
||||||
@@ -397,6 +399,7 @@ class TestMain:
|
|||||||
mock_tag: MagicMock,
|
mock_tag: MagicMock,
|
||||||
mock_login: MagicMock,
|
mock_login: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Release creation failure after retries raises ClickException."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
mock_tea.list_releases.return_value = []
|
mock_tea.list_releases.return_value = []
|
||||||
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
||||||
@@ -405,6 +408,8 @@ class TestMain:
|
|||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "Release creation failed" in result.output
|
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.subprocess.run")
|
||||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
@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_gitea_registry")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@patch("devx.ci.publish.build_package")
|
||||||
|
@patch("time.sleep")
|
||||||
def test_create_release_already_exists_is_idempotent(
|
def test_create_release_already_exists_is_idempotent(
|
||||||
self,
|
self,
|
||||||
|
mock_sleep: MagicMock,
|
||||||
mock_build: MagicMock,
|
mock_build: MagicMock,
|
||||||
mock_publish: MagicMock,
|
mock_publish: MagicMock,
|
||||||
mock_gitea_pub: MagicMock,
|
mock_gitea_pub: MagicMock,
|
||||||
@@ -507,7 +514,7 @@ class TestMain:
|
|||||||
mock_tag: MagicMock,
|
mock_tag: MagicMock,
|
||||||
mock_login: MagicMock,
|
mock_login: MagicMock,
|
||||||
) -> None:
|
) -> 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 = MagicMock()
|
||||||
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||||
mock_tea.create_release.side_effect = TeaCLIError("there is already a release for this tag")
|
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"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "already exists" in result.output
|
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.subprocess.run")
|
||||||
@patch("devx.ci.publish.get_latest_tag", return_value="v0.1.0")
|
@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_gitea_registry")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@patch("devx.ci.publish.build_package")
|
||||||
|
@patch("time.sleep")
|
||||||
def test_create_release_other_error_raises(
|
def test_create_release_other_error_raises(
|
||||||
self,
|
self,
|
||||||
|
mock_sleep: MagicMock,
|
||||||
mock_build: MagicMock,
|
mock_build: MagicMock,
|
||||||
mock_publish: MagicMock,
|
mock_publish: MagicMock,
|
||||||
mock_gitea_pub: MagicMock,
|
mock_gitea_pub: MagicMock,
|
||||||
@@ -537,7 +548,7 @@ class TestMain:
|
|||||||
mock_tag: MagicMock,
|
mock_tag: MagicMock,
|
||||||
mock_login: MagicMock,
|
mock_login: MagicMock,
|
||||||
) -> None:
|
) -> 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 = MagicMock()
|
||||||
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
mock_tea.list_releases.side_effect = TeaCLIError("api error")
|
||||||
mock_tea.create_release.side_effect = TeaCLIError("network 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"])
|
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "Release creation failed" in result.output
|
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:
|
class TestFromTag:
|
||||||
|
|||||||
Reference in New Issue
Block a user