Files
computer-playbook/utils/roles/categories.py
T
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).

This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.

Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:

  * 7 `src:` values built from a role variable holding a bare filename
    (svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
    The variable doubles as the destination name inside the container, so the
    language segment belongs at the call site, not in the variable.
  * `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
    through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
    18 roles ship exactly that and have no template fallback: the play would
    have died on each.
  * `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
    interface now reads `files/javascript/javascript.js`.
  * `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
    path through `path_absolute(... 'files', ...)`.
  * 4 `ansible.builtin.script:` invocations naming a bare command
    (pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
  * `dev-python/files/shell/install.sh` pointed at a sibling role's script.

Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00

76 lines
2.7 KiB
Python

"""Category-tree SPOT.
``meta/categories.yml`` at the repository root declares the nested
category tree every role name is derived from: the dash-joined path of a
node is the role's category prefix (``web`` -> ``app`` yields
``web-app-*``), and nodes carry the ``invokable``, ``stage``, ``modes``
and ``run_after`` policy the dispatcher, the stage lookup and the
inventory generator read.
This module owns the file's location and the tree-walking primitives.
Consumers MUST resolve the path through :func:`categories_file` rather
than spelling it themselves, so a future move touches one line.
:data:`FILE_META_CATEGORIES` stays repository-root *relative* and is not
pre-joined onto ``PROJECT_ROOT`` at import time on purpose: the root is
not fixed. ``utils.roles.validation.invokable`` is exercised against a
patched ``PROJECT_ROOT`` and test fixtures build the file inside a
temporary tree. A module level absolute constant would freeze the root at
import time and silently ignore both.
The file is not optional. It lives beside ``roles/``, which the packaging
config excludes from the wheel just as it excludes ``meta/``, so any
context that can resolve a role name also carries this file. A missing
file is a broken checkout and raises rather than degrading to an empty
category tree, which would silently strip every category prefix off every
role name.
Keep this module free of Ansible imports and cheap to import: it is
pulled in by ``plugins/filter/invokable_paths.py``, which runs in
contexts where Ansible is not installed (see
``tests/unit/python/utils/cache/test_data.py``).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from utils.cache import PROJECT_ROOT
from utils.cache.yaml import load_yaml
if TYPE_CHECKING:
from pathlib import Path
FILE_META_CATEGORIES: str = "meta/categories.yml"
def categories_file(root: Path | None = None) -> Path:
"""Absolute path of the category file below *root*.
Args:
root: tree to resolve against; the repository root when omitted.
"""
return (PROJECT_ROOT if root is None else root) / FILE_META_CATEGORIES
def load_categories_tree(categories_file):
"""Return the ``roles`` mapping of the category file.
Args:
categories_file: path-like of a ``meta/categories.yml``.
"""
return load_yaml(categories_file)["roles"]
def flatten_categories(tree, prefix=""):
"""Flattens nested category tree to all possible category paths."""
result = []
for k, v in tree.items():
current = f"{prefix}-{k}" if prefix else k
result.append(current)
if isinstance(v, dict):
for sk, sv in v.items():
if isinstance(sv, dict):
result.extend(flatten_categories({sk: sv}, current))
return result