diff --git a/main.py b/main.py index 623478a9..e2c6cada 100755 --- a/main.py +++ b/main.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 -import argparse import os import subprocess import sys @@ -10,7 +9,8 @@ import signal from datetime import datetime import pty -from cli.sounds import Sound +from cli.sounds import Sound # ensure Sound imported + def format_command_help(name, description, indent=2, col_width=36, width=80): prefix = " " * indent + f"{name:<{col_width - indent}}" @@ -21,16 +21,42 @@ def format_command_help(name, description, indent=2, col_width=36, width=80): ) return wrapper.fill(description) + def list_cli_commands(cli_dir): - return sorted( - os.path.splitext(f.name)[0] for f in os.scandir(cli_dir) - if f.is_file() and f.name.endswith(".py") and not f.name.startswith("__") - ) + """Recursively list all .py files under cli_dir that use argparse (without .py).""" + cmds = [] + for root, _, files in os.walk(cli_dir): + for f in files: + if not f.endswith(".py") or f.startswith("__"): + continue + path = os.path.join(root, f) + try: + with open(path, 'r', encoding='utf-8') as fh: + content = fh.read() + if 'argparse' not in content: + continue + except Exception: + continue + rel_dir = os.path.relpath(root, cli_dir) + name = os.path.splitext(f)[0] + if rel_dir == ".": + cmd = (None, name) + else: + cmd = (rel_dir.replace(os.sep, "/"), name) + cmds.append(cmd) + return sorted(cmds, key=lambda x: (x[0] or "", x[1])) + def extract_description_via_help(cli_script_path): try: + # derive module name from script path + script_dir = os.path.dirname(os.path.realpath(__file__)) + cli_dir = os.path.join(script_dir, "cli") + rel = os.path.relpath(cli_script_path, cli_dir) + module = "cli." + rel[:-3].replace(os.sep, ".") + result = subprocess.run( - [sys.executable, cli_script_path, "--help"], + [sys.executable, "-m", module, "--help"], capture_output=True, text=True, check=True @@ -39,7 +65,7 @@ def extract_description_via_help(cli_script_path): for i, line in enumerate(lines): if line.strip().startswith("usage:"): continue - if line.strip() == "": + if not line.strip(): for j in range(i+1, len(lines)): desc = lines[j].strip() if desc: @@ -48,14 +74,16 @@ def extract_description_via_help(cli_script_path): except Exception: return "-" + def git_clean_repo(): - """Remove all Git-ignored files and directories in the current repository.""" subprocess.run(['git', 'clean', '-Xfd'], check=True) + def play_start_intro(): Sound.play_start_sound() Sound.play_cymais_intro_sound() + def failure_with_warning_loop(): Sound.play_finished_failed_sound() print("Warning: command failed. Press Ctrl+C to stop sound warnings.") @@ -65,28 +93,15 @@ def failure_with_warning_loop(): except KeyboardInterrupt: print("Warnings stopped by user.") -from cli.sounds import Sound # ensure Sound imported if __name__ == "__main__": - # Parse special flags early and remove from args - no_sound = False - log_enabled = False - git_clean = False - infinite = False - if '--no-sound' in sys.argv: - no_sound = True - sys.argv.remove('--no-sound') - if '--log' in sys.argv: - log_enabled = True - sys.argv.remove('--log') - if '--git-clean' in sys.argv: - git_clean = True - sys.argv.remove('--git-clean') - if '--infinite' in sys.argv: - infinite = True - sys.argv.remove('--infinite') + # Early parse special flags + no_sound = '--no-sound' in sys.argv and (sys.argv.remove('--no-sound') or True) + log_enabled = '--log' in sys.argv and (sys.argv.remove('--log') or True) + git_clean = '--git-clean' in sys.argv and (sys.argv.remove('--git-clean') or True) + infinite = '--infinite' in sys.argv and (sys.argv.remove('--infinite') or True) - # Setup segfault handler to catch crashes + # Segfault handler def segv_handler(signum, frame): if not no_sound: Sound.play_finished_failed_sound() @@ -99,23 +114,23 @@ if __name__ == "__main__": sys.exit(1) signal.signal(signal.SIGSEGV, segv_handler) - # Play intro sounds + # Play intro sounds asynchronously if not no_sound: threading.Thread(target=play_start_intro, daemon=True).start() - # Change to script directory script_dir = os.path.dirname(os.path.realpath(__file__)) cli_dir = os.path.join(script_dir, "cli") os.chdir(script_dir) - # If requested, clean git-ignored files if git_clean: git_clean_repo() - available_cli_commands = list_cli_commands(cli_dir) + # Collect available commands + available = list_cli_commands(cli_dir) + args = sys.argv[1:] - # Handle help invocation - if len(sys.argv) == 1 or sys.argv[1] in ('-h', '--help'): + # Global help + if not args or args[0] in ('-h', '--help'): print("CyMaIS CLI – proxy to tools in ./cli/") print("Usage: cymais [--no-sound] [--log] [--git-clean] [--infinite] [options]") print("Options:") @@ -125,25 +140,46 @@ if __name__ == "__main__": print(" --infinite Run the proxied command in an infinite loop") print(" -h, --help Show this help message and exit") print("Available commands:") - for cmd in available_cli_commands: - path = os.path.join(cli_dir, f"{cmd}.py") - desc = extract_description_via_help(path) - print(format_command_help(cmd, desc)) + + current_folder = None + for folder, cmd in available: + if folder != current_folder: + if folder: + print(f"{folder}/") + current_folder = folder + desc = extract_description_via_help( + os.path.join(cli_dir, *(folder.split('/') if folder else []), f"{cmd}.py") + ) + # Slight indent for subcommands + print(format_command_help(cmd, desc, indent=2)) sys.exit(0) - # Special-case per-command help - if len(sys.argv) >= 3 and sys.argv[1] in available_cli_commands and sys.argv[2] in ('-h', '--help'): - subprocess.run([sys.executable, os.path.join(cli_dir, f"{sys.argv[1]}.py"), "--help"]) - sys.exit(0) + # Per-command help + for n in range(len(args), 0, -1): + candidate = os.path.join(cli_dir, *args[:n]) + ".py" + if os.path.isfile(candidate) and len(args) > n and args[n] in ('-h', '--help'): + # derive module + rel = os.path.relpath(candidate, cli_dir) + module = "cli." + rel[:-3].replace(os.sep, ".") + subprocess.run([sys.executable, "-m", module, args[n]]) + sys.exit(0) - # Execute chosen command - parser = argparse.ArgumentParser(add_help=False) - parser.add_argument('cli_command', choices=available_cli_commands) - parser.add_argument('cli_args', nargs=argparse.REMAINDER) - args = parser.parse_args() + # Resolve script path by longest matching prefix + script_path = None + cli_args = [] + module = None + for n in range(len(args), 0, -1): + candidate = os.path.join(cli_dir, *args[:n]) + ".py" + if os.path.isfile(candidate): + script_path = candidate + cli_args = args[n:] + rel = os.path.relpath(candidate, cli_dir) + module = "cli." + rel[:-3].replace(os.sep, ".") + break - cmd_path = os.path.join(cli_dir, f"{args.cli_command}.py") - full_cmd = [sys.executable, cmd_path] + args.cli_args + if not module: + print(f"Error: command '{' '.join(args)}' not found.") + sys.exit(1) log_file = None if log_enabled: @@ -152,9 +188,10 @@ if __name__ == "__main__": timestamp = datetime.now().strftime('%Y%m%dT%H%M%S') log_file_path = os.path.join(log_dir, f'{timestamp}.log') log_file = open(log_file_path, 'a', encoding='utf-8') - # 📖 Tip: Check your logs at the path below print(f"📖 Tip: Log file created at {log_file_path}") + full_cmd = [sys.executable, "-m", module] + cli_args + def run_once(): try: if log_enabled: @@ -189,7 +226,7 @@ if __name__ == "__main__": log_file.close() if rc != 0: - print(f"Command '{args.cli_command}' failed with exit code {rc}.") + print(f"Command '{os.path.basename(script_path)}' failed with exit code {rc}.") failure_with_warning_loop() sys.exit(rc) else: @@ -202,7 +239,6 @@ if __name__ == "__main__": sys.exit(1) if infinite: - # ♾️ Infinite mode activated print("♾️ Starting infinite execution mode...") count = 1 while True: diff --git a/tests/integration/test_cli_help.py b/tests/integration/test_cli_help.py new file mode 100644 index 00000000..c6d94b7a --- /dev/null +++ b/tests/integration/test_cli_help.py @@ -0,0 +1,51 @@ +import unittest +import os +import sys +import subprocess + +class CLIHelpIntegrationTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Projekt-Root ermitteln + cls.project_root = os.path.abspath( + os.path.join(os.path.dirname(__file__), '..', '..') + ) + cls.main_py = os.path.join(cls.project_root, 'main.py') + cls.cli_dir = os.path.join(cls.project_root, 'cli') + cls.python = sys.executable + + def test_all_cli_commands_help(self): + """ + Iteriere über alle .py Dateien in cli/, baue daraus die + Subcommand-Pfade und prüfe, dass `python main.py --help` + mit Exit-Code 0 endet. + """ + for root, _, files in os.walk(self.cli_dir): + for fname in files: + if not fname.endswith('.py') or fname.startswith('__'): + continue + + # Bestimme Subcommand-Segmente + rel_dir = os.path.relpath(root, self.cli_dir) + cmd_name = os.path.splitext(fname)[0] + if rel_dir == '.': + segments = [cmd_name] + else: + segments = rel_dir.split(os.sep) + [cmd_name] + + with self.subTest(command=' '.join(segments)): + cmd = [self.python, self.main_py] + segments + ['--help'] + result = subprocess.run( + cmd, capture_output=True, text=True + ) + self.assertEqual( + result.returncode, 0, + msg=( + f"Command `{ ' '.join(cmd) }` failed\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + ) + +if __name__ == '__main__': + unittest.main()