From c7669ef84bd25b8a49f0f601ec550c49f49ac358 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 26 Aug 2025 06:32:20 +0000 Subject: [PATCH] Add type hints, verbose mode, and improve script parsing Co-authored-by: blakeinvictoria --- CHANGELOG.md | 32 ++++++++ README.md | 46 ++++++++++++ pyproject.toml | 2 +- src/argorator/cli.py | 109 +++++++++++++++++++++++++-- tests/test_type_hints.py | 149 +++++++++++++++++++++++++++++++++++++ tests/test_verbose_mode.py | 137 ++++++++++++++++++++++++++++++++++ 6 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 tests/test_type_hints.py create mode 100644 tests/test_verbose_mode.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..529a120 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] - 2024-01-XX + +### Added +- **Type Hints Support**: Add type annotations to variables using special comments (`# @type VAR: type`) + - Supported types: `int`, `float`, `bool`, `str` + - Automatic validation and conversion of command-line arguments + - Boolean values accept multiple formats: true/1/yes/y/on for true, false/0/no/n/off for false +- **Verbose/Debug Mode**: Add `-v` or `--verbose` flag to see detailed parsing information + - Shows detected variables (defined, undefined, environment-backed) + - Displays type hints found in the script + - Logs argument parsing and variable assignments + - Helps troubleshoot script parsing issues + +### Changed +- Updated documentation with examples of new features + +## [0.2.0] - Previous Release + +### Added +- Initial release with core functionality +- Automatic variable detection from shell scripts +- Environment variable defaults +- Positional argument support +- Variable arguments with `$@` support +- Compile and export modes \ No newline at end of file diff --git a/README.md b/README.md index dcfd3b1..fcc33f1 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,29 @@ Processing files: ## 🛠️ Advanced Usage +### Type Hints + +Add type annotations to your variables using special comments. Argorator will validate and convert arguments automatically: + +```bash +#!/bin/bash +# @type COUNT: int +# @type PRICE: float +# @type VERBOSE: bool +# @type NAME: str + +echo "Processing $COUNT items at \$$PRICE each" +if [ "$VERBOSE" = "true" ]; then + echo "Customer: $NAME" +fi +``` + +Supported types: +- `int`: Integer values +- `float`: Floating-point numbers +- `bool`: Boolean values (accepts: true/1/yes/y/on for true, false/0/no/n/off for false) +- `str`: String values (default if no type specified) + ### Compile Mode Generate a standalone script with variables pre-filled: @@ -170,6 +193,29 @@ Generate shell export statements: $ eval "$(argorator export script.sh --var value)" ``` +### Verbose/Debug Mode + +Use `-v` or `--verbose` to see detailed information about how Argorator parses your script: + +```bash +$ argorator -v script.sh --name "Alice" --count 5 +[DEBUG] Reading script: script.sh +[DEBUG] Script size: 123 bytes +[DEBUG] Variables defined in script: [] +[DEBUG] Undefined variables (required): ['COUNT', 'NAME'] +[DEBUG] Environment variables (optional): [] +[DEBUG] Type hints found: {'COUNT': 'int'} +[DEBUG] Building argument parser... +[DEBUG] Parsing arguments: ['--name', 'Alice', '--count', '5'] +[DEBUG] Variable COUNT = 5 +[DEBUG] Variable NAME = Alice +[DEBUG] Executing command: run +[DEBUG] Shell interpreter: /bin/bash +... +``` + +This helps troubleshoot issues and understand how variables are being detected and processed. + ## 🔧 How It Works 1. **Script Analysis**: Argorator parses your shell script to identify variables and positional arguments diff --git a/pyproject.toml b/pyproject.toml index 7dd51c5..a444b05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "argorator" -version = "0.2.0" +version = "0.3.0" description = "CLI to wrap shell scripts and expose variables/positionals as argparse options" readme = "README.md" requires-python = ">=3.9" diff --git a/src/argorator/cli.py b/src/argorator/cli.py index 95e6d45..f024026 100644 --- a/src/argorator/cli.py +++ b/src/argorator/cli.py @@ -17,6 +17,15 @@ SPECIAL_VARS: Set[str] = {"@", "*", "#", "?", "$", "!", "0"} +# Global verbose flag +_verbose = False + + +def debug_print(message: str) -> None: + """Print debug message if verbose mode is enabled.""" + if _verbose: + print(f"[DEBUG] {message}", file=sys.stderr) + def read_text_file(file_path: Path) -> str: """Read and return the file's content as UTF-8 text. @@ -82,6 +91,32 @@ def parse_variable_usages(script_text: str) -> Set[str]: return {name for name in candidates if name and name not in SPECIAL_VARS} +def parse_type_hints(script_text: str) -> Dict[str, str]: + """Extract type hints from special comments in the script. + + Looks for comments like: + # @type VAR: int + # @type VAR: float + # @type VAR: bool + # @type VAR: str (default) + + Args: + script_text: The script content to analyze + + Returns: + Dictionary mapping variable names to their type hints + """ + type_hint_pattern = re.compile(r'^\s*#\s*@type\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(int|float|bool|str)\s*$', re.MULTILINE) + type_hints: Dict[str, str] = {} + + for match in type_hint_pattern.finditer(script_text): + var_name = match.group(1) + var_type = match.group(2) + type_hints[var_name] = var_type + + return type_hints + + def parse_positional_usages(script_text: str) -> Tuple[Set[int], bool]: """Detect positional parameter usage and varargs references in the script. @@ -120,20 +155,35 @@ def determine_variables(script_text: str) -> Tuple[Set[str], Dict[str, Optional[ return defined_vars, undefined_vars, env_vars -def build_dynamic_arg_parser(undefined_vars: Sequence[str], env_vars: Dict[str, str], positional_indices: Set[int], varargs: bool) -> argparse.ArgumentParser: +def build_dynamic_arg_parser(undefined_vars: Sequence[str], env_vars: Dict[str, str], positional_indices: Set[int], varargs: bool, type_hints: Optional[Dict[str, str]] = None) -> argparse.ArgumentParser: """Construct an argparse parser for script-specific variables and positionals. - Undefined variables become required options: --var (lowercase) - Env-backed variables become optional with defaults from the environment - Numeric positional references ($1, $2, ...) become positionals ARG1, ARG2, ... - Varargs ($@ or $*) collects remaining args via an ARGS positional with nargs='*' + - Type hints from comments are applied to validate and convert argument types """ parser = argparse.ArgumentParser(add_help=False) + type_hints = type_hints or {} + + # Helper function to get the type converter + def get_type_converter(var_name: str): + hint = type_hints.get(var_name, 'str') + if hint == 'int': + return int + elif hint == 'float': + return float + elif hint == 'bool': + return lambda x: x.lower() in ('true', '1', 'yes', 'y', 'on') + else: # str or unknown + return str + # Options for variables for name in undefined_vars: - parser.add_argument(f"--{name.lower()}", dest=name, required=True) + parser.add_argument(f"--{name.lower()}", dest=name, required=True, type=get_type_converter(name)) for name, value in env_vars.items(): - parser.add_argument(f"--{name.lower()}", dest=name, default=value, required=False) + parser.add_argument(f"--{name.lower()}", dest=name, default=value, required=False, type=get_type_converter(name)) # Positional arguments for index in sorted(positional_indices): parser.add_argument(f"ARG{index}") @@ -186,16 +236,20 @@ def run_script_with_args(shell_cmd: List[str], script_text: str, positional_args def build_top_level_parser() -> argparse.ArgumentParser: """Build the top-level argparse parser with run/compile/export subcommands.""" parser = argparse.ArgumentParser(prog="argorator", description="Execute or compile shell scripts with CLI-exposed variables") + parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose/debug output") subparsers = parser.add_subparsers(dest="subcmd") # run run_parser = subparsers.add_parser("run", help="Run script (default)") run_parser.add_argument("script", help="Path to the shell script") + run_parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose/debug output") # compile compile_parser = subparsers.add_parser("compile", help="Print modified script") compile_parser.add_argument("script", help="Path to the shell script") + compile_parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose/debug output") # export export_parser = subparsers.add_parser("export", help="Print export lines") export_parser.add_argument("script", help="Path to the shell script") + export_parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose/debug output") return parser @@ -208,7 +262,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int: 3) Parse script to discover variables/positionals and build a dynamic parser 4) Execute command: run/compile/export """ + global _verbose argv = list(argv) if argv is not None else sys.argv[1:] + + # Check for verbose flag early + verbose_in_argv = "-v" in argv or "--verbose" in argv + # If first token is a known subcommand, parse with subparsers; otherwise treat as implicit run subcommands = {"run", "compile", "export"} if argv and argv[0] in subcommands: @@ -217,6 +276,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int: command = ns.subcmd or "run" script_arg: Optional[str] = getattr(ns, "script", None) rest_args: List[str] = unknown + _verbose = getattr(ns, "verbose", False) if script_arg is None: print("error: script path is required", file=sys.stderr) return 2 @@ -224,14 +284,23 @@ def main(argv: Optional[Sequence[str]] = None) -> int: # Implicit run path: use a minimal parser to capture script and remainder implicit = argparse.ArgumentParser(prog="argorator", add_help=True, description="Execute or compile shell scripts with CLI-exposed variables") implicit.add_argument("script", help="Path to the shell script") + implicit.add_argument("-v", "--verbose", action="store_true", help="Enable verbose/debug output") implicit.add_argument("rest", nargs=argparse.REMAINDER, help=argparse.SUPPRESS) try: - in_ns = implicit.parse_args(argv) + # Extract verbose flag before parsing remainder + temp_argv = argv.copy() + if verbose_in_argv: + temp_argv = [arg for arg in temp_argv if arg not in ["-v", "--verbose"]] + _verbose = True + in_ns = implicit.parse_args(temp_argv) except SystemExit as exc: return int(exc.code) command = "run" script_arg = in_ns.script + # Filter out verbose flags from rest args rest_args = list(in_ns.rest or []) + if verbose_in_argv: + rest_args = [arg for arg in rest_args if arg not in ["-v", "--verbose"]] # Validate and normalize script path script_path = Path(script_arg).expanduser() try: @@ -242,14 +311,33 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if not script_path.exists() or not script_path.is_file(): print(f"error: script not found: {script_path}", file=sys.stderr) return 2 + + debug_print(f"Reading script: {script_path}") script_text = read_text_file(script_path) + debug_print(f"Script size: {len(script_text)} bytes") + # Parse script defined_vars, undefined_vars_map, env_vars = determine_variables(script_text) + debug_print(f"Variables defined in script: {sorted(defined_vars)}") + debug_print(f"Undefined variables (required): {sorted(undefined_vars_map.keys())}") + debug_print(f"Environment variables (optional): {sorted(env_vars.keys())}") + positional_indices, varargs = parse_positional_usages(script_text) + if positional_indices: + debug_print(f"Positional arguments used: ${', $'.join(str(i) for i in sorted(positional_indices))}") + if varargs: + debug_print("Varargs detected: $@ or $*") + + type_hints = parse_type_hints(script_text) + if type_hints: + debug_print(f"Type hints found: {type_hints}") + # Build dynamic parser undefined_names = sorted(undefined_vars_map.keys()) - dyn_parser = build_dynamic_arg_parser(undefined_names, env_vars, positional_indices, varargs) + debug_print("Building argument parser...") + dyn_parser = build_dynamic_arg_parser(undefined_names, env_vars, positional_indices, varargs, type_hints) try: + debug_print(f"Parsing arguments: {rest_args}") dyn_ns = dyn_parser.parse_args(rest_args) except SystemExit as exc: return int(exc.code) @@ -261,9 +349,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int: print(f"error: missing required --{name}", file=sys.stderr) return 2 assignments[name] = str(value) + debug_print(f"Variable {name} = {value}") for name in env_vars.keys(): value = getattr(dyn_ns, name, env_vars[name]) assignments[name] = str(value) + debug_print(f"Variable {name} = {value} (from {'argument' if value != env_vars[name] else 'environment'})") # Collect positional args for shell invocation positional_values: List[str] = [] for index in sorted(positional_indices): @@ -273,9 +363,14 @@ def main(argv: Optional[Sequence[str]] = None) -> int: print(f"error: missing positional argument ${index}", file=sys.stderr) return 2 positional_values.append(str(value)) + debug_print(f"Positional ${index} = {value}") if varargs: - positional_values.extend([str(v) for v in getattr(dyn_ns, "ARGS", [])]) + varargs_values = [str(v) for v in getattr(dyn_ns, "ARGS", [])] + positional_values.extend(varargs_values) + if varargs_values: + debug_print(f"Varargs = {varargs_values}") # Prepare outputs per command + debug_print(f"Executing command: {command}") if command == "export": print(generate_export_lines(assignments)) return 0 @@ -285,6 +380,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int: return 0 # run shell_cmd = detect_shell_interpreter(script_text) + debug_print(f"Shell interpreter: {' '.join(shell_cmd)}") + debug_print(f"Positional arguments: {positional_values}") return run_script_with_args(shell_cmd, modified_text, positional_values) diff --git a/tests/test_type_hints.py b/tests/test_type_hints.py new file mode 100644 index 0000000..9695c76 --- /dev/null +++ b/tests/test_type_hints.py @@ -0,0 +1,149 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +from argorator import cli + + +def write_script(tmp_path: Path, name: str, content: str) -> Path: + path = tmp_path / name + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + return path + + +def test_parse_type_hints(): + """Test that type hints are correctly parsed from comments.""" + script = """#!/bin/bash +# @type COUNT: int +# @type PRICE: float +# @type ENABLED: bool +# @type NAME: str + +echo "Count: $COUNT" +echo "Price: $PRICE" +echo "Enabled: $ENABLED" +echo "Name: $NAME" +""" + hints = cli.parse_type_hints(script) + assert hints == { + "COUNT": "int", + "PRICE": "float", + "ENABLED": "bool", + "NAME": "str" + } + + +def test_type_conversion_int(tmp_path: Path): + """Test integer type conversion.""" + script = write_script(tmp_path, "int_test.sh", """#!/bin/bash +# @type AGE: int +echo "Age: $AGE" +""") + + # Valid int + rc = cli.main(["run", str(script), "--age", "25"]) + assert rc == 0 + + # Invalid int should fail + rc = cli.main(["run", str(script), "--age", "not_a_number"]) + assert rc == 2 + + +def test_type_conversion_float(tmp_path: Path): + """Test float type conversion.""" + script = write_script(tmp_path, "float_test.sh", """#!/bin/bash +# @type PRICE: float +echo "Price: $PRICE" +""") + + # Valid float + rc = cli.main(["run", str(script), "--price", "19.99"]) + assert rc == 0 + + # Integer should also work for float + rc = cli.main(["run", str(script), "--price", "20"]) + assert rc == 0 + + # Invalid float should fail + rc = cli.main(["run", str(script), "--price", "abc"]) + assert rc == 2 + + +def test_type_conversion_bool(tmp_path: Path): + """Test boolean type conversion.""" + script = write_script(tmp_path, "bool_test.sh", """#!/bin/bash +# @type DEBUG: bool +if [ "$DEBUG" = "true" ]; then + echo "Debug is ON" +else + echo "Debug is OFF" +fi +""") + + # Test various true values + for value in ["true", "True", "TRUE", "1", "yes", "Yes", "y", "on"]: + output = subprocess.check_output( + [sys.executable, "-m", "argorator.cli", str(script), "--debug", value], + text=True + ) + assert "Debug is ON" in output + + # Test various false values + for value in ["false", "False", "0", "no", "n", "off"]: + output = subprocess.check_output( + [sys.executable, "-m", "argorator.cli", str(script), "--debug", value], + text=True + ) + assert "Debug is OFF" in output + + +def test_mixed_types(tmp_path: Path): + """Test script with multiple typed variables.""" + script = write_script(tmp_path, "mixed_test.sh", """#!/bin/bash +# @type ITERATIONS: int +# @type THRESHOLD: float +# @type VERBOSE: bool + +echo "Running $ITERATIONS iterations" +echo "Threshold: $THRESHOLD" +if [ "$VERBOSE" = "true" ]; then + echo "Verbose mode enabled" +fi +""") + + output = subprocess.check_output( + [sys.executable, "-m", "argorator.cli", str(script), + "--iterations", "10", "--threshold", "0.95", "--verbose", "yes"], + text=True + ) + + assert "Running 10 iterations" in output + assert "Threshold: 0.95" in output + assert "Verbose mode enabled" in output + + +def test_type_hints_with_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Test that type hints work with environment variable defaults.""" + monkeypatch.setenv("MAX_RETRIES", "3") + + script = write_script(tmp_path, "env_test.sh", """#!/bin/bash +# @type MAX_RETRIES: int +echo "Max retries: $MAX_RETRIES" +""") + + # Should use env var default + output = subprocess.check_output( + [sys.executable, "-m", "argorator.cli", str(script)], + text=True + ) + assert "Max retries: 3" in output + + # Should override with typed value + output = subprocess.check_output( + [sys.executable, "-m", "argorator.cli", str(script), "--max_retries", "5"], + text=True + ) + assert "Max retries: 5" in output \ No newline at end of file diff --git a/tests/test_verbose_mode.py b/tests/test_verbose_mode.py new file mode 100644 index 0000000..dc30331 --- /dev/null +++ b/tests/test_verbose_mode.py @@ -0,0 +1,137 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +from argorator import cli + + +def write_script(tmp_path: Path, name: str, content: str) -> Path: + path = tmp_path / name + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + return path + + +def test_verbose_flag_parsing(): + """Test that verbose flag is properly parsed.""" + # Test with run subcommand + rc = cli.main(["run", "-v", "--help"]) + assert rc == 0 + + # Test with compile subcommand + rc = cli.main(["compile", "--verbose", "--help"]) + assert rc == 0 + + # Test with export subcommand + rc = cli.main(["export", "-v", "--help"]) + assert rc == 0 + + +def test_verbose_output_shows_debug_info(tmp_path: Path): + """Test that verbose mode produces debug output.""" + script = write_script(tmp_path, "verbose_test.sh", """#!/bin/bash +# @type COUNT: int +echo "Hello $NAME" +echo "Count: $COUNT" +""") + + # Run with verbose flag and capture stderr + result = subprocess.run( + [sys.executable, "-m", "argorator.cli", "-v", str(script), + "--name", "Test", "--count", "42"], + capture_output=True, + text=True + ) + + assert result.returncode == 0 + stderr = result.stderr + + # Check for expected debug messages + assert "[DEBUG] Reading script:" in stderr + assert "[DEBUG] Script size:" in stderr + assert "[DEBUG] Undefined variables (required): ['COUNT', 'NAME']" in stderr + assert "[DEBUG] Type hints found: {'COUNT': 'int'}" in stderr + assert "[DEBUG] Variable COUNT = 42" in stderr + assert "[DEBUG] Variable NAME = Test" in stderr + assert "[DEBUG] Executing command: run" in stderr + + +def test_verbose_with_implicit_run(tmp_path: Path): + """Test verbose mode works with implicit run (no subcommand).""" + script = write_script(tmp_path, "implicit_test.sh", """#!/bin/bash +echo "Value: $VALUE" +""") + + # Run without explicit 'run' subcommand + result = subprocess.run( + [sys.executable, "-m", "argorator.cli", str(script), "-v", "--value", "123"], + capture_output=True, + text=True + ) + + assert result.returncode == 0 + assert "[DEBUG]" in result.stderr + assert "Value: 123" in result.stdout + + +def test_verbose_with_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Test verbose mode shows environment variable info.""" + monkeypatch.setenv("DEFAULT_USER", "admin") + + script = write_script(tmp_path, "env_test.sh", """#!/bin/bash +echo "User: $DEFAULT_USER" +""") + + # Run with verbose to see env var detection + result = subprocess.run( + [sys.executable, "-m", "argorator.cli", "-v", str(script)], + capture_output=True, + text=True + ) + + assert result.returncode == 0 + assert "[DEBUG] Environment variables (optional): ['DEFAULT_USER']" in result.stderr + assert "[DEBUG] Variable DEFAULT_USER = admin (from environment)" in result.stderr + + +def test_verbose_with_positionals(tmp_path: Path): + """Test verbose mode with positional arguments.""" + script = write_script(tmp_path, "pos_test.sh", """#!/bin/bash +echo "Args: $1 $2" +echo "All: $@" +""") + + result = subprocess.run( + [sys.executable, "-m", "argorator.cli", "-v", str(script), "first", "second", "third"], + capture_output=True, + text=True + ) + + assert result.returncode == 0 + stderr = result.stderr + + assert "[DEBUG] Positional arguments used: $1, $2" in stderr + assert "[DEBUG] Varargs detected: $@ or $*" in stderr + assert "[DEBUG] Positional $1 = first" in stderr + assert "[DEBUG] Positional $2 = second" in stderr + assert "[DEBUG] Varargs = ['third']" in stderr + + +def test_no_verbose_no_debug_output(tmp_path: Path): + """Test that without verbose flag, no debug output is shown.""" + script = write_script(tmp_path, "quiet_test.sh", """#!/bin/bash +echo "Hello $NAME" +""") + + result = subprocess.run( + [sys.executable, "-m", "argorator.cli", str(script), "--name", "Test"], + capture_output=True, + text=True + ) + + assert result.returncode == 0 + assert "[DEBUG]" not in result.stderr + assert result.stderr == "" # Should be empty + assert "Hello Test" in result.stdout \ No newline at end of file