Skip to content

Commit 29d0329

Browse files
authored
Change cache_dir location to prefer venv and project_directory (#439)
1 parent a9387df commit 29d0329

File tree

10 files changed

+120
-83
lines changed

10 files changed

+120
-83
lines changed

.config/pydoclint-baseline.txt

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,6 @@ src/ansible_compat/loaders.py
5151
DOC501: Function `colpath_from_path` has "raise" statements, but the docstring does not have a "Raises" section
5252
DOC503: Function `colpath_from_path` exceptions in the "Raises" section in the docstring do not match those in the function body Raises values in the docstring: []. Raised exceptions in the body: ['InvalidPrerequisiteError'].
5353
--------------------
54-
src/ansible_compat/prerun.py
55-
DOC101: Function `get_cache_dir`: Docstring contains fewer arguments than in function signature.
56-
DOC103: Function `get_cache_dir`: 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: [project_dir: Path].
57-
DOC201: Function `get_cache_dir` does not have a return section in docstring
58-
--------------------
5954
src/ansible_compat/runtime.py
6055
DOC601: Class `Collection`: 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.)
6156
DOC603: Class `Collection`: 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: [name: str, path: Path, version: str]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.)
@@ -69,7 +64,7 @@ src/ansible_compat/runtime.py
6964
DOC501: Method `Plugins.__getattribute__` has "raise" statements, but the docstring does not have a "Raises" section
7065
DOC503: Method `Plugins.__getattribute__` exceptions in the "Raises" section in the docstring do not match those in the function body Raises values in the docstring: []. Raised exceptions in the body: ['AnsibleCompatError'].
7166
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.)
72-
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 | None, 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.)
67+
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.)
7368
DOC101: Method `Runtime.__init__`: Docstring contains fewer arguments than in function signature.
7469
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].
7570
DOC501: Method `Runtime.__init__` has "raise" statements, but the docstring does not have a "Raises" section
@@ -110,8 +105,6 @@ src/ansible_compat/runtime.py
110105
DOC501: Method `Runtime._prepare_ansible_paths` has "raise" statements, but the docstring does not have a "Raises" section
111106
DOC503: Method `Runtime._prepare_ansible_paths` exceptions in the "Raises" section in the docstring do not match those in the function body Raises values in the docstring: []. Raised exceptions in the body: ['RuntimeError'].
112107
DOC201: Method `Runtime._get_roles_path` does not have a return section in docstring
113-
DOC101: Method `Runtime._install_galaxy_role`: Docstring contains fewer arguments than in function signature.
114-
DOC103: Method `Runtime._install_galaxy_role`: 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: [project_dir: Path].
115108
DOC501: Method `Runtime._install_galaxy_role` has "raise" statements, but the docstring does not have a "Raises" section
116109
DOC503: Method `Runtime._install_galaxy_role` exceptions in the "Raises" section in the docstring do not match those in the function body Raises values in the docstring: []. Raised exceptions in the body: ['InvalidPrerequisiteError'].
117110
DOC101: Method `Runtime._update_env`: Docstring contains fewer arguments than in function signature.
@@ -285,13 +278,6 @@ test/test_runtime.py
285278
DOC101: Function `test_prepare_environment_symlink`: Docstring contains fewer arguments than in function signature.
286279
DOC103: Function `test_prepare_environment_symlink`: 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: [caplog: pytest.LogCaptureFixture, dest: str | Path, message: str].
287280
--------------------
288-
test/test_runtime_scan_path.py
289-
DOC601: Class `ScanSysPath`: 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.)
290-
DOC603: Class `ScanSysPath`: 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: [raises_not_found: bool, scan: bool]. (Please read https://jsh9.github.io/pydoclint/checking_class_attributes.html on how to correctly document class attributes.)
291-
DOC201: Method `ScanSysPath.__str__` does not have a return section in docstring
292-
DOC101: Function `test_scan_sys_path`: Docstring contains fewer arguments than in function signature.
293-
DOC103: Function `test_scan_sys_path`: 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: [monkeypatch: MonkeyPatch, param: ScanSysPath, runtime_tmp: Runtime, tmp_path: Path, venv_module: VirtualEnvironment].
294-
--------------------
295281
test/test_schema.py
296282
DOC101: Function `json_from_asset`: Docstring contains fewer arguments than in function signature.
297283
DOC103: Function `json_from_asset`: 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: [file_name: str].

.taplo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[formatting]
2+
# compatibility between toml-sort-fix pre-commit hook and panekj.even-betterer-toml extension
3+
align_comments = false
4+
array_trailing_comma = false
5+
compact_arrays = true
6+
compact_entries = false
7+
compact_inline_tables = true
8+
inline_table_expand = false
9+
reorder_keys = true

.vscode/settings.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
{
2+
"[json]": {
3+
"editor.defaultFormatter": "esbenp.prettier-vscode"
4+
},
25
"[jsonc]": {
36
"editor.defaultFormatter": "esbenp.prettier-vscode"
47
},
@@ -14,8 +17,6 @@
1417
"editor.formatOnSave": true
1518
},
1619
"editor.formatOnSave": true,
17-
"evenBetterToml.formatter.alignComments": false,
18-
"evenBetterToml.formatter.allowedBlankLines": 2,
1920
"files.exclude": {
2021
"*.egg-info": true,
2122
".pytest_cache": true,
@@ -37,8 +38,14 @@
3738
"python.testing.pytestArgs": ["tests"],
3839
"python.testing.pytestEnabled": true,
3940
"python.testing.unittestEnabled": false,
41+
"sortLines.filterBlankLines": true,
4042
"yaml.completion": true,
4143
"yaml.customTags": ["!encrypted/pkcs1-oaep scalar", "!vault scalar"],
4244
"yaml.format.enable": false,
43-
"yaml.validate": true
45+
"yaml.validate": true,
46+
"evenBetterToml.formatter.alignComments": false,
47+
"evenBetterToml.formatter.arrayTrailingComma": true,
48+
"[toml]": {
49+
"editor.defaultFormatter": "panekj.even-betterer-toml"
50+
}
4451
}

pyproject.toml

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,16 @@ known-first-party = ["ansible_compat"]
397397
known-third-party = ["packaging"]
398398

399399
[tool.ruff.lint.per-file-ignores]
400-
"test/**/*.py" = ["DOC402", "DOC501", "SLF001", "S101", "S404", "FBT001", "PLC2701"]
400+
"test/**/*.py" = [
401+
"DOC402",
402+
"DOC501",
403+
"FBT001",
404+
"PLC2701",
405+
"PLR0917",
406+
"S101",
407+
"S404",
408+
"SLF001"
409+
]
401410

402411
[tool.ruff.lint.pydocstyle]
403412
convention = "google"
@@ -430,4 +439,10 @@ sort_table_keys = true
430439
[tool.uv.pip]
431440
annotation-style = "line"
432441
custom-compile-command = "tox run deps"
433-
no-emit-package = ["ansible-core", "pip", "resolvelib", "typing_extensions", "uv"]
442+
no-emit-package = [
443+
"ansible-core",
444+
"pip",
445+
"resolvelib",
446+
"typing_extensions",
447+
"uv"
448+
]

src/ansible_compat/prerun.py

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,29 @@
11
"""Utilities for configuring ansible runtime environment."""
22

3-
import hashlib
43
import os
54
from pathlib import Path
65

76

8-
def get_cache_dir(project_dir: Path) -> Path:
9-
"""Compute cache directory to be used based on project path."""
10-
# we only use the basename instead of the full path in order to ensure that
11-
# we would use the same key regardless the location of the user home
12-
# directory or where the project is clones (as long the project folder uses
13-
# the same name).
14-
basename = project_dir.resolve().name.encode(encoding="utf-8")
15-
# 6 chars of entropy should be enough
16-
cache_key = hashlib.sha256(basename).hexdigest()[:6]
17-
cache_dir = (
18-
Path(os.getenv("XDG_CACHE_HOME", "~/.cache")).expanduser()
19-
/ "ansible-compat"
20-
/ cache_key
21-
)
7+
def get_cache_dir(project_dir: Path, *, isolated: bool = True) -> Path:
8+
"""Compute cache directory to be used based on project path.
9+
10+
Args:
11+
project_dir: Path to the project directory.
12+
isolated: Whether to use isolated cache directory.
13+
14+
Returns:
15+
Cache directory path.
16+
"""
17+
if "VIRTUAL_ENV" in os.environ:
18+
cache_dir = Path(os.environ["VIRTUAL_ENV"]) / ".ansible"
19+
elif isolated:
20+
cache_dir = project_dir / ".ansible"
21+
else:
22+
cache_dir = Path(os.environ.get("ANSIBLE_HOME", "~/.ansible")).expanduser()
23+
24+
# Ensure basic folder structure exists so `ansible-galaxy list` does not
25+
# fail with: None of the provided paths were usable. Please specify a valid path with
26+
for name in ("roles", "collections"): # pragma: no cover
27+
(cache_dir / name).mkdir(parents=True, exist_ok=True)
28+
2229
return cache_dir

src/ansible_compat/runtime.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ class Runtime:
150150

151151
_version: Version | None = None
152152
collections: OrderedDict[str, Collection] = OrderedDict()
153-
cache_dir: Path | None = None
153+
cache_dir: Path
154154
# Used to track if we have already initialized the Ansible runtime as attempts
155155
# to do it multiple tilmes will cause runtime warnings from within ansible-core
156156
initialized: bool = False
@@ -209,8 +209,8 @@ def __init__(
209209
if "PYTHONWARNINGS" not in self.environ: # pragma: no cover
210210
self.environ["PYTHONWARNINGS"] = "ignore:Blowfish has been deprecated"
211211

212-
if isolated:
213-
self.cache_dir = get_cache_dir(self.project_dir)
212+
self.cache_dir = get_cache_dir(self.project_dir, isolated=self.isolated)
213+
214214
self.config = AnsibleConfig(cache_dir=self.cache_dir)
215215

216216
# Add the sys.path to the collection paths if not isolated
@@ -356,8 +356,7 @@ def _ensure_module_available(self) -> None:
356356

357357
def clean(self) -> None:
358358
"""Remove content of cache_dir."""
359-
if self.cache_dir:
360-
shutil.rmtree(self.cache_dir, ignore_errors=True)
359+
shutil.rmtree(self.cache_dir, ignore_errors=True)
361360

362361
def run( # ruff: disable=PLR0913
363362
self,
@@ -567,8 +566,7 @@ def install_requirements( # noqa: C901
567566
]
568567
if self.verbosity > 0:
569568
cmd.extend(["-" + ("v" * self.verbosity)])
570-
if self.cache_dir:
571-
cmd.extend(["--roles-path", f"{self.cache_dir}/roles"])
569+
cmd.extend(["--roles-path", f"{self.cache_dir}/roles"])
572570

573571
if offline:
574572
_logger.warning(
@@ -668,8 +666,7 @@ def prepare_environment( # noqa: C901
668666
destination=destination,
669667
)
670668

671-
if self.cache_dir:
672-
destination = self.cache_dir / "collections"
669+
destination = self.cache_dir / "collections"
673670
for name, min_version in required_collections.items():
674671
self.install_collection(
675672
f"{name}:>={min_version}",
@@ -837,10 +834,7 @@ def _get_roles_path(self) -> Path:
837834
not mentioned or set to `False`, it returns the first path in
838835
`default_roles_path`.
839836
"""
840-
if self.cache_dir:
841-
path = Path(f"{self.cache_dir}/roles")
842-
else:
843-
path = Path(self.config.default_roles_path[0]).expanduser()
837+
path = Path(f"{self.cache_dir}/roles")
844838
return path
845839

846840
def _install_galaxy_role(

test/test_prerun.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
"""Tests for ansible_compat.prerun module."""
22

3+
from __future__ import annotations
4+
35
from pathlib import Path
6+
from typing import TYPE_CHECKING
7+
8+
if TYPE_CHECKING:
9+
from _pytest.monkeypatch import MonkeyPatch
410

511
from ansible_compat.prerun import get_cache_dir
612

@@ -10,3 +16,25 @@ def test_get_cache_dir_relative() -> None:
1016
relative_path = Path()
1117
abs_path = relative_path.resolve()
1218
assert get_cache_dir(relative_path) == get_cache_dir(abs_path)
19+
20+
21+
def test_get_cache_dir_no_isolation_no_venv(monkeypatch: MonkeyPatch) -> None:
22+
"""Test behaviors of get_cache_dir.
23+
24+
Args:
25+
monkeypatch: Pytest fixture for monkeypatching
26+
"""
27+
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
28+
monkeypatch.delenv("ANSIBLE_HOME", raising=False)
29+
assert get_cache_dir(Path(), isolated=False) == Path("~/.ansible").expanduser()
30+
31+
32+
def test_get_cache_dir_isolation_no_venv(monkeypatch: MonkeyPatch) -> None:
33+
"""Test behaviors of get_cache_dir.
34+
35+
Args:
36+
monkeypatch: Pytest fixture for monkeypatching
37+
"""
38+
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
39+
monkeypatch.delenv("ANSIBLE_HOME", raising=False)
40+
assert get_cache_dir(Path(), isolated=True) == Path() / ".ansible"

test/test_runtime.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,11 +175,6 @@ def test_runtime_install_role(
175175
f"{Path(runtime.config.default_roles_path[0]).expanduser()}/{role_name}",
176176
).is_symlink()
177177
runtime.clean()
178-
# also test that clean does not break when cache_dir is missing
179-
tmp_dir = runtime.cache_dir
180-
runtime.cache_dir = None
181-
runtime.clean()
182-
runtime.cache_dir = tmp_dir
183178

184179

185180
def test_prepare_environment_with_collections(runtime_tmp: Runtime) -> None:
@@ -654,10 +649,9 @@ def test_upgrade_collection(runtime_tmp: Runtime) -> None:
654649
runtime_tmp.require_collection("community.molecule", "0.1.0")
655650

656651

657-
def test_require_collection_no_cache_dir() -> None:
652+
def test_require_collection_not_isolated() -> None:
658653
"""Check require_collection without a cache directory."""
659-
runtime = Runtime()
660-
assert not runtime.cache_dir
654+
runtime = Runtime(isolated=False)
661655
runtime.require_collection("community.molecule", "0.1.0", install=True)
662656

663657

@@ -1024,6 +1018,11 @@ def test_runtime_has_playbook() -> None:
10241018
"""Tests has_playbook method."""
10251019
runtime = Runtime(require_module=True)
10261020

1021+
runtime.prepare_environment(
1022+
required_collections={"community.molecule": "0.1.0"},
1023+
install_local=True,
1024+
)
1025+
10271026
assert not runtime.has_playbook("this-does-not-exist.yml")
10281027
# call twice to ensure cache is used:
10291028
assert not runtime.has_playbook("this-does-not-exist.yml")

test/test_runtime_scan_path.py

Lines changed: 18 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import json
44
import textwrap
5-
from dataclasses import dataclass, fields
65
from pathlib import Path
76

87
import pytest
@@ -19,26 +18,11 @@
1918
V2_COLLECTION_FULL_NAME = f"{V2_COLLECTION_NAMESPACE}.{V2_COLLECTION_NAME}"
2019

2120

22-
@dataclass
23-
class ScanSysPath:
24-
"""Parameters for scan tests."""
25-
26-
scan: bool
27-
raises_not_found: bool
28-
29-
def __str__(self) -> str:
30-
"""Return a string representation of the object."""
31-
parts = [
32-
f"{field.name}{str(getattr(self, field.name))[0]}" for field in fields(self)
33-
]
34-
return "-".join(parts)
35-
36-
3721
@pytest.mark.parametrize(
38-
("param"),
22+
("scan", "raises_not_found"),
3923
(
40-
ScanSysPath(scan=False, raises_not_found=True),
41-
ScanSysPath(scan=True, raises_not_found=False),
24+
pytest.param(False, True, id="0"),
25+
pytest.param(True, False, id="1"),
4226
),
4327
ids=str,
4428
)
@@ -47,16 +31,23 @@ def test_scan_sys_path(
4731
monkeypatch: MonkeyPatch,
4832
runtime_tmp: Runtime,
4933
tmp_path: Path,
50-
param: ScanSysPath,
34+
scan: bool,
35+
raises_not_found: bool,
5136
) -> None:
5237
"""Confirm sys path is scanned for collections.
5338
54-
:param venv_module: Fixture for a virtual environment
55-
:param monkeypatch: Fixture for monkeypatching
56-
:param runtime_tmp: Fixture for a Runtime object
57-
:param tmp_dir: Fixture for a temporary directory
58-
:param param: The parameters for the test
39+
Args:
40+
venv_module: Fixture for a virtual environment
41+
monkeypatch: Fixture for monkeypatching
42+
runtime_tmp: Fixture for a Runtime object
43+
tmp_path: Fixture for a temporary directory
44+
scan: Whether to scan the sys path
45+
raises_not_found: Whether the collection is expected to be found
5946
"""
47+
# Isolated the test from the others, so ansible will not find collections
48+
# that might be installed by other tests.
49+
monkeypatch.setenv("VIRTUAL_ENV", venv_module.project.as_posix())
50+
monkeypatch.setenv("ANSIBLE_HOME", tmp_path.as_posix())
6051
first_site_package_dir = venv_module.site_package_dirs()[0]
6152

6253
installed_to = (
@@ -76,7 +67,7 @@ def test_scan_sys_path(
7667
# Confirm the collection is installed
7768
assert installed_to.exists()
7869
# Set the sys scan path environment variable
79-
monkeypatch.setenv("ANSIBLE_COLLECTIONS_SCAN_SYS_PATH", str(param.scan))
70+
monkeypatch.setenv("ANSIBLE_COLLECTIONS_SCAN_SYS_PATH", str(scan))
8071
# Set the ansible collections paths to avoid bleed from other tests
8172
monkeypatch.setenv("ANSIBLE_COLLECTIONS_PATH", str(tmp_path))
8273

@@ -91,7 +82,7 @@ def test_scan_sys_path(
9182
)
9283

9384
proc = venv_module.python_script_run(script)
94-
if param.raises_not_found:
85+
if raises_not_found:
9586
assert proc.returncode != 0, (proc.stdout, proc.stderr)
9687
assert "InvalidPrerequisiteError" in proc.stderr
9788
assert "'community.molecule' not found" in proc.stderr

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ passenv =
7474
LANG
7575
LC_*
7676
setenv =
77+
ANSIBLE_HOME = {envdir}/.ansible
7778
ANSIBLE_DEVEL_WARNING='false'
7879
COVERAGE_FILE = {env:COVERAGE_FILE:{envdir}/.coverage.{envname}}
7980
COVERAGE_PROCESS_START={toxinidir}/pyproject.toml

0 commit comments

Comments
 (0)