diff --git a/README.md b/README.md index a6c5ac9..b25a620 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ optional arguments: $ argorator deploy.sh --service api --environment prod --version v1.2.3 Deploying api to prod Version: v1.2.3 + +# Or use JSON input +$ argorator deploy.sh --json-input '{"service": "api", "environment": "prod", "version": "v1.2.3"}' +Deploying api to prod +Version: v1.2.3 ``` That's it! No modifications needed to your script. @@ -185,6 +190,35 @@ Processing files: ## 🛠️ Advanced Usage +### JSON Input + +Provide all parameters as a JSON object instead of individual arguments: + +```bash +# Via --json-input option +$ argorator script.sh --json-input '{"name": "Alice", "age": 30, "city": "NYC"}' + +# Via stdin (useful for automation) +$ echo '{"name": "Bob", "age": 25}' | argorator script.sh + +# From a file +$ argorator script.sh --json-input "$(cat params.json)" +``` + +This is especially useful for: +- Automation and CI/CD pipelines +- Configuration management +- Programmatic script execution + +Scripts can opt out of JSON input by adding a directive comment: + +```bash +#!/bin/bash +# argorator: no-json-input + +echo "This script doesn't accept JSON input" +``` + ### Compile Mode Generate a standalone script with variables pre-filled: diff --git a/src/argorator/cli.py b/src/argorator/cli.py index 2ee888c..5771dda 100644 --- a/src/argorator/cli.py +++ b/src/argorator/cli.py @@ -6,6 +6,7 @@ export lines. """ import argparse +import json import os import re import shlex @@ -18,6 +19,47 @@ SPECIAL_VARS: Set[str] = {"@", "*", "#", "?", "$", "!", "0"} +def check_json_input_allowed(script_text: str) -> bool: + """Check if the script allows JSON input. + + Scripts can opt out of JSON input by including the directive comment: + # argorator: no-json-input + + Args: + script_text: The script content to check + + Returns: + True if JSON input is allowed, False if opted out + """ + # Check for opt-out directive + directive_pattern = re.compile(r'^\s*#\s*argorator:\s*no-json-input', re.MULTILINE | re.IGNORECASE) + return not bool(directive_pattern.search(script_text)) + + +def parse_json_input(json_str: str) -> Dict[str, any]: + """Parse JSON input string into a dictionary. + + The JSON should contain key-value pairs where keys correspond to + variable names or positional arguments (ARG1, ARG2, ARGS). + + Args: + json_str: JSON string to parse + + Returns: + Dictionary mapping argument names to values + + Raises: + ValueError: If JSON is invalid + """ + try: + data = json.loads(json_str) + if not isinstance(data, dict): + raise ValueError("JSON input must be an object") + return data + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON input: {e}") + + def read_text_file(file_path: Path) -> str: """Read and return the file's content as UTF-8 text. @@ -120,15 +162,78 @@ 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: +class ArgoratorHelpFormatter(argparse.HelpFormatter): + """Custom help formatter that adds JSON input examples.""" + + def _format_usage(self, usage, actions, groups, prefix): + """Override to add JSON input information to usage.""" + # Get the default usage + usage_text = super()._format_usage(usage, actions, groups, prefix) + + # Check if JSON input is enabled + has_json_input = any(action.dest == 'json_input' for action in actions) + if not has_json_input: + return usage_text + + # Add JSON input usage examples + prog = self._prog + json_usage = f"\n\nAlternatively, parameters can be provided as JSON:\n" + json_usage += f" {prog} --json-input '{{\"name\": \"value\"}}'\n" + json_usage += f" echo '{{\"name\": \"value\"}}' | {prog}" + + return usage_text.rstrip() + json_usage + "\n" + + +class ArgoratorArgumentParser(argparse.ArgumentParser): + """Custom argument parser that handles JSON input specially.""" + + def __init__(self, *args, **kwargs): + self.json_input_allowed = kwargs.pop('json_input_allowed', True) + super().__init__(*args, **kwargs) + + def parse_args(self, args=None, namespace=None): + """Override to handle JSON input before checking required arguments.""" + # Check if --json-input is present in args + if args and self.json_input_allowed and '--json-input' in args: + # Create a copy of actions and make them all not required temporarily + original_required = {} + for action in self._actions: + if action.required: + original_required[action] = True + action.required = False + + try: + # Parse with all arguments optional + namespace = super().parse_args(args, namespace) + # Restore required flags + for action, was_required in original_required.items(): + action.required = was_required + return namespace + except SystemExit: + # Restore required flags even on error + for action, was_required in original_required.items(): + action.required = was_required + raise + else: + return super().parse_args(args, namespace) + + +def build_dynamic_arg_parser(undefined_vars: Sequence[str], env_vars: Dict[str, str], positional_indices: Set[int], varargs: bool, json_input_allowed: bool = True) -> 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='*' + - If json_input_allowed, adds --json-input option for JSON parameter input """ - parser = argparse.ArgumentParser(add_help=True) + parser = ArgoratorArgumentParser(add_help=True, formatter_class=ArgoratorHelpFormatter, json_input_allowed=json_input_allowed) + + # Add JSON input option if allowed + if json_input_allowed: + parser.add_argument("--json-input", type=str, metavar="JSON", + help="Provide parameters as a JSON object (can also be piped via stdin)") + # Options for variables for name in undefined_vars: parser.add_argument(f"--{name.lower()}", dest=name, required=True) @@ -249,19 +354,67 @@ def main(argv: Optional[Sequence[str]] = None) -> int: positional_indices, varargs = parse_positional_usages(script_text) # Build dynamic parser undefined_names = sorted(undefined_vars_map.keys()) - dyn_parser = build_dynamic_arg_parser(undefined_names, env_vars, positional_indices, varargs) - try: - dyn_ns = dyn_parser.parse_args(rest_args) - except SystemExit as exc: - return int(exc.code) + json_input_allowed = check_json_input_allowed(script_text) + dyn_parser = build_dynamic_arg_parser(undefined_names, env_vars, positional_indices, varargs, json_input_allowed) + + # Check if JSON input is provided via stdin first + json_data = {} + json_from_stdin = False + if json_input_allowed and not sys.stdin.isatty() and not rest_args: + # Try to read JSON from stdin if no args provided + try: + stdin_data = sys.stdin.read() + if stdin_data.strip(): + json_data = parse_json_input(stdin_data) + json_from_stdin = True + except ValueError as e: + print(f"error: failed to parse stdin as JSON: {e}", file=sys.stderr) + return 2 + + # Parse arguments, but handle JSON input specially + if json_from_stdin: + # Create a minimal namespace with just the JSON data + dyn_ns = argparse.Namespace() + else: + try: + dyn_ns = dyn_parser.parse_args(rest_args) + except SystemExit as exc: + return int(exc.code) + + # Handle JSON input if provided via --json-input + if json_input_allowed and hasattr(dyn_ns, 'json_input') and dyn_ns.json_input: + try: + json_data = parse_json_input(dyn_ns.json_input) + except ValueError as e: + print(f"error: {e}", file=sys.stderr) + return 2 + + # Apply JSON data to namespace + if json_data: + for key, value in json_data.items(): + # Convert lowercase keys to match variable names + # Handle both lowercase (--name) and uppercase (NAME) formats + attr_name = key + # Check if this is a known variable name + if key.upper() in undefined_names or key.upper() in env_vars: + attr_name = key.upper() + elif key in undefined_names or key in env_vars: + attr_name = key + # Handle positional arguments (ARG1, ARG2, ARGS) + elif key.upper().startswith('ARG') or key == 'ARGS' or key.upper() == 'ARGS': + attr_name = key.upper() + + setattr(dyn_ns, attr_name, value) + # Collect resolved variable assignments assignments: Dict[str, str] = {} for name in undefined_names: value = getattr(dyn_ns, name, None) - if value is None: - print(f"error: missing required --{name}", file=sys.stderr) + if value is None and not json_data: + print(f"error: missing required --{name.lower()}", file=sys.stderr) return 2 - assignments[name] = str(value) + elif value is not None: + assignments[name] = str(value) for name in env_vars.keys(): value = getattr(dyn_ns, name, env_vars[name]) assignments[name] = str(value) @@ -270,12 +423,15 @@ def main(argv: Optional[Sequence[str]] = None) -> int: for index in sorted(positional_indices): attr = f"ARG{index}" value = getattr(dyn_ns, attr, None) - if value is None: + if value is None and not json_data: print(f"error: missing positional argument ${index}", file=sys.stderr) return 2 - positional_values.append(str(value)) + elif value is not None: + positional_values.append(str(value)) if varargs: - positional_values.extend([str(v) for v in getattr(dyn_ns, "ARGS", [])]) + args_value = getattr(dyn_ns, "ARGS", []) + if args_value: + positional_values.extend([str(v) for v in args_value]) # Prepare outputs per command if command == "export": print(generate_export_lines(assignments)) diff --git a/tests/test_json_input.py b/tests/test_json_input.py new file mode 100644 index 0000000..2ddf012 --- /dev/null +++ b/tests/test_json_input.py @@ -0,0 +1,241 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from argorator import cli + + +SCRIPT_WITH_VARS = """#!/bin/bash +echo "Name: $NAME" +echo "Age: $AGE" +echo "City: $CITY" +""" + +SCRIPT_WITH_POSITIONALS = """#!/bin/bash +echo "First: $1" +echo "Second: $2" +echo "Rest: $@" +""" + +SCRIPT_WITH_ENV_VARS = """#!/bin/bash +echo "Home: $HOME" +echo "Custom: $CUSTOM_VAR" +""" + +SCRIPT_NO_JSON = """#!/bin/bash +# argorator: no-json-input +echo "Name: $NAME" +""" + +SCRIPT_STDIN_EXPECTED = """#!/bin/bash +# This script expects stdin input +read line +echo "Read from stdin: $line" +echo "Variable: $VAR" +""" + + +def write_temp_script(tmp_path: Path, content: str) -> Path: + path = tmp_path / "test_script.sh" + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + return path + + +def test_json_input_option_with_vars(tmp_path: Path, capsys): + """Test JSON input via --json-input option with variables.""" + script = write_temp_script(tmp_path, SCRIPT_WITH_VARS) + json_data = {"NAME": "Alice", "AGE": "30", "CITY": "NYC"} + + # Test with compile to verify variable injection + rc = cli.main(["compile", str(script), "--json-input", json.dumps(json_data)]) + assert rc == 0 + + captured = capsys.readouterr() + assert "NAME=Alice" in captured.out + assert "AGE=30" in captured.out + assert "CITY=NYC" in captured.out + + +def test_json_input_option_with_positionals(tmp_path: Path, capsys): + """Test JSON input with positional arguments.""" + script = write_temp_script(tmp_path, SCRIPT_WITH_POSITIONALS) + json_data = {"ARG1": "first", "ARG2": "second", "ARGS": ["extra1", "extra2"]} + + # Use subprocess to capture output from run command + import subprocess + result = subprocess.run( + [sys.executable, "-m", "argorator.cli", str(script), "--json-input", json.dumps(json_data)], + capture_output=True, text=True + ) + assert result.returncode == 0 + assert "First: first" in result.stdout + assert "Second: second" in result.stdout + assert "extra1 extra2" in result.stdout + + +def test_json_input_stdin(tmp_path: Path, monkeypatch, capsys): + """Test JSON input via stdin.""" + script = write_temp_script(tmp_path, SCRIPT_WITH_VARS) + json_data = {"NAME": "Bob", "AGE": "25", "CITY": "LA"} + + # Mock stdin + import io + monkeypatch.setattr('sys.stdin', io.StringIO(json.dumps(json_data))) + monkeypatch.setattr('sys.stdin.isatty', lambda: False) + + # Use compile to verify variable injection + rc = cli.main(["compile", str(script)]) + assert rc == 0 + + captured = capsys.readouterr() + assert "NAME=Bob" in captured.out + assert "AGE=25" in captured.out + assert "CITY=LA" in captured.out + + +def test_json_input_lowercase_keys(tmp_path: Path, capsys): + """Test JSON input with lowercase keys.""" + script = write_temp_script(tmp_path, SCRIPT_WITH_VARS) + json_data = {"name": "Charlie", "age": "40", "city": "SF"} + + rc = cli.main(["compile", str(script), "--json-input", json.dumps(json_data)]) + assert rc == 0 + + captured = capsys.readouterr() + assert "NAME=Charlie" in captured.out + assert "AGE=40" in captured.out + assert "CITY=SF" in captured.out + + +def test_json_input_with_env_defaults(tmp_path: Path, monkeypatch, capsys): + """Test JSON input with environment variable defaults.""" + monkeypatch.setenv("HOME", "/home/test") + script = write_temp_script(tmp_path, SCRIPT_WITH_ENV_VARS) + json_data = {"CUSTOM_VAR": "custom_value"} + + rc = cli.main(["compile", str(script), "--json-input", json.dumps(json_data)]) + assert rc == 0 + + captured = capsys.readouterr() + assert "HOME=/home/test" in captured.out + assert "CUSTOM_VAR=custom_value" in captured.out + + +def test_json_input_override_env(tmp_path: Path, monkeypatch, capsys): + """Test JSON input overriding environment variables.""" + monkeypatch.setenv("HOME", "/home/test") + script = write_temp_script(tmp_path, SCRIPT_WITH_ENV_VARS) + json_data = {"HOME": "/home/override", "CUSTOM_VAR": "custom"} + + rc = cli.main(["compile", str(script), "--json-input", json.dumps(json_data)]) + assert rc == 0 + + captured = capsys.readouterr() + assert "HOME=/home/override" in captured.out + assert "CUSTOM_VAR=custom" in captured.out + + +def test_no_json_directive(tmp_path: Path, capsys): + """Test that no-json-input directive disables JSON input.""" + script = write_temp_script(tmp_path, SCRIPT_NO_JSON) + + # Verify --json-input is not in help when disabled + rc = cli.main([str(script), "--help"]) + assert rc == 0 + captured = capsys.readouterr() + assert "--json-input" not in captured.out + + # Should work with regular arguments + rc = cli.main(["compile", str(script), "--name", "Test"]) + assert rc == 0 + + captured = capsys.readouterr() + assert "NAME=Test" in captured.out + + +def test_invalid_json_input(tmp_path: Path, capsys): + """Test error handling for invalid JSON.""" + script = write_temp_script(tmp_path, SCRIPT_WITH_VARS) + + rc = cli.main([str(script), "--json-input", "invalid json"]) + assert rc == 2 + + captured = capsys.readouterr() + assert "error:" in captured.err + assert "Invalid JSON" in captured.err + + +def test_json_input_non_object(tmp_path: Path, capsys): + """Test error for JSON that's not an object.""" + script = write_temp_script(tmp_path, SCRIPT_WITH_VARS) + + rc = cli.main([str(script), "--json-input", '["array", "not", "object"]']) + assert rc == 2 + + captured = capsys.readouterr() + assert "error:" in captured.err + assert "must be an object" in captured.err + + +def test_json_help_text(tmp_path: Path, capsys): + """Test that JSON input option appears in help.""" + script = write_temp_script(tmp_path, SCRIPT_WITH_VARS) + + # Help always exits with 0 + rc = cli.main([str(script), "--help"]) + assert rc == 0 + + captured = capsys.readouterr() + assert "--json-input" in captured.out + assert "JSON object" in captured.out + assert "via stdin" in captured.out + assert "Alternatively, parameters can be provided as JSON:" in captured.out + + +def test_json_help_text_disabled(tmp_path: Path, capsys): + """Test that JSON input doesn't appear in help when disabled.""" + script = write_temp_script(tmp_path, SCRIPT_NO_JSON) + + rc = cli.main([str(script), "--help"]) + assert rc == 0 + + captured = capsys.readouterr() + assert "--json-input" not in captured.out + assert "Alternatively, parameters can be provided as JSON:" not in captured.out + + +def test_check_json_input_allowed(): + """Test the check_json_input_allowed function.""" + # Should allow by default + assert cli.check_json_input_allowed("#!/bin/bash\necho test") is True + + # Should disallow with directive + assert cli.check_json_input_allowed("#!/bin/bash\n# argorator: no-json-input\necho test") is False + + # Case insensitive + assert cli.check_json_input_allowed("#!/bin/bash\n# ARGORATOR: NO-JSON-INPUT\necho test") is False + + # With spaces + assert cli.check_json_input_allowed("#!/bin/bash\n# argorator: no-json-input \necho test") is False + + +def test_parse_json_input(): + """Test the parse_json_input function.""" + # Valid JSON + data = cli.parse_json_input('{"key": "value", "num": 42}') + assert data == {"key": "value", "num": 42} + + # Invalid JSON + with pytest.raises(ValueError) as exc_info: + cli.parse_json_input("not json") + assert "Invalid JSON" in str(exc_info.value) + + # Non-object JSON + with pytest.raises(ValueError) as exc_info: + cli.parse_json_input('["array"]') + assert "must be an object" in str(exc_info.value) \ No newline at end of file