From fbc878aa1436eb75984286dccee0ead7fb16ae29 Mon Sep 17 00:00:00 2001 From: Jomardyan Date: Thu, 20 Nov 2025 14:12:18 +0100 Subject: [PATCH 1/2] Move sample script into examples and refresh docs --- README.md | 2 + dashboard/backend/test_app.py | 2 +- docs/installation.md | 2 +- examples/sample_script.py | 5 + release.sh | 2 +- runner.py | 167 ++++++++++++++++++- runners/v7_enhancement.py | 291 ---------------------------------- setup.ps1 | 12 +- setup.sh | 12 +- test_script.py | 9 -- tests/conftest.py | 2 +- tests/test_dashboard.py | 2 +- tests/test_integration.py | 24 ++- tests/test_runner_core.py | 28 +++- tests/unit/test_runner.py | 56 ++++--- 15 files changed, 268 insertions(+), 348 deletions(-) create mode 100644 examples/sample_script.py delete mode 100644 runners/v7_enhancement.py delete mode 100644 test_script.py diff --git a/README.md b/README.md index 1f28624..d78541c 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ python -m runner script.py --slack-webhook "YOUR_WEBHOOK_URL" python-script-runner myscript.py ``` +> Need a quick smoke test? Run the bundled sample script with `python -m runner examples/sample_script.py` to see the default metrics output without creating your own file first. + ### 📊 Default Output - Comprehensive Metrics Report Every run automatically displays a detailed metrics report with: diff --git a/dashboard/backend/test_app.py b/dashboard/backend/test_app.py index 7ce4667..f4bbe97 100644 --- a/dashboard/backend/test_app.py +++ b/dashboard/backend/test_app.py @@ -57,7 +57,7 @@ def test_database(): cursor.execute("""INSERT INTO executions (script_path, script_args, start_time, end_time, execution_time_seconds, exit_code, success, stdout_lines, stderr_lines, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ('test_script.py', '', now, now, 1.5, 0, True, 10, 0, now)) + ('sample_script.py', '', now, now, 1.5, 0, True, 10, 0, now)) exec_id = cursor.lastrowid cursor.execute("""INSERT INTO metrics (execution_id, metric_name, metric_value) diff --git a/docs/installation.md b/docs/installation.md index f50796a..e800ead 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -57,7 +57,7 @@ docker run --rm psr myscript.py ```bash python runner.py --version python runner.py --help -python runner.py test_script.py +python runner.py examples/sample_script.py ``` ## Troubleshooting diff --git a/examples/sample_script.py b/examples/sample_script.py new file mode 100644 index 0000000..cdd4ec2 --- /dev/null +++ b/examples/sample_script.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +"""Simple sample script for Python Script Runner demonstration.""" + +print("Python Script Runner - Sample Script") +print("✅ Sample completed successfully") diff --git a/release.sh b/release.sh index 9da2c63..6e3a787 100755 --- a/release.sh +++ b/release.sh @@ -728,7 +728,7 @@ cmd_validate() { # Check Python compilation print_step "Checking code quality..." local compile_output - if compile_output=$(python3 -m py_compile runner.py test_script.py 2>&1); then + if compile_output=$(python3 -m py_compile runner.py examples/sample_script.py 2>&1); then print_success "Compilation successful" else print_error "Python compilation failed:" diff --git a/runner.py b/runner.py index b79e7d8..90e8920 100644 --- a/runner.py +++ b/runner.py @@ -6278,6 +6278,7 @@ def __init__(self, script_path: str, script_args: Optional[List[str]] = None, self.max_output_lines = None self.hooks = ExecutionHook() self.monitor_interval = 0.1 + self.config_file = config_file # UPDATED: Phase 2 retry config (replaces old retry_count and retry_delay) self.retry_config = RetryConfig() # Default configuration @@ -6291,8 +6292,10 @@ def __init__(self, script_path: str, script_args: Optional[List[str]] = None, # NEW: Phase 2 features self.enable_history = enable_history self.history_manager = None + self.history_db_path = None if enable_history: db_path = history_db or 'script_runner_history.db' + self.history_db_path = db_path self.history_manager = HistoryManager(db_path=db_path) # NEW: Trend Analysis (Phase 2) @@ -6497,6 +6500,30 @@ def validate_script(self) -> bool: self.logger.warning(f"Script does not have .py extension: {self.script_path}") return True + def get_execution_plan(self) -> Dict[str, Any]: + """Return a structured view of how the script will be executed. + + This helper is used by the CLI ``--dry-run`` flag to show what the + runner would do without actually launching the subprocess. It surfaces + key configuration such as the script path, arguments, timeouts, logging + level, configuration file, and history database state. + + Returns: + Dict[str, Any]: Execution summary including paths and toggles. + """ + return { + 'script_path': os.path.abspath(self.script_path), + 'script_args': list(self.script_args), + 'timeout': self.timeout, + 'log_level': logging.getLevelName(self.logger.level), + 'config_file': os.path.abspath(self.config_file) if self.config_file else None, + 'history_enabled': self.enable_history, + 'history_db': os.path.abspath(self.history_db_path) if self.history_db_path else None, + 'monitor_interval': self.monitor_interval, + 'retry_strategy': self.retry_config.strategy, + 'max_attempts': self.retry_config.max_attempts, + } + def run_script(self, retry_on_failure: bool = False) -> Dict: """Execute script with advanced retry and monitoring capabilities. @@ -7021,6 +7048,129 @@ def estimate_execution_costs(self) -> Optional[Dict]: self.logger.warning(f"Cost estimation failed: {e}") return None + # ------------------------------------------------------------------ + # General-purpose helpers derived from the previous v7 enhancement + # module. These helpers make the advanced features directly + # accessible from ScriptRunner without requiring a separate wrapper. + # ------------------------------------------------------------------ + def pre_execution_security_scan( + self, script_path: Optional[str] = None, block_on_critical: bool = False + ) -> Dict[str, Any]: + """Run code analysis before execution. + + Args: + script_path: Optional explicit script path; defaults to runner script. + block_on_critical: Whether to mark the scan as failed when critical + findings are present. + + Returns: + Dict[str, Any]: Scan outcome including findings and block status. + """ + target = script_path or self.script_path + + if not self.enable_code_analysis or not self.code_analyzer: + return {'success': True, 'findings': []} + + try: + result = self.code_analyzer.analyze(target) + critical_findings = getattr(result, 'critical_findings', []) + findings = getattr(result, 'findings', []) + + if critical_findings and block_on_critical: + self.logger.error(f"Critical security findings detected in {target}") + return { + 'success': False, + 'findings': [f.to_dict() if hasattr(f, 'to_dict') else f for f in critical_findings], + 'blocked': True, + } + + return { + 'success': True, + 'findings': [f.to_dict() if hasattr(f, 'to_dict') else f for f in findings], + 'critical_count': len(critical_findings), + } + except Exception as e: + self.logger.error(f"Security scan error: {e}") + return {'success': False, 'error': str(e)} + + def scan_dependencies(self, requirements_file: str = 'requirements.txt') -> Dict[str, Any]: + """Scan dependencies for vulnerabilities using the configured scanner.""" + if not self.enable_dependency_scanning or not self.dependency_scanner: + return {'success': True, 'vulnerabilities': []} + + if not os.path.exists(requirements_file): + return {'success': False, 'error': f'{requirements_file} not found'} + + try: + result = self.dependency_scanner.scan_requirements(requirements_file) + vulnerabilities = getattr(result, 'vulnerabilities', []) + return { + 'success': getattr(result, 'success', True), + 'vulnerability_count': len(vulnerabilities), + 'vulnerabilities': [v.to_dict() if hasattr(v, 'to_dict') else v for v in vulnerabilities], + 'sbom': getattr(result, 'sbom', None), + } + except Exception as e: + self.logger.error(f"Dependency scan error: {e}") + return {'success': False, 'error': str(e)} + + def scan_secrets(self, path: str = '.') -> Dict[str, Any]: + """Scan a path for hardcoded secrets.""" + if not self.enable_secret_scanning or not self.secret_scanner: + return {'success': True, 'secrets': []} + + try: + if os.path.isfile(path): + result = self.secret_scanner.scan_file(path) + else: + result = self.secret_scanner.scan_directory(path) + + secrets = getattr(result, 'secrets', []) + return { + 'success': getattr(result, 'success', True), + 'has_secrets': getattr(result, 'has_secrets', bool(secrets)), + 'secret_count': len(secrets), + 'secrets': [s.to_dict() if hasattr(s, 'to_dict') else s for s in secrets], + } + except Exception as e: + self.logger.error(f"Secret scan error: {e}") + return {'success': False, 'error': str(e)} + + def start_tracing_span(self, span_name: str): + """Start a distributed tracing span using the configured tracer.""" + if self.tracing_manager: + return self.tracing_manager.trace_span(span_name) + + from contextlib import contextmanager + + @contextmanager + def noop(): + yield None + + return noop() + + def start_cost_tracking(self) -> None: + """Begin monitoring execution costs if enabled.""" + if self.cost_tracker: + self.cost_tracker.start_monitoring() + self.logger.info("Cost tracking started") + + def stop_cost_tracking(self) -> Dict[str, Any]: + """Stop cost tracking and return a summary report.""" + if not self.cost_tracker: + return {} + + try: + report = self.cost_tracker.get_cost_report() + return { + 'total_estimated_cost_usd': getattr(report, 'total_estimated_cost_usd', 0), + 'cost_by_provider': getattr(report, 'cost_by_provider', {}), + 'cost_by_service': getattr(report, 'cost_by_service', {}), + } + except Exception as e: + self.logger.error(f"Cost tracking error: {e}") + return {} + def start_execution_tracing(self) -> Optional[Any]: """Start OpenTelemetry tracing for script execution. @@ -7148,8 +7298,10 @@ def main(): parser.add_argument('script', nargs='?', help='Python script to execute') parser.add_argument('script_args', nargs='*', help='Arguments to pass to the script') parser.add_argument('--timeout', type=int, default=None, help='Execution timeout in seconds') - parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], + parser.add_argument('--log-level', choices=['DEBUG', 'INFO', 'WARNING', 'ERROR'], default='INFO', help='Logging level') + parser.add_argument('--dry-run', action='store_true', + help='Validate the script and show execution plan without running it') parser.add_argument('--config', help='Configuration file (YAML)') parser.add_argument('--monitor-interval', type=float, default=0.1, help='Process monitor sampling interval (seconds)') @@ -8479,6 +8631,19 @@ def main(): enable_history=not args.disable_history ) + if args.dry_run: + try: + runner.validate_script() + except Exception as exc: + logging.error(f"Dry-run validation failed: {exc}") + return 1 + + plan = runner.get_execution_plan() + print("\nDRY-RUN: Execution plan (no script executed)") + for key, value in plan.items(): + print(f" {key}: {value}") + return 0 + runner.monitor_interval = args.monitor_interval runner.suppress_warnings = args.suppress_warnings diff --git a/runners/v7_enhancement.py b/runners/v7_enhancement.py deleted file mode 100644 index e4e6272..0000000 --- a/runners/v7_enhancement.py +++ /dev/null @@ -1,291 +0,0 @@ -""" -Python Script Runner v7.0 - ScriptRunner Enhancement with v7 Features - -This module integrates all v7.0 features (workflows, tracing, security, costs) -seamlessly into the existing ScriptRunner class while maintaining 100% backward -compatibility. - -Features: -- Workflow Engine integration -- OpenTelemetry distributed tracing -- Automated security scanning -- Dependency vulnerability scanning -- Secret detection -- Multi-cloud cost tracking -""" - -import os -import sys -import logging -from typing import Dict, List, Optional, Any -from pathlib import Path - -# Import v7 features -try: - from runners.workflows.workflow_engine import WorkflowEngine - from runners.tracers.otel_manager import TracingManager - from runners.scanners.code_analyzer import CodeAnalyzer - from runners.scanners.dependency_scanner import DependencyVulnerabilityScanner - from runners.security.secret_scanner import SecretScanner - from runners.integrations.cloud_cost_tracker import CloudCostTracker - V7_FEATURES_AVAILABLE = True -except ImportError as e: - V7_FEATURES_AVAILABLE = False - print(f"Warning: v7 features not fully available: {e}") - - -logger = logging.getLogger(__name__) - - -class V7ScriptRunnerEnhancer: - """Enhances ScriptRunner with v7.0 features while maintaining backward compatibility""" - - def __init__(self, script_runner, config: Optional[Dict[str, Any]] = None): - """Initialize enhancer with existing ScriptRunner instance - - Args: - script_runner: Existing ScriptRunner instance - config: Configuration dict for v7 features - """ - self.runner = script_runner - self.config = config or {} - self.logger = logging.getLogger(__name__) - - # Initialize v7 feature managers - self.workflow_engine = None - self.tracing_manager = None - self.code_analyzer = None - self.dependency_scanner = None - self.secret_scanner = None - self.cost_tracker = None - - # Feature flags - self.enable_workflows = self.config.get('workflows', {}).get('enabled', False) - self.enable_tracing = self.config.get('tracing', {}).get('enabled', False) - self.enable_security = self.config.get('security', {}).get('enabled', False) - self.enable_costs = self.config.get('costs', {}).get('enabled', False) - - self._initialize_features() - - def _initialize_features(self): - """Initialize all enabled v7 features""" - if not V7_FEATURES_AVAILABLE: - self.logger.warning("v7 features not available") - return - - try: - # Initialize workflow engine - if self.enable_workflows: - self.workflow_engine = WorkflowEngine() - self.logger.info("✓ Workflow Engine initialized") - - # Initialize tracing - if self.enable_tracing: - tracing_config = self.config.get('tracing', {}) - self.tracing_manager = TracingManager( - service_name=tracing_config.get('service_name', 'script_runner'), - exporter_type=tracing_config.get('exporter_type', 'jaeger'), - sampling_rate=tracing_config.get('sampling_rate', 0.1) - ) - self.logger.info("✓ Tracing Manager initialized") - - # Initialize security scanning - if self.enable_security: - self.code_analyzer = CodeAnalyzer() - self.dependency_scanner = DependencyVulnerabilityScanner() - self.secret_scanner = SecretScanner() - self.logger.info("✓ Security scanners initialized") - - # Initialize cost tracking - if self.enable_costs: - self.cost_tracker = CloudCostTracker() - self.logger.info("✓ Cost tracker initialized") - - except Exception as e: - self.logger.error(f"Error initializing v7 features: {e}") - - def pre_execution_security_scan(self, script_path: str) -> Dict[str, Any]: - """Run pre-execution security scanning - - Args: - script_path: Path to script to scan - - Returns: - Dict with security findings - """ - if not self.enable_security or not self.code_analyzer: - return {'success': True, 'findings': []} - - try: - # Scan the script for vulnerabilities - result = self.code_analyzer.analyze(script_path) - - # Check for critical findings - if result.critical_findings and self.config.get('security', {}).get('block_on_critical', False): - self.logger.error(f"Critical security findings detected in {script_path}") - return { - 'success': False, - 'findings': [f.to_dict() for f in result.critical_findings], - 'blocked': True - } - - return { - 'success': True, - 'findings': [f.to_dict() for f in result.findings], - 'critical_count': len(result.critical_findings) - } - except Exception as e: - self.logger.error(f"Security scan error: {e}") - return {'success': False, 'error': str(e)} - - def scan_dependencies(self, requirements_file: str = 'requirements.txt') -> Dict[str, Any]: - """Scan project dependencies for vulnerabilities - - Args: - requirements_file: Path to requirements.txt - - Returns: - Dict with vulnerability findings - """ - if not self.enable_security or not self.dependency_scanner: - return {'success': True, 'vulnerabilities': []} - - if not os.path.exists(requirements_file): - return {'success': False, 'error': f'{requirements_file} not found'} - - try: - result = self.dependency_scanner.scan_requirements(requirements_file) - return { - 'success': result.success, - 'vulnerability_count': len(result.vulnerabilities), - 'vulnerabilities': [v.to_dict() for v in result.vulnerabilities], - 'sbom': result.sbom if hasattr(result, 'sbom') else None - } - except Exception as e: - self.logger.error(f"Dependency scan error: {e}") - return {'success': False, 'error': str(e)} - - def scan_secrets(self, path: str = '.') -> Dict[str, Any]: - """Scan for hardcoded secrets - - Args: - path: Path to scan (file or directory) - - Returns: - Dict with detected secrets - """ - if not self.enable_security or not self.secret_scanner: - return {'success': True, 'secrets': []} - - try: - if os.path.isfile(path): - result = self.secret_scanner.scan_file(path) - else: - result = self.secret_scanner.scan_directory(path) - - return { - 'success': result.success if hasattr(result, 'success') else True, - 'has_secrets': result.has_secrets if hasattr(result, 'has_secrets') else False, - 'secret_count': len(result.secrets) if hasattr(result, 'secrets') else 0, - 'secrets': [s.to_dict() for s in result.secrets] if hasattr(result, 'secrets') else [] - } - except Exception as e: - self.logger.error(f"Secret scan error: {e}") - return {'success': False, 'error': str(e)} - - def start_tracing_span(self, span_name: str): - """Start a distributed tracing span - - Args: - span_name: Name of the span - - Returns: - Context manager for the span - """ - if self.tracing_manager: - return self.tracing_manager.trace_span(span_name) - else: - # Return no-op context manager - from contextlib import contextmanager - @contextmanager - def noop(): - yield None - return noop() - - def start_cost_tracking(self): - """Start cloud cost tracking""" - if self.cost_tracker: - self.cost_tracker.start_monitoring() - self.logger.info("Cost tracking started") - - def stop_cost_tracking(self) -> Dict[str, Any]: - """Stop cost tracking and get cost report - - Returns: - Dict with cost analysis - """ - if not self.cost_tracker: - return {} - - try: - report = self.cost_tracker.get_cost_report() - return { - 'total_estimated_cost_usd': report.total_estimated_cost_usd if hasattr(report, 'total_estimated_cost_usd') else 0, - 'cost_by_provider': report.cost_by_provider if hasattr(report, 'cost_by_provider') else {}, - 'cost_by_service': report.cost_by_service if hasattr(report, 'cost_by_service') else {} - } - except Exception as e: - self.logger.error(f"Cost tracking error: {e}") - return {} - - -def enhance_script_runner(runner, config: Optional[Dict[str, Any]] = None) -> V7ScriptRunnerEnhancer: - """Enhance existing ScriptRunner instance with v7 features - - Args: - runner: ScriptRunner instance - config: Configuration dict for v7 features - - Returns: - V7ScriptRunnerEnhancer instance - - Example: - >>> from runner import ScriptRunner - >>> runner = ScriptRunner('script.py') - >>> v7_enhancer = enhance_script_runner(runner, { - ... 'workflows': {'enabled': True}, - ... 'tracing': {'enabled': True, 'sampling_rate': 0.1}, - ... 'security': {'enabled': True, 'block_on_critical': True}, - ... 'costs': {'enabled': True} - ... }) - >>> v7_enhancer.pre_execution_security_scan('script.py') - """ - return V7ScriptRunnerEnhancer(runner, config) - - -def load_v7_config(config_file: str) -> Dict[str, Any]: - """Load v7 feature configuration from YAML file - - Args: - config_file: Path to config.yaml - - Returns: - Configuration dict - """ - try: - import yaml - except ImportError: - logger.warning("PyYAML not installed, using default config") - return {} - - if not os.path.exists(config_file): - logger.warning(f"Config file {config_file} not found") - return {} - - try: - with open(config_file, 'r') as f: - config = yaml.safe_load(f) or {} - return config - except Exception as e: - logger.error(f"Error loading config: {e}") - return {} diff --git a/setup.ps1 b/setup.ps1 index 11eb813..997ca5d 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -425,14 +425,14 @@ catch { if ($SETUP_COMMAND -eq "develop") { Write-Section "✓ Development installation completed!" Write-Host "You can now run the script runner:" - Write-Host " python-script-runner test_script.py" - Write-Host " python -m runner test_script.py" + Write-Host " python-script-runner examples/sample_script.py" + Write-Host " python -m runner examples/sample_script.py" Write-Host "" } elseif ($SETUP_COMMAND -eq "install") { Write-Section "✓ Installation completed!" Write-Host "You can now run the script runner:" - Write-Host " python-script-runner test_script.py" + Write-Host " python-script-runner examples/sample_script.py" Write-Host "" } elseif ($SETUP_COMMAND -eq "py2exe") { @@ -442,7 +442,7 @@ elseif ($SETUP_COMMAND -eq "py2exe") { Write-Host " dist\python-script-runner.exe" Write-Host "" Write-Host "To test the executable, run:" - Write-Host " .\dist\python-script-runner.exe test_script.py" + Write-Host " .\dist\python-script-runner.exe examples/sample_script.py" Write-Host "" Write-Host "You can distribute this executable to other Windows machines" Write-Host "without requiring Python installation." @@ -472,8 +472,8 @@ Write-Success "Virtual environment is ACTIVE" Write-Host "" Write-Host "The virtual environment will remain active in this PowerShell session." Write-Host "You can now run commands like:" -Write-Host " python-script-runner test_script.py" -Write-Host " python -m runner test_script.py" +Write-Host " python-script-runner examples/sample_script.py" +Write-Host " python -m runner examples/sample_script.py" Write-Host "" Write-Host "To deactivate the virtual environment, type: deactivate" Write-Host "" diff --git a/setup.sh b/setup.sh index f43e454..cd0f925 100755 --- a/setup.sh +++ b/setup.sh @@ -241,8 +241,8 @@ if [ "$SETUP_COMMAND" = "develop" ]; then echo "======================================${NC}" echo "" echo "You can now run the script runner:" - echo " python-script-runner test_script.py" - echo " python -m runner test_script.py" + echo " python-script-runner examples/sample_script.py" + echo " python -m runner examples/sample_script.py" echo "" elif [ "$SETUP_COMMAND" = "install" ]; then echo -e "${GREEN}======================================" @@ -250,7 +250,7 @@ elif [ "$SETUP_COMMAND" = "install" ]; then echo "======================================${NC}" echo "" echo "You can now run the script runner:" - echo " python-script-runner test_script.py" + echo " python-script-runner examples/sample_script.py" echo "" elif [ "$SETUP_COMMAND" = "py2app" ]; then if [ -d "dist/Python Script Runner.app" ]; then @@ -262,7 +262,7 @@ elif [ "$SETUP_COMMAND" = "py2app" ]; then echo " dist/Python Script Runner.app" echo "" echo "To test the app, run:" - echo " \"./dist/Python Script Runner.app/Contents/MacOS/Python Script Runner\" test_script.py" + echo " \"./dist/Python Script Runner.app/Contents/MacOS/Python Script Runner\" examples/sample_script.py" echo "" echo "To install the app, drag it to /Applications" else @@ -298,8 +298,8 @@ echo -e "${GREEN}✓ Virtual environment is ACTIVE${NC}" echo "" echo "The virtual environment will remain active in this terminal." echo "You can now run commands like:" -echo " python-script-runner test_script.py" -echo " python -m runner test_script.py" +echo " python-script-runner examples/sample_script.py" +echo " python -m runner examples/sample_script.py" echo "" echo "To deactivate the virtual environment, type: deactivate" echo "" diff --git a/test_script.py b/test_script.py deleted file mode 100644 index fdac975..0000000 --- a/test_script.py +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env python3 -""" -Minimal test script for Python Script Runner -Used for validation and testing purposes -""" - -print("Python Script Runner - Test Script") -print("✅ Test completed successfully") -exit(0) diff --git a/tests/conftest.py b/tests/conftest.py index f664b9c..1dea752 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -201,7 +201,7 @@ def get_password(): password = "hardcoded_password" # This is a security issue return password """ - py_file = tmp_path / "test_script.py" + py_file = tmp_path / "sample_script.py" py_file.write_text(code) return py_file diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 7ce4667..f4bbe97 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -57,7 +57,7 @@ def test_database(): cursor.execute("""INSERT INTO executions (script_path, script_args, start_time, end_time, execution_time_seconds, exit_code, success, stdout_lines, stderr_lines, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - ('test_script.py', '', now, now, 1.5, 0, True, 10, 0, now)) + ('sample_script.py', '', now, now, 1.5, 0, True, 10, 0, now)) exec_id = cursor.lastrowid cursor.execute("""INSERT INTO metrics (execution_id, metric_name, metric_value) diff --git a/tests/test_integration.py b/tests/test_integration.py index a5bf15a..b42638f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -15,6 +15,7 @@ import tempfile import json import time +import subprocess from pathlib import Path from unittest.mock import Mock, patch @@ -66,16 +67,33 @@ def test_history_database_creation(self, tmp_path): """Test that history database is properly created""" script_file = tmp_path / "test_db.py" script_file.write_text("print('test'); exit(0)") - + db_file = tmp_path / "test.db" runner = ScriptRunner(str(script_file), enable_history=True) - + result = runner.run_script() - + # Check if metrics are collected assert 'metrics' in result assert len(result['metrics']) > 0 + def test_cli_dry_run_shows_execution_plan(self, tmp_path): + """Ensure CLI dry-run validates script and prints plan without running it.""" + script_file = tmp_path / "dry_run_target.py" + script_file.write_text("print('should not run during dry-run')") + + result = subprocess.run( + [sys.executable, "-m", "runner", str(script_file), "--dry-run", "--timeout", "3"], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert "DRY-RUN: Execution plan" in result.stdout + assert "dry_run_target.py" in result.stdout + assert "timeout: 3" in result.stdout + @pytest.mark.integration class TestAlertIntegration: diff --git a/tests/test_runner_core.py b/tests/test_runner_core.py index 53eb81f..e25d99b 100644 --- a/tests/test_runner_core.py +++ b/tests/test_runner_core.py @@ -60,11 +60,35 @@ def test_runner_with_history(self, tmp_path): script_file = tmp_path / "test.py" script_file.write_text("print('hello')") db_file = tmp_path / "history.db" - + runner = ScriptRunner(str(script_file), enable_history=True, history_db=str(db_file)) - + assert runner.enable_history is True + def test_execution_plan_summary(self, tmp_path): + """Ensure execution plan surfaces key configuration without running script.""" + script_file = tmp_path / "plan.py" + script_file.write_text("print('dry run')") + db_file = tmp_path / "history.db" + + runner = ScriptRunner( + str(script_file), + script_args=["--flag", "value"], + timeout=5, + history_db=str(db_file), + enable_history=True, + log_level="DEBUG", + ) + + plan = runner.get_execution_plan() + + assert plan["script_path"].endswith("plan.py") + assert plan["script_args"] == ["--flag", "value"] + assert plan["timeout"] == 5 + assert plan["history_enabled"] is True + assert plan["history_db"].endswith("history.db") + assert plan["log_level"] == "DEBUG" + @pytest.mark.unit class TestScriptExecution: diff --git a/tests/unit/test_runner.py b/tests/unit/test_runner.py index 132ea26..25b135e 100644 --- a/tests/unit/test_runner.py +++ b/tests/unit/test_runner.py @@ -400,34 +400,40 @@ def test_script_syntax_error_handling(self, tmp_path): class TestV7FeatureIntegration: """Test integration with v7 features""" - - def test_v7_enhancement_available(self, tmp_path): - """Test v7 enhancement loading""" - try: - from runners.v7_enhancement import enhance_script_runner - script_file = tmp_path / "test.py" - script_file.write_text("print('test')\nexit(0)") - - runner = ScriptRunner(str(script_file)) - # Should be able to enhance - assert runner is not None - except ImportError: - pytest.skip("v7 features not available") - - @patch('runners.v7_enhancement.V7ScriptRunnerEnhancer') - def test_v7_security_scanning(self, mock_enhancer, tmp_path): - """Test v7 security scanning integration""" + + def test_v7_features_exposed_directly(self, tmp_path): + """Runner should expose v7 helpers without a separate enhancer.""" script_file = tmp_path / "test.py" script_file.write_text("print('test')\nexit(0)") - + runner = ScriptRunner(str(script_file)) - - # Mock enhancer - enhancer_instance = Mock() - mock_enhancer.return_value = enhancer_instance - - # Should be able to use enhancer - assert runner is not None + + assert hasattr(runner, 'start_tracing_span') + assert hasattr(runner, 'start_cost_tracking') + assert runner.pre_execution_security_scan()['success'] is True + + def test_pre_execution_security_scan_blocks_on_critical(self, tmp_path): + """Security scan helper should block when critical findings are present.""" + script_file = tmp_path / "test.py" + script_file.write_text("print('test')\nexit(0)") + + runner = ScriptRunner(str(script_file)) + + class FakeFinding: + def to_dict(self): + return {'id': 'C1'} + + class FakeResult: + findings = [FakeFinding()] + critical_findings = [FakeFinding()] + + runner.code_analyzer = Mock(analyze=Mock(return_value=FakeResult())) + runner.enable_code_analysis = True + + scan_result = runner.pre_execution_security_scan(block_on_critical=True) + + assert scan_result['success'] is False + assert scan_result['blocked'] is True class TestIntegration: From 533b58e0da11a473ddb34edaa22f5b837022652e Mon Sep 17 00:00:00 2001 From: Jomardyan Date: Thu, 20 Nov 2025 14:19:14 +0100 Subject: [PATCH 2/2] Add dependency-aware task scheduler execution --- README.md | 41 ++++++++++ runner.py | 165 ++++++++++++++++++++++++++++++++++++---- tests/test_scheduler.py | 60 +++++++++++++++ 3 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 tests/test_scheduler.py diff --git a/README.md b/README.md index d78541c..722ee3d 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,47 @@ print(f"Start Time: {result['metrics']['start_time']}") print(f"Exit Code: {result['exit_code']}") print(f"Success: {result['metrics']['success']}") ``` + +## ⏱️ Task Scheduling & Automation + +Python Script Runner ships with a lightweight scheduler to automate recurring or event-driven jobs without standing up extra infrastructure. The scheduler includes: + +- **Automation & recurrence**: Define hourly, daily, weekly, or custom interval schedules (`every_5min`, `every_30min`) or supply a cron expression for more complex windows. +- **Dependency-aware execution**: Chain tasks together so downstream jobs only start after upstream tasks complete successfully. +- **Error handling & visibility**: Execution results are captured in-memory with status, error messages, and next-run timestamps for quick troubleshooting. +- **Event triggers**: Bind tasks to custom events (for example, `on_script_failure`) and trigger them manually via the CLI. + +Basic usage: + +```python +from runner import TaskScheduler + +scheduler = TaskScheduler() + +# Schedule a daily report and a dependent distribution step +scheduler.add_scheduled_task("generate_report", "reports/daily.py", schedule="daily") +scheduler.add_scheduled_task( + "distribute_report", + "reports/distribute.py", + dependencies=["generate_report"], +) + +# Run any tasks that are due (e.g., inside a cron shell) +for result in scheduler.run_due_tasks(): + print(result) +``` + +You can also interact via the CLI: + +```bash +python -m runner \ + --add-scheduled-task nightly_cleanup \ + --script scripts/cleanup.py \ + --schedule daily \ + --list-scheduled-tasks +``` + +The scheduler respects dependency ordering automatically; if a prerequisite task fails, dependent tasks are skipped until the next eligible run. **Benefit**: SQLite database provides immutable audit trail for SOC2/HIPAA compliance. Every execution logged with full context. --- diff --git a/runner.py b/runner.py index 90e8920..8d60c54 100644 --- a/runner.py +++ b/runner.py @@ -2888,12 +2888,13 @@ def get_optimization_report(self, script_path: str, days: int = 30) -> str: class ScheduledTask: """Represents a scheduled task""" - + def __init__(self, task_id: str, script_path: str, schedule: Optional[str] = None, cron_expr: Optional[str] = None, trigger_events: Optional[List[str]] = None, - enabled: bool = True): + enabled: bool = True, script_args: Optional[List[str]] = None, + dependencies: Optional[List[str]] = None): """Initialize scheduled task - + Args: task_id: Unique task identifier script_path: Path to script to execute @@ -2901,6 +2902,8 @@ def __init__(self, task_id: str, script_path: str, schedule: Optional[str] = Non cron_expr: Cron expression for complex schedules trigger_events: Event names that trigger execution enabled: Whether task is enabled + script_args: Arguments to pass to the script during execution + dependencies: Other task IDs that must complete successfully first """ self.task_id = task_id self.script_path = script_path @@ -2908,44 +2911,73 @@ def __init__(self, task_id: str, script_path: str, schedule: Optional[str] = Non self.cron_expr = cron_expr self.trigger_events = trigger_events or [] self.enabled = enabled + self.script_args = script_args or [] + self.dependencies = dependencies or [] self.last_run: Optional[datetime] = None self.next_run: Optional[datetime] = None self.run_count = 0 - self.last_status = None + self.last_status: Optional[str] = None + self.last_error: Optional[str] = None class TaskScheduler: """Manages scheduled script execution and event-driven triggers""" - - def __init__(self, logger: Optional[logging.Logger] = None): + + def __init__(self, logger: Optional[logging.Logger] = None, history_db: Optional[str] = None): """Initialize scheduler - + Args: logger: Logger instance + history_db: Optional history database path passed to ScriptRunner """ self.logger = logger or logging.getLogger(__name__) self.tasks = {} self.events = {} - self.triggered_tasks = [] - + self.triggered_tasks: List[str] = [] + self.history_db = history_db + self.execution_log: List[Dict[str, Any]] = [] + def add_scheduled_task(self, task_id: str, script_path: str, - schedule: Optional[str] = None, cron_expr: Optional[str] = None) -> ScheduledTask: + schedule: Optional[str] = None, cron_expr: Optional[str] = None, + script_args: Optional[List[str]] = None, + dependencies: Optional[List[str]] = None) -> ScheduledTask: """Add a scheduled task - + Args: task_id: Unique identifier script_path: Script to run schedule: Simple schedule string cron_expr: Cron expression - + script_args: Arguments for the script + dependencies: List of prerequisite task IDs + Returns: ScheduledTask object """ - task = ScheduledTask(task_id, script_path, schedule, cron_expr) + task = ScheduledTask(task_id, script_path, schedule, cron_expr, script_args=script_args, + dependencies=dependencies) self.tasks[task_id] = task self._calculate_next_run(task) self.logger.info(f"Added task '{task_id}': {script_path}") return task + + def add_dependencies(self, task_id: str, dependencies: List[str]) -> bool: + """Register dependencies for an existing task. + + Args: + task_id: Task that should wait on dependencies + dependencies: Other task IDs that must complete successfully + + Returns: + bool: True if dependencies were added + """ + if task_id not in self.tasks: + self.logger.error(f"Task '{task_id}' not found") + return False + + self.tasks[task_id].dependencies = list(dependencies) + self.logger.info(f"Task '{task_id}' dependencies set: {', '.join(dependencies)}") + return True def add_event_trigger(self, task_id: str, event_name: str) -> bool: """Add event trigger for a task @@ -2982,7 +3014,15 @@ def trigger_event(self, event_name: str) -> List[str]: tasks = self.events.get(event_name, []) self.logger.info(f"Event '{event_name}' triggered: {len(tasks)} tasks") return tasks - + + def _dependencies_satisfied(self, task: ScheduledTask) -> bool: + """Check whether all dependencies for a task are successful.""" + for dep_id in task.dependencies: + dep = self.tasks.get(dep_id) + if not dep or dep.last_status != "success": + return False + return True + def get_due_tasks(self) -> List[ScheduledTask]: """Get tasks that are due for execution @@ -3015,6 +3055,89 @@ def mark_executed(self, task_id: str, status: str = "success"): task.run_count += 1 self._calculate_next_run(task) self.logger.info(f"Task '{task_id}' executed: {status}") + + def run_task(self, task_id: str, runner_factory: Optional[Callable[..., Any]] = None, + runner_kwargs: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Execute a task immediately using the provided runner factory.""" + if task_id not in self.tasks: + raise ValueError(f"Task '{task_id}' not found") + + task = self.tasks[task_id] + runner_kwargs = runner_kwargs or {} + runner_factory = runner_factory or ScriptRunner + + if not self._dependencies_satisfied(task): + self.logger.info(f"Task '{task_id}' skipped: waiting on dependencies") + return {"task_id": task_id, "status": "skipped", "reason": "dependencies_pending"} + + try: + runner = runner_factory( + task.script_path, + script_args=task.script_args, + history_db=self.history_db, + **runner_kwargs, + ) + execution = runner.run_script() + status = "success" if execution.get("returncode") == 0 else "failed" + error = execution.get("stderr") if status == "failed" else None + except Exception as exc: + execution = None + status = "failed" + error = str(exc) + + task.last_error = error + self.mark_executed(task_id, status) + + log_entry = { + "task_id": task_id, + "status": status, + "timestamp": datetime.now().isoformat(), + "error": error, + "next_run": task.next_run.isoformat() if task.next_run else None, + } + self.execution_log.append(log_entry) + + if status != "success": + self.logger.error(f"Task '{task_id}' failed: {error}") + else: + self.logger.info(f"Task '{task_id}' completed successfully") + + return { + "task_id": task_id, + "status": status, + "error": error, + "metrics": execution.get("metrics") if execution else None, + } + + def run_due_tasks(self, runner_factory: Optional[Callable[..., Any]] = None, + runner_kwargs: Optional[Dict[str, Any]] = None, + stop_on_error: bool = False) -> List[Dict[str, Any]]: + """Execute all due tasks whose dependencies are satisfied.""" + results: List[Dict[str, Any]] = [] + pending = {task.task_id: task for task in self.get_due_tasks()} + + runner_factory = runner_factory or ScriptRunner + runner_kwargs = runner_kwargs or {} + + while pending: + progressed = False + for task_id, task in list(pending.items()): + if not self._dependencies_satisfied(task): + continue + + result = self.run_task(task_id, runner_factory=runner_factory, runner_kwargs=runner_kwargs) + results.append(result) + progressed = True + pending.pop(task_id, None) + + if stop_on_error and result.get("status") != "success": + return results + + if not progressed: + self.logger.info("No further progress possible; remaining tasks waiting on dependencies") + break + + return results def _calculate_next_run(self, task: ScheduledTask): """Calculate next run time for task @@ -3045,6 +3168,14 @@ def _calculate_next_run(self, task: ScheduledTask): task.next_run = now + timedelta(seconds=amount) except Exception as e: self.logger.error(f"Error parsing schedule '{task.schedule}': {e}") + elif task.cron_expr: + try: + from croniter import croniter # type: ignore + + iterator = croniter(task.cron_expr, now) + task.next_run = iterator.get_next(datetime) + except Exception as e: + self.logger.error(f"Error parsing cron expression '{task.cron_expr}': {e}") else: task.next_run = now + timedelta(hours=1) # Default to 1 hour @@ -3069,7 +3200,9 @@ def get_task_status(self, task_id: str) -> Dict: "next_run": task.next_run.isoformat() if task.next_run else None, "run_count": task.run_count, "last_status": task.last_status, - "triggers": task.trigger_events + "triggers": task.trigger_events, + "dependencies": task.dependencies, + "last_error": task.last_error, } def list_tasks(self) -> List[Dict]: @@ -7853,6 +7986,8 @@ def main(): print(f" Enabled: {task['enabled']}") print(f" Runs: {task['run_count']}") print(f" Last status: {task['last_status']}") + if task.get('dependencies'): + print(f" Depends on: {', '.join(task['dependencies'])}") if task['triggers']: print(f" Triggers: {', '.join(task['triggers'])}") else: diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 0000000..bc13b46 --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,60 @@ +import datetime +from typing import List + +import pytest + +from runner import TaskScheduler + + +class _DummyRunner: + calls: List[str] = [] + + def __init__(self, script_path: str, **_: object) -> None: + self.script_path = script_path + + def run_script(self, retry_on_failure: bool = False): # noqa: D401 + """Pretend to execute the script and record the call order.""" + _DummyRunner.calls.append(self.script_path) + return {"returncode": 0, "stderr": "", "metrics": {"execution_time_seconds": 0.1}} + + +def test_run_due_tasks_executes_and_logs_success(): + scheduler = TaskScheduler() + task = scheduler.add_scheduled_task("demo", "examples/sample_script.py", schedule="hourly") + task.next_run = datetime.datetime.now() - datetime.timedelta(minutes=1) + + _DummyRunner.calls = [] + results = scheduler.run_due_tasks(runner_factory=_DummyRunner) + + assert results and results[0]["status"] == "success" + assert scheduler.execution_log[-1]["status"] == "success" + assert scheduler.tasks["demo"].run_count == 1 + assert _DummyRunner.calls == ["examples/sample_script.py"] + + +def test_dependencies_run_after_prerequisites(): + scheduler = TaskScheduler() + parent = scheduler.add_scheduled_task("parent", "examples/sample_script.py") + child = scheduler.add_scheduled_task( + "child", + "examples/sample_script.py", + dependencies=["parent"], + ) + + parent.next_run = datetime.datetime.now() - datetime.timedelta(minutes=1) + child.next_run = datetime.datetime.now() - datetime.timedelta(minutes=1) + + execution_order: List[str] = [] + + class _DependencyAwareRunner: + def __init__(self, script_path: str, **_: object) -> None: + self.script_path = script_path + + def run_script(self, retry_on_failure: bool = False): # noqa: D401 + execution_order.append(self.script_path) + return {"returncode": 0, "stderr": "", "metrics": {}} + + scheduler.run_due_tasks(runner_factory=_DependencyAwareRunner) + + assert execution_order == ["examples/sample_script.py", "examples/sample_script.py"] + assert scheduler.tasks["child"].last_status == "success"