Files
computer-playbook/tests/unit/cli/core/test_discovery.py
T
kevinveenbirkenbachandClaude Opus 4.7 a26bfc4e94 refactor(cli): reorg admin/contributing tree + per-folder help system
Module moves (with imports, paths, argv literals, scripts, docs, and
parents[N] formulas updated in lockstep):

- cli/deploy             -> cli/administration/deploy
- cli/validate/inventory -> cli/administration/inventory/validate
- cli/vault              -> cli/administration/secrets/vault
- cli/administration/inventory/encrypt -> cli/administration/secrets/seal
- cli/mirror             -> cli/contributing/mirror

The new cli/administration/secrets cluster collocates the whole-file
ansible-vault wrapper next to the inventory-aware per-field sealer.
The user-facing CLI subcommand paths follow the package layout, e.g.
"infinito administration deploy dedicated <inv>" and
"infinito administration inventory validate <inv>".

Help system redesign in cli/core/{discovery,help,app}.py:

- Discovery now distinguishes runnable commands (directories with
  __main__.py) from category folders (directories that only group
  further commands). Empty support packages are filtered out.
- Each category folder MAY carry a README.md; its H1 heading is used
  as the title and its first paragraph as the one-line description in
  the help output.
- "infinito" / "infinito --help" prints only the top-level entries
  (one level deep). "infinito <category>" navigates into a folder and
  shows its sub-folders and commands one level deep, with the README
  title as the page heading and the invocation as a dim subtitle.
- New "--tree [N]" flag walks the tree under the (optional) path
  prefix recursively. N caps the depth (1-based); omit N for an
  unbounded walk. The output uses Unicode tree connectors, an emoji
  per entry (folder for categories, gear for commands), a globally
  aligned description column, and multi-line wrapped descriptions
  with tree-continuity bars on continuation lines.
- Category and command listings use the full invocation
  ("infinito meta callorder") rather than the bare leaf name, so the
  output is copy-pasteable.

Convention doc + 17 CLI READMEs:

- New docs/contributing/artefact/files/cli/readme_md.md describes the
  H1+paragraph layout the help system reads, and how it differs from
  role/docs READMEs.
- README.md added for cli/{administration, build, contributing, meta}
  and their immediate sub-folders, plus cli/meta/roles/applications
  and its resolution sub-folder. Each follows the "title with trailing
  emoji + single descriptive paragraph" convention.

Mirror resolver/sync description strings (the help system reads them
from argparse --help output) added so they no longer surface as
"options:" in the global index. cli/mirror/cleanup already had a
description and is unchanged.

Fixes uncovered by the move:

