Files
computer-playbook/tests/unit/utils/cache/test_files.py
T
kevinveenbirkenbachandClaude Opus 4.7 b3de2a4d7c refactor(meta): land req-011 meta/info.yml + utils/cache cleanup
req-011 — descriptive role-level metadata
  * Move logo / homepage / video / display out of
    meta/main.yml.galaxy_info into a new project-owned
    roles/<role>/meta/info.yml (file-root convention, no `info:` wrapper).
  * 78 role meta/main.yml files migrated via ruamel round-trip
    (key-order / quoting / inline-comments preserved). Roles with none
    of the four fields are left untouched (no empty info.yml created).
  * web-app-dashboard/lookup_plugins/docker_cards.py reads logo +
    display from meta/info.yml; description / galaxy_tags continue to
    come from galaxy_info (Galaxy-spec fields).
  * docker_cards.py now routes ALL its file reads through the
    process-wide caches: load_yaml_any for vars/main.yml,
    meta/main.yml, meta/info.yml; read_text from utils.cache.files for
    README.md. The orphan `import yaml` is gone.

New utils/cache/files (renamed from tests/utils/fs)
  * The process-wide filesystem cache (read_text, iter_project_files,
    iter_project_files_with_content, _all_project_files) is not
    test-specific; it now lives under utils/cache/files.py next to the
    other process-wide caches. 34 consumer imports rewritten across
    tests/ and docs/. The empty tests/utils/ package is removed.
  * New unit suite tests/unit/utils/cache/test_files.py — 12 cases
    covering read_text, iter_project_files (skip-dirs, extensions,
    exclude_tests, exclude_dirs, walk-cache invariants), the combined
    iterator, non-UTF-8 silent skip, and _reset.

utils/cache redundancy cleanup
  * applications.py + users.py + cli/create/inventory/services_disabler.py
    drop their direct yaml.safe_load calls and their private
    _load_yaml_mapping/_load_yaml_variant_list copies; everything routes
    through utils.cache.yaml.load_yaml / load_yaml_any. The dead
    utils.cache.base._load_yaml_mapping is removed.
  * users.py:_load_store_users (tokens.yml read) likewise migrated.
  * _load_yaml_variant_list moves out of base.py to applications.py
    (its only consumer) and is renamed _load_variants_overrides — also
    routes through the cached loader now.
  * utils.cache.yaml._reset_cache_for_tests is renamed _reset (kept as
    alias) for parity with the per-domain reset helpers.
  * utils.cache.files grows a _reset() that clears its two LRU caches.
  * utils.cache.__init__._reset_cache_for_tests now orchestrates ALL
    six caches (applications, users, domains, base, yaml, files) so
    tests get clean state across cache layers — needed because
    applications/users now share the yaml cache and would otherwise
    serve stale parses across reset boundaries.
  * utils.cache.files imports PROJECT_ROOT from base instead of
    redefining it.
  * tests/unit/utils/cache/test_data.py + test_base.py: obsolete
    TestLoadYamlMapping / TestLoadYamlVariantList classes deleted
    (covered by test_yaml.py + test_variants.py).
  * README.md updated for the new module list (files, yaml) and the
    six-module orchestrator.

Galaxy schema lint
  * tests/lint/repository/test_role_meta_main_galaxy_schema.py: split
    galaxy_info field requirements into hard-required {author,
    description, license} and soft-recommended {min_ansible_version,
    platforms}. Soft-recommended -> emit a GitHub Actions ::warning::
    annotation via utils.annotations.message.warning (titled
    "meta/main.yml galaxy_info" with file= pointing at the offender),
    no test failure. Rationale: min_ansible_version + platforms are
    only enforced by Galaxy when *publishing*, which Infinito.Nexus
    does not do.
  * Galaxy lint goes green (was 226 offenders before req-011 +
    license_url/repository/documentation strip; was 62 before this
    relaxation; now 0 hard offenders, 62 soft warnings).
  * roles/svc-opt-keyboard-color/meta/main.yml: backfill `license`
    (only role missing the hard-required field).

New lint
  * tests/lint/repository/test_role_meta_info.py — enforces meta/info.yml
    file-root convention, the four-field allowlist (logo, homepage,
    video, display), and shape (logo.class string, homepage/video
    non-empty strings, display bool). Also fails if any
    meta/main.yml.galaxy_info still carries one of the four migrated
    fields.

Docs
  * docs/contributing/design/services/layout.md: new section
    "Descriptive Role-Level Metadata — meta/info.yml" with allowed-
    fields table, optionality rule, materialised path, and consumer
    note. File-Layout table grows the meta/info.yml row;
    meta/main.yml row gets the explicit "no logo/homepage/video/display"
    note. req-011 added to header and Related Pages.
  * utils/cache/README.md: files.py + yaml.py rows added; six-module
    reset orchestrator documented.

Validation
  * Unit:        1573 passed.
  * Lint:        55 passed.
  * Integration: 161 passed (1 skipped) via pre-commit hook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:27:17 +02:00

176 lines
6.7 KiB
Python

