Compare commits

...
6 Commits
Author SHA1 Message Date
devx-ci-bot 8f3c483eff release: v0.48.0 [skip ci] 2026-08-08 21:25:56 +00:00
emilandemo 30389ff3e7 DEVX-150: feat(setup): mirror Ansible collections from Gitea registry with auth
Post-merge / detect-and-configure (push) Successful in 26s
Post-merge / release-and-maintain (push) Successful in 1m0s
Co-authored-by: emil User <emil.simeonov@tutanota.com>
2026-08-08 21:25:00 +00:00
gitea-actions-bot ef1ff15593 chore: update badge URLs to commit 8cca99ef [skip ci] 2026-08-05 19:04:47 +00:00
devx-ci-bot 0d0580c4fd release: v0.47.10 [skip ci] 2026-08-05 19:03:37 +00:00
kiretoandemo ed3bd75367 DEVX-149: fix: unique molecule container names per CI runner
Post-merge / detect-and-configure (push) Successful in 30s
Post-merge / release-and-maintain (push) Successful in 2m22s
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-05 19:01:54 +00:00
gitea-actions-bot ed0a282a52 chore: update badge URLs to commit ef796e01 [skip ci] 2026-08-03 23:02:08 +00:00
9 changed files with 434 additions and 23 deletions
+12
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file.
## [0.48.0] - 2026-08-08
### Features
- *(setup)* Mirror Ansible collections from Gitea registry with auth
## [0.47.10] - 2026-08-05
### Bug Fixes
- Unique molecule container names per CI runner
## [0.47.9] - 2026-08-03
### Bug Fixes
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.47.9",
"devx>=0.48.0",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.47.9"`) or use a version constraint
> (for example, `"devx>=0.47.9,<0.48"`).
> `dependencies` (for example, `"devx==0.48.0"`) or use a version constraint
> (for example, `"devx>=0.48.0,<0.49"`).
### Optional extras
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7640b110dca9ada68de0ce502c2fee1977c88e9f/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/8cca99efebc53b4a55839dec0e715a4bfcf5350a/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.47.9",
"devx>=0.48.0",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.47.9"` or `"devx>=0.47.9,<0.48"`.
Pin a specific version if needed: `"devx==0.48.0"` or `"devx>=0.48.0,<0.49"`.
### Optional extras
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.47.9",
"devx>=0.48.0",
]
[project.optional-dependencies]
dev = [
"devx>=0.47.9",
"devx>=0.48.0",
]
```
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.47.9"
__version__ = "0.48.0"
+44
View File
@@ -251,18 +251,62 @@ def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
# Clean up containers left behind by the killed test.
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
destroy_cmd = ["molecule", "destroy"]
if scenario != "default":
destroy_cmd.extend(["-s", scenario])
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
destroy_cmd,
cwd=str(cwd),
env=env,
check=False,
capture_output=True,
timeout=120,
)
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait()
# Clean up containers left behind by the interrupted test.
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
destroy_cmd = ["molecule", "destroy"]
if scenario != "default":
destroy_cmd.extend(["-s", scenario])
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
destroy_cmd,
cwd=str(cwd),
env=env,
check=False,
capture_output=True,
timeout=120,
)
sys.exit(1)
rc = process.returncode
if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
# Run molecule destroy to clean up containers left behind by the
# failed test. Without this, containers stay running and accumulate
# on the runner, consuming disk/memory and degrading CI performance.
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
destroy_cmd = ["molecule", "destroy"]
if scenario != "default":
destroy_cmd.extend(["-s", scenario])
with contextlib.suppress(subprocess.SubprocessError, OSError):
subprocess.run( # nosec B603, B607
destroy_cmd,
cwd=str(cwd),
env=env,
check=False,
capture_output=True,
timeout=120,
)
sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair))
+102 -2
View File
@@ -15,6 +15,7 @@ from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from tenacity import retry, stop_after_attempt, wait_exponential
from devx.tokens import get_developer_token
@@ -56,13 +57,112 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections if requirements exist."""
"""Install required Ansible Galaxy collections if requirements exist.
If the requirements file uses ``type: url`` entries pointing to the
Gitea package registry, downloads them with authentication (using
``CI_GITEA_TOKEN`` / ``CI_GITEA_API_TOKEN``) and installs from local
files with ``--offline``. Falls back to direct galaxy install if the
mirror download fails or no token is available.
Retries up to 3 times with exponential backoff to handle transient
network timeouts when contacting galaxy.ansible.com.
"""
galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy")
requirements = Path("ansible/requirements.yml")
if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.")
return
_run([galaxy, "collection", "install", "-r", str(requirements)])
# Try Gitea mirror first if requirements use type: url
if _try_gitea_mirror_install(galaxy, requirements):
return
# Fall back to direct galaxy install with retries
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=2, min=2, max=10), reraise=True)
def _do_install() -> None:
_run([galaxy, "collection", "install", "-r", str(requirements)])
_do_install()
def _try_gitea_mirror_install(galaxy: str, requirements: Path) -> bool:
"""Download ``type: url`` entries from Gitea with auth and install locally.
Returns ``True`` if the mirror install succeeded, ``False`` to fall back
to direct galaxy install.
"""
import tempfile
import urllib.request # noqa: PTH123 # nosec B404
import yaml # pyright: ignore[reportMissingImports]
try:
data = yaml.safe_load(requirements.read_text())
except Exception:
return False
collections = data.get("collections", []) if data else []
url_entries = [c for c in collections if c.get("type") == "url"]
if not url_entries:
return False
# Resolve Gitea token for authenticated downloads
token = os.environ.get("CI_GITEA_API_TOKEN", "").strip()
if not token:
token = os.environ.get("CI_GITEA_TOKEN", "").strip()
if not token:
token = os.environ.get("DEVELOPER_GITEA_API_TOKEN", "").strip()
if not token:
click.echo(" No Gitea token found — falling back to galaxy.ansible.com")
return False
# Download each tarball with auth
tmpdir = Path(tempfile.mkdtemp(prefix="ansible-collections-"))
local_entries = []
try:
for entry in url_entries:
source = entry.get("source", "")
if "/api/packages/" not in source:
local_entries.append(entry)
continue
filename = source.rsplit("/", 1)[-1]
dest = tmpdir / filename
click.echo(f" Downloading {entry.get('name', filename)} from Gitea mirror...")
req = urllib.request.Request(source) # nosec B310
req.add_header("Authorization", f"token {token}")
try:
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: PTH123 # nosec B310
dest.write_bytes(resp.read())
except Exception as e:
click.echo(f" WARN: mirror download failed for {entry.get('name')}: {e}")
click.echo(" Falling back to galaxy.ansible.com")
return False
local_entries.append(
{
"name": entry["name"],
"version": entry.get("version"),
"type": "file",
"source": str(dest),
}
)
# Add non-url entries as-is
for entry in collections:
if entry.get("type") != "url":
local_entries.append(entry)
# Write local requirements file
local_req = tmpdir / "requirements.yml"
local_req.write_text(yaml.dump({"collections": local_entries}))
click.echo(" Installing collections from Gitea mirror (offline)...")
_run([galaxy, "collection", "install", "-r", str(local_req), "--offline"])
return True
finally:
import shutil as _shutil
_shutil.rmtree(tmpdir, ignore_errors=True)
def _configure_tea_login() -> None:
+9 -1
View File
@@ -3830,5 +3830,13 @@
"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)"
},
"Cleaning up: running molecule destroy for {scenario}": {
"en": "Cleaning up: running molecule destroy for {scenario}",
"bg": "Изчистване: изпълнение на molecule destroy за {scenario}",
"de": "Aufräumen: molecule destroy wird ausgeführt für {scenario}",
"pl": "Czyszczenie: uruchamianie molecule destroy dla {scenario}",
"ru": "Очистка: запуск molecule destroy для {scenario}",
"zh": "清理:正在为 {scenario} 运行 molecule destroy"
}
}
}
+247
View File
@@ -14,6 +14,7 @@ from devx.tools.setup import (
_install_pre_commit_hooks,
_install_python_deps,
_run,
_try_gitea_mirror_install,
_verify,
main,
)
@@ -106,6 +107,7 @@ class TestInstallAnsibleCollections:
with patch("devx.tools.setup.Path") as mock_path:
mock_path.return_value.exists.return_value = True
mock_path.return_value.__str__ = lambda _: str(req)
mock_path.return_value.read_text = lambda: req.read_text()
_install_ansible_collections(".venv/bin")
mock_run.assert_called_once()
@@ -114,6 +116,251 @@ class TestInstallAnsibleCollections:
_install_ansible_collections(".venv/bin")
mock_run.assert_not_called()
@patch("tenacity.nap.time.sleep")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
@patch("devx.tools.setup._run")
def test_retries_on_transient_failure(
self, mock_run: MagicMock, mock_which: MagicMock, mock_sleep: MagicMock, tmp_path: Path
) -> None:
"""ansible-galaxy install should retry on transient network errors."""
import subprocess as _subprocess
req = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True)
req.write_text("collections: []")
# First call fails (timeout), second succeeds
mock_run.side_effect = [
_subprocess.CalledProcessError(1, ["ansible-galaxy", "collection", "install"]),
None,
]
with patch("devx.tools.setup.Path") as mock_path:
mock_path.return_value.exists.return_value = True
mock_path.return_value.__str__ = lambda _: str(req)
mock_path.return_value.read_text = lambda: req.read_text()
_install_ansible_collections(".venv/bin")
assert mock_run.call_count == 2
@patch("tenacity.nap.time.sleep")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
@patch("devx.tools.setup._run")
def test_exhausts_retries_then_raises(
self, mock_run: MagicMock, mock_which: MagicMock, mock_sleep: MagicMock, tmp_path: Path
) -> None:
"""After 3 attempts, the error should propagate."""
import subprocess as _subprocess
req = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True)
req.write_text("collections: []")
mock_run.side_effect = _subprocess.CalledProcessError(1, ["ansible-galaxy"])
with patch("devx.tools.setup.Path") as mock_path:
mock_path.return_value.exists.return_value = True
mock_path.return_value.__str__ = lambda _: str(req)
mock_path.return_value.read_text = lambda: req.read_text()
with pytest.raises(_subprocess.CalledProcessError):
_install_ansible_collections(".venv/bin")
assert mock_run.call_count == 3
@patch("devx.tools.setup._try_gitea_mirror_install", return_value=True)
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
@patch("devx.tools.setup._run")
def test_mirror_install_skips_galaxy_fallback(
self, mock_run: MagicMock, mock_which: MagicMock, mock_mirror: MagicMock, tmp_path: Path
) -> None:
"""When mirror install succeeds, galaxy fallback is not called."""
req = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True)
req.write_text("collections: []")
with patch("devx.tools.setup.Path") as mock_path:
mock_path.return_value.exists.return_value = True
mock_path.return_value.__str__ = lambda _: str(req)
mock_path.return_value.read_text = lambda: req.read_text()
_install_ansible_collections(".venv/bin")
# _run should not be called because mirror install returns True
mock_run.assert_not_called()
class TestTryGiteaMirrorInstall:
"""Tests for _try_gitea_mirror_install — Gitea mirror with auth + fallback."""
_GITEA_URL = "https://git.example.com/api/packages/org/generic/ansible-collections/1.0.0/ansible-posix-1.0.0.tar.gz"
@patch.dict(os.environ, {}, clear=True)
def test_no_url_entries_returns_false(self, tmp_path: Path) -> None:
"""Requirements without type: url entries should return False."""
req = tmp_path / "requirements.yml"
req.write_text("collections:\n - name: ansible.posix\n version: '1.0.0'\n")
result = _try_gitea_mirror_install("ansible-galaxy", req)
assert result is False
@patch.dict(os.environ, {}, clear=True)
def test_no_token_returns_false(self, tmp_path: Path) -> None:
"""No Gitea token set → return False to fall back to galaxy."""
req = tmp_path / "requirements.yml"
req.write_text(
"collections:\n"
" - name: ansible.posix\n"
" version: '1.0.0'\n"
" type: url\n"
f" source: '{self._GITEA_URL}'\n"
)
result = _try_gitea_mirror_install("ansible-galaxy", req)
assert result is False
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_yaml_parse_error_returns_false(self, tmp_path: Path) -> None:
"""Malformed YAML → return False."""
req = tmp_path / "requirements.yml"
req.write_text("not: valid: yaml: [[")
result = _try_gitea_mirror_install("ansible-galaxy", req)
assert result is False
@patch("devx.tools.setup._run")
@patch("urllib.request.urlopen")
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_successful_mirror_install(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
"""Valid URL entries + token → downloads with auth and installs offline."""
req = tmp_path / "requirements.yml"
req.write_text(
f"collections:\n"
f" - name: ansible.posix\n"
f" version: '1.0.0'\n"
f" type: url\n"
f" source: '{self._GITEA_URL}'\n"
)
mock_resp = MagicMock()
mock_resp.read.return_value = b"fake-tarball"
mock_resp.__enter__ = lambda _: mock_resp
mock_resp.__exit__ = lambda *a: None
mock_urlopen.return_value = mock_resp
result = _try_gitea_mirror_install("ansible-galaxy", req)
assert result is True
# Verify auth header was added
call_args = mock_urlopen.call_args[0][0]
assert call_args.get_header("Authorization") == "token tok123"
# Verify offline install was called
install_cmd = mock_run.call_args[0][0]
assert "collection" in install_cmd
assert "install" in install_cmd
assert "--offline" in install_cmd
@patch("urllib.request.urlopen")
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_download_failure_returns_false(self, mock_urlopen: MagicMock, tmp_path: Path) -> None:
"""Download failure → return False to fall back to galaxy."""
req = tmp_path / "requirements.yml"
req.write_text(
f"collections:\n"
f" - name: ansible.posix\n"
f" version: '1.0.0'\n"
f" type: url\n"
f" source: '{self._GITEA_URL}'\n"
)
mock_urlopen.side_effect = Exception("401 Unauthorized")
result = _try_gitea_mirror_install("ansible-galaxy", req)
assert result is False
@patch("devx.tools.setup._run")
@patch("urllib.request.urlopen")
@patch.dict(os.environ, {"CI_GITEA_API_TOKEN": "tok456"}, clear=True)
def test_prefers_api_token_over_legacy(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
"""CI_GITEA_API_TOKEN takes priority over CI_GITEA_TOKEN."""
req = tmp_path / "requirements.yml"
req.write_text(
f"collections:\n"
f" - name: ansible.posix\n"
f" version: '1.0.0'\n"
f" type: url\n"
f" source: '{self._GITEA_URL}'\n"
)
mock_resp = MagicMock()
mock_resp.read.return_value = b"fake-tarball"
mock_resp.__enter__ = lambda _: mock_resp
mock_resp.__exit__ = lambda *a: None
mock_urlopen.return_value = mock_resp
result = _try_gitea_mirror_install("ansible-galaxy", req)
assert result is True
call_args = mock_urlopen.call_args[0][0]
assert call_args.get_header("Authorization") == "token tok456"
@patch("devx.tools.setup._run")
@patch("urllib.request.urlopen")
@patch.dict(os.environ, {"DEVELOPER_GITEA_API_TOKEN": "tok789"}, clear=True)
def test_developer_token_fallback(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
"""DEVELOPER_GITEA_API_TOKEN is used when CI tokens are absent."""
req = tmp_path / "requirements.yml"
req.write_text(
f"collections:\n"
f" - name: ansible.posix\n"
f" version: '1.0.0'\n"
f" type: url\n"
f" source: '{self._GITEA_URL}'\n"
)
mock_resp = MagicMock()
mock_resp.read.return_value = b"fake-tarball"
mock_resp.__enter__ = lambda _: mock_resp
mock_resp.__exit__ = lambda *a: None
mock_urlopen.return_value = mock_resp
result = _try_gitea_mirror_install("ansible-galaxy", req)
assert result is True
call_args = mock_urlopen.call_args[0][0]
assert call_args.get_header("Authorization") == "token tok789"
@patch("devx.tools.setup._run")
@patch("urllib.request.urlopen")
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_non_gitea_url_passed_through(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
"""URL entries not pointing to /api/packages/ are kept as-is (no download)."""
external_url = "https://galaxy.ansible.com/download/ansible-posix-1.0.0.tar.gz"
req = tmp_path / "requirements.yml"
req.write_text(
f"collections:\n"
f" - name: ansible.posix\n"
f" version: '1.0.0'\n"
f" type: url\n"
f" source: '{external_url}'\n"
)
# Should not call urlopen since the URL is not a Gitea package URL
result = _try_gitea_mirror_install("ansible-galaxy", req)
assert result is True
mock_urlopen.assert_not_called()
@patch("devx.tools.setup._run")
@patch("urllib.request.urlopen")
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_mixed_entries_gitea_and_non_gitea(
self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path
) -> None:
"""Mix of Gitea URL entries and regular galaxy entries."""
req = tmp_path / "requirements.yml"
req.write_text(
f"collections:\n"
f" - name: ansible.posix\n"
f" version: '1.0.0'\n"
f" type: url\n"
f" source: '{self._GITEA_URL}'\n"
f" - name: community.general\n"
f" version: '13.0.0'\n"
)
mock_resp = MagicMock()
mock_resp.read.return_value = b"fake-tarball"
mock_resp.__enter__ = lambda _: mock_resp
mock_resp.__exit__ = lambda *a: None
mock_urlopen.return_value = mock_resp
result = _try_gitea_mirror_install("ansible-galaxy", req)
assert result is True
# Only the Gitea URL entry should trigger a download
mock_urlopen.assert_called_once()
class TestConfigureTeaLogin:
@patch("devx.tools.setup.shutil.which", return_value=None)