Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8e6c69e9 | ||
|
|
ccb7023965 | ||
|
|
7dd15f1461 | ||
|
|
d4e4621fa1 | ||
|
|
a6f814c446 | ||
|
|
04aa5acb1f | ||
|
|
6973f9d851 | ||
|
|
2669a0ea73 | ||
|
|
03f057b55a | ||
|
|
706d6dafe0 | ||
|
|
03ddce427c | ||
|
|
9642d6884c |
@@ -32,7 +32,11 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
is-release: ${{ steps.check.outputs.is-release }}
|
is-release: ${{ steps.check.outputs.is-release }}
|
||||||
@@ -116,7 +120,11 @@ jobs:
|
|||||||
needs: [build-and-push]
|
needs: [build-and-push]
|
||||||
if: always() && needs.build-and-push.result == 'success'
|
if: always() && needs.build-and-push.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
+11
-3
@@ -18,7 +18,11 @@ jobs:
|
|||||||
# Saves ~4x checkout+setup overhead vs 5 separate jobs.
|
# Saves ~4x checkout+setup overhead vs 5 separate jobs.
|
||||||
validate:
|
validate:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -46,7 +50,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 8 --max-single-seconds 0.5
|
python3 -m devx.tools.check_test_speed --max-seconds 15 --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"
|
||||||
@@ -140,7 +144,11 @@ jobs:
|
|||||||
github.event_name == 'pull_request' &&
|
github.event_name == 'pull_request' &&
|
||||||
needs.validate.result == 'success'
|
needs.validate.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
detect-and-configure:
|
detect-and-configure:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -99,7 +103,11 @@ jobs:
|
|||||||
needs: [detect-and-configure]
|
needs: [detect-and-configure]
|
||||||
if: always() && needs.detect-and-configure.result == 'success'
|
if: always() && needs.detect-and-configure.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
outputs:
|
outputs:
|
||||||
tag: ${{ steps.release-tag.outputs.tag }}
|
tag: ${{ steps.release-tag.outputs.tag }}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ repos:
|
|||||||
|
|
||||||
- id: check-test-speed
|
- id: check-test-speed
|
||||||
name: unit test speed check
|
name: unit test speed check
|
||||||
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5
|
||||||
language: system
|
language: system
|
||||||
types: [python]
|
types: [python]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
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.49.5] - 2026-08-07
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Skip dep resolution in setup-image with --no-deps
|
||||||
|
|
||||||
|
## [0.49.4] - 2026-08-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Add container.credentials for private registry auth
|
||||||
|
|
||||||
|
## [0.49.3] - 2026-08-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Retry ansible-galaxy collection install on transient timeouts
|
||||||
|
|
||||||
|
## [0.49.2] - 2026-08-07
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Add fallback URL for tea download
|
||||||
|
|
||||||
## [0.49.1] - 2026-08-07
|
## [0.49.1] - 2026-08-07
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ setup-release: $(VENV)/bin/activate .env
|
|||||||
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
||||||
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
||||||
setup-image:
|
setup-image:
|
||||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
|
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir --no-deps -e . 2>/dev/null; \
|
||||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||||
|
|
||||||
install-hooks:
|
install-hooks:
|
||||||
|
|||||||
@@ -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.49.1",
|
"devx>=0.49.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[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.49.1"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.49.5"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.49.1,<0.50"`).
|
> (for example, `"devx>=0.49.5,<0.50"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
+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.49.1",
|
"devx>=0.49.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[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.49.1"` or `"devx>=0.49.1,<0.50"`.
|
Pin a specific version if needed: `"devx==0.49.5"` or `"devx>=0.49.5,<0.50"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.49.1",
|
"devx>=0.49.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.49.1",
|
"devx>=0.49.5",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -64,11 +64,16 @@ molecule = [
|
|||||||
"ansible-core==2.21.1",
|
"ansible-core==2.21.1",
|
||||||
]
|
]
|
||||||
# Deploy tools (for infra staging/production deployments)
|
# Deploy tools (for infra staging/production deployments)
|
||||||
|
# Versions aligned with infra's pyproject.toml to avoid reinstalls on every CI job.
|
||||||
|
# bcrypt and PyJWT are infra deps not in devx core — included here so the CI
|
||||||
|
# image has them and setup-image can use --no-deps (skip dep resolution).
|
||||||
deploy = [
|
deploy = [
|
||||||
"ansible-core==2.21.1",
|
"ansible-core==2.21.1",
|
||||||
"boto3==1.43.37",
|
"boto3==1.43.44",
|
||||||
"docker==7.1.0",
|
"docker==7.1.0",
|
||||||
"cryptography==49.0.0",
|
"cryptography==50.0.0",
|
||||||
|
"bcrypt==5.0.0",
|
||||||
|
"PyJWT==2.13.0",
|
||||||
]
|
]
|
||||||
# Full dev environment (local development)
|
# Full dev environment (local development)
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -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.49.1"
|
__version__ = "0.49.5"
|
||||||
|
|||||||
@@ -70,6 +70,25 @@ def _download(url: str, dest: Path) -> None:
|
|||||||
shutil.copyfileobj(resp, f)
|
shutil.copyfileobj(resp, f)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_with_fallback(urls: list[str], binary_name: str) -> Path:
|
||||||
|
"""Try downloading a binary from a list of URLs, falling back on failure.
|
||||||
|
|
||||||
|
Returns the path to the installed binary. Raises if all URLs fail.
|
||||||
|
"""
|
||||||
|
target_dir = _ensure_target_dir()
|
||||||
|
dest = target_dir / binary_name
|
||||||
|
errors: list[str] = []
|
||||||
|
for url in urls:
|
||||||
|
try:
|
||||||
|
_download(url, dest)
|
||||||
|
dest.chmod(0o755)
|
||||||
|
return dest
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(f"{url}: {exc}")
|
||||||
|
click.echo(f" {binary_name}: retrying — {exc}")
|
||||||
|
raise click.ClickException(f"Failed to download {binary_name} from all URLs: {'; '.join(errors)}")
|
||||||
|
|
||||||
|
|
||||||
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
||||||
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
|
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
|
||||||
|
|
||||||
@@ -164,8 +183,13 @@ def install_tea() -> bool:
|
|||||||
click.echo("tea: already installed")
|
click.echo("tea: already installed")
|
||||||
return True
|
return True
|
||||||
arch = _arch()
|
arch = _arch()
|
||||||
url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}"
|
# dl.gitea.com is the primary CDN, but it can return 403 from some networks.
|
||||||
dest = _download_binary(url, "tea")
|
# Fall back to the gitea.com release downloads URL.
|
||||||
|
urls = [
|
||||||
|
f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}",
|
||||||
|
f"https://gitea.com/gitea/tea/releases/download/v{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}",
|
||||||
|
]
|
||||||
|
dest = _download_with_fallback(urls, "tea")
|
||||||
click.echo(f"tea: installed to {dest}")
|
click.echo(f"tea: installed to {dest}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -15,6 +15,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, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
from devx.tokens import get_developer_token
|
from devx.tokens import get_developer_token
|
||||||
|
|
||||||
@@ -56,14 +57,23 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _install_ansible_collections(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.
|
||||||
|
|
||||||
|
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")
|
galaxy = shutil.which("ansible-galaxy") or str(Path(bin_dir) / "ansible-galaxy")
|
||||||
requirements = Path("ansible/requirements.yml")
|
requirements = Path("ansible/requirements.yml")
|
||||||
if not requirements.exists():
|
if not requirements.exists():
|
||||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@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)])
|
_run([galaxy, "collection", "install", "-r", str(requirements)])
|
||||||
|
|
||||||
|
_do_install()
|
||||||
|
|
||||||
|
|
||||||
def _configure_tea_login() -> None:
|
def _configure_tea_login() -> None:
|
||||||
"""Configure tea CLI login from .env if a Gitea token is set.
|
"""Configure tea CLI login from .env if a Gitea token is set.
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ def _install_in_image(
|
|||||||
link.symlink_to(opt_venv)
|
link.symlink_to(opt_venv)
|
||||||
|
|
||||||
# Build pip install command
|
# Build pip install command
|
||||||
|
# --no-deps: the CI image already has all dependencies pre-installed.
|
||||||
|
# We only need to install the project itself in editable mode.
|
||||||
spec = f".[{extras}]" if extras else "."
|
spec = f".[{extras}]" if extras else "."
|
||||||
pip_bin = str(Path(venv_link) / "bin" / "pip")
|
pip_bin = str(Path(venv_link) / "bin" / "pip")
|
||||||
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
|
cmd = [pip_bin, "install", "--no-cache-dir", "--no-deps", "-e", spec]
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -233,6 +233,33 @@ class TestInstallTea:
|
|||||||
assert install_tools.install_tea() is True
|
assert install_tools.install_tea() is True
|
||||||
assert (tmp_path / "tea").exists()
|
assert (tmp_path / "tea").exists()
|
||||||
|
|
||||||
|
def test_install_fallback_to_second_url(self, tmp_path: Path) -> None:
|
||||||
|
"""First URL fails (403), second URL succeeds."""
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def _download_side_effect(url: str, dest: Path) -> None:
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] == 1:
|
||||||
|
raise OSError("HTTP Error 403: Forbidden")
|
||||||
|
Path(dest).write_bytes(b"binary")
|
||||||
|
|
||||||
|
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||||
|
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||||
|
with patch.object(platform, "machine", return_value="x86_64"):
|
||||||
|
with patch.object(install_tools, "_download", side_effect=_download_side_effect):
|
||||||
|
assert install_tools.install_tea() is True
|
||||||
|
assert (tmp_path / "tea").exists()
|
||||||
|
assert call_count[0] == 2
|
||||||
|
|
||||||
|
def test_install_all_urls_fail(self, tmp_path: Path) -> None:
|
||||||
|
"""All URLs fail — should raise ClickException."""
|
||||||
|
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||||
|
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||||
|
with patch.object(platform, "machine", return_value="x86_64"):
|
||||||
|
with patch.object(install_tools, "_download", side_effect=OSError("403 Forbidden")):
|
||||||
|
with pytest.raises(ClickException, match="Failed to download tea"):
|
||||||
|
install_tools.install_tea()
|
||||||
|
|
||||||
|
|
||||||
class TestInstallHadolint:
|
class TestInstallHadolint:
|
||||||
def test_already_installed(self) -> None:
|
def test_already_installed(self) -> None:
|
||||||
|
|||||||
@@ -114,6 +114,49 @@ class TestInstallAnsibleCollections:
|
|||||||
_install_ansible_collections(".venv/bin")
|
_install_ansible_collections(".venv/bin")
|
||||||
mock_run.assert_not_called()
|
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)
|
||||||
|
_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)
|
||||||
|
with pytest.raises(_subprocess.CalledProcessError):
|
||||||
|
_install_ansible_collections(".venv/bin")
|
||||||
|
assert mock_run.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
class TestConfigureTeaLogin:
|
class TestConfigureTeaLogin:
|
||||||
@patch("devx.tools.setup.shutil.which", return_value=None)
|
@patch("devx.tools.setup.shutil.which", return_value=None)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class TestInstallInImage:
|
|||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
cmd = mock_run.call_args[0][0]
|
cmd = mock_run.call_args[0][0]
|
||||||
assert "--no-cache-dir" in cmd
|
assert "--no-cache-dir" in cmd
|
||||||
|
assert "--no-deps" in cmd
|
||||||
assert "-e" in cmd
|
assert "-e" in cmd
|
||||||
assert "." in cmd
|
assert "." in cmd
|
||||||
# No extras → spec is "."
|
# No extras → spec is "."
|
||||||
|
|||||||
Reference in New Issue
Block a user