"""Unit tests for `utils.cache.files`.
Covers the project-tree walk cache, the file-content cache, and the
combined ``iter_project_files_with_content`` helper. All tests run
against a synthetic tempdir; ``PROJECT_ROOT`` is monkey-patched onto
the module so the real repo tree is never touched.
"""
from __future__ import annotations
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from utils.cache import files as files_module
from utils.cache.files import (
_reset,
iter_project_files,
iter_project_files_with_content,
read_text,
)
def _touch(path: Path, content: str = "") -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
class _ProjectRootFixture:
"""Mixin that sets up a synthetic PROJECT_ROOT for every test in the
subclass. Combined with ``unittest.TestCase`` via multiple inheritance
so the lint guard ``test_all_test_files_have_tests`` can see the
``TestCase`` base directly on the concrete test classes.
"""
def setUp(self) -> None:
super().setUp()
_reset()
self._tmp = TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name)
self._patcher = patch.object(files_module, "PROJECT_ROOT", self.root)
self._patcher.start()
self.addCleanup(self._patcher.stop)
self.addCleanup(_reset)
class TestReadText(_ProjectRootFixture, unittest.TestCase):
def test_returns_utf8_content(self) -> None:
path = _touch(self.root / "a.txt", "héllo")
self.assertEqual(read_text(str(path)), "héllo")
def test_second_call_hits_cache(self) -> None:
path = _touch(self.root / "a.txt", "first")
self.assertEqual(read_text(str(path)), "first")
path.write_text("changed", encoding="utf-8")
# Same string path → cache hit; the on-disk change is not seen.
self.assertEqual(read_text(str(path)), "first")
def test_reset_invalidates_cache(self) -> None:
path = _touch(self.root / "a.txt", "first")
self.assertEqual(read_text(str(path)), "first")
path.write_text("second", encoding="utf-8")
_reset()
self.assertEqual(read_text(str(path)), "second")
def test_missing_file_raises(self) -> None:
with self.assertRaises(FileNotFoundError):
read_text(str(self.root / "nope.txt"))
class TestIterProjectFiles(_ProjectRootFixture, unittest.TestCase):
def setUp(self) -> None:
super().setUp()
# Build a small synthetic tree.
_touch(self.root / "roles/web-app-foo/meta/services.yml", "foo: 1")
_touch(self.root / "roles/web-app-foo/templates/x.j2", "{{ x }}")
_touch(self.root / "docs/readme.md", "# Hi")
_touch(self.root / "tests/unit/test_a.py", "import unittest")
_touch(self.root / ".git/HEAD", "ref: refs/heads/main")
_touch(self.root / "node_modules/pkg/index.js", "module.exports = {};")
_touch(self.root / "__pycache__/dead.pyc", "")
def test_skip_dirs_pruned(self) -> None:
all_paths = set(iter_project_files())
# The pruned dirs MUST never appear in the walk.
self.assertFalse(
any("/.git/" in p for p in all_paths),
f".git not pruned in {all_paths}",
)
self.assertFalse(any("/__pycache__/" in p for p in all_paths))
self.assertFalse(any("/node_modules/" in p for p in all_paths))
def test_extensions_filter(self) -> None:
ymls = list(iter_project_files(extensions=(".yml",)))
self.assertEqual(len(ymls), 1)
self.assertTrue(ymls[0].endswith("services.yml"))
markdown = list(iter_project_files(extensions=(".md",)))
self.assertEqual(len(markdown), 1)
self.assertTrue(markdown[0].endswith("readme.md"))
def test_exclude_tests_skips_tests_subtree(self) -> None:
with_tests = list(iter_project_files(extensions=(".py",)))
self.assertEqual(len(with_tests), 1)
without_tests = list(
iter_project_files(extensions=(".py",), exclude_tests=True)
)
self.assertEqual(without_tests, [])
def test_exclude_dirs_path_segment_match(self) -> None:
# Excluding "docs" must drop the markdown file under docs/.
kept = list(iter_project_files(exclude_dirs=("docs",)))
self.assertFalse(any(p.endswith("readme.md") for p in kept))
def test_walk_is_cached_across_calls(self) -> None:
# First call populates the cache, then we add a new file: a fresh
# walk would discover it; the cache MUST not.
first = set(iter_project_files())
_touch(self.root / "after.txt", "late")
second = set(iter_project_files())
self.assertEqual(first, second)
# _reset() clears the cache → the new file is now visible.
_reset()
third = set(iter_project_files())
self.assertIn(str(self.root / "after.txt"), third)
class TestIterProjectFilesWithContent(_ProjectRootFixture, unittest.TestCase):
def test_yields_path_and_content(self) -> None:
_touch(self.root / "a.txt", "alpha")
_touch(self.root / "b.txt", "beta")
result = sorted(iter_project_files_with_content(extensions=(".txt",)))
self.assertEqual(
result,
sorted(
[
(str(self.root / "a.txt"), "alpha"),
(str(self.root / "b.txt"), "beta"),
]
),
)
def test_non_utf8_files_silently_skipped(self) -> None:
good = self.root / "good.bin"
bad = self.root / "bad.bin"
good.write_text("ok", encoding="utf-8")
bad.write_bytes(b"\xff\xfe\x00invalid")
result = list(iter_project_files_with_content(extensions=(".bin",)))
self.assertEqual(result, [(str(good), "ok")])
class TestReset(_ProjectRootFixture, unittest.TestCase):
def test_reset_clears_both_caches(self) -> None:
_touch(self.root / "x.txt", "v1")
list(iter_project_files()) # populate walk cache
read_text(str(self.root / "x.txt")) # populate text cache
# Mutate underlying state.
_touch(self.root / "y.txt", "v2")
(self.root / "x.txt").write_text("v1-changed", encoding="utf-8")
# Cached views still see the OLD state.
self.assertNotIn(str(self.root / "y.txt"), set(iter_project_files()))
self.assertEqual(read_text(str(self.root / "x.txt")), "v1")
# Reset → fresh views.
_reset()
self.assertIn(str(self.root / "y.txt"), set(iter_project_files()))
self.assertEqual(read_text(str(self.root / "x.txt")), "v1-changed")
if __name__ == "__main__": # pragma: no cover
unittest.main()