diff --git a/app/cli.py b/app/cli.py index f202821..f20ebb0 100644 --- a/app/cli.py +++ b/app/cli.py @@ -4,7 +4,7 @@ import click import requests -from app.commands import check, download, progress, setup, verify +from app.commands import check, download, progress, repl, setup, verify from app.commands.version import version from app.utils.click import ClickColor, CliContextKey, warn from app.utils.version import Version @@ -53,7 +53,7 @@ def cli(ctx: click.Context, verbose: bool) -> None: def start() -> None: - commands = [check, download, progress, setup, verify, version] + commands = [check, download, progress, repl, setup, verify, version] for command in commands: cli.add_command(command) cli(obj={}) diff --git a/app/commands/__init__.py b/app/commands/__init__.py index 1455c3e..7d59f09 100644 --- a/app/commands/__init__.py +++ b/app/commands/__init__.py @@ -1,8 +1,9 @@ -__all__ = ["check", "download", "progress", "setup", "verify", "version"] +__all__ = ["check", "download", "progress", "repl", "setup", "verify", "version"] from .check import check from .download import download from .progress.progress import progress +from .repl import repl from .setup_folder import setup from .verify import verify from .version import version diff --git a/app/commands/repl.py b/app/commands/repl.py new file mode 100644 index 0000000..8019827 --- /dev/null +++ b/app/commands/repl.py @@ -0,0 +1,217 @@ +import cmd +import os +import shlex +import subprocess +import sys +from typing import List + +import click + +from app.commands.check import check +from app.commands.download import download +from app.commands.progress.progress import progress +from app.commands.setup_folder import setup +from app.commands.verify import verify +from app.commands.version import version +from app.utils.click import CliContextKey, ClickColor +from app.utils.version import Version +from app.version import __version__ + + +GITMASTERY_COMMANDS = { + "check": check, + "download": download, + "progress": progress, + "setup": setup, + "verify": verify, + "version": version, +} + + +class GitMasteryREPL(cmd.Cmd): + """Interactive REPL for Git-Mastery commands.""" + + intro = click.style( + "\nWelcome to the Git-Mastery REPL!\n" + "Type 'help' for available commands, or 'exit' to quit.\n" + "Git-Mastery commands work with or without the 'gitmastery' prefix.\n" + "Shell commands are also supported.\n", + fg=ClickColor.BRIGHT_CYAN, + ) + + def __init__(self) -> None: + super().__init__() + self._update_prompt() + + def _update_prompt(self) -> None: + """Update prompt to show current directory.""" + cwd = os.path.basename(os.getcwd()) or os.getcwd() + self.prompt = click.style(f"gitmastery [{cwd}]> ", fg=ClickColor.BRIGHT_GREEN) + + def postcmd(self, stop: bool, line: str) -> bool: + """Update prompt after each command.""" + self._update_prompt() + return stop + + def precmd(self, line: str) -> str: + """Pre-process command line before execution.""" + # Strip 'gitmastery' prefix if present + stripped = line.strip() + if stripped.startswith("gitmastery "): + return stripped[len("gitmastery ") :] + return line + + def default(self, line: str) -> None: + """Handle commands not recognized by cmd module.""" + try: + parts = shlex.split(line) + except ValueError as e: + click.echo(click.style(f"Input error: {e}", fg=ClickColor.BRIGHT_RED)) + return + + if not parts: + return + + command_name = parts[0] + args = parts[1:] + + if command_name in GITMASTERY_COMMANDS: + self._run_gitmastery_command(command_name, args) + return + + self._run_shell_command(line) + + def _run_gitmastery_command(self, command_name: str, args: List[str]) -> None: + """Execute a gitmastery command.""" + command = GITMASTERY_COMMANDS[command_name] + original_cwd = os.getcwd() + try: + ctx = command.make_context(command_name, args) + ctx.ensure_object(dict) + ctx.obj[CliContextKey.VERBOSE] = False + ctx.obj[CliContextKey.VERSION] = Version.parse_version_string(__version__) + with ctx: + command.invoke(ctx) + except click.ClickException as e: + e.show() + except click.Abort: + click.echo("Aborted.") + except SystemExit: + pass + except Exception as e: + click.echo(click.style(f"Error: {e}", fg=ClickColor.BRIGHT_RED)) + finally: + try: + os.chdir(original_cwd) + except (FileNotFoundError, PermissionError, OSError) as e: + click.echo( + click.style( + f"Warning: Could not restore original directory: {e}", + fg=ClickColor.BRIGHT_YELLOW, + ) + ) + + def _run_shell_command(self, line: str) -> None: + """Execute a shell command via subprocess.""" + try: + result = subprocess.run(line, shell=True) + if result.returncode != 0: + click.echo( + click.style( + f"Command exited with code {result.returncode}", + fg=ClickColor.BRIGHT_YELLOW, + ) + ) + except Exception as e: + click.echo(click.style(f"Shell error: {e}", fg=ClickColor.BRIGHT_RED)) + + def do_cd(self, path: str) -> bool: + """Change directory.""" + if not path: + path = os.path.expanduser("~") + else: + try: + parts = shlex.split(path) + path = parts[0] if parts else "" + except ValueError: + pass + try: + os.chdir(os.path.expanduser(path)) + except FileNotFoundError: + click.echo( + click.style(f"Directory not found: {path}", fg=ClickColor.BRIGHT_RED) + ) + except PermissionError: + click.echo( + click.style(f"Permission denied: {path}", fg=ClickColor.BRIGHT_RED) + ) + return False + + def do_exit(self, arg: str) -> bool: + """Exit the Git-Mastery REPL.""" + click.echo(click.style("Goodbye!", fg=ClickColor.BRIGHT_CYAN)) + return True + + def do_quit(self, arg: str) -> bool: + """Exit the Git-Mastery REPL.""" + return self.do_exit(arg) + + def do_help(self, arg: str) -> bool: # type: ignore[override] + """Show help for commands.""" + if arg: + # Check if it's a gitmastery command + if arg in GITMASTERY_COMMANDS: + command = GITMASTERY_COMMANDS[arg] + click.echo(f"\n{arg}: {command.help or 'No description available.'}\n") + # Show command usage + with click.Context(command) as ctx: + click.echo(command.get_help(ctx)) + return False + # Fall back to cmd module's help + super().do_help(arg) + return False + + # Show general help + click.echo( + click.style("\nGit-Mastery Commands:", bold=True, fg=ClickColor.BRIGHT_CYAN) + ) + for name, command in GITMASTERY_COMMANDS.items(): + help_text = command.help or "No description available." + click.echo(f" {click.style(f'{name:<20}', bold=True)} {help_text}") + + click.echo( + click.style("\nBuilt-in Commands:", bold=True, fg=ClickColor.BRIGHT_CYAN) + ) + click.echo(f" {click.style(f'{'help':<20}', bold=True)} Show this help message") + click.echo(f" {click.style(f'{'exit':<20}', bold=True)} Exit the REPL") + click.echo(f" {click.style(f'{'quit':<20}', bold=True)} Exit the REPL") + + click.echo( + click.style( + "\nAll other commands are passed to the shell.", + fg=ClickColor.BRIGHT_YELLOW, + ) + ) + click.echo() + return False + + def emptyline(self) -> bool: # type: ignore[override] + """Do nothing on empty line (don't repeat last command).""" + return False + + def do_EOF(self, arg: str) -> bool: + """Handle Ctrl+D.""" + click.echo() # Print newline + return self.do_exit(arg) + + +@click.command() +def repl() -> None: + """Start an interactive REPL session.""" + repl_instance = GitMasteryREPL() + + try: + repl_instance.cmdloop() + except KeyboardInterrupt: + click.echo(click.style("\nInterrupted. Goodbye!", fg=ClickColor.BRIGHT_CYAN)) + sys.exit(0) diff --git a/app/utils/gitmastery.py b/app/utils/gitmastery.py index a2f5ff6..a70d00a 100644 --- a/app/utils/gitmastery.py +++ b/app/utils/gitmastery.py @@ -33,6 +33,21 @@ ] +def _clear_exercise_utils_modules() -> None: + """Clear cached exercise_utils modules from sys.modules. + + This is especially important in REPL context where modules persist + between command invocations. + """ + modules_to_remove = [ + key + for key in sys.modules + if key == "exercise_utils" or key.startswith("exercise_utils.") + ] + for mod in modules_to_remove: + del sys.modules[mod] + + class ExercisesRepo: def __init__(self) -> None: """Creates a sparse clone of the exercises repository. @@ -126,6 +141,9 @@ def load_file_as_namespace( py_file = exercises_repo.fetch_file_contents(file_path, False) namespace: Dict[str, Any] = {} + # Clear any cached exercise_utils modules to ensure fresh imports + _clear_exercise_utils_modules() + with tempfile.TemporaryDirectory() as tmpdir: package_root = os.path.join(tmpdir, "exercise_utils") os.makedirs(package_root, exist_ok=True) @@ -142,6 +160,8 @@ def load_file_as_namespace( exec(py_file, namespace) finally: sys.path.remove(tmpdir) + # Clean up cached modules again after execution + _clear_exercise_utils_modules() sys.dont_write_bytecode = False return cls(namespace)