Compare commits

...
9 Commits
Author SHA1 Message Date
devx-ci-bot a0c4c1c7f0 release: v0.11.0 [skip ci] 2026-06-24 22:17:36 +00:00
emil fd0c4de31e DEVX-43: feat: add publish step to post-merge release job, make publish idempotent
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Failing after 44s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 44s
2026-06-24 22:16:37 +00:00
gitea-actions-bot 8f7af97335 chore: update badge URLs to commit 0bb83e89 [skip ci] 2026-06-24 22:57:46 +02:00
devx-ci-bot a14d838564 release: v0.10.2 [skip ci] 2026-06-24 20:56:35 +00:00
emil 7dcb9c03c0 DEVX-42: fix: badge generation respects pyproject.toml testpaths, shows stdout in warnings
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 51s
Post-merge / badges (push) Successful in 1m5s
2026-06-24 20:55:35 +00:00
gitea-actions-bot 6f2b110c17 chore: update badge URLs to commit 55e25b06 [skip ci] 2026-06-24 20:34:59 +00:00
devx-ci-bot cfb856ff75 release: v0.10.1 [skip ci] 2026-06-24 20:34:06 +00:00
emil 95adf86895 DEVX-41: fix: badge generation REPO_ROOT, auto-detect package, error feedback
Post-merge / detect-type (push) Successful in 7s
Post-merge / validate-commit-msg (push) Successful in 7s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 45s
Post-merge / vikunja (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 40s
Post-merge / badges (push) Successful in 48s
2026-06-24 20:33:12 +00:00
gitea-actions-bot 7cf039ebbe chore: update badge URLs to commit 16bc9c20 [skip ci] 2026-06-24 22:04:42 +02:00
12 changed files with 569 additions and 218 deletions
+20 -1
View File
@@ -75,7 +75,7 @@ jobs:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
timeout-minutes: 10 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -94,6 +94,25 @@ jobs:
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release python3 -m devx.ci.release
- name: Publish release
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$TAG" ]; then
echo "No tag found — skipping publish"
exit 0
fi
HEAD_MSG=$(git log -1 --format=%s)
if echo "$HEAD_MSG" | grep -q "^release: ${TAG}"; then
echo "Publishing release $TAG..."
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
else
echo "HEAD is not a release commit for $TAG — skipping publish"
fi
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
+18
View File
@@ -2,6 +2,24 @@
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.11.0] - 2026-06-24
### Features
- Add publish step to post-merge release job, make publish idempotent
## [0.10.2] - 2026-06-24
### Bug Fixes
- Badge generation respects pyproject.toml testpaths, shows stdout in warnings
## [0.10.1] - 2026-06-24
### Bug Fixes
- Badge generation REPO_ROOT, auto-detect package, error feedback
## [0.10.0] - 2026-06-24 ## [0.10.0] - 2026-06-24
### Features ### Features
+6 -6
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) [![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) [![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/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
+6 -6
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) [![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) [![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/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/ffc2c8a949c3b2b41b95c4deeaff1b5350e1c330/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/0bb83e890be86f336d6a716a0d374e42d219fdda/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
+1 -1
View File
@@ -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.10.0" __version__ = "0.11.0"
+11
View File
@@ -211,6 +211,17 @@ def main(tag: str, repo: str, registry_url: str | None, skip_build: bool) -> Non
click.echo(_("--skip-build: skipping package build and PyPI publish.")) click.echo(_("--skip-build: skipping package build and PyPI publish."))
tea = TeaCLI(repo=repo) tea = TeaCLI(repo=repo)
# Check if release already exists (idempotent — avoids failure when
# called multiple times, e.g. by both post-merge and publish workflows)
try:
releases = tea.list_releases(repo)
if any(r.get("tag_name") == tag for r in releases):
click.echo(_("Gitea release {tag} already exists — skipping creation.", tag=tag))
return
except TeaCLIError:
pass # If listing fails, proceed to create
release_body = generate_release_notes(tag) release_body = generate_release_notes(tag)
try: try:
+12 -2
View File
@@ -18,6 +18,7 @@ Usage::
from __future__ import annotations from __future__ import annotations
import contextlib import contextlib
import os
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
@@ -27,7 +28,16 @@ from typing import Any
import click import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
def _repo_root() -> Path:
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
workspace = os.environ.get("GITHUB_WORKSPACE")
if workspace:
path = Path(workspace)
if path.is_dir():
return path
return Path.cwd()
# Badge filenames that get pushed to the badges branch # Badge filenames that get pushed to the badges branch
BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"] BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"]
@@ -116,7 +126,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
Switches back to master, replaces ``raw/branch/badges/`` URLs with Switches back to master, replaces ``raw/branch/badges/`` URLs with
``raw/commit/<sha>/`` URLs, commits and pushes. ``raw/commit/<sha>/`` URLs, commits and pushes.
""" """
root = repo_root or REPO_ROOT root = repo_root or _repo_root()
# Switch back to master # Switch back to master
_run(["git", "checkout", "master"]) # nosec B607 _run(["git", "checkout", "master"]) # nosec B607
+224 -53
View File
@@ -5,12 +5,21 @@ Runs pytest-cov, doc-coverage, lint checks, and version extraction,
then writes SVG badge files that can be served as static files from then writes SVG badge files that can be served as static files from
the Gitea raw file API. the Gitea raw file API.
The repo root is resolved from ``GITHUB_WORKSPACE`` or ``os.getcwd()``,
so this module works correctly both when run from a source checkout
and when devx is installed as a pip package in CI.
The package name and coverage target are auto-detected from the
``src/`` directory structure, making this module reusable across
all oblachno-oss repos without per-repo configuration.
Usage: Usage:
python3 -m devx.tools.generate_badges --output-dir .badges/ python3 -m devx.tools.generate_badges --output-dir .badges/
""" """
from __future__ import annotations from __future__ import annotations
import os
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
import sys import sys
@@ -18,8 +27,7 @@ from pathlib import Path
import click import click
REPO_ROOT = Path(__file__).resolve().parents[4] # Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%") _COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
_PASSED_RE = re.compile(r"(\d+) passed") _PASSED_RE = re.compile(r"(\d+) passed")
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%") _DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
@@ -37,18 +45,59 @@ COLOR_HEX: dict[str, str] = {
} }
def _find_package_init() -> Path | None: def resolve_repo_root() -> Path:
"""Find the first package __init__.py under src/ that defines __version__.""" """Resolve the repository root directory.
src_dir = REPO_ROOT / "src"
if not src_dir.exists(): Uses ``GITHUB_WORKSPACE`` env var (set by Gitea Actions) or
falls back to ``os.getcwd()``. This ensures the correct repo
root is used even when devx is installed as a pip package.
"""
workspace = os.environ.get("GITHUB_WORKSPACE")
if workspace:
path = Path(workspace)
if path.is_dir():
return path
return Path.cwd()
def detect_package_name(repo_root: Path) -> str | None:
"""Auto-detect the Python package name from ``src/`` directory.
Looks for the first subdirectory under ``src/`` that contains
an ``__init__.py`` file with ``__version__``.
Returns the package directory name (e.g., ``devx``,
``gitea_runner_manager``) or ``None`` if no package is found.
"""
src_dir = repo_root / "src"
if not src_dir.is_dir():
return None return None
for init_file in src_dir.rglob("__init__.py"): for entry in sorted(src_dir.iterdir()):
try: if not entry.is_dir():
content = init_file.read_text()
except OSError:
continue continue
if "__version__" in content: init_file = entry / "__init__.py"
return init_file if init_file.exists():
return entry.name
return None
def detect_coverage_target(repo_root: Path) -> str | None:
"""Auto-detect the pytest-cov target from pyproject.toml.
Parses ``addopts`` in ``[tool.pytest.ini_options]`` for
``--cov=src/<package>``. Falls back to ``src/<package>`` if
the package is detected but no explicit cov target is found.
"""
pyproject = repo_root / "pyproject.toml"
if pyproject.exists():
content = pyproject.read_text()
match = re.search(r"--cov=(\S+)", content)
if match:
return match.group(1)
# Fallback: derive from package name
pkg = detect_package_name(repo_root)
if pkg:
return f"src/{pkg}"
return None return None
@@ -57,14 +106,15 @@ def _xml_escape(text: str) -> str:
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;") return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def run_command(cmd: list[str]) -> tuple[int, str, str]: def run_command(cmd: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
"""Run a command and return (returncode, stdout, stderr).""" """Run a command and return (returncode, stdout, stderr)."""
root = str(cwd or resolve_repo_root())
result = subprocess.run( # nosec B603 result = subprocess.run( # nosec B603
cmd, cmd,
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
cwd=str(REPO_ROOT), cwd=root,
) )
return result.returncode, result.stdout, result.stderr return result.returncode, result.stdout, result.stderr
@@ -139,15 +189,25 @@ def extract_doc_coverage(output: str) -> int | None:
return None return None
def read_version() -> str: def read_version(repo_root: Path) -> str:
"""Read __version__ from the package __init__.py.""" """Read __version__ from the package __init__.py under src/.
init_file = _find_package_init()
if init_file is None: Auto-detects the package directory and reads ``__version__``
from its ``__init__.py``.
"""
pkg = detect_package_name(repo_root)
if pkg is None:
click.echo(" WARNING: No Python package found under src/ — version badge will show 'unknown'")
return "unknown"
init_file = repo_root / "src" / pkg / "__init__.py"
if not init_file.exists():
click.echo(f" WARNING: {init_file} not found — version badge will show 'unknown'")
return "unknown" return "unknown"
content = init_file.read_text() content = init_file.read_text()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content) match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if match: if match:
return match.group(1) return match.group(1)
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'")
return "unknown" return "unknown"
@@ -179,65 +239,170 @@ def doc_coverage_color(pct: int) -> str:
return "orange" return "orange"
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]: def detect_testpaths(repo_root: Path) -> list[str]:
"""Generate all badge SVG files and return badge data as a dict.""" """Detect test paths from pyproject.toml or filesystem.
badges: dict[str, dict[str, str | int]] = {}
# 1. Code coverage + test count (single pytest-cov run) Parses ``testpaths`` in ``[tool.pytest.ini_options]`` from
rc, stdout, stderr = run_command( pyproject.toml. Falls back to ``["tests"]`` if the tests/
[ directory exists. Returns an empty list if no test paths
are found (pytest will use its own defaults).
"""
pyproject = repo_root / "pyproject.toml"
if pyproject.exists():
content = pyproject.read_text()
# Match: testpaths = ["dir1", "dir2"]
match = re.search(r"testpaths\s*=\s*\[([^\]]+)\]", content)
if match:
paths = re.findall(r'["\']([^"\']+)["\']', match.group(1))
resolved = []
for p in paths:
p = p.strip()
if (repo_root / p).exists():
resolved.append(p)
if resolved:
return resolved
# Fallback: tests/ directory
tests_dir = repo_root / "tests"
if tests_dir.is_dir():
return ["tests"]
return []
def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], dict[str, str | int]]:
"""Run pytest-cov and collect coverage + test count badges.
Returns (coverage_badge, tests_badge). If pytest is not
available or no tests are found, returns 'unknown' badges
with a clear warning explaining the failure.
"""
cov_target = detect_coverage_target(repo_root)
if cov_target is None:
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
testpaths = detect_testpaths(repo_root)
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}")
cmd = [
sys.executable, sys.executable,
"-m", "-m",
"pytest", "pytest",
"tests/", *testpaths,
"-v", "--cov",
"--cov=src/devx", cov_target,
"--cov-report=term-missing", "--cov-report=term-missing",
"--cov-fail-under=0", "--cov-fail-under=0",
"-q",
] ]
) rc, stdout, stderr = run_command(cmd, cwd=repo_root)
combined = stdout + "\n" + stderr combined = stdout + "\n" + stderr
coverage = extract_coverage(combined) coverage = extract_coverage(combined)
if coverage is not None: if coverage is not None:
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage)) cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
else: else:
badges["coverage"] = make_badge("coverage", "unknown", "red") click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
cov_badge = make_badge("coverage", "unknown", "red")
test_count = extract_test_count(combined) test_count = extract_test_count(combined)
if test_count is not None: if test_count is not None:
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red") tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
else: else:
badges["tests"] = make_badge("tests", "unknown", "red") click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
tests_badge = make_badge("tests", "unknown", "red")
# 2. Documentation coverage return cov_badge, tests_badge
rc, stdout, _ = run_command(
[
sys.executable, def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
"-m", """Run doc_coverage and collect the docs badge."""
"devx.ci.doc_coverage", rc, stdout, stderr = run_command(
] [sys.executable, "-m", "devx.ci.doc_coverage"],
cwd=repo_root,
) )
doc_pct = extract_doc_coverage(stdout) doc_pct = extract_doc_coverage(stdout)
if doc_pct is not None: if doc_pct is not None:
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct)) return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
click.echo(f" WARNING: Could not extract doc coverage (rc={rc})")
click.echo(f" stderr: {stderr.strip()[:200]}")
return make_badge("docs", "unknown", "red")
def collect_quality(repo_root: Path) -> dict[str, str | int]:
"""Run lint checks and collect the quality badge.
Runs ruff check, ruff format --check, pyright, and bandit.
If any tool is not installed, it is skipped with a warning.
"""
results: list[bool] = []
tool_names: list[str] = []
for cmd, name in [
([sys.executable, "-m", "ruff", "check", "src/", "tests/"], "ruff check"),
([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"], "ruff format"),
([sys.executable, "-m", "pyright"], "pyright"),
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
]:
rc, _, stderr = run_command(cmd, cwd=repo_root)
if rc == 0:
results.append(True)
tool_names.append(f"{name}: pass")
else: else:
badges["docs"] = make_badge("docs", "unknown", "red") results.append(False)
# Distinguish "tool not installed" from "tool found issues"
if "No module named" in stderr or "not found" in stderr.lower():
click.echo(f" WARNING: {name} not installed — skipping (counted as pass)")
results[-1] = True
tool_names.append(f"{name}: not installed (skipped)")
else:
tool_names.append(f"{name}: FAIL")
click.echo(f" WARNING: {name} failed (rc={rc})")
click.echo(f" stderr: {stderr.strip()[:200]}")
# 3. Code quality (ruff + pyright + bandit all pass) all_pass = all(results)
lint_rc, _, _ = run_command([sys.executable, "-m", "ruff", "check", "src/", "tests/"]) click.echo(f" Quality checks: {', '.join(tool_names)}")
format_rc, _, _ = run_command([sys.executable, "-m", "ruff", "format", "--check", "src/", "tests/"]) return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
type_rc, _, _ = run_command([sys.executable, "-m", "pyright"])
bandit_rc, _, _ = run_command([sys.executable, "-m", "bandit", "-r", "src/"])
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red") def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str, dict[str, str | int]]:
"""Generate all badge SVG files and return badge data as a dict.
Args:
output_dir: Directory to write SVG files.
repo_root: Repository root (auto-detected if None).
"""
root = repo_root or resolve_repo_root()
click.echo(f" Repo root: {root}")
pkg = detect_package_name(root)
click.echo(f" Package: {pkg or 'none'}")
badges: dict[str, dict[str, str | int]] = {}
# 1. Code coverage + test count (single pytest-cov run)
click.echo(" Collecting coverage and tests...")
cov_badge, tests_badge = collect_coverage_and_tests(root)
badges["coverage"] = cov_badge
badges["tests"] = tests_badge
# 2. Documentation coverage
click.echo(" Collecting doc coverage...")
badges["docs"] = collect_doc_coverage(root)
# 3. Code quality (ruff + pyright + bandit)
click.echo(" Collecting code quality...")
badges["quality"] = collect_quality(root)
# 4. Version # 4. Version
version = read_version() click.echo(" Collecting version...")
version = read_version(root)
badges["version"] = make_badge("version", f"v{version}", "blue") badges["version"] = make_badge("version", f"v{version}", "blue")
# 5. Python version (static but nice) # 5. Python version (static)
badges["python"] = make_badge("python", "3.12", "blue") badges["python"] = make_badge("python", "3.12", "blue")
# Write SVG files # Write SVG files
@@ -254,14 +419,20 @@ def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
@click.command() @click.command()
@click.option( @click.option(
"--output-dir", "--output-dir",
default=str(REPO_ROOT / ".badges"), default=".badges",
help="Directory to write badge SVG files.", help="Directory to write badge SVG files.",
) )
def cli(output_dir: str) -> None: @click.option(
"--repo-root",
default=None,
help="Repository root (auto-detected if not specified).",
)
def cli(output_dir: str, repo_root: str | None) -> None:
"""Generate self-contained SVG badge files from project metrics.""" """Generate self-contained SVG badge files from project metrics."""
out = Path(output_dir) out = Path(output_dir)
root = Path(repo_root) if repo_root else None
click.echo(f"Generating badges in {out}...") click.echo(f"Generating badges in {out}...")
badges = generate_badges(out) badges = generate_badges(out, repo_root=root)
click.echo(f"\nGenerated {len(badges)} badges:") click.echo(f"\nGenerated {len(badges)} badges:")
for name, badge in badges.items(): for name, badge in badges.items():
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})") click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
+7
View File
@@ -657,6 +657,13 @@
"ru": "Директория molecule не найдена: {path}", "ru": "Директория molecule не найдена: {path}",
"zh": "未找到 molecule 目录: {path}" "zh": "未找到 molecule 目录: {path}"
}, },
"Gitea release {tag} already exists — skipping creation.": {
"bg": "Gitea release {tag} вече съществува — прескачане на създаването.",
"de": "Gitea-Release {tag} existiert bereits — Erstellung übersprungen.",
"en": "Gitea release {tag} already exists — skipping creation.",
"ru": "Gitea release {tag} уже существует — пропуск создания.",
"zh": "Gitea release {tag} 已存在 — 跳过创建。"
},
"Nice! Gitea release {tag} created.": { "Nice! Gitea release {tag} created.": {
"bg": "Отлично! Gitea release {tag} е създаден.", "bg": "Отлично! Gitea release {tag} е създаден.",
"de": "Prima! Gitea-Release {tag} erstellt.", "de": "Prima! Gitea-Release {tag} erstellt.",
+198 -142
View File
@@ -1,4 +1,4 @@
"""Unit tests for scripts/generate_badges.py.""" """Unit tests for devx/tools/generate_badges.py."""
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -8,7 +8,13 @@ from click.testing import CliRunner
from devx.tools.generate_badges import ( from devx.tools.generate_badges import (
COLOR_HEX, COLOR_HEX,
cli, cli,
collect_coverage_and_tests,
collect_doc_coverage,
collect_quality,
coverage_color, coverage_color,
detect_coverage_target,
detect_package_name,
detect_testpaths,
doc_coverage_color, doc_coverage_color,
extract_coverage, extract_coverage,
extract_doc_coverage, extract_doc_coverage,
@@ -17,10 +23,107 @@ from devx.tools.generate_badges import (
make_badge, make_badge,
read_version, read_version,
render_svg, render_svg,
resolve_repo_root,
run_command, run_command,
) )
class TestResolveRepoRoot:
def test_uses_github_workspace_when_set(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
assert resolve_repo_root() == tmp_path
def test_falls_back_to_cwd_when_no_workspace(self, tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
monkeypatch.chdir(tmp_path)
assert resolve_repo_root() == tmp_path
def test_falls_back_to_cwd_when_workspace_invalid(self, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent/path")
result = resolve_repo_root()
assert result == Path.cwd()
class TestDetectPackageName:
def test_detects_package_with_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
(pkg / "__init__.py").write_text('__version__ = "1.0.0"\n')
assert detect_package_name(tmp_path) == "mypkg"
def test_returns_none_when_no_src(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert detect_package_name(tmp_path) is None
def test_returns_none_when_no_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
# No __init__.py
assert detect_package_name(tmp_path) is None
def test_picks_first_package_alphabetically(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
for name in ["zpkg", "apkg"]:
d = src / name
d.mkdir(parents=True)
(d / "__init__.py").write_text("")
assert detect_package_name(tmp_path) == "apkg"
def test_skips_non_dir_entries(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
src.mkdir(parents=True)
(src / "README.md").write_text("not a package")
pkg = src / "mypkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("")
assert detect_package_name(tmp_path) == "mypkg"
class TestDetectCoverageTarget:
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "pyproject.toml").write_text(
'[tool.pytest.ini_options]\naddopts = "--cov=src/devx --cov-report=term-missing"\n'
)
assert detect_coverage_target(tmp_path) == "src/devx"
def test_falls_back_to_src_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
src = tmp_path / "src"
pkg = src / "mypkg"
pkg.mkdir(parents=True)
(pkg / "__init__.py").write_text('__version__ = "1.0"\n')
assert detect_coverage_target(tmp_path) == "src/mypkg"
def test_returns_none_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert detect_coverage_target(tmp_path) is None
class TestDetectTestpaths:
def test_parses_from_pyproject(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "scripts" / "tests").mkdir(parents=True)
(tmp_path / "tests" / "unit").mkdir(parents=True)
(tmp_path / "pyproject.toml").write_text(
'[tool.pytest.ini_options]\ntestpaths = ["scripts/tests", "tests/unit"]\n'
)
assert detect_testpaths(tmp_path) == ["scripts/tests", "tests/unit"]
def test_filters_nonexistent_paths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "tests").mkdir()
(tmp_path / "pyproject.toml").write_text('[tool.pytest.ini_options]\ntestpaths = ["tests", "nonexistent"]\n')
assert detect_testpaths(tmp_path) == ["tests"]
def test_falls_back_to_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "tests").mkdir()
assert detect_testpaths(tmp_path) == ["tests"]
def test_returns_empty_when_no_tests_dir(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
assert detect_testpaths(tmp_path) == []
def test_returns_empty_when_pyproject_has_no_testpaths(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
(tmp_path / "pyproject.toml").write_text("[tool.pytest.ini_options]\naddopts = '-ra'\n")
assert detect_testpaths(tmp_path) == []
class TestRunCommand: class TestRunCommand:
@patch("devx.tools.generate_badges.subprocess.run") @patch("devx.tools.generate_badges.subprocess.run")
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None: def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
@@ -157,100 +260,122 @@ class TestDocCoverageColor:
class TestReadVersion: class TestReadVersion:
@patch("devx.tools.generate_badges._find_package_init") def test_reads_version_from_init(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
def test_reads_version_from_init(self, mock_find: MagicMock) -> None: src = tmp_path / "src" / "mypkg"
mock_init = MagicMock() src.mkdir(parents=True)
mock_init.read_text.return_value = '__version__ = "0.5.0"\n' (src / "__init__.py").write_text('__version__ = "0.5.0"\n')
mock_find.return_value = mock_init assert read_version(tmp_path) == "0.5.0"
assert read_version() == "0.5.0"
@patch("devx.tools.generate_badges._find_package_init") def test_returns_unknown_when_no_version(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
def test_returns_unknown_when_no_version(self, mock_find: MagicMock) -> None: src = tmp_path / "src" / "mypkg"
mock_init = MagicMock() src.mkdir(parents=True)
mock_init.read_text.return_value = "no version here\n" (src / "__init__.py").write_text("no version here\n")
mock_find.return_value = mock_init assert read_version(tmp_path) == "unknown"
assert read_version() == "unknown"
@patch("devx.tools.generate_badges._find_package_init", return_value=None) def test_returns_unknown_when_no_package(self, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
def test_returns_unknown_when_no_init(self, mock_find: MagicMock) -> None: assert read_version(tmp_path) == "unknown"
assert read_version() == "unknown"
@patch("devx.tools.generate_badges.detect_package_name", return_value="mypkg")
def test_returns_unknown_when_init_missing(self, mock_pkg: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
# Package detected but __init__.py doesn't exist (edge case)
assert read_version(tmp_path) == "unknown"
class TestFindPackageInit: class TestCollectCoverageAndTests:
@patch("devx.tools.generate_badges.REPO_ROOT") @patch("devx.tools.generate_badges.run_command")
def test_no_src_dir(self, mock_root: MagicMock) -> None: @patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"])
"""Returns None when src/ directory doesn't exist.""" @patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
from devx.tools.generate_badges import _find_package_init def test_extracts_coverage_and_tests(
self, mock_target: MagicMock, mock_testpaths: MagicMock, mock_run: MagicMock, tmp_path: Path
) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (0, "1018 passed in 4.23s\nTOTAL 3546 0 100%", "")
cov, tests = collect_coverage_and_tests(tmp_path)
assert cov["message"] == "100%"
assert tests["message"] == "1018 passing"
mock_src = MagicMock() @patch("devx.tools.generate_badges.run_command")
mock_src.exists.return_value = False @patch("devx.tools.generate_badges.detect_testpaths", return_value=["tests"])
mock_root.__truediv__ = MagicMock(return_value=mock_src) @patch("devx.tools.generate_badges.detect_coverage_target", return_value="src/devx")
assert _find_package_init() is None def test_returns_unknown_when_no_match(
self, mock_target: MagicMock, mock_testpaths: MagicMock, mock_run: MagicMock, tmp_path: Path
) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (1, "garbled output", "some error")
cov, tests = collect_coverage_and_tests(tmp_path)
assert cov["message"] == "unknown"
assert tests["message"] == "unknown"
@patch("devx.tools.generate_badges.REPO_ROOT") @patch("devx.tools.generate_badges.detect_coverage_target", return_value=None)
def test_no_version_in_init_files(self, mock_root: MagicMock, tmp_path: Path) -> None: def test_returns_lightgrey_when_no_target(self, mock_target: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
"""Returns None when no __init__.py has __version__.""" cov, tests = collect_coverage_and_tests(tmp_path)
from devx.tools.generate_badges import _find_package_init assert cov["message"] == "unknown"
assert cov["color"] == "lightgrey"
assert tests["message"] == "unknown"
assert tests["color"] == "lightgrey"
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "__init__.py").write_text("# no version here\n")
mock_root.__truediv__ = MagicMock(return_value=src_dir)
assert _find_package_init() is None
@patch("devx.tools.generate_badges.REPO_ROOT") class TestCollectDocCoverage:
def test_finds_init_with_version(self, mock_root: MagicMock, tmp_path: Path) -> None: @patch("devx.tools.generate_badges.run_command")
"""Returns the __init__.py that has __version__.""" def test_extracts_doc_coverage(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
from devx.tools.generate_badges import _find_package_init mock_run.return_value = (0, "Doc coverage: 20/20 (100%)", "")
badge = collect_doc_coverage(tmp_path)
assert badge["message"] == "100%"
src_dir = tmp_path / "src" @patch("devx.tools.generate_badges.run_command")
pkg_dir = src_dir / "mypkg" def test_returns_unknown_when_no_match(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
pkg_dir.mkdir(parents=True) mock_run.return_value = (1, "no doc coverage", "error")
(src_dir / "__init__.py").write_text("# no version\n") badge = collect_doc_coverage(tmp_path)
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n') assert badge["message"] == "unknown"
mock_root.__truediv__ = MagicMock(return_value=src_dir)
result = _find_package_init()
assert result is not None
assert "__version__" in result.read_text()
@patch("devx.tools.generate_badges.REPO_ROOT")
def test_handles_oserror(self, mock_root: MagicMock, tmp_path: Path) -> None:
"""Handles OSError when reading init files."""
from devx.tools.generate_badges import _find_package_init
src_dir = tmp_path / "src" class TestCollectQuality:
src_dir.mkdir() @patch("devx.tools.generate_badges.run_command")
init_file = src_dir / "__init__.py" def test_all_pass_returns_a(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
init_file.write_text('__version__ = "1.0.0"\n') mock_run.return_value = (0, "", "")
mock_root.__truediv__ = MagicMock(return_value=src_dir) badge = collect_quality(tmp_path)
# Patch Path.read_text to raise OSError assert badge["message"] == "A"
with patch.object(Path, "read_text", side_effect=OSError("permission denied")): assert badge["color"] == "brightgreen"
result = _find_package_init()
assert result is None @patch("devx.tools.generate_badges.run_command")
def test_lint_failure_returns_f(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (1, "", "some error")
badge = collect_quality(tmp_path)
assert badge["message"] == "F"
assert badge["color"] == "red"
@patch("devx.tools.generate_badges.run_command")
def test_tool_not_installed_counts_as_pass(self, mock_run: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (1, "", "No module named ruff")
badge = collect_quality(tmp_path)
assert badge["message"] == "A"
class TestGenerateBadges: class TestGenerateBadges:
@patch("devx.tools.generate_badges.run_command") @patch("devx.tools.generate_badges.collect_quality")
@patch("devx.tools.generate_badges.collect_doc_coverage")
@patch("devx.tools.generate_badges.collect_coverage_and_tests")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0") @patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0) @patch("devx.tools.generate_badges.detect_package_name", return_value="devx")
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_generates_all_badge_files( def test_generates_all_badge_files(
self, self,
mock_doc_cov: MagicMock, mock_pkg: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock, mock_version: MagicMock,
mock_run: MagicMock, mock_cov_tests: MagicMock,
mock_doc: MagicMock,
mock_quality: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None: # type: ignore[no-untyped-def]
mock_run.return_value = (0, "output", "") mock_cov_tests.return_value = (
badges = generate_badges(tmp_path) make_badge("coverage", "100%", "brightgreen"),
make_badge("tests", "573 passing", "brightgreen"),
)
mock_doc.return_value = make_badge("docs", "100%", "brightgreen")
mock_quality.return_value = make_badge("code quality", "A", "brightgreen")
badges = generate_badges(tmp_path, repo_root=tmp_path)
expected = {"coverage", "tests", "docs", "quality", "version", "python"} expected = {"coverage", "tests", "docs", "quality", "version", "python"}
assert set(badges.keys()) == expected assert set(badges.keys()) == expected
# Verify SVG files were written
for name in expected: for name in expected:
svg_file = tmp_path / f"{name}.svg" svg_file = tmp_path / f"{name}.svg"
assert svg_file.exists() assert svg_file.exists()
@@ -258,78 +383,10 @@ class TestGenerateBadges:
assert content.startswith("<svg") assert content.startswith("<svg")
assert "</svg>" in content assert "</svg>" in content
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_pass_when_all_lint_passes(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "A"
assert badges["quality"]["color"] == "brightgreen"
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=100.0)
@patch("devx.tools.generate_badges.extract_test_count", return_value=573)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_fails_when_lint_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.side_effect = [
(0, "output", ""),
(0, "output", ""),
(1, "error", ""),
(0, "output", ""),
(0, "output", ""),
(0, "output", ""),
]
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "F"
assert badges["quality"]["color"] == "red"
@patch("devx.tools.generate_badges.run_command")
@patch("devx.tools.generate_badges.read_version", return_value="0.5.0")
@patch("devx.tools.generate_badges.extract_coverage", return_value=None)
@patch("devx.tools.generate_badges.extract_test_count", return_value=None)
@patch("devx.tools.generate_badges.extract_doc_coverage", return_value=None)
def test_badges_show_unknown_when_extraction_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (1, "garbled output", "")
badges = generate_badges(tmp_path)
assert badges["coverage"]["message"] == "unknown"
assert badges["coverage"]["color"] == "red"
assert badges["tests"]["message"] == "unknown"
assert badges["tests"]["color"] == "red"
assert badges["docs"]["message"] == "unknown"
assert badges["docs"]["color"] == "red"
class TestCli: class TestCli:
@patch("devx.tools.generate_badges.generate_badges") @patch("devx.tools.generate_badges.generate_badges")
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None: def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None: # type: ignore[no-untyped-def]
mock_gen.return_value = { mock_gen.return_value = {
"coverage": make_badge("coverage", "100%", "brightgreen"), "coverage": make_badge("coverage", "100%", "brightgreen"),
"tests": make_badge("tests", "573 passing", "brightgreen"), "tests": make_badge("tests", "573 passing", "brightgreen"),
@@ -339,7 +396,6 @@ class TestCli:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Generating badges" in result.output assert "Generating badges" in result.output
assert "Generated 2 badges" in result.output assert "Generated 2 badges" in result.output
mock_gen.assert_called_once_with(tmp_path)
def test_main_module_block() -> None: def test_main_module_block() -> None:
+43
View File
@@ -162,6 +162,7 @@ class TestMain:
mock_notes: MagicMock, mock_notes: MagicMock,
) -> None: ) -> None:
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -187,6 +188,7 @@ class TestMain:
) -> None: ) -> None:
"""When no PYPI_TOKEN, publishes to Gitea PyPI registry.""" """When no PYPI_TOKEN, publishes to Gitea PyPI registry."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -209,6 +211,7 @@ class TestMain:
) -> None: ) -> None:
"""--registry-url flag publishes to the specified Gitea registry.""" """--registry-url flag publishes to the specified Gitea registry."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -236,6 +239,7 @@ class TestMain:
) -> None: ) -> None:
"""DEVX_PYPI_REGISTRY_URL env var sets the registry URL.""" """DEVX_PYPI_REGISTRY_URL env var sets the registry URL."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
@@ -256,6 +260,7 @@ class TestMain:
) -> None: ) -> None:
"""When no PYPI_TOKEN and no registry URL, skips publish and creates release only.""" """When no PYPI_TOKEN and no registry URL, skips publish and creates release only."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""]) result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""])
@@ -307,6 +312,7 @@ class TestMain:
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None: ) -> None:
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.side_effect = TeaCLIError("server error") mock_tea.create_release.side_effect = TeaCLIError("server error")
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
@@ -323,6 +329,7 @@ class TestMain:
) -> None: ) -> None:
"""--skip-build skips build_package and PyPI publish, only creates Gitea release.""" """--skip-build skips build_package and PyPI publish, only creates Gitea release."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"]) result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
@@ -330,3 +337,39 @@ class TestMain:
assert "skip" in result.output.lower() assert "skip" in result.output.lower()
mock_build.assert_not_called() mock_build.assert_not_called()
mock_tea.create_release.assert_called_once() mock_tea.create_release.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_skips_release_creation_when_already_exists(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If the Gitea release already exists, skip creation (idempotent)."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = [{"tag_name": "v1.0.0"}]
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "already exists" in result.output
mock_tea.create_release.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package")
def test_proceeds_to_create_when_list_releases_fails(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
"""If list_releases raises TeaCLIError, proceed to create the release."""
mock_tea = MagicMock()
mock_tea.list_releases.side_effect = TeaCLIError("api error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "Gitea release v1.0.0 created" in result.output
mock_tea.create_release.assert_called_once()
+16
View File
@@ -287,3 +287,19 @@ class TestMain:
result = runner.invoke(push_badges.main, ["--no-readme-update"]) result = runner.invoke(push_badges.main, ["--no-readme-update"])
assert result.exit_code != 0 assert result.exit_code != 0
mock_sleep.assert_not_called() mock_sleep.assert_not_called()
class TestRepoRoot:
def test_uses_github_workspace(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path))
assert push_badges._repo_root() == tmp_path
def test_falls_back_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_WORKSPACE", raising=False)
monkeypatch.chdir(tmp_path)
assert push_badges._repo_root() == tmp_path
def test_falls_back_when_workspace_invalid(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent")
monkeypatch.chdir(tmp_path)
assert push_badges._repo_root() == tmp_path