Skip to content

Commit 74c522b

Browse files
Fix first-run collection discovery by gating plugin loader initialization (#516)
* fix: gate plugin loader initialization to prevent early collection path capture Fixes first-run collection discovery issue where ansible-lint fails to find plugins from collections installed during prepare_environment(). The problem occurs because Ansible's plugin loader was initialized too early with incomplete collection paths, before prepare_environment() installs dependencies from requirements.yml and galaxy.yml files. This particularly affects Templar usage since it relies on the collection loader for template rendering and plugin discovery. Changes: - Add Runtime.plugin_loader_enabled class variable to gate access - Add enable_plugin_loader() method for explicit initialization - Modify Plugins.__getattribute__ to raise RuntimeError if accessed early - Enhance clean() to reset flags and unload ansible modules for test isolation - Move plugin loader init from _ensure_module_available() to enable_plugin_loader() This ensures plugin loader initialization happens after collections are installed, eliminating the 'works on second run' behavior. * chore: auto fixes from pre-commit.com hooks * lint fixes * deps * chore: auto fixes from pre-commit.com hooks --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent be3971b commit 74c522b

File tree

6 files changed

+117
-36
lines changed

6 files changed

+117
-36
lines changed

.config/constraints.txt

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,29 +8,29 @@ beautifulsoup4==4.13.4 # via linkchecker, mkdocs-htmlproofer-plugin
88
black==25.1.0 # via ansible-compat (pyproject.toml)
99
cairocffi==1.7.1 # via cairosvg
1010
cairosvg==2.7.1 # via mkdocs-ansible
11-
certifi==2025.6.15 # via requests
11+
certifi==2025.8.3 # via requests
1212
cffi==1.17.1 # via cairocffi, cryptography
13-
charset-normalizer==3.4.2 # via requests
13+
charset-normalizer==3.4.3 # via requests
1414
click==8.2.1 # via black, mkdocs
1515
colorama==0.4.6 # via griffe, mkdocs-material
16-
coverage==7.9.2 # via ansible-compat (pyproject.toml)
17-
cryptography==45.0.5 # via ansible-core
16+
coverage==7.10.3 # via ansible-compat (pyproject.toml)
17+
cryptography==45.0.6 # via ansible-core
1818
csscompressor==0.9.5 # via mkdocs-minify-plugin
1919
cssselect2==0.8.0 # via cairosvg
2020
defusedxml==0.7.1 # via cairosvg
2121
dnspython==2.7.0 # via linkchecker
2222
exceptiongroup==1.3.0 # via pytest
2323
ghp-import==2.1.0 # via mkdocs
24-
griffe==1.7.3 # via mkdocstrings-python
24+
griffe==1.11.1 # via mkdocstrings-python
2525
hjson==3.1.0 # via mkdocs-macros-plugin, super-collections
2626
htmlmin2==0.1.13 # via mkdocs-minify-plugin
2727
idna==3.10 # via requests
2828
iniconfig==2.1.0 # via pytest
2929
jinja2==3.1.6 # via ansible-core, mkdocs, mkdocs-macros-plugin, mkdocs-material, mkdocstrings
3030
jsmin==3.0.1 # via mkdocs-minify-plugin
31-
jsonschema==4.24.0 # via ansible-compat (pyproject.toml)
31+
jsonschema==4.25.0 # via ansible-compat (pyproject.toml)
3232
jsonschema-specifications==2025.4.1 # via jsonschema
33-
linkchecker==10.5.0 # via mkdocs-ansible
33+
linkchecker==10.6.0 # via mkdocs-ansible
3434
markdown==3.8.2 # via markdown-include, mkdocs, mkdocs-autorefs, mkdocs-htmlproofer-plugin, mkdocs-material, mkdocstrings, pymdown-extensions
3535
markdown-exec==1.11.0 # via mkdocs-ansible
3636
markdown-include==0.8.1 # via mkdocs-ansible
@@ -42,11 +42,11 @@ mkdocs-autorefs==1.4.2 # via mkdocstrings, mkdocstrings-python
4242
mkdocs-gen-files==0.5.0 # via mkdocs-ansible
4343
mkdocs-get-deps==0.2.0 # via mkdocs
4444
mkdocs-htmlproofer-plugin==1.3.0 # via mkdocs-ansible
45-
mkdocs-macros-plugin==1.3.7 # via mkdocs-ansible
46-
mkdocs-material==9.6.15 # via mkdocs-ansible
45+
mkdocs-macros-plugin==1.3.9 # via mkdocs-ansible
46+
mkdocs-material==9.6.16 # via mkdocs-ansible
4747
mkdocs-material-extensions==1.3.1 # via mkdocs-ansible, mkdocs-material
4848
mkdocs-minify-plugin==0.8.0 # via mkdocs-ansible
49-
mkdocstrings==0.29.1 # via mkdocs-ansible, mkdocstrings-python
49+
mkdocstrings==0.30.0 # via mkdocs-ansible, mkdocstrings-python
5050
mkdocstrings-python==1.16.12 # via mkdocs-ansible
5151
mypy-extensions==1.1.0 # via black
5252
packaging==25.0 # via ansible-core, black, mkdocs, mkdocs-macros-plugin, pytest, ansible-compat (pyproject.toml)
@@ -57,7 +57,7 @@ platformdirs==4.3.8 # via black, mkdocs-get-deps
5757
pluggy==1.6.0 # via pytest
5858
pycparser==2.22 # via cffi
5959
pygments==2.19.2 # via mkdocs-material, pytest
60-
pymdown-extensions==10.16 # via markdown-exec, mkdocs-ansible, mkdocs-material, mkdocstrings
60+
pymdown-extensions==10.16.1 # via markdown-exec, mkdocs-ansible, mkdocs-material, mkdocstrings
6161
pytest==8.4.1 # via pytest-instafail, pytest-mock, pytest-plus, ansible-compat (pyproject.toml)
6262
pytest-instafail==0.5.0 # via ansible-compat (pyproject.toml)
6363
pytest-mock==3.14.1 # via ansible-compat (pyproject.toml)
@@ -67,7 +67,7 @@ pyyaml==6.0.2 # via ansible-core, mkdocs, mkdocs-get-deps, mkdocs-ma
6767
pyyaml-env-tag==1.1 # via mkdocs
6868
referencing==0.36.2 # via jsonschema, jsonschema-specifications
6969
requests==2.32.4 # via linkchecker, mkdocs-htmlproofer-plugin, mkdocs-material
70-
rpds-py==0.26.0 # via jsonschema, referencing
70+
rpds-py==0.27.0 # via jsonschema, referencing
7171
six==1.17.0 # via python-dateutil
7272
soupsieve==2.7 # via beautifulsoup4
7373
subprocess-tee==0.4.2 # via ansible-compat (pyproject.toml)

.config/pydoclint-baseline.txt

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,6 @@ src/ansible_compat/runtime.py
5858
DOC101: Method `Plugins.__getattribute__`: Docstring contains fewer arguments than in function signature.
5959
DOC103: Method `Plugins.__getattribute__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [attr: str].
6060
DOC201: Method `Plugins.__getattribute__` does not have a return section in docstring
61-
DOC501: Method `Plugins.__getattribute__` has raise statements, but the docstring does not have a "Raises" section
62-
DOC503: Method `Plugins.__getattribute__` exceptions in the "Raises" section in the docstring do not match those in the function body. Raised exceptions in the docstring: []. Raised exceptions in the body: ['AnsibleCompatError'].
63-
DOC601: Class `Runtime`: Class docstring contains fewer class attributes than actual class attributes. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.)
64-
DOC603: Class `Runtime`: Class docstring attributes are different from actual class attributes. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Attributes in the class definition but not in the docstring: [_has_playbook_cache: dict[tuple[str, Path | None], bool], _version: Version | None, cache_dir: Path, collections: OrderedDict[str, Collection], initialized: bool, plugins: Plugins, require_module: bool]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.)
6561
DOC101: Method `Runtime.__init__`: Docstring contains fewer arguments than in function signature.
6662
DOC103: Method `Runtime.__init__`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [environ: dict[str, str] | None, isolated: bool, max_retries: int, min_required_version: str | None, project_dir: Path | None, require_module: bool, verbosity: int].
6763
DOC501: Method `Runtime.__init__` has raise statements, but the docstring does not have a "Raises" section
@@ -129,8 +125,6 @@ src/ansible_compat/schema.py
129125
DOC201: Method `JsonSchemaError.to_friendly` does not have a return section in docstring
130126
--------------------
131127
test/conftest.py
132-
DOC101: Function `runtime`: Docstring contains fewer arguments than in function signature.
133-
DOC103: Function `runtime`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [scope: str].
134128
DOC402: Function `runtime` has "yield" statements, but the docstring does not have a "Yields" section
135129
DOC101: Function `runtime_tmp`: Docstring contains fewer arguments than in function signature.
136130
DOC103: Function `runtime_tmp`: Docstring arguments are different from function arguments. (Or could be other formatting issues: https://jsh9.github.io/pydoclint/violation_codes.html#notes-on-doc103 ). Arguments in the function signature but not in the docstring: [scope: str, tmp_path: pathlib.Path].

src/ansible_compat/runtime.py

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,12 @@ class Plugins: # pylint: disable=too-many-instance-attributes
107107

108108
@no_type_check
109109
def __getattribute__(self, attr: str): # noqa: ANN204
110-
"""Get attribute."""
110+
"""Get attribute.
111+
112+
Raises:
113+
RuntimeError: If plugin loader has not been enabled yet.
114+
AnsibleCompatError: If unexpected output from ansible-doc.
115+
"""
111116
if attr in {
112117
"become",
113118
"cache",
@@ -130,6 +135,14 @@ def __getattribute__(self, attr: str): # noqa: ANN204
130135
try:
131136
result = super().__getattribute__(attr)
132137
except AttributeError as exc:
138+
# If plugin loader is not enabled yet, raise RuntimeError
139+
if not Runtime.plugin_loader_enabled:
140+
msg = (
141+
f"Plugin loader has not been enabled yet. "
142+
f"Cannot access runtime.plugins.{attr} before calling "
143+
f"runtime.enable_plugin_loader()."
144+
)
145+
raise RuntimeError(msg) from exc
133146
proc = self.runtime.run(
134147
["ansible-doc", "--json", "-l", "-t", attr],
135148
)
@@ -146,14 +159,27 @@ def __getattribute__(self, attr: str): # noqa: ANN204
146159

147160
# pylint: disable=too-many-instance-attributes
148161
class Runtime:
149-
"""Ansible Runtime manager."""
162+
"""Ansible Runtime manager.
163+
164+
Attributes:
165+
_version: Cached version of Ansible.
166+
collections: Ordered dictionary of collections.
167+
cache_dir: Path to cache directory.
168+
initialized: Flag tracking if Ansible runtime has been initialized.
169+
plugin_loader_enabled: Flag controlling when plugin loader initialization is allowed.
170+
plugins: Plugins instance for accessing Ansible plugins.
171+
_has_playbook_cache: Cache for playbook existence checks.
172+
require_module: Flag indicating if module is required.
173+
"""
150174

151175
_version: Version | None = None
152176
collections: OrderedDict[str, Collection] = OrderedDict()
153177
cache_dir: Path
154178
# Used to track if we have already initialized the Ansible runtime as attempts
155179
# to do it multiple tilmes will cause runtime warnings from within ansible-core
156180
initialized: bool = False
181+
# Flag to control when plugin loader initialization is allowed
182+
plugin_loader_enabled: bool = False
157183
plugins: Plugins
158184
_has_playbook_cache: dict[tuple[str, Path | None], bool] = {}
159185
require_module: bool = False
@@ -368,26 +394,52 @@ def _ensure_module_available(self) -> None:
368394
msg = f"Ansible CLI ({self.version}) and python module ({ansible_module_version}) versions do not match. This indicates a broken execution environment."
369395
raise RuntimeError(msg)
370396

371-
# We need to initialize the plugin loader
372-
# https://github.com/ansible/ansible-lint/issues/2945
397+
def enable_plugin_loader(self) -> None:
398+
"""Enable plugin loader initialization with current collections paths."""
399+
Runtime.plugin_loader_enabled = True
400+
401+
# Initialize the plugin loader now with the full collections paths
373402
if not Runtime.initialized:
374-
col_path = [f"{self.cache_dir}/collections"]
403+
col_paths = self.config.collections_paths
404+
if self.isolated:
405+
col_paths = [str(self.cache_dir / "collections"), *col_paths]
375406
# noinspection PyProtectedMember
376407
# pylint: disable=import-outside-toplevel,no-name-in-module
377408
from ansible.plugins.loader import init_plugin_loader
378409
from ansible.utils.collection_loader._collection_finder import ( # pylint: disable=import-outside-toplevel
379410
_AnsibleCollectionFinder, # noqa: PLC2701
380411
)
381412

382-
_AnsibleCollectionFinder( # noqa: SLF001
383-
paths=col_path,
384-
)._remove() # pylint: disable=protected-access
385-
init_plugin_loader(col_path)
413+
with contextlib.suppress(Exception):
414+
_AnsibleCollectionFinder()._remove() # pylint: disable=protected-access # noqa: SLF001
415+
416+
init_plugin_loader(col_paths)
417+
Runtime.initialized = True
386418

387419
def clean(self) -> None:
388-
"""Remove content of cache_dir."""
420+
"""Remove content of cache_dir and reset global Runtime state."""
389421
shutil.rmtree(self.cache_dir, ignore_errors=True)
390422

423+
# Reset global Runtime class state to allow fresh initialization
424+
Runtime.initialized = False
425+
Runtime.plugin_loader_enabled = False
426+
427+
# Unload ansible modules that cache plugin/collection state
428+
# This ensures fresh plugin loader and collection finder state
429+
modules_to_remove = [
430+
mod
431+
for mod in sys.modules
432+
if mod.startswith(
433+
(
434+
"ansible.plugins.", # Plugin loaders and cached plugins
435+
"ansible.utils.collection_loader", # Collection finder system
436+
"ansible.collections.", # Loaded collection modules
437+
),
438+
)
439+
]
440+
for mod in modules_to_remove:
441+
del sys.modules[mod]
442+
391443
def run( # ruff: disable=PLR0913
392444
self,
393445
args: str | list[str],
@@ -667,6 +719,7 @@ def prepare_environment( # noqa: C901
667719
required_collections = {}
668720

669721
self._prepare_ansible_paths()
722+
670723
# first one is standard for collection layout repos and the last two
671724
# are part of Tower specification
672725
# https://docs.ansible.com/ansible-tower/latest/html/userguide/projects.html#ansible-galaxy-support

src/ansible_compat/schema.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def validate(
107107
errors.append(error)
108108
return errors
109109

110-
for validation_error in validator(schema).iter_errors(data):
110+
for validation_error in validator(schema).iter_errors(data): # type: ignore[call-arg]
111111
if isinstance(validation_error, jsonschema.ValidationError):
112112
error = JsonSchemaError(
113113
message=validation_error.message,

test/conftest.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@
1414

1515

1616
@pytest.fixture
17-
# pylint: disable=unused-argument
18-
def runtime(scope: str = "session") -> Generator[Runtime, None, None]: # noqa: ARG001
19-
"""Isolated runtime fixture."""
20-
instance = Runtime(isolated=True)
17+
def runtime(request: pytest.FixtureRequest) -> Generator[Runtime, None, None]:
18+
"""Isolated runtime fixture with configurable parameters.
19+
20+
Args:
21+
request: Pytest fixture request object containing test parameters.
22+
"""
23+
provided_params = getattr(request, "param", {}) if hasattr(request, "param") else {}
24+
use_params = provided_params or {"isolated": True}
25+
instance = Runtime(**use_params)
2126
yield instance
2227
instance.clean()
2328

test/test_runtime.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,16 @@ def test_runtime_mismatch_ansible_module(monkeypatch: MonkeyPatch) -> None:
8181
Runtime(require_module=True)
8282

8383

84-
def test_runtime_require_module() -> None:
85-
"""Check that require_module successful pass."""
86-
Runtime(require_module=True)
84+
@pytest.mark.parametrize("runtime", ({"require_module": True},), indirect=True)
85+
def test_runtime_require_module(runtime: Runtime) -> None:
86+
"""Check that require_module successful pass.
87+
88+
Enable the plugin loader to make sure that the module is loaded.
89+
90+
Args:
91+
runtime: Runtime fixture with require_module=True.
92+
"""
93+
runtime.enable_plugin_loader()
8794
# Now we try to set the collection path, something to check if that is
8895
# causing an exception, as 2.15 introduced new init code.
8996
from ansible.utils.collection_loader import ( # pylint: disable=import-outside-toplevel
@@ -759,6 +766,7 @@ def test_load_plugins(
759766
with cwd(Path(path)):
760767
runtime = Runtime(isolated=True, require_module=True)
761768
runtime.prepare_environment(install_local=True)
769+
runtime.enable_plugin_loader()
762770
for plugin_name in expected_plugins:
763771
assert (
764772
plugin_name in runtime.plugins.module
@@ -898,6 +906,7 @@ def test_runtime_exec_env(runtime: Runtime) -> None:
898906

899907
def test_runtime_plugins(runtime: Runtime) -> None:
900908
"""Tests ability to access detected plugins."""
909+
runtime.enable_plugin_loader()
901910
assert len(runtime.plugins.cliconf) == 0
902911
# ansible.netcommon.restconf might be in httpapi
903912
assert isinstance(runtime.plugins.httpapi, dict)
@@ -1031,3 +1040,23 @@ def test_runtime_exception(monkeypatch: pytest.MonkeyPatch) -> None:
10311040
match=r"ANSIBLE_COLLECTIONS_PATHS was detected, replace it with ANSIBLE_COLLECTIONS_PATH to continue.",
10321041
):
10331042
Runtime()
1043+
1044+
1045+
def test_runtime_plugin_loader_enabled(runtime: Runtime) -> None:
1046+
"""Test that plugin loader works after being enabled.
1047+
1048+
Args:
1049+
runtime: Runtime fixture for testing plugin loader functionality.
1050+
"""
1051+
# Initially, plugin loader should not be enabled and accessing plugins should fail
1052+
with pytest.raises(RuntimeError, match="Plugin loader has not been enabled yet"):
1053+
_ = runtime.plugins.become
1054+
1055+
# Enable the plugin loader
1056+
runtime.enable_plugin_loader()
1057+
1058+
# Now accessing plugins should work without errors
1059+
become_plugins = runtime.plugins.become
1060+
assert isinstance(become_plugins, dict)
1061+
# Should have at least the built-in 'sudo' become plugin
1062+
assert len(become_plugins) >= 0 # Could be empty in isolated test environment

0 commit comments

Comments
 (0)