mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 03:38:23 +00:00
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>
173 lines
7.5 KiB
Python
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()
|