diff --git a/README.md b/README.md index da01bb5..1120876 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ extra index and list devx in your dependencies: ```toml [project] dependencies = [ - "devx>=0.49.2", + "devx>=0.49.3", ] [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.49.2"`) or use a version constraint -> (for example, `"devx>=0.49.2,<0.50"`). +> `dependencies` (for example, `"devx==0.49.3"`) or use a version constraint +> (for example, `"devx>=0.49.3,<0.50"`). ### Optional extras diff --git a/docs/index.md b/docs/index.md index bb96c82..d51bcac 100644 --- a/docs/index.md +++ b/docs/index.md @@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry: ```toml [project] dependencies = [ - "devx>=0.49.2", + "devx>=0.49.3", ] [tool.pip] extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" ``` -Pin a specific version if needed: `"devx==0.49.2"` or `"devx>=0.49.2,<0.50"`. +Pin a specific version if needed: `"devx==0.49.3"` or `"devx>=0.49.3,<0.50"`. ### Optional extras diff --git a/docs/user/getting-started.md b/docs/user/getting-started.md index 32c377d..7b61d41 100644 --- a/docs/user/getting-started.md +++ b/docs/user/getting-started.md @@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`: ```toml [project] dependencies = [ - "devx>=0.49.2", + "devx>=0.49.3", ] [project.optional-dependencies] dev = [ - "devx>=0.49.2", + "devx>=0.49.3", ] ``` diff --git a/src/devx/__init__.py b/src/devx/__init__.py index ed9b892..6cb4266 100644 --- a/src/devx/__init__.py +++ b/src/devx/__init__.py @@ -1,3 +1,3 @@ """devx — reusable development and CI/CD tools for oblachno-oss projects.""" -__version__ = "0.49.2" +__version__ = "0.49.3" diff --git a/src/devx/tools/setup.py b/src/devx/tools/setup.py index f960522..777e015 100644 --- a/src/devx/tools/setup.py +++ b/src/devx/tools/setup.py @@ -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,22 @@ 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. + + 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)]) + + @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 _configure_tea_login() -> None: diff --git a/tests/unit/test_setup.py b/tests/unit/test_setup.py index 08383d3..48248d9 100644 --- a/tests/unit/test_setup.py +++ b/tests/unit/test_setup.py @@ -114,6 +114,49 @@ 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) + _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: @patch("devx.tools.setup.shutil.which", return_value=None)