- import unittest.mock added to four dedicated-deploy tests so
  unittest.mock.patch resolves under stdlib unittest discovery
  (Python 3.13 no longer surfaces unittest.mock through "import
  unittest" alone).
- parents[N] index in the new
  cli/administration/inventory/validate/__init__.py corrected from
  3 to 4 to match the deeper layout.
- test_paths.INVENTORY_VALIDATOR_PATH assertion and test_runner
  substring assertions updated to match the new validator path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:17:09 +02:00

173 lines
7.5 KiB
Python

import tempfile
import unittest
from pathlib import Path
from cli.core.discovery import (
discover_commands,
iter_dir_entries,
resolve_command_module,
)
class TestDiscovery(unittest.TestCase):
def _touch(self, path: Path, content: str = "") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def test_discover_commands_finds_packages_with_main(self):
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
# Dispatcher exists but must NOT be treated as a command
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
# commands
self._touch(cli_dir / "deploy" / "container" / "__main__.py", "# cmd\n")
self._touch(cli_dir / "build" / "tree" / "__main__.py", "# cmd\n")
self._touch(cli_dir / "make" / "__main__.py", "# cmd\n")
cmds = discover_commands(cli_dir)
modules = {c.module for c in cmds}
self.assertIn("cli.deploy.container", modules)
self.assertIn("cli.build.tree", modules)
self.assertIn("cli.make", modules)
self.assertNotIn("cli", modules) # root must not be a command
def test_discover_commands_ignores_pycache(self):
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
pycache = cli_dir / "x" / "__pycache__"
self._touch(pycache / "__main__.py", "# should be ignored\n")
cmds = discover_commands(cli_dir)
modules = {c.module for c in cmds}
self.assertNotIn("cli.x.__pycache__", modules)
def test_resolve_command_module_longest_prefix_wins(self):
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
# Both deploy and deploy/container exist -> prefer deploy/container
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
self._touch(cli_dir / "deploy" / "__main__.py", "# cmd\n")
self._touch(cli_dir / "deploy" / "container" / "__main__.py", "# cmd\n")
module, remaining = resolve_command_module(
cli_dir, ["deploy", "container", "--foo", "bar"]
)
self.assertEqual(module, "cli.deploy.container")
self.assertEqual(remaining, ["--foo", "bar"])
def test_resolve_command_module_returns_none_when_not_found(self):
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
module, remaining = resolve_command_module(cli_dir, ["nope", "--x"])
self.assertIsNone(module)
self.assertEqual(remaining, ["nope", "--x"])
def test_resolve_command_module_stops_at_first_flag(self):
# `infinito administration inventory provision <abs-path> --host localhost ...`:
# the subcommand is `administration inventory provision`; the absolute
# positional arg MUST NOT be tried as a sub-package, otherwise pathlib
# resets the search root and downstream args (huge `--vars` JSON) end up
# concatenated as a single path that hits ENAMETOOLONG.
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
self._touch(
cli_dir / "administration" / "inventory" / "provision" / "__main__.py",
"# cmd\n",
)
argv = [
"administration",
"inventory",
"provision",
"/home/user/inventories/localhost-0",
"--host",
"localhost",
"--vars",
'{"a": 1, "b": 2}',
]
module, remaining = resolve_command_module(cli_dir, argv)
self.assertEqual(module, "cli.administration.inventory.provision")
self.assertEqual(remaining, argv[3:])
def test_resolve_command_module_survives_oserror_on_long_path(self):
# Defence in depth: even if a freshly-introduced positional arg
# somehow slips past the flag-stop heuristic and produces a path
# that stat() refuses (ENAMETOOLONG, ENOENT, EACCES), the
# dispatcher MUST keep trying shorter prefixes instead of crashing.
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
self._touch(cli_dir / "build" / "tree" / "__main__.py", "# cmd\n")
# 5000-char arg blows past most filesystem name-length limits.
blob = "x" * 5000
argv = ["build", "tree", blob]
module, remaining = resolve_command_module(cli_dir, argv)
self.assertEqual(module, "cli.build.tree")
self.assertEqual(remaining, [blob])
class TestIterDirEntries(unittest.TestCase):
def _touch(self, path: Path, content: str = "") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def test_returns_immediate_subdirs_split_by_command_vs_folder(self):
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
# Runnable command at top level.
self._touch(cli_dir / "make" / "__main__.py", "# cmd\n")
# Category folder (no own __main__.py) with a command underneath.
self._touch(cli_dir / "meta" / "j2" / "__main__.py", "# cmd\n")
# Empty support package — must NOT show up.
self._touch(cli_dir / "core" / "__init__.py", "")
# __pycache__ must be ignored.
(cli_dir / "__pycache__").mkdir(parents=True, exist_ok=True)
entries = iter_dir_entries(cli_dir, ())
names = [(e.name, e.is_command) for e in entries]
self.assertIn(("make", True), names)
self.assertIn(("meta", False), names)
self.assertNotIn(("core", False), [(n, c) for n, c in names])
self.assertNotIn(("__pycache__", False), [(n, c) for n, c in names])
def test_descends_into_named_path(self):
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
self._touch(cli_dir / "meta" / "j2" / "__main__.py", "# cmd\n")
self._touch(cli_dir / "meta" / "callorder" / "__main__.py", "# cmd\n")
self._touch(cli_dir / "meta" / "roles" / "__init__.py", "")
# `meta/roles/all` is a runnable command underneath the otherwise
# empty `meta/roles` folder; the folder MUST surface as a category.
self._touch(cli_dir / "meta" / "roles" / "all" / "__main__.py", "# cmd\n")
entries = iter_dir_entries(cli_dir, ("meta",))
names = sorted((e.name, e.is_command) for e in entries)
self.assertEqual(
names,
[("callorder", True), ("j2", True), ("roles", False)],
)
if __name__ == "__main__":
unittest.main()