From d96351ea53afb613f352fc262b4a7b8f626ef1f6 Mon Sep 17 00:00:00 2001 From: aemous Date: Mon, 20 Oct 2025 13:42:02 -0400 Subject: [PATCH 01/10] Initial architecture and code for aws-cli-linter. --- awsclilinter/.gitignore | 27 ++++ awsclilinter/Makefile | 21 +++ awsclilinter/README.md | 104 ++++++++++++ awsclilinter/awsclilinter/__init__.py | 1 + awsclilinter/awsclilinter/cli.py | 108 +++++++++++++ awsclilinter/awsclilinter/linter.py | 33 ++++ awsclilinter/awsclilinter/rules/__init__.py | 3 + .../awsclilinter/rules/base64_rule.py | 52 ++++++ awsclilinter/awsclilinter/rules_base.py | 36 +++++ awsclilinter/examples/upload_s3_files.sh | 13 ++ awsclilinter/pyproject.toml | 29 ++++ awsclilinter/requirements-dev.lock | 11 ++ awsclilinter/requirements-dev.txt | 4 + awsclilinter/requirements.lock | 1 + awsclilinter/requirements.txt | 1 + awsclilinter/tests/__init__.py | 0 awsclilinter/tests/test_cli.py | 150 ++++++++++++++++++ awsclilinter/tests/test_linter.py | 43 +++++ awsclilinter/tests/test_rules.py | 41 +++++ 19 files changed, 678 insertions(+) create mode 100644 awsclilinter/.gitignore create mode 100644 awsclilinter/Makefile create mode 100644 awsclilinter/README.md create mode 100644 awsclilinter/awsclilinter/__init__.py create mode 100644 awsclilinter/awsclilinter/cli.py create mode 100644 awsclilinter/awsclilinter/linter.py create mode 100644 awsclilinter/awsclilinter/rules/__init__.py create mode 100644 awsclilinter/awsclilinter/rules/base64_rule.py create mode 100644 awsclilinter/awsclilinter/rules_base.py create mode 100644 awsclilinter/examples/upload_s3_files.sh create mode 100644 awsclilinter/pyproject.toml create mode 100644 awsclilinter/requirements-dev.lock create mode 100644 awsclilinter/requirements-dev.txt create mode 100644 awsclilinter/requirements.lock create mode 100644 awsclilinter/requirements.txt create mode 100644 awsclilinter/tests/__init__.py create mode 100644 awsclilinter/tests/test_cli.py create mode 100644 awsclilinter/tests/test_linter.py create mode 100644 awsclilinter/tests/test_rules.py diff --git a/awsclilinter/.gitignore b/awsclilinter/.gitignore new file mode 100644 index 000000000000..c491fb8c56a9 --- /dev/null +++ b/awsclilinter/.gitignore @@ -0,0 +1,27 @@ +*.py[co] +*.DS_Store + +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +.pytest_cache/ + +# Unit test / coverage reports +.coverage +htmlcov/ + +# Virtualenvs +.venv/ +venv/ +env/ + +# Keep lockfiles +!*.lock + +# Pyenv +.python-version diff --git a/awsclilinter/Makefile b/awsclilinter/Makefile new file mode 100644 index 000000000000..d880ce1bfce4 --- /dev/null +++ b/awsclilinter/Makefile @@ -0,0 +1,21 @@ +.PHONY: setup test format lint clean + +setup: + ./setup.sh + +test: + pytest tests/ -v + +format: + black awsclilinter tests + isort awsclilinter tests + +lint: + black --check awsclilinter tests + isort --check awsclilinter tests + +clean: + rm -rf venv + rm -rf build dist *.egg-info + find . -type d -name __pycache__ -exec rm -rf {} + + find . -type f -name "*.pyc" -delete diff --git a/awsclilinter/README.md b/awsclilinter/README.md new file mode 100644 index 000000000000..6dc8ad722eac --- /dev/null +++ b/awsclilinter/README.md @@ -0,0 +1,104 @@ +# AWS CLI Linter + +A CLI tool that lints bash scripts for AWS CLI v1 usage and updates them to avoid breaking changes introduced in AWS CLI v2. + +## Installation + +1. Create a virtual environment: +```bash +python3.12 -m venv venv +source venv/bin/activate +``` + +2. Install dependencies: +```bash +pip install -r requirements.txt +# Or use lockfile for reproducible builds: +pip install -r requirements.lock +``` + +3. Install the package in development mode: +```bash +pip install -e . +``` + +## Usage + +### Dry-run mode (default) +Display issues without modifying the script: +```bash +upgrade-aws-cli --script upload_s3_files.sh +``` + +### Fix mode +Automatically update the input script: +```bash +upgrade-aws-cli --script upload_s3_files.sh --fix +``` + +### Output mode +Create a new fixed script without modifying the original: +```bash +upgrade-aws-cli --script upload_s3_files.sh --output upload_s3_files_v2.sh +``` + +### Interactive mode +Review and accept/reject each change individually: +```bash +upgrade-aws-cli --script upload_s3_files.sh --interactive --output upload_s3_files_v2.sh +``` + +In interactive mode, you can: +- Press `y` to accept the current change +- Press `n` to skip the current change +- Press `u` to accept all remaining changes +- Press `x` to cancel and exit + +Note: `--interactive` requires either `--output` to specify where to write changes, or no output flag for dry-run. It cannot be used with `--fix`. + +## Development + +### Running tests +```bash +pytest +``` + +### Code formatting +```bash +black awsclilinter tests +isort awsclilinter tests +``` + +## Adding New Linting Rules + +To add a new linting rule: + +1. Create a new rule class in `awsclilinter/rules/` that inherits from `LintRule` +2. Implement the required methods: `name`, `description`, and `check` +3. Add the rule to the rules list in `awsclilinter/cli.py` + +Example: +```python +from awsclilinter.rules_base import LintRule, LintFinding + +class MyCustomRule(LintRule): + @property + def name(self) -> str: + return "my-custom-rule" + + @property + def description(self) -> str: + return "Description of what this rule checks" + + def check(self, root) -> List[LintFinding]: + # Implementation using ast-grep + pass +``` + +## Architecture + +- `rules_base.py`: Base classes for linting rules (`LintRule`, `LintFinding`) +- `rules/`: Directory containing individual linting rule implementations +- `linter.py`: Main `ScriptLinter` class that orchestrates rule checking +- `cli.py`: CLI interface using argparse +- `tests/`: Unit tests using pytest diff --git a/awsclilinter/awsclilinter/__init__.py b/awsclilinter/awsclilinter/__init__.py new file mode 100644 index 000000000000..3dc1f76bc69e --- /dev/null +++ b/awsclilinter/awsclilinter/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/awsclilinter/awsclilinter/cli.py b/awsclilinter/awsclilinter/cli.py new file mode 100644 index 000000000000..9ed039598a5c --- /dev/null +++ b/awsclilinter/awsclilinter/cli.py @@ -0,0 +1,108 @@ +import argparse +import sys +from pathlib import Path +from typing import List + +from awsclilinter.linter import ScriptLinter +from awsclilinter.rules.base64_rule import Base64BinaryFormatRule +from awsclilinter.rules_base import LintFinding + + +def get_user_choice(prompt: str) -> str: + """Get user input for interactive mode.""" + while True: + choice = input(prompt).lower().strip() + if choice in ["y", "n", "u", "x"]: + return choice + print("Invalid choice. Please enter y, n, u, or x.") + + +def display_finding(finding: LintFinding, index: int, total: int): + """Display a finding to the user.""" + print(f"\n[{index}/{total}] {finding.rule_name}") + print(f"Lines {finding.line_start}-{finding.line_end}: {finding.description}") + print(f"\nOriginal:\n {finding.original_text}") + print(f"\nSuggested:\n {finding.suggested_fix}") + + +def interactive_mode(findings: List[LintFinding]) -> List[LintFinding]: + """Run interactive mode and return accepted findings.""" + accepted = [] + for i, finding in enumerate(findings, 1): + display_finding(finding, i, len(findings)) + choice = get_user_choice("\nApply this fix? [y]es, [n]o, [u]pdate all, [x] cancel: ") + + if choice == "y": + accepted.append(finding) + elif choice == "u": + accepted.extend(findings[i - 1 :]) + break + elif choice == "x": + print("Cancelled.") + sys.exit(0) + + return accepted + + +def main(): + """Main entry point for the CLI tool.""" + parser = argparse.ArgumentParser( + description="Lint and upgrade bash scripts from AWS CLI v1 to v2" + ) + parser.add_argument("--script", required=True, help="Path to the bash script to lint") + parser.add_argument( + "--fix", action="store_true", help="Apply fixes to the script (modifies in place)" + ) + parser.add_argument("--output", help="Output path for the fixed script") + parser.add_argument( + "-i", + "--interactive", + action="store_true", + help="Interactive mode to review each change", + ) + + args = parser.parse_args() + + if args.fix and args.output: + print("Error: Cannot use both --fix and --output") + sys.exit(1) + + if args.fix and args.interactive: + print("Error: Cannot use both --fix and --interactive") + sys.exit(1) + + script_path = Path(args.script) + if not script_path.exists(): + print(f"Error: Script not found: {args.script}") + sys.exit(1) + + script_content = script_path.read_text() + + rules = [Base64BinaryFormatRule()] + linter = ScriptLinter(rules) + findings = linter.lint(script_content) + + if not findings: + print("No issues found.") + return + + if args.interactive: + findings = interactive_mode(findings) + if not findings: + print("No changes accepted.") + return + + if args.fix or args.output: + fixed_content = linter.apply_fixes(script_content, findings) + output_path = Path(args.output) if args.output else script_path + output_path.write_text(fixed_content) + print(f"Fixed script written to: {output_path}") + else: + print(f"\nFound {len(findings)} issue(s):\n") + for i, finding in enumerate(findings, 1): + display_finding(finding, i, len(findings)) + print("\n\nRun with --fix to apply changes or --interactive to review each change.") + + +if __name__ == "__main__": + main() diff --git a/awsclilinter/awsclilinter/linter.py b/awsclilinter/awsclilinter/linter.py new file mode 100644 index 000000000000..ceacf7bafd15 --- /dev/null +++ b/awsclilinter/awsclilinter/linter.py @@ -0,0 +1,33 @@ +from typing import List + +from ast_grep_py import SgRoot + +from awsclilinter.rules_base import LintFinding, LintRule + + +class ScriptLinter: + """Linter for bash scripts to detect AWS CLI v1 to v2 migration issues.""" + + def __init__(self, rules: List[LintRule]): + self.rules = rules + + def lint(self, script_content: str) -> List[LintFinding]: + """Lint the script and return all findings.""" + root = SgRoot(script_content, "bash") + findings = [] + for rule in self.rules: + findings.extend(rule.check(root)) + return sorted(findings, key=lambda f: (f.line_start, f.line_end)) + + def apply_fixes(self, script_content: str, findings: List[LintFinding]) -> str: + """Apply fixes to the script content.""" + root = SgRoot(script_content, "bash") + node = root.root() + + edits = [] + for finding in findings: + matches = node.find_all(pattern=finding.original_text) + for match in matches: + edits.append(match.replace(finding.suggested_fix)) + + return node.commit_edits(edits) diff --git a/awsclilinter/awsclilinter/rules/__init__.py b/awsclilinter/awsclilinter/rules/__init__.py new file mode 100644 index 000000000000..323cd7a7e44a --- /dev/null +++ b/awsclilinter/awsclilinter/rules/__init__.py @@ -0,0 +1,3 @@ +from awsclilinter.rules_base import LintFinding, LintRule + +__all__ = ["LintRule", "LintFinding"] diff --git a/awsclilinter/awsclilinter/rules/base64_rule.py b/awsclilinter/awsclilinter/rules/base64_rule.py new file mode 100644 index 000000000000..a6c46dc42958 --- /dev/null +++ b/awsclilinter/awsclilinter/rules/base64_rule.py @@ -0,0 +1,52 @@ +from typing import List + +from awsclilinter.rules_base import LintFinding, LintRule + + +class Base64BinaryFormatRule(LintRule): + """Detects AWS CLI commands with file:// that need --cli-binary-format.""" + + @property + def name(self) -> str: + return "base64-binary-format" + + @property + def description(self) -> str: + return ( + "AWS CLI v2 requires --cli-binary-format raw-in-base64-out " + "for commands using file:// protocol" + ) + + def check(self, root) -> List[LintFinding]: + """Check for AWS CLI commands with file:// missing --cli-binary-format.""" + node = root.root() + base64_broken_nodes = node.find_all( + all=[ + {"kind": "command"}, + {"pattern": "aws $SERVICE $OPERATION $$$ARGS"}, + {"has": {"kind": "word", "regex": r"\Afile://"}}, + {"not": {"has": {"kind": "word", "pattern": "--cli-binary-format"}}}, + ] + ) + + findings = [] + for stmt in base64_broken_nodes: + service = stmt.get_match("SERVICE").text() + operation = stmt.get_match("OPERATION").text() + args = " ".join([match.text() for match in stmt.get_multiple_matches("ARGS")]) + + original = stmt.text() + suggested = f"aws {service} {operation} {args} --cli-binary-format raw-in-base64-out" + + findings.append( + LintFinding( + line_start=stmt.range().start.line, + line_end=stmt.range().end.line, + original_text=original, + suggested_fix=suggested, + rule_name=self.name, + description=self.description, + ) + ) + + return findings diff --git a/awsclilinter/awsclilinter/rules_base.py b/awsclilinter/awsclilinter/rules_base.py new file mode 100644 index 000000000000..cb175b4cc441 --- /dev/null +++ b/awsclilinter/awsclilinter/rules_base.py @@ -0,0 +1,36 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import List + + +@dataclass +class LintFinding: + """Represents a linting issue found in the script.""" + + line_start: int + line_end: int + original_text: str + suggested_fix: str + rule_name: str + description: str + + +class LintRule(ABC): + """Base class for all linting rules.""" + + @property + @abstractmethod + def name(self) -> str: + """Return the name of the rule.""" + pass + + @property + @abstractmethod + def description(self) -> str: + """Return a description of what the rule checks.""" + pass + + @abstractmethod + def check(self, root) -> List[LintFinding]: + """Check the AST root for violations and return findings.""" + pass diff --git a/awsclilinter/examples/upload_s3_files.sh b/awsclilinter/examples/upload_s3_files.sh new file mode 100644 index 000000000000..3fd428cf721a --- /dev/null +++ b/awsclilinter/examples/upload_s3_files.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Example script with AWS CLI v1 patterns + +# TODO update examples to commands that specify file:// for blob-type params + +# This command needs --cli-binary-format flag +aws s3api put-object --bucket mybucket --key mykey --body file://data.json + +# This command also needs the flag +aws dynamodb put-item --table-name mytable --item file://item.json + +# This command doesn't use file:// so it's fine +aws s3 ls s3://mybucket diff --git a/awsclilinter/pyproject.toml b/awsclilinter/pyproject.toml new file mode 100644 index 000000000000..41a6d043212b --- /dev/null +++ b/awsclilinter/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "awsclilinter" +version = "0.1.0" +description = "CLI tool to lint and upgrade bash scripts from AWS CLI v1 to v2" +requires-python = ">=3.12" +dependencies = [ + "ast-grep-py>=0.39.6", +] + +[project.scripts] +upgrade-aws-cli = "awsclilinter.cli:main" + +[tool.black] +line-length = 100 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 100 + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] diff --git a/awsclilinter/requirements-dev.lock b/awsclilinter/requirements-dev.lock new file mode 100644 index 000000000000..ffbcaf6b35e4 --- /dev/null +++ b/awsclilinter/requirements-dev.lock @@ -0,0 +1,11 @@ +ast-grep-py==0.39.6 +black==24.1.1 +click==8.3.0 +iniconfig==2.3.0 +isort==5.13.2 +mypy_extensions==1.1.0 +packaging==25.0 +pathspec==0.12.1 +platformdirs==4.5.0 +pluggy==1.6.0 +pytest==8.0.0 diff --git a/awsclilinter/requirements-dev.txt b/awsclilinter/requirements-dev.txt new file mode 100644 index 000000000000..c5abd4669027 --- /dev/null +++ b/awsclilinter/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==8.0.0 +black==24.1.1 +isort==5.13.2 diff --git a/awsclilinter/requirements.lock b/awsclilinter/requirements.lock new file mode 100644 index 000000000000..63578da3ceea --- /dev/null +++ b/awsclilinter/requirements.lock @@ -0,0 +1 @@ +ast-grep-py==0.39.6 diff --git a/awsclilinter/requirements.txt b/awsclilinter/requirements.txt new file mode 100644 index 000000000000..63578da3ceea --- /dev/null +++ b/awsclilinter/requirements.txt @@ -0,0 +1 @@ +ast-grep-py==0.39.6 diff --git a/awsclilinter/tests/__init__.py b/awsclilinter/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/awsclilinter/tests/test_cli.py b/awsclilinter/tests/test_cli.py new file mode 100644 index 000000000000..f59d5d963b81 --- /dev/null +++ b/awsclilinter/tests/test_cli.py @@ -0,0 +1,150 @@ +from unittest.mock import mock_open, patch + +import pytest + +from awsclilinter.cli import main + + +class TestCLI: + """Test cases for CLI interface.""" + + def test_script_not_found(self, capsys): + """Test error when script file doesn't exist.""" + with patch("sys.argv", ["upgrade-aws-cli", "--script", "nonexistent.sh"]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + def test_fix_and_output_conflict(self, capsys): + """Test error when both --fix and --output are provided.""" + with patch( + "sys.argv", ["upgrade-aws-cli", "--script", "test.sh", "--fix", "--output", "out.sh"] + ): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + def test_fix_and_interactive_conflict(self, capsys): + """Test error when both --fix and --interactive are provided.""" + with patch( + "sys.argv", ["upgrade-aws-cli", "--script", "test.sh", "--fix", "--interactive"] + ): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 1 + + def test_no_issues_found(self, tmp_path, capsys): + """Test output when no issues are found.""" + script_file = tmp_path / "test.sh" + script_file.write_text("echo 'hello world'") + + with patch("sys.argv", ["upgrade-aws-cli", "--script", str(script_file)]): + main() + captured = capsys.readouterr() + assert "No issues found" in captured.out + + def test_dry_run_mode(self, tmp_path, capsys): + """Test dry run mode displays findings.""" + script_file = tmp_path / "test.sh" + script_file.write_text("aws s3api put-object --bucket mybucket --body file://data.json") + + with patch("sys.argv", ["upgrade-aws-cli", "--script", str(script_file)]): + main() + captured = capsys.readouterr() + assert "Found" in captured.out + assert "issue" in captured.out + + def test_fix_mode(self, tmp_path): + """Test fix mode modifies the script.""" + script_file = tmp_path / "test.sh" + script_file.write_text("aws s3api put-object --bucket mybucket --body file://data.json") + + with patch("sys.argv", ["upgrade-aws-cli", "--script", str(script_file), "--fix"]): + main() + fixed_content = script_file.read_text() + assert "--cli-binary-format" in fixed_content + + def test_output_mode(self, tmp_path): + """Test output mode creates new file.""" + script_file = tmp_path / "test.sh" + output_file = tmp_path / "output.sh" + script_file.write_text("aws s3api put-object --bucket mybucket --body file://data.json") + + with patch( + "sys.argv", + ["upgrade-aws-cli", "--script", str(script_file), "--output", str(output_file)], + ): + main() + assert output_file.exists() + assert "--cli-binary-format" in output_file.read_text() + + def test_interactive_mode_accept_all(self, tmp_path): + """Test interactive mode with 'y' to accept all changes.""" + script_file = tmp_path / "test.sh" + output_file = tmp_path / "output.sh" + script_file.write_text( + "aws s3api put-object --bucket mybucket --body file://data.json\naws dynamodb put-item --table mytable --item file://item.json" + ) + + with patch( + "sys.argv", + [ + "upgrade-aws-cli", + "--script", + str(script_file), + "--interactive", + "--output", + str(output_file), + ], + ): + with patch("builtins.input", side_effect=["y", "y"]): + main() + fixed_content = output_file.read_text() + assert fixed_content.count("--cli-binary-format") == 2 + + def test_interactive_mode_reject_all(self, tmp_path, capsys): + """Test interactive mode with 'n' to reject all changes.""" + script_file = tmp_path / "test.sh" + original = "aws s3api put-object --bucket mybucket --body file://data.json" + script_file.write_text(original) + + with patch("sys.argv", ["upgrade-aws-cli", "--script", str(script_file), "--interactive"]): + with patch("builtins.input", return_value="n"): + main() + captured = capsys.readouterr() + assert "No changes accepted" in captured.out + + def test_interactive_mode_update_all(self, tmp_path): + """Test interactive mode with 'u' to accept remaining changes.""" + script_file = tmp_path / "test.sh" + output_file = tmp_path / "output.sh" + script_file.write_text( + "aws s3api put-object --bucket mybucket --body file://data.json\naws dynamodb put-item --table mytable --item file://item.json" + ) + + with patch( + "sys.argv", + [ + "upgrade-aws-cli", + "--script", + str(script_file), + "--interactive", + "--output", + str(output_file), + ], + ): + with patch("builtins.input", return_value="u"): + main() + fixed_content = output_file.read_text() + assert fixed_content.count("--cli-binary-format") == 2 + + def test_interactive_mode_cancel(self, tmp_path): + """Test interactive mode with 'x' to cancel.""" + script_file = tmp_path / "test.sh" + script_file.write_text("aws s3api put-object --bucket mybucket --body file://data.json") + + with patch("sys.argv", ["upgrade-aws-cli", "--script", str(script_file), "--interactive"]): + with patch("builtins.input", return_value="x"): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 diff --git a/awsclilinter/tests/test_linter.py b/awsclilinter/tests/test_linter.py new file mode 100644 index 000000000000..57ba93934e40 --- /dev/null +++ b/awsclilinter/tests/test_linter.py @@ -0,0 +1,43 @@ +from awsclilinter.linter import ScriptLinter +from awsclilinter.rules.base64_rule import Base64BinaryFormatRule + + +class TestScriptLinter: + """Test cases for ScriptLinter.""" + + def test_lint_finds_issues(self): + """Test that linter finds issues in script.""" + script = "aws s3api put-object --bucket mybucket --key mykey --body file://data.json" + linter = ScriptLinter([Base64BinaryFormatRule()]) + findings = linter.lint(script) + + assert len(findings) == 1 + assert findings[0].rule_name == "base64-binary-format" + assert "file://" in findings[0].original_text + + def test_lint_no_issues(self): + """Test that linter returns no findings for compliant script.""" + script = "aws s3api put-object --bucket mybucket --key mykey --body file://data.json --cli-binary-format raw-in-base64-out" + linter = ScriptLinter([Base64BinaryFormatRule()]) + findings = linter.lint(script) + + assert len(findings) == 0 + + def test_apply_fixes(self): + """Test that fixes are applied correctly.""" + script = "aws s3api put-object --bucket mybucket --key mykey --body file://data.json" + linter = ScriptLinter([Base64BinaryFormatRule()]) + findings = linter.lint(script) + fixed = linter.apply_fixes(script, findings) + + assert "--cli-binary-format raw-in-base64-out" in fixed + assert "file://data.json" in fixed + + def test_multiple_issues(self): + """Test linter with multiple issues.""" + script = """aws s3api put-object --bucket mybucket --key mykey --body file://data.json +aws dynamodb put-item --table-name mytable --item file://item.json""" + linter = ScriptLinter([Base64BinaryFormatRule()]) + findings = linter.lint(script) + + assert len(findings) == 2 diff --git a/awsclilinter/tests/test_rules.py b/awsclilinter/tests/test_rules.py new file mode 100644 index 000000000000..ca7e4a49d394 --- /dev/null +++ b/awsclilinter/tests/test_rules.py @@ -0,0 +1,41 @@ +from ast_grep_py import SgRoot + +from awsclilinter.rules.base64_rule import Base64BinaryFormatRule + + +class TestBase64BinaryFormatRule: + """Test cases for Base64BinaryFormatRule.""" + + def test_rule_properties(self): + """Test rule name and description.""" + rule = Base64BinaryFormatRule() + assert rule.name == "base64-binary-format" + assert "cli-binary-format" in rule.description + + def test_detects_missing_flag(self): + """Test detection of missing --cli-binary-format flag.""" + script = "aws s3api put-object --bucket mybucket --body file://data.json" + root = SgRoot(script, "bash") + rule = Base64BinaryFormatRule() + findings = rule.check(root) + + assert len(findings) == 1 + assert "--cli-binary-format" in findings[0].suggested_fix + + def test_no_detection_with_flag(self): + """Test no detection when flag is present.""" + script = "aws s3api put-object --bucket mybucket --body file://data.json --cli-binary-format raw-in-base64-out" + root = SgRoot(script, "bash") + rule = Base64BinaryFormatRule() + findings = rule.check(root) + + assert len(findings) == 0 + + def test_no_detection_without_file_protocol(self): + """Test no detection when file:// is not used.""" + script = "aws s3api put-object --bucket mybucket --body data.json" + root = SgRoot(script, "bash") + rule = Base64BinaryFormatRule() + findings = rule.check(root) + + assert len(findings) == 0 From 1ed785aa67f43109d1d4d1933a98d273f24f1f62 Mon Sep 17 00:00:00 2001 From: aemous Date: Tue, 21 Oct 2025 11:02:19 -0400 Subject: [PATCH 02/10] Progress. --- awsclilinter/Makefile | 17 +++- awsclilinter/README.md | 52 +++--------- awsclilinter/awsclilinter/cli.py | 79 +++++++++++++++---- awsclilinter/awsclilinter/linter.py | 9 +-- .../awsclilinter/rules/base64_rule.py | 21 ++--- awsclilinter/awsclilinter/rules_base.py | 5 +- awsclilinter/examples/upload_s3_files.sh | 13 ++- awsclilinter/pyproject.toml | 4 +- awsclilinter/requirements-dev.lock | 11 --- awsclilinter/requirements.lock | 1 - awsclilinter/tests/test_cli.py | 4 +- awsclilinter/tests/test_linter.py | 2 +- awsclilinter/tests/test_rules.py | 2 +- 13 files changed, 118 insertions(+), 102 deletions(-) delete mode 100644 awsclilinter/requirements-dev.lock delete mode 100644 awsclilinter/requirements.lock diff --git a/awsclilinter/Makefile b/awsclilinter/Makefile index d880ce1bfce4..ebd7170e2265 100644 --- a/awsclilinter/Makefile +++ b/awsclilinter/Makefile @@ -1,7 +1,22 @@ .PHONY: setup test format lint clean setup: - ./setup.sh + @echo "Setting up AWS CLI Linter..." + @if [ ! -d "venv" ]; then \ + echo "Creating virtual environment..."; \ + python3.12 -m venv venv; \ + fi + @echo "Installing dependencies..." + @. venv/bin/activate && pip install --upgrade pip + @if [ -f "requirements-dev-lock.txt" ]; then \ + echo "Using lockfile for reproducible builds..."; \ + . venv/bin/activate && pip install -r requirements-dev-lock.txt; \ + else \ + . venv/bin/activate && pip install -r requirements-dev.txt; \ + fi + @echo "Installing package..." + @. venv/bin/activate && pip install -e . + @echo "Setup complete! Activate the virtual environment with: source venv/bin/activate" test: pytest tests/ -v diff --git a/awsclilinter/README.md b/awsclilinter/README.md index 6dc8ad722eac..65345e0572c9 100644 --- a/awsclilinter/README.md +++ b/awsclilinter/README.md @@ -1,6 +1,11 @@ # AWS CLI Linter -A CLI tool that lints bash scripts for AWS CLI v1 usage and updates them to avoid breaking changes introduced in AWS CLI v2. +A CLI tool that lints bash scripts for AWS CLI v1 usage and updates them to avoid breaking +changes introduced in AWS CLI v2. Not all of the breaking changes can be detected statically, +thus not all of them are supported by this tool. + +For a full list of the breaking changes introduced with AWS CLI v2, see +[Breaking changes between AWS CLI version 1 and AWS CLI version 2](https://docs.aws.amazon.com/cli/latest/userguide/cliv2-migration-changes.html#cliv2-migration-changes-breaking). ## Installation @@ -13,8 +18,6 @@ source venv/bin/activate 2. Install dependencies: ```bash pip install -r requirements.txt -# Or use lockfile for reproducible builds: -pip install -r requirements.lock ``` 3. Install the package in development mode: @@ -52,53 +55,16 @@ In interactive mode, you can: - Press `y` to accept the current change - Press `n` to skip the current change - Press `u` to accept all remaining changes -- Press `x` to cancel and exit - -Note: `--interactive` requires either `--output` to specify where to write changes, or no output flag for dry-run. It cannot be used with `--fix`. +- Press `q` to cancel and quit ## Development ### Running tests ```bash -pytest +make test ``` ### Code formatting ```bash -black awsclilinter tests -isort awsclilinter tests -``` - -## Adding New Linting Rules - -To add a new linting rule: - -1. Create a new rule class in `awsclilinter/rules/` that inherits from `LintRule` -2. Implement the required methods: `name`, `description`, and `check` -3. Add the rule to the rules list in `awsclilinter/cli.py` - -Example: -```python -from awsclilinter.rules_base import LintRule, LintFinding - -class MyCustomRule(LintRule): - @property - def name(self) -> str: - return "my-custom-rule" - - @property - def description(self) -> str: - return "Description of what this rule checks" - - def check(self, root) -> List[LintFinding]: - # Implementation using ast-grep - pass +make format ``` - -## Architecture - -- `rules_base.py`: Base classes for linting rules (`LintRule`, `LintFinding`) -- `rules/`: Directory containing individual linting rule implementations -- `linter.py`: Main `ScriptLinter` class that orchestrates rule checking -- `cli.py`: CLI interface using argparse -- `tests/`: Unit tests using pytest diff --git a/awsclilinter/awsclilinter/cli.py b/awsclilinter/awsclilinter/cli.py index 9ed039598a5c..d1ef881e70ed 100644 --- a/awsclilinter/awsclilinter/cli.py +++ b/awsclilinter/awsclilinter/cli.py @@ -7,37 +7,84 @@ from awsclilinter.rules.base64_rule import Base64BinaryFormatRule from awsclilinter.rules_base import LintFinding +# ANSI color codes +RED = "\033[31m" +GREEN = "\033[32m" +CYAN = "\033[36m" +RESET = "\033[0m" + def get_user_choice(prompt: str) -> str: """Get user input for interactive mode.""" while True: choice = input(prompt).lower().strip() - if choice in ["y", "n", "u", "x"]: + if choice in ["y", "n", "u", "q"]: return choice - print("Invalid choice. Please enter y, n, u, or x.") - + print("Invalid choice. Please enter y, n, u, or q.") + + +def display_finding(finding: LintFinding, index: int, total: int, script_content: str): + """Display a finding to the user with context.""" + lines = script_content.splitlines() + dest_lines = finding.edit.inserted_text.splitlines() + start_line = finding.line_start + end_line = finding.line_end + src_lines_removed = end_line - start_line + 1 + new_lines_added = len(dest_lines) + + # Create a map from line numbers to their indices within the full script file + line_positions = [] + pos = 0 + for i, line in enumerate(lines): + line_positions.append((pos, pos + len(line))) + pos += len(line) + 1 + + # Get context lines (3 before and 3 after) + context_start = max(0, start_line - 3) + context_end = min(len(lines), end_line + 4) + src_context_size = context_end - context_start + dest_context_size = src_context_size + (new_lines_added - src_lines_removed) -def display_finding(finding: LintFinding, index: int, total: int): - """Display a finding to the user.""" print(f"\n[{index}/{total}] {finding.rule_name}") - print(f"Lines {finding.line_start}-{finding.line_end}: {finding.description}") - print(f"\nOriginal:\n {finding.original_text}") - print(f"\nSuggested:\n {finding.suggested_fix}") + print(f"{finding.description}") + print( + f"\n{CYAN}@@ -{context_start + 1},{src_context_size} " + f"+{context_start + 1},{dest_context_size} @@{RESET}" + ) + + for i in range(context_start, context_end): + line = lines[i] if i < len(lines) else "" + + if start_line <= i <= end_line: + # This line is actually being modified + print(f"{RED}-{line}{RESET}") + + if i == end_line: + line_start_pos, _ = line_positions[i] + start_pos_in_line = max(0, finding.edit.start_pos - line_start_pos) + end_pos_in_line = min(len(line), finding.edit.end_pos - line_start_pos) + new_line = line[:start_pos_in_line] + finding.suggested_fix + line[end_pos_in_line:] + # Print the new line suggestion. The line number will always be the start line + # returned by ast-grep. + print(f"{GREEN}+{new_line}{RESET}") + else: + # Context line + print(f"{line}") -def interactive_mode(findings: List[LintFinding]) -> List[LintFinding]: +def interactive_mode(findings: List[LintFinding], script_content: str) -> List[LintFinding]: """Run interactive mode and return accepted findings.""" accepted = [] for i, finding in enumerate(findings, 1): - display_finding(finding, i, len(findings)) - choice = get_user_choice("\nApply this fix? [y]es, [n]o, [u]pdate all, [x] cancel: ") + display_finding(finding, i, len(findings), script_content) + choice = get_user_choice("\nApply this fix? [y]es, [n]o, [u]pdate all, [q]uit: ") if choice == "y": accepted.append(finding) elif choice == "u": accepted.extend(findings[i - 1 :]) break - elif choice == "x": + elif choice == "q": print("Cancelled.") sys.exit(0) @@ -87,12 +134,14 @@ def main(): return if args.interactive: - findings = interactive_mode(findings) + findings = interactive_mode(findings, script_content) if not findings: print("No changes accepted.") return - if args.fix or args.output: + if args.fix or args.output or args.interactive: + # Interactive mode is functionally equivalent to --fix, except the user + # can select a subset of the changes to apply. fixed_content = linter.apply_fixes(script_content, findings) output_path = Path(args.output) if args.output else script_path output_path.write_text(fixed_content) @@ -100,7 +149,7 @@ def main(): else: print(f"\nFound {len(findings)} issue(s):\n") for i, finding in enumerate(findings, 1): - display_finding(finding, i, len(findings)) + display_finding(finding, i, len(findings), script_content) print("\n\nRun with --fix to apply changes or --interactive to review each change.") diff --git a/awsclilinter/awsclilinter/linter.py b/awsclilinter/awsclilinter/linter.py index ceacf7bafd15..f9f5885d0b09 100644 --- a/awsclilinter/awsclilinter/linter.py +++ b/awsclilinter/awsclilinter/linter.py @@ -23,11 +23,4 @@ def apply_fixes(self, script_content: str, findings: List[LintFinding]) -> str: """Apply fixes to the script content.""" root = SgRoot(script_content, "bash") node = root.root() - - edits = [] - for finding in findings: - matches = node.find_all(pattern=finding.original_text) - for match in matches: - edits.append(match.replace(finding.suggested_fix)) - - return node.commit_edits(edits) + return node.commit_edits([f.edit for f in findings]) diff --git a/awsclilinter/awsclilinter/rules/base64_rule.py b/awsclilinter/awsclilinter/rules/base64_rule.py index a6c46dc42958..728433e4ac4b 100644 --- a/awsclilinter/awsclilinter/rules/base64_rule.py +++ b/awsclilinter/awsclilinter/rules/base64_rule.py @@ -1,5 +1,7 @@ from typing import List +from ast_grep_py.ast_grep_py import SgRoot + from awsclilinter.rules_base import LintFinding, LintRule @@ -8,16 +10,17 @@ class Base64BinaryFormatRule(LintRule): @property def name(self) -> str: - return "base64-binary-format" + return "binary-params-base64" @property def description(self) -> str: return ( - "AWS CLI v2 requires --cli-binary-format raw-in-base64-out " - "for commands using file:// protocol" + "In AWS CLI v2, an input parameter typed as binary large object (BLOB) expects " + "the input to be base64-encoded. To retain v1 behavior after upgrading to AWS CLI v2, " + "add `--cli-binary-format raw-in-base64-out`." ) - def check(self, root) -> List[LintFinding]: + def check(self, root: SgRoot) -> List[LintFinding]: """Check for AWS CLI commands with file:// missing --cli-binary-format.""" node = root.root() base64_broken_nodes = node.find_all( @@ -31,17 +34,17 @@ def check(self, root) -> List[LintFinding]: findings = [] for stmt in base64_broken_nodes: - service = stmt.get_match("SERVICE").text() - operation = stmt.get_match("OPERATION").text() - args = " ".join([match.text() for match in stmt.get_multiple_matches("ARGS")]) - original = stmt.text() - suggested = f"aws {service} {operation} {args} --cli-binary-format raw-in-base64-out" + # To retain v1 behavior after migrating to v2, append + # --cli-binary-format raw-in-base64-out + suggested = original + " --cli-binary-format raw-in-base64-out" + edit = stmt.replace(suggested) findings.append( LintFinding( line_start=stmt.range().start.line, line_end=stmt.range().end.line, + edit=edit, original_text=original, suggested_fix=suggested, rule_name=self.name, diff --git a/awsclilinter/awsclilinter/rules_base.py b/awsclilinter/awsclilinter/rules_base.py index cb175b4cc441..991ae5b99e58 100644 --- a/awsclilinter/awsclilinter/rules_base.py +++ b/awsclilinter/awsclilinter/rules_base.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from typing import List +from ast_grep_py.ast_grep_py import Edit, SgRoot + @dataclass class LintFinding: @@ -9,6 +11,7 @@ class LintFinding: line_start: int line_end: int + edit: Edit original_text: str suggested_fix: str rule_name: str @@ -31,6 +34,6 @@ def description(self) -> str: pass @abstractmethod - def check(self, root) -> List[LintFinding]: + def check(self, root: SgRoot) -> List[LintFinding]: """Check the AST root for violations and return findings.""" pass diff --git a/awsclilinter/examples/upload_s3_files.sh b/awsclilinter/examples/upload_s3_files.sh index 3fd428cf721a..ed38ffff9a60 100644 --- a/awsclilinter/examples/upload_s3_files.sh +++ b/awsclilinter/examples/upload_s3_files.sh @@ -1,13 +1,12 @@ #!/bin/bash # Example script with AWS CLI v1 patterns -# TODO update examples to commands that specify file:// for blob-type params +aws secretsmanager put-secret-value --secret-id secret1213 \ + --secret-binary file://data.json -# This command needs --cli-binary-format flag -aws s3api put-object --bucket mybucket --key mykey --body file://data.json +if + aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json ; then + echo "command succeeded." +fi -# This command also needs the flag -aws dynamodb put-item --table-name mytable --item file://item.json - -# This command doesn't use file:// so it's fine aws s3 ls s3://mybucket diff --git a/awsclilinter/pyproject.toml b/awsclilinter/pyproject.toml index 41a6d043212b..454ea39e7c68 100644 --- a/awsclilinter/pyproject.toml +++ b/awsclilinter/pyproject.toml @@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta" [project] name = "awsclilinter" -version = "0.1.0" +version = "1.0.0" description = "CLI tool to lint and upgrade bash scripts from AWS CLI v1 to v2" -requires-python = ">=3.12" +requires-python = ">=3.9" dependencies = [ "ast-grep-py>=0.39.6", ] diff --git a/awsclilinter/requirements-dev.lock b/awsclilinter/requirements-dev.lock deleted file mode 100644 index ffbcaf6b35e4..000000000000 --- a/awsclilinter/requirements-dev.lock +++ /dev/null @@ -1,11 +0,0 @@ -ast-grep-py==0.39.6 -black==24.1.1 -click==8.3.0 -iniconfig==2.3.0 -isort==5.13.2 -mypy_extensions==1.1.0 -packaging==25.0 -pathspec==0.12.1 -platformdirs==4.5.0 -pluggy==1.6.0 -pytest==8.0.0 diff --git a/awsclilinter/requirements.lock b/awsclilinter/requirements.lock deleted file mode 100644 index 63578da3ceea..000000000000 --- a/awsclilinter/requirements.lock +++ /dev/null @@ -1 +0,0 @@ -ast-grep-py==0.39.6 diff --git a/awsclilinter/tests/test_cli.py b/awsclilinter/tests/test_cli.py index f59d5d963b81..fb7c72653a8e 100644 --- a/awsclilinter/tests/test_cli.py +++ b/awsclilinter/tests/test_cli.py @@ -1,4 +1,4 @@ -from unittest.mock import mock_open, patch +from unittest.mock import patch import pytest @@ -144,7 +144,7 @@ def test_interactive_mode_cancel(self, tmp_path): script_file.write_text("aws s3api put-object --bucket mybucket --body file://data.json") with patch("sys.argv", ["upgrade-aws-cli", "--script", str(script_file), "--interactive"]): - with patch("builtins.input", return_value="x"): + with patch("builtins.input", return_value="q"): with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 0 diff --git a/awsclilinter/tests/test_linter.py b/awsclilinter/tests/test_linter.py index 57ba93934e40..d405cbeb8eb0 100644 --- a/awsclilinter/tests/test_linter.py +++ b/awsclilinter/tests/test_linter.py @@ -12,7 +12,7 @@ def test_lint_finds_issues(self): findings = linter.lint(script) assert len(findings) == 1 - assert findings[0].rule_name == "base64-binary-format" + assert findings[0].rule_name == "binary-params-base64" assert "file://" in findings[0].original_text def test_lint_no_issues(self): diff --git a/awsclilinter/tests/test_rules.py b/awsclilinter/tests/test_rules.py index ca7e4a49d394..fa69f3f9bb33 100644 --- a/awsclilinter/tests/test_rules.py +++ b/awsclilinter/tests/test_rules.py @@ -9,7 +9,7 @@ class TestBase64BinaryFormatRule: def test_rule_properties(self): """Test rule name and description.""" rule = Base64BinaryFormatRule() - assert rule.name == "base64-binary-format" + assert rule.name == "binary-params-base64" assert "cli-binary-format" in rule.description def test_detects_missing_flag(self): From e7d2f15913dfab51ea19d64233a8c9906fbf2fa4 Mon Sep 17 00:00:00 2001 From: aemous Date: Tue, 21 Oct 2025 11:34:16 -0400 Subject: [PATCH 03/10] Progress --- awsclilinter/README.md | 6 +- awsclilinter/awsclilinter/__init__.py | 2 +- awsclilinter/awsclilinter/cli.py | 20 ++-- .../awsclilinter/rules/base64_rule.py | 4 +- awsclilinter/examples/upload_s3_files.sh | 2 +- awsclilinter/requirements-dev-lock.txt | 97 +++++++++++++++++++ awsclilinter/tests/test_cli.py | 18 ++-- awsclilinter/tests/test_linter.py | 16 +-- awsclilinter/tests/test_rules.py | 13 +-- 9 files changed, 140 insertions(+), 38 deletions(-) create mode 100644 awsclilinter/requirements-dev-lock.txt diff --git a/awsclilinter/README.md b/awsclilinter/README.md index 65345e0572c9..7fd78c2d19ce 100644 --- a/awsclilinter/README.md +++ b/awsclilinter/README.md @@ -1,7 +1,7 @@ # AWS CLI Linter A CLI tool that lints bash scripts for AWS CLI v1 usage and updates them to avoid breaking -changes introduced in AWS CLI v2. Not all of the breaking changes can be detected statically, +changes introduced in AWS CLI v2. Not all breaking changes can be detected statically, thus not all of them are supported by this tool. For a full list of the breaking changes introduced with AWS CLI v2, see @@ -9,6 +9,9 @@ For a full list of the breaking changes introduced with AWS CLI v2, see ## Installation +Run `make setup` to set up a virtual environment with the linter installed. Alternatively, +you can follow the steps below. + 1. Create a virtual environment: ```bash python3.12 -m venv venv @@ -18,6 +21,7 @@ source venv/bin/activate 2. Install dependencies: ```bash pip install -r requirements.txt +pip install -r requirements-dev-lock.txt ``` 3. Install the package in development mode: diff --git a/awsclilinter/awsclilinter/__init__.py b/awsclilinter/awsclilinter/__init__.py index 3dc1f76bc69e..5becc17c04a9 100644 --- a/awsclilinter/awsclilinter/__init__.py +++ b/awsclilinter/awsclilinter/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "1.0.0" diff --git a/awsclilinter/awsclilinter/cli.py b/awsclilinter/awsclilinter/cli.py index d1ef881e70ed..9a37b51c51ba 100644 --- a/awsclilinter/awsclilinter/cli.py +++ b/awsclilinter/awsclilinter/cli.py @@ -13,6 +13,9 @@ CYAN = "\033[36m" RESET = "\033[0m" +# The number of lines to show before an after a fix suggestion, for context within the script +CONTEXT_SIZE = 3 + def get_user_choice(prompt: str) -> str: """Get user input for interactive mode.""" @@ -25,7 +28,7 @@ def get_user_choice(prompt: str) -> str: def display_finding(finding: LintFinding, index: int, total: int, script_content: str): """Display a finding to the user with context.""" - lines = script_content.splitlines() + src_lines = script_content.splitlines() dest_lines = finding.edit.inserted_text.splitlines() start_line = finding.line_start end_line = finding.line_end @@ -35,13 +38,13 @@ def display_finding(finding: LintFinding, index: int, total: int, script_content # Create a map from line numbers to their indices within the full script file line_positions = [] pos = 0 - for i, line in enumerate(lines): + for i, line in enumerate(src_lines): line_positions.append((pos, pos + len(line))) pos += len(line) + 1 - # Get context lines (3 before and 3 after) - context_start = max(0, start_line - 3) - context_end = min(len(lines), end_line + 4) + # Get context lines + context_start = max(0, start_line - CONTEXT_SIZE) + context_end = min(len(src_lines), end_line + CONTEXT_SIZE + 1) src_context_size = context_end - context_start dest_context_size = src_context_size + (new_lines_added - src_lines_removed) @@ -53,10 +56,10 @@ def display_finding(finding: LintFinding, index: int, total: int, script_content ) for i in range(context_start, context_end): - line = lines[i] if i < len(lines) else "" + line = src_lines[i] if i < len(src_lines) else "" if start_line <= i <= end_line: - # This line is actually being modified + # This line is being modified print(f"{RED}-{line}{RESET}") if i == end_line: @@ -64,8 +67,7 @@ def display_finding(finding: LintFinding, index: int, total: int, script_content start_pos_in_line = max(0, finding.edit.start_pos - line_start_pos) end_pos_in_line = min(len(line), finding.edit.end_pos - line_start_pos) new_line = line[:start_pos_in_line] + finding.suggested_fix + line[end_pos_in_line:] - # Print the new line suggestion. The line number will always be the start line - # returned by ast-grep. + # Print the new line suggestion. print(f"{GREEN}+{new_line}{RESET}") else: # Context line diff --git a/awsclilinter/awsclilinter/rules/base64_rule.py b/awsclilinter/awsclilinter/rules/base64_rule.py index 728433e4ac4b..674b3568c893 100644 --- a/awsclilinter/awsclilinter/rules/base64_rule.py +++ b/awsclilinter/awsclilinter/rules/base64_rule.py @@ -6,7 +6,9 @@ class Base64BinaryFormatRule(LintRule): - """Detects AWS CLI commands with file:// that need --cli-binary-format.""" + """Detects AWS CLI commands with file:// that need --cli-binary-format. This is a best-effort + attempt at statically detecting the breaking change with how AWS CLI v2 treats binary + parameters.""" @property def name(self) -> str: diff --git a/awsclilinter/examples/upload_s3_files.sh b/awsclilinter/examples/upload_s3_files.sh index ed38ffff9a60..81a24d17278d 100644 --- a/awsclilinter/examples/upload_s3_files.sh +++ b/awsclilinter/examples/upload_s3_files.sh @@ -5,7 +5,7 @@ aws secretsmanager put-secret-value --secret-id secret1213 \ --secret-binary file://data.json if - aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json ; then + aws kinesis put-record --stream-name samplestream --data file://data --partition-key samplepartitionkey ; then echo "command succeeded." fi diff --git a/awsclilinter/requirements-dev-lock.txt b/awsclilinter/requirements-dev-lock.txt new file mode 100644 index 000000000000..2938acef36e3 --- /dev/null +++ b/awsclilinter/requirements-dev-lock.txt @@ -0,0 +1,97 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --allow-unsafe --generate-hashes --output-file=requirements-dev-lock.txt requirements-dev.txt +# +ast-grep-py==0.39.6 \ + --hash=sha256:01d3e4a7dfea92ee43ff712843f795a9f9a821c4bd0139fd2ce773851dee263d \ + --hash=sha256:292a2eb0cd76b0ed39ef512f4489bb9936978ef54b5d601918bc697f263d1357 \ + --hash=sha256:2a7fffe7dcc55ea7678628072b511ea0252433748f6f3b19b6f2483f11874e3c \ + --hash=sha256:3358b5d26d90cb928951923b4a7ac3973f7d7303d1e372321279023447b1dd33 \ + --hash=sha256:3ef4cc66c228654b2fb41e72d90bd5276e1cf947f8300ffc78abd67274136b1b \ + --hash=sha256:3f61646794e1c74023e8881ad93fd8b13778bfe5e50b61a4e4f8d3e8802bc914 \ + --hash=sha256:4380389a83fd7f06fe4c30e6c68ac786b99a8ce513ac0742148d27a1c987c0c0 \ + --hash=sha256:46de5659378e2c1f1915629eb4f7e9f2add7b1a69ad774835c263c6e4b61613c \ + --hash=sha256:4fc47c76d12f03407616ae60a4e210e4e337fcfd278ad24d6cf17f81cdb2b338 \ + --hash=sha256:4fd970464f63af98e66cceabee836ac0304a612ff060e9461cd026c2193c809f \ + --hash=sha256:63cce05fa68dd9d626711ed46a775f1f091fc6e075e685471c02d492b2d77a8a \ + --hash=sha256:65172cf9514f633d5922ba4074cd2e34470ee08abb29e6d8eb4059ac370ec45f \ + --hash=sha256:678a68502ea4887e3724842429b5adc0da8a719cb4720e2bdd95a1d4f9f208ed \ + --hash=sha256:74a2e7fab3da96e403c7c257652218cbe7e5fc15055842215a91f14d158c9589 \ + --hash=sha256:828dd474c2504fc7544733b8f200245be0d2ae67060f6e3d0fe7c5852d0bf9cf \ + --hash=sha256:931229ec21c3b116f1bffb65fca8f67bddf7b31f3b89d02230ae331e30997768 \ + --hash=sha256:a77156ea53c6e6efaf7cfb8cb45f8731b198dc0ef2ea1e5b31b1c92fe281c203 \ + --hash=sha256:b70440bfdbc1ed71b26430dd05ee4fc19bb97b43b1d1f0fea8ee073f5a4e1bec \ + --hash=sha256:c11245346a78deedb6b7bc65cca6a6c22783f2a33e1e999f3ba7d7bf00d89db8 \ + --hash=sha256:c5194c8ec3bf05fc25dda5a1f9d77a47c7ad08f997c1d579bdfbb0bc12afa683 \ + --hash=sha256:ca8e8cd36aa81f89448cdadf00875e5ac0e14cff0cc11526dd1a1821a35cdae9 \ + --hash=sha256:cb804e6e048c35c873a396737f616124fb54343d392e87390e3cd515d44d281c \ + --hash=sha256:d540136365e95b767cbc2772fd08b8968bd151e1aaaafaa51d91303659367120 \ + --hash=sha256:dc18747c0f2614984c855636e2f7430998bd83c952cb964aa115eca096bfcb8b \ + --hash=sha256:e473ef786fb3e12192ef53682f68af634b30716859dbc44764164981be777fcd \ + --hash=sha256:f4227d320719de840ed0bb32a281de3b9d2fa33897dcb3f93d2ae3391affc70e \ + --hash=sha256:fbe02b082474831fc2716cf8e8b312c5da97a992967e50ac5e37f83de385fe18 + # via -r /Users/aemous/GitHub/aws-cli2/awsclilinter/requirements.txt +black==24.1.1 \ + --hash=sha256:0269dfdea12442022e88043d2910429bed717b2d04523867a85dacce535916b8 \ + --hash=sha256:07204d078e25327aad9ed2c64790d681238686bce254c910de640c7cc4fc3aa6 \ + --hash=sha256:08b34e85170d368c37ca7bf81cf67ac863c9d1963b2c1780c39102187ec8dd62 \ + --hash=sha256:1a95915c98d6e32ca43809d46d932e2abc5f1f7d582ffbe65a5b4d1588af7445 \ + --hash=sha256:2588021038bd5ada078de606f2a804cadd0a3cc6a79cb3e9bb3a8bf581325a4c \ + --hash=sha256:2fa6a0e965779c8f2afb286f9ef798df770ba2b6cee063c650b96adec22c056a \ + --hash=sha256:34afe9da5056aa123b8bfda1664bfe6fb4e9c6f311d8e4a6eb089da9a9173bf9 \ + --hash=sha256:3897ae5a21ca132efa219c029cce5e6bfc9c3d34ed7e892113d199c0b1b444a2 \ + --hash=sha256:40657e1b78212d582a0edecafef133cf1dd02e6677f539b669db4746150d38f6 \ + --hash=sha256:48b5760dcbfe5cf97fd4fba23946681f3a81514c6ab8a45b50da67ac8fbc6c7b \ + --hash=sha256:5242ecd9e990aeb995b6d03dc3b2d112d4a78f2083e5a8e86d566340ae80fec4 \ + --hash=sha256:5cdc2e2195212208fbcae579b931407c1fa9997584f0a415421748aeafff1168 \ + --hash=sha256:5d7b06ea8816cbd4becfe5f70accae953c53c0e53aa98730ceccb0395520ee5d \ + --hash=sha256:7258c27115c1e3b5de9ac6c4f9957e3ee2c02c0b39222a24dc7aa03ba0e986f5 \ + --hash=sha256:854c06fb86fd854140f37fb24dbf10621f5dab9e3b0c29a690ba595e3d543024 \ + --hash=sha256:a21725862d0e855ae05da1dd25e3825ed712eaaccef6b03017fe0853a01aa45e \ + --hash=sha256:a83fe522d9698d8f9a101b860b1ee154c1d25f8a82ceb807d319f085b2627c5b \ + --hash=sha256:b3d64db762eae4a5ce04b6e3dd745dcca0fb9560eb931a5be97472e38652a161 \ + --hash=sha256:e298d588744efda02379521a19639ebcd314fba7a49be22136204d7ed1782717 \ + --hash=sha256:e2c8dfa14677f90d976f68e0c923947ae68fa3961d61ee30976c388adc0b02c8 \ + --hash=sha256:ecba2a15dfb2d97105be74bbfe5128bc5e9fa8477d8c46766505c1dda5883aac \ + --hash=sha256:fc1ec9aa6f4d98d022101e015261c056ddebe3da6a8ccfc2c792cbe0349d48b7 + # via -r requirements-dev.txt +click==8.3.0 \ + --hash=sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc \ + --hash=sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4 + # via black +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +isort==5.13.2 \ + --hash=sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109 \ + --hash=sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6 + # via -r requirements-dev.txt +mypy-extensions==1.1.0 \ + --hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \ + --hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558 + # via black +packaging==25.0 \ + --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ + --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f + # via + # black + # pytest +pathspec==0.12.1 \ + --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \ + --hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712 + # via black +platformdirs==4.5.0 \ + --hash=sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312 \ + --hash=sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3 + # via black +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest +pytest==8.0.0 \ + --hash=sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c \ + --hash=sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6 + # via -r requirements-dev.txt diff --git a/awsclilinter/tests/test_cli.py b/awsclilinter/tests/test_cli.py index fb7c72653a8e..b86dc83549a7 100644 --- a/awsclilinter/tests/test_cli.py +++ b/awsclilinter/tests/test_cli.py @@ -46,7 +46,7 @@ def test_no_issues_found(self, tmp_path, capsys): def test_dry_run_mode(self, tmp_path, capsys): """Test dry run mode displays findings.""" script_file = tmp_path / "test.sh" - script_file.write_text("aws s3api put-object --bucket mybucket --body file://data.json") + script_file.write_text("aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json") with patch("sys.argv", ["upgrade-aws-cli", "--script", str(script_file)]): main() @@ -57,7 +57,7 @@ def test_dry_run_mode(self, tmp_path, capsys): def test_fix_mode(self, tmp_path): """Test fix mode modifies the script.""" script_file = tmp_path / "test.sh" - script_file.write_text("aws s3api put-object --bucket mybucket --body file://data.json") + script_file.write_text("aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json") with patch("sys.argv", ["upgrade-aws-cli", "--script", str(script_file), "--fix"]): main() @@ -68,7 +68,7 @@ def test_output_mode(self, tmp_path): """Test output mode creates new file.""" script_file = tmp_path / "test.sh" output_file = tmp_path / "output.sh" - script_file.write_text("aws s3api put-object --bucket mybucket --body file://data.json") + script_file.write_text("aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json") with patch( "sys.argv", @@ -83,7 +83,9 @@ def test_interactive_mode_accept_all(self, tmp_path): script_file = tmp_path / "test.sh" output_file = tmp_path / "output.sh" script_file.write_text( - "aws s3api put-object --bucket mybucket --body file://data.json\naws dynamodb put-item --table mytable --item file://item.json" + "aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json\n" + "aws kinesis put-record --stream-name samplestream --data file://data " + "--partition-key samplepartitionkey" ) with patch( @@ -105,7 +107,7 @@ def test_interactive_mode_accept_all(self, tmp_path): def test_interactive_mode_reject_all(self, tmp_path, capsys): """Test interactive mode with 'n' to reject all changes.""" script_file = tmp_path / "test.sh" - original = "aws s3api put-object --bucket mybucket --body file://data.json" + original = "aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json" script_file.write_text(original) with patch("sys.argv", ["upgrade-aws-cli", "--script", str(script_file), "--interactive"]): @@ -119,7 +121,9 @@ def test_interactive_mode_update_all(self, tmp_path): script_file = tmp_path / "test.sh" output_file = tmp_path / "output.sh" script_file.write_text( - "aws s3api put-object --bucket mybucket --body file://data.json\naws dynamodb put-item --table mytable --item file://item.json" + "aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json\n" + "aws kinesis put-record --stream-name samplestream --data file://data " + "--partition-key samplepartitionkey" ) with patch( @@ -141,7 +145,7 @@ def test_interactive_mode_update_all(self, tmp_path): def test_interactive_mode_cancel(self, tmp_path): """Test interactive mode with 'x' to cancel.""" script_file = tmp_path / "test.sh" - script_file.write_text("aws s3api put-object --bucket mybucket --body file://data.json") + script_file.write_text("aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json") with patch("sys.argv", ["upgrade-aws-cli", "--script", str(script_file), "--interactive"]): with patch("builtins.input", return_value="q"): diff --git a/awsclilinter/tests/test_linter.py b/awsclilinter/tests/test_linter.py index d405cbeb8eb0..d2ae6e047a24 100644 --- a/awsclilinter/tests/test_linter.py +++ b/awsclilinter/tests/test_linter.py @@ -7,7 +7,7 @@ class TestScriptLinter: def test_lint_finds_issues(self): """Test that linter finds issues in script.""" - script = "aws s3api put-object --bucket mybucket --key mykey --body file://data.json" + script = "aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json" linter = ScriptLinter([Base64BinaryFormatRule()]) findings = linter.lint(script) @@ -15,17 +15,9 @@ def test_lint_finds_issues(self): assert findings[0].rule_name == "binary-params-base64" assert "file://" in findings[0].original_text - def test_lint_no_issues(self): - """Test that linter returns no findings for compliant script.""" - script = "aws s3api put-object --bucket mybucket --key mykey --body file://data.json --cli-binary-format raw-in-base64-out" - linter = ScriptLinter([Base64BinaryFormatRule()]) - findings = linter.lint(script) - - assert len(findings) == 0 - def test_apply_fixes(self): """Test that fixes are applied correctly.""" - script = "aws s3api put-object --bucket mybucket --key mykey --body file://data.json" + script = "aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json" linter = ScriptLinter([Base64BinaryFormatRule()]) findings = linter.lint(script) fixed = linter.apply_fixes(script, findings) @@ -35,8 +27,8 @@ def test_apply_fixes(self): def test_multiple_issues(self): """Test linter with multiple issues.""" - script = """aws s3api put-object --bucket mybucket --key mykey --body file://data.json -aws dynamodb put-item --table-name mytable --item file://item.json""" + script = """aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json + aws kinesis put-record --stream-name samplestream --data file://data --partition-key samplepartitionkey""" linter = ScriptLinter([Base64BinaryFormatRule()]) findings = linter.lint(script) diff --git a/awsclilinter/tests/test_rules.py b/awsclilinter/tests/test_rules.py index fa69f3f9bb33..bdc9cb605be4 100644 --- a/awsclilinter/tests/test_rules.py +++ b/awsclilinter/tests/test_rules.py @@ -7,14 +7,13 @@ class TestBase64BinaryFormatRule: """Test cases for Base64BinaryFormatRule.""" def test_rule_properties(self): - """Test rule name and description.""" + """Test rule description.""" rule = Base64BinaryFormatRule() - assert rule.name == "binary-params-base64" assert "cli-binary-format" in rule.description def test_detects_missing_flag(self): """Test detection of missing --cli-binary-format flag.""" - script = "aws s3api put-object --bucket mybucket --body file://data.json" + script = "aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json" root = SgRoot(script, "bash") rule = Base64BinaryFormatRule() findings = rule.check(root) @@ -24,7 +23,7 @@ def test_detects_missing_flag(self): def test_no_detection_with_flag(self): """Test no detection when flag is present.""" - script = "aws s3api put-object --bucket mybucket --body file://data.json --cli-binary-format raw-in-base64-out" + script = "aws secretsmanager put-secret-value --secret-id secret1213 --secret-binary file://data.json --cli-binary-format raw-in-base64-out" root = SgRoot(script, "bash") rule = Base64BinaryFormatRule() findings = rule.check(root) @@ -32,8 +31,10 @@ def test_no_detection_with_flag(self): assert len(findings) == 0 def test_no_detection_without_file_protocol(self): - """Test no detection when file:// is not used.""" - script = "aws s3api put-object --bucket mybucket --body data.json" + """Test no detection when file:// is not used. Even though the breaking change may + still occur without the use of file://, only the case where file:// is used can be detected + statically.""" + script = "aws secretsmanager put-secret-value --secret-id secret1213 --secret-string secret123" root = SgRoot(script, "bash") rule = Base64BinaryFormatRule() findings = rule.check(root) From c8ca3eeaa147c7aefbc7afc894b8f4fdc640f7f3 Mon Sep 17 00:00:00 2001 From: aemous Date: Tue, 21 Oct 2025 11:49:36 -0400 Subject: [PATCH 04/10] Inject + at start of each line in inserted test. --- awsclilinter/awsclilinter/cli.py | 5 ++++- awsclilinter/awsclilinter/rules/base64_rule.py | 4 +++- awsclilinter/awsclilinter/rules_base.py | 1 - awsclilinter/tests/test_rules.py | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/awsclilinter/awsclilinter/cli.py b/awsclilinter/awsclilinter/cli.py index 9a37b51c51ba..ba34424d3712 100644 --- a/awsclilinter/awsclilinter/cli.py +++ b/awsclilinter/awsclilinter/cli.py @@ -66,7 +66,10 @@ def display_finding(finding: LintFinding, index: int, total: int, script_content line_start_pos, _ = line_positions[i] start_pos_in_line = max(0, finding.edit.start_pos - line_start_pos) end_pos_in_line = min(len(line), finding.edit.end_pos - line_start_pos) - new_line = line[:start_pos_in_line] + finding.suggested_fix + line[end_pos_in_line:] + new_line = line[:start_pos_in_line] + finding.edit.inserted_text + line[end_pos_in_line:] + # In case the inserted text takes up multiple lines, + # inject a + at the start of each line. + new_line = new_line.replace("\n", "\n+") # Print the new line suggestion. print(f"{GREEN}+{new_line}{RESET}") else: diff --git a/awsclilinter/awsclilinter/rules/base64_rule.py b/awsclilinter/awsclilinter/rules/base64_rule.py index 674b3568c893..acafa5c0756e 100644 --- a/awsclilinter/awsclilinter/rules/base64_rule.py +++ b/awsclilinter/awsclilinter/rules/base64_rule.py @@ -42,13 +42,15 @@ def check(self, root: SgRoot) -> List[LintFinding]: suggested = original + " --cli-binary-format raw-in-base64-out" edit = stmt.replace(suggested) + print(f"suggested fix: {suggested}") + print(f"edit text: {edit.inserted_text}") + findings.append( LintFinding( line_start=stmt.range().start.line, line_end=stmt.range().end.line, edit=edit, original_text=original, - suggested_fix=suggested, rule_name=self.name, description=self.description, ) diff --git a/awsclilinter/awsclilinter/rules_base.py b/awsclilinter/awsclilinter/rules_base.py index 991ae5b99e58..5900a2c09ff8 100644 --- a/awsclilinter/awsclilinter/rules_base.py +++ b/awsclilinter/awsclilinter/rules_base.py @@ -13,7 +13,6 @@ class LintFinding: line_end: int edit: Edit original_text: str - suggested_fix: str rule_name: str description: str diff --git a/awsclilinter/tests/test_rules.py b/awsclilinter/tests/test_rules.py index bdc9cb605be4..0248a44b7a12 100644 --- a/awsclilinter/tests/test_rules.py +++ b/awsclilinter/tests/test_rules.py @@ -19,7 +19,7 @@ def test_detects_missing_flag(self): findings = rule.check(root) assert len(findings) == 1 - assert "--cli-binary-format" in findings[0].suggested_fix + assert "--cli-binary-format" in findings[0].edit.inserted_text def test_no_detection_with_flag(self): """Test no detection when flag is present.""" From a6ff73da42fd299a74893d78853f84560eeab5dc Mon Sep 17 00:00:00 2001 From: aemous Date: Tue, 21 Oct 2025 11:50:30 -0400 Subject: [PATCH 05/10] Remove printlines. --- awsclilinter/awsclilinter/rules/base64_rule.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/awsclilinter/awsclilinter/rules/base64_rule.py b/awsclilinter/awsclilinter/rules/base64_rule.py index acafa5c0756e..8672c5e9b2f5 100644 --- a/awsclilinter/awsclilinter/rules/base64_rule.py +++ b/awsclilinter/awsclilinter/rules/base64_rule.py @@ -42,9 +42,6 @@ def check(self, root: SgRoot) -> List[LintFinding]: suggested = original + " --cli-binary-format raw-in-base64-out" edit = stmt.replace(suggested) - print(f"suggested fix: {suggested}") - print(f"edit text: {edit.inserted_text}") - findings.append( LintFinding( line_start=stmt.range().start.line, From b52cc70e8b2ad0c3fffc1cbb4dd1bcb3eba4c984 Mon Sep 17 00:00:00 2001 From: aemous Date: Tue, 21 Oct 2025 13:09:43 -0400 Subject: [PATCH 06/10] Add awsclilinter to setuptools exclude param. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 300d0084cb93..723d269901e7 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,7 @@ def find_version(*file_paths): scripts=['bin/aws', 'bin/aws.cmd', 'bin/aws_completer', 'bin/aws_zsh_completer.sh', 'bin/aws_bash_completer'], - packages=find_packages(exclude=['tests*']), + packages=find_packages(exclude=['tests*', 'awsclilinter']), include_package_data=True, install_requires=install_requires, extras_require={}, From 4ae8d0a043822d6abf847e187b0c785c2f553020 Mon Sep 17 00:00:00 2001 From: aemous Date: Tue, 21 Oct 2025 13:20:01 -0400 Subject: [PATCH 07/10] Prune awsclilinter in MANIFEST.in. --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index 34fb540e8276..98ea78d55fa6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,3 +6,4 @@ include .pre-commit-config.yaml recursive-include awscli/examples *.rst *.txt recursive-include awscli/data *.json recursive-include awscli/topics *.rst *.json +prune awsclilinter \ No newline at end of file From 66dcb9a9e636bb7a5ba2f8cb56b1ebe4ba99dcb1 Mon Sep 17 00:00:00 2001 From: aemous Date: Tue, 21 Oct 2025 14:36:28 -0400 Subject: [PATCH 08/10] Add pyproject.toml, setup.py, and MANIFEST.in. --- awsclilinter/MANIFEST.in | 7 +++++ awsclilinter/pyproject.toml | 30 +++++++++++++++++++ awsclilinter/setup.py | 59 +++++++++++++++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 awsclilinter/MANIFEST.in create mode 100644 awsclilinter/setup.py diff --git a/awsclilinter/MANIFEST.in b/awsclilinter/MANIFEST.in new file mode 100644 index 000000000000..777927b30491 --- /dev/null +++ b/awsclilinter/MANIFEST.in @@ -0,0 +1,7 @@ +include README.md +include requirements.txt +include requirements-dev.txt +include requirements-dev-lock.txt +recursive-include awsclilinter *.py +recursive-exclude tests * +recursive-exclude examples * diff --git a/awsclilinter/pyproject.toml b/awsclilinter/pyproject.toml index 454ea39e7c68..9e29ba0ccfcc 100644 --- a/awsclilinter/pyproject.toml +++ b/awsclilinter/pyproject.toml @@ -6,11 +6,41 @@ build-backend = "setuptools.build_meta" name = "awsclilinter" version = "1.0.0" description = "CLI tool to lint and upgrade bash scripts from AWS CLI v1 to v2" +readme = "README.md" requires-python = ">=3.9" +license = "Apache-2.0" +keywords = ["aws", "cli", "linter", "bash", "script", "migration", "v1", "v2"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Utilities", +] dependencies = [ "ast-grep-py>=0.39.6", ] +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "black>=24.1.1", + "isort>=5.13.2", +] + +[project.urls] +Homepage = "https://github.com/aws/aws-cli" +"Bug Tracker" = "https://github.com/aws/aws-cli/issues" +Documentation = "https://github.com/aws/aws-cli" +"Source Code" = "https://github.com/aws/aws-cli" + [project.scripts] upgrade-aws-cli = "awsclilinter.cli:main" diff --git a/awsclilinter/setup.py b/awsclilinter/setup.py new file mode 100644 index 000000000000..85fa3e2f6f05 --- /dev/null +++ b/awsclilinter/setup.py @@ -0,0 +1,59 @@ +"""Setup configuration for awsclilinter package.""" + +from setuptools import find_packages, setup + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="awsclilinter", + version="1.0.0", + author="Amazon Web Services", + description="CLI tool to lint and upgrade bash scripts from AWS CLI v1 to v2", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/aws/aws-cli", + project_urls={ + "Bug Tracker": "https://github.com/aws/aws-cli/issues", + "Documentation": "https://github.com/aws/aws-cli", + "Source Code": "https://github.com/aws/aws-cli", + }, + packages=find_packages(exclude=["tests", "tests.*"]), + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", + "Topic :: Utilities", + ], + python_requires=">=3.9", + install_requires=[ + "ast-grep-py>=0.39.6", + ], + extras_require={ + "dev": [ + "pytest>=8.0.0", + "black>=24.1.1", + "isort>=5.13.2", + ], + }, + entry_points={ + "console_scripts": [ + "upgrade-aws-cli=awsclilinter.cli:main", + ], + }, + license="Apache-2.0", + keywords="aws cli linter bash script migration v1 v2", + zip_safe=False, +) From 46e1ed6617944b3da06eae085f9b18c8cb00c27c Mon Sep 17 00:00:00 2001 From: aemous Date: Tue, 21 Oct 2025 14:37:51 -0400 Subject: [PATCH 09/10] Update project name in readme. --- awsclilinter/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awsclilinter/README.md b/awsclilinter/README.md index 7fd78c2d19ce..e34a4f6dd1f6 100644 --- a/awsclilinter/README.md +++ b/awsclilinter/README.md @@ -1,4 +1,4 @@ -# AWS CLI Linter +# AWS CLI v1-to-v2 Upgrade Linter A CLI tool that lints bash scripts for AWS CLI v1 usage and updates them to avoid breaking changes introduced in AWS CLI v2. Not all breaking changes can be detected statically, From b6b55d2eba24f8cfb95bff5c631842ed217b0d33 Mon Sep 17 00:00:00 2001 From: aemous Date: Tue, 21 Oct 2025 15:47:04 -0400 Subject: [PATCH 10/10] Fix typoe in license. --- awsclilinter/LICENSE.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 awsclilinter/LICENSE.txt diff --git a/awsclilinter/LICENSE.txt b/awsclilinter/LICENSE.txt new file mode 100644 index 000000000000..3d176f2a1677 --- /dev/null +++ b/awsclilinter/LICENSE.txt @@ -0,0 +1,12 @@ +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +Licensed under the Apache License, Version 2.0 (the "License"). You +may not use this file except in compliance with the License. A copy of +the License is located at + + http://aws.amazon.com/apache2.0/ + +or in the "license" file accompanying this file. This file is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF +ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License.