fix(utils/cache): allow utils.cache.data import without ansible

CI run 24934007615 broke every per-app deploy job (test-deploy-server,
test-deploy-universal — ~70 jobs) with the same trace:

    cli/deploy/development/init.py:10
      → cli/deploy/development/inventory.py:26
        → utils/cache/data.py:15
          → plugins/lookup/application_gid.py:7
            → ansible.plugins.lookup
    ModuleNotFoundError: No module named 'ansible'

`scripts/tests/deploy/ci/dedicated.sh:167` runs `python3 -m
cli.deploy.development init` directly on the GitHub Actions runner host
to materialise the dev inventory. The runner Python (3.12) ships without
`ansible`, but `utils.cache.data` was eager-importing
`ApplicationGidLookup` (extends `ansible.plugins.lookup.LookupBase`) and
`_templar_render_best_effort` (uses `ansible.errors.AnsibleError`) at
module scope. The cheap `get_variants` entry point — the only thing the
init step actually needs — therefore refused to import.

Move both ansible-coupled imports into the call sites that genuinely
need them:

- `_templar_render_best_effort` is now a lazy import inside
  `_materialize_builtin_user_aliases` and `_render_with_templar` (the
  two functions whose closures use it).
- `ApplicationGidLookup` is now a lazy import inside `_build_variants`
  (the only caller that instantiates it). The forward reference in
  `_build_role_base_config(gid_lookup: ApplicationGidLookup)` keeps
  working because `from __future__ import annotations` is already in
  effect; a `TYPE_CHECKING` import keeps static type checkers happy.

`merge_with_defaults` is pure-Python and stays at module scope.

Add `TestImportableWithoutAnsible` (two tests, one for `utils.cache.
data.get_variants`, one for the full
`cli.deploy.development.inventory.plan_dev_inventory_matrix` chain) that
artificially blocks `ansible` via a `MetaPathFinder` and asserts the
imports still complete. Without this guard a future eager import of an
ansible-coupled symbol would silently break ~70 CI jobs again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.7 committed 2026-04-25 19:00:56 +02:00
1 parent 2f5e2b4154
commit 8e4886d705
2 files changed
+107 -3

No files matched your search

