Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:04:14 +08:00
commit 70c36b5eff
248 changed files with 47482 additions and 0 deletions

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Extract command structure from Fire CLI for documentation"""
import ast
import sys
import json
from pathlib import Path
from typing import List, Dict, Optional
class CommandExtractor:
"""Extract command structure from Fire CLI Python files"""
def __init__(self, filepath: Path):
self.filepath = filepath
self.tree = None
self.commands = []
def extract(self) -> List[Dict]:
"""Extract all commands from Fire CLI"""
try:
content = self.filepath.read_text()
self.tree = ast.parse(content)
except Exception as e:
print(f"Error parsing file: {e}", file=sys.stderr)
return []
# Find all classes
for node in self.tree.body:
if isinstance(node, ast.ClassDef):
self._extract_from_class(node)
return self.commands
def _extract_from_class(self, class_node: ast.ClassDef, parent_path: str = ""):
"""Extract commands from a class"""
class_name = class_node.name
class_doc = ast.get_docstring(class_node) or ""
current_path = f"{parent_path}.{class_name}" if parent_path else class_name
for item in class_node.body:
if isinstance(item, ast.FunctionDef):
# Skip private methods
if item.name.startswith('_'):
continue
command = self._extract_command(item, current_path)
if command:
self.commands.append(command)
elif isinstance(item, ast.ClassDef):
# Nested class - recurse
self._extract_from_class(item, current_path)
def _extract_command(self, func_node: ast.FunctionDef, class_path: str) -> Optional[Dict]:
"""Extract command information from function"""
func_name = func_node.name
docstring = ast.get_docstring(func_node) or ""
# Parse docstring for description and args
description = ""
args_help = {}
if docstring:
lines = docstring.split('\n')
desc_lines = []
in_args = False
for line in lines:
line = line.strip()
if line.startswith('Args:'):
in_args = True
continue
elif line.startswith('Returns:') or line.startswith('Raises:'):
in_args = False
continue
if not in_args and line:
desc_lines.append(line)
elif in_args and line:
# Parse arg line: "arg_name: description"
if ':' in line:
arg_name, arg_desc = line.split(':', 1)
args_help[arg_name.strip()] = arg_desc.strip()
description = ' '.join(desc_lines)
# Extract arguments
args = []
for arg in func_node.args.args:
if arg.arg == 'self':
continue
arg_info = {
'name': arg.arg,
'type': self._get_type_annotation(arg),
'help': args_help.get(arg.arg, ''),
}
# Check for default value
defaults_offset = len(func_node.args.args) - len(func_node.args.defaults)
arg_index = func_node.args.args.index(arg)
if arg_index >= defaults_offset:
default_index = arg_index - defaults_offset
default_value = self._get_default_value(func_node.args.defaults[default_index])
arg_info['default'] = default_value
arg_info['required'] = False
else:
arg_info['required'] = True
args.append(arg_info)
return {
'name': func_name,
'path': f"{class_path}.{func_name}",
'description': description,
'arguments': args
}
def _get_type_annotation(self, arg: ast.arg) -> Optional[str]:
"""Extract type annotation from argument"""
if arg.annotation:
return ast.unparse(arg.annotation)
return None
def _get_default_value(self, node) -> str:
"""Extract default value from AST node"""
try:
return ast.unparse(node)
except:
return repr(node)
def print_tree(self):
"""Print command tree in human-readable format"""
print(f"\n{'='*60}")
print(f"Fire CLI Commands: {self.filepath.name}")
print(f"{'='*60}\n")
for cmd in self.commands:
print(f"📌 {cmd['path']}")
if cmd['description']:
print(f" {cmd['description']}")
if cmd['arguments']:
print(f" Arguments:")
for arg in cmd['arguments']:
required = "required" if arg['required'] else "optional"
type_str = f": {arg['type']}" if arg['type'] else ""
default_str = f" = {arg['default']}" if 'default' in arg else ""
help_str = f" - {arg['help']}" if arg['help'] else ""
print(f"{arg['name']}{type_str}{default_str} ({required}){help_str}")
print()
def export_json(self) -> str:
"""Export commands as JSON"""
return json.dumps(self.commands, indent=2)
def export_markdown(self) -> str:
"""Export commands as Markdown"""
lines = [f"# {self.filepath.name} Commands\n"]
for cmd in self.commands:
lines.append(f"## `{cmd['path']}`\n")
if cmd['description']:
lines.append(f"{cmd['description']}\n")
if cmd['arguments']:
lines.append("### Arguments\n")
for arg in cmd['arguments']:
required = "**required**" if arg['required'] else "*optional*"
type_str = f" (`{arg['type']}`)" if arg['type'] else ""
default_str = f" Default: `{arg['default']}`" if 'default' in arg else ""
lines.append(f"- `{arg['name']}`{type_str} - {required}{default_str}")
if arg['help']:
lines.append(f" - {arg['help']}")
lines.append("")
return '\n'.join(lines)
def main():
if len(sys.argv) < 2:
print("Usage: extract-commands.py <fire-cli-file.py> [--json|--markdown]")
sys.exit(1)
filepath = Path(sys.argv[1])
output_format = sys.argv[2] if len(sys.argv) > 2 else '--tree'
if not filepath.exists():
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
extractor = CommandExtractor(filepath)
extractor.extract()
if output_format == '--json':
print(extractor.export_json())
elif output_format == '--markdown':
print(extractor.export_markdown())
else:
extractor.print_tree()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,179 @@
#!/bin/bash
# Generate Fire CLI from specification
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATES_DIR="$(dirname "$SCRIPT_DIR")/templates"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to display usage
usage() {
cat << EOF
Usage: $(basename "$0") [OPTIONS]
Generate a Python Fire CLI application from templates.
OPTIONS:
-n, --name NAME CLI name (required)
-d, --description DESC CLI description (required)
-t, --template TYPE Template type: basic, nested, rich, typed, config, multi (default: basic)
-o, --output FILE Output file path (required)
-c, --class-name NAME Main class name (default: CLI)
-v, --version VERSION Version number (default: 1.0.0)
-h, --help Show this help message
TEMPLATE TYPES:
basic - Simple single-class Fire CLI
nested - Multi-class CLI with command groups
rich - Fire CLI with rich console output
typed - Type-annotated Fire CLI with full type hints
config - Fire CLI with comprehensive configuration management
multi - Complex multi-command Fire CLI
EXAMPLES:
$(basename "$0") -n mycli -d "My CLI tool" -o mycli.py
$(basename "$0") -n deploy-tool -d "Deployment CLI" -t nested -o deploy.py
$(basename "$0") -n mytool -d "Advanced tool" -t typed -c MyTool -o tool.py
EOF
exit 1
}
# Parse command line arguments
CLI_NAME=""
DESCRIPTION=""
TEMPLATE="basic"
OUTPUT_FILE=""
CLASS_NAME="CLI"
VERSION="1.0.0"
while [[ $# -gt 0 ]]; do
case $1 in
-n|--name)
CLI_NAME="$2"
shift 2
;;
-d|--description)
DESCRIPTION="$2"
shift 2
;;
-t|--template)
TEMPLATE="$2"
shift 2
;;
-o|--output)
OUTPUT_FILE="$2"
shift 2
;;
-c|--class-name)
CLASS_NAME="$2"
shift 2
;;
-v|--version)
VERSION="$2"
shift 2
;;
-h|--help)
usage
;;
*)
echo -e "${RED}Error: Unknown option $1${NC}"
usage
;;
esac
done
# Validate required arguments
if [[ -z "$CLI_NAME" ]]; then
echo -e "${RED}Error: CLI name is required${NC}"
usage
fi
if [[ -z "$DESCRIPTION" ]]; then
echo -e "${RED}Error: Description is required${NC}"
usage
fi
if [[ -z "$OUTPUT_FILE" ]]; then
echo -e "${RED}Error: Output file is required${NC}"
usage
fi
# Validate template type
TEMPLATE_FILE=""
case $TEMPLATE in
basic)
TEMPLATE_FILE="$TEMPLATES_DIR/basic-fire-cli.py.template"
;;
nested)
TEMPLATE_FILE="$TEMPLATES_DIR/nested-fire-cli.py.template"
;;
rich)
TEMPLATE_FILE="$TEMPLATES_DIR/rich-fire-cli.py.template"
;;
typed)
TEMPLATE_FILE="$TEMPLATES_DIR/typed-fire-cli.py.template"
;;
config)
TEMPLATE_FILE="$TEMPLATES_DIR/config-fire-cli.py.template"
;;
multi)
TEMPLATE_FILE="$TEMPLATES_DIR/multi-command-fire-cli.py.template"
;;
*)
echo -e "${RED}Error: Invalid template type: $TEMPLATE${NC}"
echo "Valid types: basic, nested, rich, typed, config, multi"
exit 1
;;
esac
if [[ ! -f "$TEMPLATE_FILE" ]]; then
echo -e "${RED}Error: Template file not found: $TEMPLATE_FILE${NC}"
exit 1
fi
# Prepare variables
CLI_NAME_LOWER=$(echo "$CLI_NAME" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
DEFAULT_PROJECT_NAME="my-project"
SUBCOMMAND_GROUP_NAME="Resources"
SUBCOMMAND_GROUP_DESCRIPTION="Resource management commands"
SUBCOMMAND_GROUP_NAME_LOWER="resources"
RESOURCE_NAME="resource"
echo -e "${BLUE}Generating Fire CLI...${NC}"
echo -e "${BLUE} Name: ${NC}$CLI_NAME"
echo -e "${BLUE} Description: ${NC}$DESCRIPTION"
echo -e "${BLUE} Template: ${NC}$TEMPLATE"
echo -e "${BLUE} Class: ${NC}$CLASS_NAME"
echo -e "${BLUE} Version: ${NC}$VERSION"
echo -e "${BLUE} Output: ${NC}$OUTPUT_FILE"
# Generate CLI by replacing template variables
sed -e "s/{{CLI_NAME}}/$CLI_NAME/g" \
-e "s/{{CLI_DESCRIPTION}}/$DESCRIPTION/g" \
-e "s/{{CLASS_NAME}}/$CLASS_NAME/g" \
-e "s/{{CLI_NAME_LOWER}}/$CLI_NAME_LOWER/g" \
-e "s/{{VERSION}}/$VERSION/g" \
-e "s/{{DEFAULT_PROJECT_NAME}}/$DEFAULT_PROJECT_NAME/g" \
-e "s/{{SUBCOMMAND_GROUP_NAME}}/$SUBCOMMAND_GROUP_NAME/g" \
-e "s/{{SUBCOMMAND_GROUP_DESCRIPTION}}/$SUBCOMMAND_GROUP_DESCRIPTION/g" \
-e "s/{{SUBCOMMAND_GROUP_NAME_LOWER}}/$SUBCOMMAND_GROUP_NAME_LOWER/g" \
-e "s/{{RESOURCE_NAME}}/$RESOURCE_NAME/g" \
"$TEMPLATE_FILE" > "$OUTPUT_FILE"
# Make executable
chmod +x "$OUTPUT_FILE"
echo -e "${GREEN}✓ Generated Fire CLI: $OUTPUT_FILE${NC}"
echo -e "${YELLOW}Next steps:${NC}"
echo -e " 1. Review and customize: ${BLUE}$OUTPUT_FILE${NC}"
echo -e " 2. Install dependencies: ${BLUE}pip install fire rich${NC}"
echo -e " 3. Test the CLI: ${BLUE}python $OUTPUT_FILE --help${NC}"
echo -e " 4. Validate: ${BLUE}$SCRIPT_DIR/validate-fire-cli.py $OUTPUT_FILE${NC}"

View File

@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""Test Fire CLI commands programmatically"""
import sys
import importlib.util
import inspect
from pathlib import Path
from typing import Any, List, Dict
import json
class FireCLITester:
"""Test Fire CLI commands without running them"""
def __init__(self, filepath: Path):
self.filepath = filepath
self.module = None
self.cli_class = None
def load_cli(self) -> bool:
"""Load CLI module dynamically"""
try:
spec = importlib.util.spec_from_file_location("cli_module", self.filepath)
self.module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(self.module)
# Find main CLI class (first class in module)
for name, obj in inspect.getmembers(self.module):
if inspect.isclass(obj) and obj.__module__ == self.module.__name__:
self.cli_class = obj
break
if not self.cli_class:
print("Error: No CLI class found in module", file=sys.stderr)
return False
return True
except Exception as e:
print(f"Error loading CLI module: {e}", file=sys.stderr)
return False
def get_commands(self) -> Dict[str, Any]:
"""Get all available commands"""
if not self.cli_class:
return {}
commands = {}
instance = self.cli_class()
# Get methods from main class
for name, method in inspect.getmembers(instance, predicate=inspect.ismethod):
if not name.startswith('_'):
commands[name] = {
'type': 'method',
'signature': str(inspect.signature(method)),
'doc': inspect.getdoc(method) or 'No documentation'
}
# Get nested classes (command groups)
for name, obj in inspect.getmembers(self.cli_class):
if inspect.isclass(obj) and not name.startswith('_'):
commands[name] = {
'type': 'command_group',
'doc': inspect.getdoc(obj) or 'No documentation',
'methods': {}
}
# Get methods from nested class
for method_name, method in inspect.getmembers(obj, predicate=inspect.isfunction):
if not method_name.startswith('_'):
commands[name]['methods'][method_name] = {
'signature': str(inspect.signature(method)),
'doc': inspect.getdoc(method) or 'No documentation'
}
return commands
def test_instantiation(self) -> bool:
"""Test if CLI class can be instantiated"""
try:
instance = self.cli_class()
print("✅ CLI class instantiation: PASSED")
return True
except Exception as e:
print(f"❌ CLI class instantiation: FAILED - {e}")
return False
def test_method_signatures(self) -> bool:
"""Test if all methods have valid signatures"""
try:
instance = self.cli_class()
errors = []
for name, method in inspect.getmembers(instance, predicate=inspect.ismethod):
if name.startswith('_'):
continue
try:
sig = inspect.signature(method)
# Check for invalid parameter types
for param_name, param in sig.parameters.items():
if param.kind == inspect.Parameter.VAR_KEYWORD:
errors.append(f"Method '{name}' uses **kwargs (works but not recommended)")
except Exception as e:
errors.append(f"Method '{name}' signature error: {e}")
if errors:
print("⚠️ Method signatures: WARNINGS")
for error in errors:
print(f"{error}")
return True # Warnings, not failures
else:
print("✅ Method signatures: PASSED")
return True
except Exception as e:
print(f"❌ Method signatures: FAILED - {e}")
return False
def test_docstrings(self) -> bool:
"""Test if all public methods have docstrings"""
try:
instance = self.cli_class()
missing = []
for name, method in inspect.getmembers(instance, predicate=inspect.ismethod):
if name.startswith('_'):
continue
doc = inspect.getdoc(method)
if not doc:
missing.append(name)
if missing:
print("⚠️ Docstrings: WARNINGS")
print(f" Missing docstrings for: {', '.join(missing)}")
return True # Warnings, not failures
else:
print("✅ Docstrings: PASSED")
return True
except Exception as e:
print(f"❌ Docstrings: FAILED - {e}")
return False
def print_summary(self):
"""Print CLI summary"""
commands = self.get_commands()
print(f"\n{'='*60}")
print(f"Fire CLI Test Report: {self.filepath.name}")
print(f"{'='*60}\n")
print(f"CLI Class: {self.cli_class.__name__}")
print(f"Total Commands: {len(commands)}\n")
print("Available Commands:")
for cmd_name, cmd_info in commands.items():
if cmd_info['type'] == 'method':
print(f"{cmd_name}{cmd_info['signature']}")
elif cmd_info['type'] == 'command_group':
print(f"{cmd_name}/ (command group)")
for method_name, method_info in cmd_info['methods'].items():
print(f"{method_name}{method_info['signature']}")
print()
def run_tests(self) -> bool:
"""Run all tests"""
print(f"\nTesting Fire CLI: {self.filepath.name}\n")
results = []
results.append(self.test_instantiation())
results.append(self.test_method_signatures())
results.append(self.test_docstrings())
print()
return all(results)
def main():
if len(sys.argv) < 2:
print("Usage: test-fire-cli.py <fire-cli-file.py> [--summary]")
sys.exit(1)
filepath = Path(sys.argv[1])
show_summary = '--summary' in sys.argv
if not filepath.exists():
print(f"Error: File not found: {filepath}", file=sys.stderr)
sys.exit(1)
tester = FireCLITester(filepath)
if not tester.load_cli():
sys.exit(1)
if show_summary:
tester.print_summary()
else:
passed = tester.run_tests()
tester.print_summary()
sys.exit(0 if passed else 1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Validate Fire CLI structure and docstrings"""
import ast
import sys
from pathlib import Path
from typing import List, Dict, Tuple
class FireCLIValidator:
"""Validates Fire CLI Python files for proper structure"""
def __init__(self, filepath: Path):
self.filepath = filepath
self.errors: List[str] = []
self.warnings: List[str] = []
self.tree = None
def validate(self) -> bool:
"""Run all validation checks"""
if not self._parse_file():
return False
self._check_fire_import()
self._check_main_class()
self._check_docstrings()
self._check_fire_call()
self._check_method_signatures()
return len(self.errors) == 0
def _parse_file(self) -> bool:
"""Parse Python file into AST"""
try:
content = self.filepath.read_text()
self.tree = ast.parse(content)
return True
except SyntaxError as e:
self.errors.append(f"Syntax error: {e}")
return False
except Exception as e:
self.errors.append(f"Failed to parse file: {e}")
return False
def _check_fire_import(self):
"""Check for fire import"""
has_fire_import = False
for node in ast.walk(self.tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.name == 'fire':
has_fire_import = True
break
elif isinstance(node, ast.ImportFrom):
if node.module == 'fire':
has_fire_import = True
break
if not has_fire_import:
self.errors.append("Missing 'import fire' statement")
def _check_main_class(self):
"""Check for main CLI class"""
classes = [node for node in self.tree.body if isinstance(node, ast.ClassDef)]
if not classes:
self.errors.append("No classes found - Fire CLI requires at least one class")
return
main_class = classes[0] # Assume first class is main
# Check class docstring
docstring = ast.get_docstring(main_class)
if not docstring:
self.warnings.append(f"Class '{main_class.name}' missing docstring")
# Check for __init__ method
has_init = any(
isinstance(node, ast.FunctionDef) and node.name == '__init__'
for node in main_class.body
)
if not has_init:
self.warnings.append(f"Class '{main_class.name}' missing __init__ method")
def _check_docstrings(self):
"""Check method docstrings"""
for node in ast.walk(self.tree):
if isinstance(node, ast.ClassDef):
for item in node.body:
if isinstance(item, ast.FunctionDef):
# Skip private methods
if item.name.startswith('_'):
continue
docstring = ast.get_docstring(item)
if not docstring:
self.warnings.append(
f"Method '{item.name}' missing docstring "
"(used for Fire help text)"
)
else:
# Check for Args section in docstring
if item.args.args and len(item.args.args) > 1: # Skip 'self'
if 'Args:' not in docstring:
self.warnings.append(
f"Method '{item.name}' docstring missing 'Args:' section"
)
def _check_fire_call(self):
"""Check for fire.Fire() call"""
has_fire_call = False
for node in ast.walk(self.tree):
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Attribute):
if (isinstance(node.func.value, ast.Name) and
node.func.value.id == 'fire' and
node.func.attr == 'Fire'):
has_fire_call = True
break
if not has_fire_call:
self.errors.append("Missing 'fire.Fire()' call (required to run CLI)")
def _check_method_signatures(self):
"""Check method signatures for Fire compatibility"""
for node in ast.walk(self.tree):
if isinstance(node, ast.ClassDef):
for item in node.body:
if isinstance(item, ast.FunctionDef):
# Skip private and special methods
if item.name.startswith('_'):
continue
# Check for *args or **kwargs (Fire handles these but warn)
if item.args.vararg or item.args.kwarg:
self.warnings.append(
f"Method '{item.name}' uses *args or **kwargs - "
"Fire will handle these, but explicit params are clearer"
)
def print_results(self):
"""Print validation results"""
print(f"\n{'='*60}")
print(f"Fire CLI Validation: {self.filepath.name}")
print(f"{'='*60}\n")
if self.errors:
print("❌ ERRORS:")
for error in self.errors:
print(f"{error}")
print()
if self.warnings:
print("⚠️ WARNINGS:")
for warning in self.warnings:
print(f"{warning}")
print()
if not self.errors and not self.warnings:
print("✅ All checks passed!")
elif not self.errors:
print(f"✅ Validation passed with {len(self.warnings)} warning(s)")
else:
print(f"❌ Validation failed with {len(self.errors)} error(s)")
print()
def main():
if len(sys.argv) != 2:
print("Usage: validate-fire-cli.py <fire-cli-file.py>")
sys.exit(1)
filepath = Path(sys.argv[1])
if not filepath.exists():
print(f"Error: File not found: {filepath}")
sys.exit(1)
if not filepath.suffix == '.py':
print(f"Error: File must be a Python file (.py): {filepath}")
sys.exit(1)
validator = FireCLIValidator(filepath)
is_valid = validator.validate()
validator.print_results()
sys.exit(0 if is_valid else 1)
if __name__ == '__main__':
main()