+80
View File
@@ -507,5 +507,85 @@ class TestImplicitRolesDir(unittest.TestCase):
self.assertIn("contact", defaults)
class TestImportableWithoutAnsible(unittest.TestCase):
"""Pin: `utils.cache.data` MUST be importable without `ansible` on
sys.path.
Why pin: the `cli.deploy.development` CLI runs on the GitHub Actions
runner host (see scripts/tests/deploy/ci/dedicated.sh) where the
runner Python has no `ansible` package. CI run 24934007615 broke
every per-app deploy job because `from utils.cache.data import
get_variants` transitively pulled `from
plugins.lookup.application_gid import LookupModule` and that pulled
`from ansible.plugins.lookup import LookupBase`, raising
`ModuleNotFoundError: No module named 'ansible'` before the actual
deploy could even start. The fix moved the two ansible-coupled
imports (`ApplicationGidLookup`, `_templar_render_best_effort`) out
of the module-level import block into lazy imports at the call sites
that genuinely need them. This test simulates the CI runner by
blocking `ansible` via a `MetaPathFinder` and asserts that the cheap
entry point still imports.
"""
def _import_under_ansible_block(self, module_name: str, attr: str):
"""Re-import `module_name` with `ansible` artificially blocked."""
import importlib
import sys
class _Block:
def find_spec(self, name, path=None, target=None):
if name == "ansible" or name.startswith("ansible."):
raise ImportError(f"simulated: ansible blocked for {name}")
return None
original_meta_path = list(sys.meta_path)
snapshot = {
k: sys.modules[k]
for k in list(sys.modules)
if k == "ansible"
or k.startswith("ansible.")
or k == module_name
or k.startswith(module_name + ".")
or k.startswith("plugins.lookup.application_gid")
or k.startswith("utils.templating")
}
# Drop the cached modules so the next import goes through the block.
for k in snapshot:
sys.modules.pop(k, None)
sys.meta_path.insert(0, _Block())
try:
mod = importlib.import_module(module_name)
return getattr(mod, attr)
finally:
sys.meta_path[:] = original_meta_path
# Restore originals so the rest of the test session keeps working
# against the real (ansible-aware) implementations.
for k in list(sys.modules):
if (
k == module_name
or k.startswith(module_name + ".")
or k.startswith("plugins.lookup.application_gid")
or k.startswith("utils.templating")
):
sys.modules.pop(k, None)
for k, m in snapshot.items():
sys.modules[k] = m
def test_get_variants_importable_without_ansible(self):
get_variants_fn = self._import_under_ansible_block(
"utils.cache.data", "get_variants"
)
self.assertTrue(callable(get_variants_fn))
def test_inventory_module_importable_without_ansible(self):
# The actual call chain that broke in CI run 24934007615:
# cli.deploy.development.init -> .inventory -> utils.cache.data
plan_fn = self._import_under_ansible_block(
"cli.deploy.development.inventory", "plan_dev_inventory_matrix"
)
self.assertTrue(callable(plan_fn))
if __name__ == "__main__":
unittest.main()
+27 -3
View File
@@ -6,14 +6,25 @@ import os
import threading
from collections import OrderedDict
from pathlib import Path
from typing import Any, Mapping, Optional
from typing import TYPE_CHECKING, Any, Mapping, Optional
from urllib.parse import urlparse
import yaml
# `merge_with_defaults` is a pure-Python helper with no `ansible` dependency,
# so it stays at module scope.
from plugins.filter.merge_with_defaults import merge_with_defaults
from plugins.lookup.application_gid import LookupModule as ApplicationGidLookup
from utils.templating import _templar_render_best_effort
# `ApplicationGidLookup` and `_templar_render_best_effort` transitively pull
# in `ansible`, but several callers of this module (notably the
# `cli.deploy.development` CLI on the GitHub Actions runner host) only need
# the cheap, ansible-free entry points such as `get_variants`. Importing
# either of those at module scope would make `import utils.cache.data` raise
# `ModuleNotFoundError: No module named 'ansible'` on hosts that ship the
# CLI but no ansible interpreter (see CI run 24934007615 for the full
# trace). Defer the imports to the call sites that actually need them.
if TYPE_CHECKING:
from plugins.lookup.application_gid import LookupModule as ApplicationGidLookup
try:
from ansible.parsing.vault import EncryptedString as _AnsibleEncryptedString
@@ -434,6 +445,10 @@ def _materialize_builtin_user_aliases(
variables: Optional[Mapping[str, Any]],
templar: Any = None,
) -> dict[str, Any]:
# Lazy import: pulls `ansible.errors.AnsibleError` transitively, see the
# module-level note on why this stays out of the import block.
from utils.templating import _templar_render_best_effort
def _normalize_domain_candidate(value: Any) -> str:
text = str(value or "").strip()
if not text:
@@ -550,6 +565,10 @@ def _render_with_templar(
if templar is None:
return value
# Lazy import: pulls `ansible.errors.AnsibleError` transitively, see the
# module-level note on why this stays out of the import block.
from utils.templating import _templar_render_best_effort
# Start from whatever the templar already had available so that
# ansible_facts/hostvars stay accessible during nested renders. Overlay the
# caller-supplied variables on top, then inject our raw.*_RAW helpers.
@@ -650,6 +669,11 @@ def _build_variants(roles_dir: Path) -> dict[str, list[Any]]:
role keeps its pre-variant behaviour. The single-variant case is
equivalent to the legacy `_build_application_defaults` output.
"""
# Lazy import: `ApplicationGidLookup` extends `ansible.plugins.lookup.
# LookupBase`. See the module-level note on why this stays out of the
# top-of-file import block.
from plugins.lookup.application_gid import LookupModule as ApplicationGidLookup
gid_lookup = ApplicationGidLookup()
variants: dict[str, list[Any]] = {}