diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..ff34600 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -0,0 +1,403 @@ +name: Build Game + +# Build pipeline for creating game executables across multiple platforms +# +# Features: +# - Manual trigger with platform selection +# - Tag-based automatic builds for releases +# - Multi-platform builds (Windows, Linux, macOS, Android) +# - Artifact storage for one week +# - Configurable build options + +on: + # Manual trigger with platform selection + workflow_dispatch: + inputs: + platforms: + description: 'Platforms to build (comma-separated: windows,linux,macos,android)' + required: true + default: 'windows,linux' + type: string + build_type: + description: 'Build type' + required: true + default: 'release' + type: choice + options: + - release + - debug + version_override: + description: 'Override version (optional)' + required: false + type: string + + # Automatic trigger on git tags (for releases) + push: + tags: + - 'v*' # Version tags (v1.0.0, v2.1.0, etc.) + - 'release-*' # Release tags + +env: + GODOT_VERSION: "4.4-dev2" + PROJECT_NAME: "Skelly" + BUILD_DIR: "builds" + +jobs: + # Preparation job - determines build configuration + prepare: + name: Prepare Build + runs-on: ubuntu-latest + outputs: + platforms: ${{ steps.config.outputs.platforms }} + build_type: ${{ steps.config.outputs.build_type }} + version: ${{ steps.config.outputs.version }} + artifact_name: ${{ steps.config.outputs.artifact_name }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure build parameters + id: config + run: | + # Determine platforms to build + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + platforms="${{ github.event.inputs.platforms }}" + build_type="${{ github.event.inputs.build_type }}" + version_override="${{ github.event.inputs.version_override }}" + else + # Tag-triggered build - build all platforms + platforms="windows,linux,macos,android" + build_type="release" + version_override="" + fi + + # Determine version + if [[ -n "$version_override" ]]; then + version="$version_override" + elif [[ "${{ github.ref_type }}" == "tag" ]]; then + version="${{ github.ref_name }}" + else + # Generate version from git info + commit_short=$(git rev-parse --short HEAD) + branch_name="${{ github.ref_name }}" + timestamp=$(date +%Y%m%d-%H%M) + version="${branch_name}-${commit_short}-${timestamp}" + fi + + # Create artifact name + artifact_name="${{ env.PROJECT_NAME }}-${version}-${build_type}" + + echo "platforms=${platforms}" >> $GITHUB_OUTPUT + echo "build_type=${build_type}" >> $GITHUB_OUTPUT + echo "version=${version}" >> $GITHUB_OUTPUT + echo "artifact_name=${artifact_name}" >> $GITHUB_OUTPUT + + echo "๐Ÿ”ง Build Configuration:" + echo " Platforms: ${platforms}" + echo " Build Type: ${build_type}" + echo " Version: ${version}" + echo " Artifact: ${artifact_name}" + + # Windows build job + build-windows: + name: Build Windows + runs-on: ubuntu-latest + needs: prepare + if: contains(needs.prepare.outputs.platforms, 'windows') + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Godot + uses: chickensoft-games/setup-godot@v1 + with: + version: ${{ env.GODOT_VERSION }} + use-dotnet: false + + - name: Create build directory + run: mkdir -p ${{ env.BUILD_DIR }} + + - name: Import project assets + run: | + echo "๐Ÿ“ฆ Importing project assets..." + godot --headless --verbose --editor --quit || true + sleep 2 + + - name: Build Windows executable + run: | + echo "๐Ÿ—๏ธ Building Windows executable..." + godot --headless --verbose --export-${{ needs.prepare.outputs.build_type }} "Windows Desktop" \ + ${{ env.BUILD_DIR }}/skelly-windows-${{ needs.prepare.outputs.version }}.exe + + # Verify build output + if [[ -f "${{ env.BUILD_DIR }}/skelly-windows-${{ needs.prepare.outputs.version }}.exe" ]]; then + echo "โœ… Windows build successful" + ls -la ${{ env.BUILD_DIR }}/ + else + echo "โŒ Windows build failed" + exit 1 + fi + + - name: Upload Windows build + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.prepare.outputs.artifact_name }}-windows + path: ${{ env.BUILD_DIR }}/skelly-windows-${{ needs.prepare.outputs.version }}.exe + retention-days: 7 + compression-level: 6 + + # Linux build job + build-linux: + name: Build Linux + runs-on: ubuntu-latest + needs: prepare + if: contains(needs.prepare.outputs.platforms, 'linux') + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Godot + uses: chickensoft-games/setup-godot@v1 + with: + version: ${{ env.GODOT_VERSION }} + use-dotnet: false + + - name: Create build directory + run: mkdir -p ${{ env.BUILD_DIR }} + + - name: Import project assets + run: | + echo "๐Ÿ“ฆ Importing project assets..." + godot --headless --verbose --editor --quit || true + sleep 2 + + - name: Build Linux executable + run: | + echo "๐Ÿ—๏ธ Building Linux executable..." + godot --headless --verbose --export-${{ needs.prepare.outputs.build_type }} "Linux" \ + ${{ env.BUILD_DIR }}/skelly-linux-${{ needs.prepare.outputs.version }}.x86_64 + + # Make executable + chmod +x ${{ env.BUILD_DIR }}/skelly-linux-${{ needs.prepare.outputs.version }}.x86_64 + + # Verify build output + if [[ -f "${{ env.BUILD_DIR }}/skelly-linux-${{ needs.prepare.outputs.version }}.x86_64" ]]; then + echo "โœ… Linux build successful" + ls -la ${{ env.BUILD_DIR }}/ + else + echo "โŒ Linux build failed" + exit 1 + fi + + - name: Upload Linux build + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.prepare.outputs.artifact_name }}-linux + path: ${{ env.BUILD_DIR }}/skelly-linux-${{ needs.prepare.outputs.version }}.x86_64 + retention-days: 7 + compression-level: 6 + + # macOS build job + build-macos: + name: Build macOS + runs-on: ubuntu-latest + needs: prepare + if: contains(needs.prepare.outputs.platforms, 'macos') + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Godot + uses: chickensoft-games/setup-godot@v1 + with: + version: ${{ env.GODOT_VERSION }} + use-dotnet: false + + - name: Create build directory + run: mkdir -p ${{ env.BUILD_DIR }} + + - name: Import project assets + run: | + echo "๐Ÿ“ฆ Importing project assets..." + godot --headless --verbose --editor --quit || true + sleep 2 + + - name: Build macOS application + run: | + echo "๐Ÿ—๏ธ Building macOS application..." + godot --headless --verbose --export-${{ needs.prepare.outputs.build_type }} "macOS" \ + ${{ env.BUILD_DIR }}/skelly-macos-${{ needs.prepare.outputs.version }}.zip + + # Verify build output + if [[ -f "${{ env.BUILD_DIR }}/skelly-macos-${{ needs.prepare.outputs.version }}.zip" ]]; then + echo "โœ… macOS build successful" + ls -la ${{ env.BUILD_DIR }}/ + else + echo "โŒ macOS build failed" + exit 1 + fi + + - name: Upload macOS build + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.prepare.outputs.artifact_name }}-macos + path: ${{ env.BUILD_DIR }}/skelly-macos-${{ needs.prepare.outputs.version }}.zip + retention-days: 7 + compression-level: 6 + + # Android build job + build-android: + name: Build Android + runs-on: ubuntu-latest + needs: prepare + if: contains(needs.prepare.outputs.platforms, 'android') + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + api-level: 33 + build-tools: 33.0.0 + + - name: Setup Godot + uses: chickensoft-games/setup-godot@v1 + with: + version: ${{ env.GODOT_VERSION }} + use-dotnet: false + + - name: Setup Android export templates + run: | + echo "๐Ÿ“ฑ Setting up Android export templates..." + # Download Android export templates + mkdir -p ~/.local/share/godot/export_templates/${{ env.GODOT_VERSION }} + # Templates will be automatically downloaded by Godot during export + + - name: Create build directory + run: mkdir -p ${{ env.BUILD_DIR }} + + - name: Import project assets + run: | + echo "๐Ÿ“ฆ Importing project assets..." + godot --headless --verbose --editor --quit || true + sleep 2 + + - name: Build Android APK + run: | + echo "๐Ÿ—๏ธ Building Android APK..." + + # Set ANDROID_HOME if not already set + export ANDROID_HOME=${ANDROID_HOME:-$ANDROID_SDK_ROOT} + + godot --headless --verbose --export-${{ needs.prepare.outputs.build_type }} "Android" \ + ${{ env.BUILD_DIR }}/skelly-android-${{ needs.prepare.outputs.version }}.apk + + # Verify build output + if [[ -f "${{ env.BUILD_DIR }}/skelly-android-${{ needs.prepare.outputs.version }}.apk" ]]; then + echo "โœ… Android build successful" + ls -la ${{ env.BUILD_DIR }}/ + + # Show APK info + echo "๐Ÿ“ฑ APK Information:" + file ${{ env.BUILD_DIR }}/skelly-android-${{ needs.prepare.outputs.version }}.apk + else + echo "โŒ Android build failed" + exit 1 + fi + + - name: Upload Android build + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.prepare.outputs.artifact_name }}-android + path: ${{ env.BUILD_DIR }}/skelly-android-${{ needs.prepare.outputs.version }}.apk + retention-days: 7 + compression-level: 6 + + # Summary job - creates release summary + summary: + name: Build Summary + runs-on: ubuntu-latest + needs: [prepare, build-windows, build-linux, build-macos, build-android] + if: always() + + steps: + - name: Generate build summary + run: | + echo "๐ŸŽฎ Build Summary for ${{ needs.prepare.outputs.artifact_name }}" + echo "==================================" + echo "" + echo "๐Ÿ“‹ Configuration:" + echo " Version: ${{ needs.prepare.outputs.version }}" + echo " Build Type: ${{ needs.prepare.outputs.build_type }}" + echo " Platforms: ${{ needs.prepare.outputs.platforms }}" + echo "" + echo "๐Ÿ“Š Build Results:" + + platforms="${{ needs.prepare.outputs.platforms }}" + + if [[ "$platforms" == *"windows"* ]]; then + windows_status="${{ needs.build-windows.result }}" + echo " ๐ŸชŸ Windows: $windows_status" + fi + + if [[ "$platforms" == *"linux"* ]]; then + linux_status="${{ needs.build-linux.result }}" + echo " ๐Ÿง Linux: $linux_status" + fi + + if [[ "$platforms" == *"macos"* ]]; then + macos_status="${{ needs.build-macos.result }}" + echo " ๐ŸŽ macOS: $macos_status" + fi + + if [[ "$platforms" == *"android"* ]]; then + android_status="${{ needs.build-android.result }}" + echo " ๐Ÿค– Android: $android_status" + fi + + echo "" + echo "๐Ÿ“ฆ Artifacts are available for 7 days" + echo "๐Ÿ”— Download from: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + - name: Check overall build status + run: | + # Check if any required builds failed + platforms="${{ needs.prepare.outputs.platforms }}" + failed_builds=() + + if [[ "$platforms" == *"windows"* ]] && [[ "${{ needs.build-windows.result }}" != "success" ]]; then + failed_builds+=("Windows") + fi + + if [[ "$platforms" == *"linux"* ]] && [[ "${{ needs.build-linux.result }}" != "success" ]]; then + failed_builds+=("Linux") + fi + + if [[ "$platforms" == *"macos"* ]] && [[ "${{ needs.build-macos.result }}" != "success" ]]; then + failed_builds+=("macOS") + fi + + if [[ "$platforms" == *"android"* ]] && [[ "${{ needs.build-android.result }}" != "success" ]]; then + failed_builds+=("Android") + fi + + if [[ ${#failed_builds[@]} -gt 0 ]]; then + echo "โŒ Build failed for: ${failed_builds[*]}" + exit 1 + else + echo "โœ… All builds completed successfully!" + fi \ No newline at end of file diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 44b0b5f..8b005c2 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -84,7 +84,16 @@ jobs: id: format run: | echo "๐ŸŽจ Running GDScript formatting..." - python tools/run_development.py --format + python tools/run_development.py --format --silent --yaml > format_results.yaml + + - name: Upload formatting results + if: always() + uses: actions/upload-artifact@v3 + with: + name: format-results + path: | + format_results.yaml + retention-days: 7 - name: Check for formatting changes id: check-changes @@ -156,15 +165,15 @@ jobs: id: lint run: | echo "๐Ÿ” Running GDScript linting..." - python tools/run_development.py --lint + python tools/run_development.py --lint --silent --yaml > lint_results.yaml - name: Upload linting results - if: failure() + if: always() uses: actions/upload-artifact@v3 with: name: lint-results path: | - **/*.gd + lint_results.yaml retention-days: 7 test: @@ -201,15 +210,15 @@ jobs: id: test run: | echo "๐Ÿงช Running GDScript tests..." - python tools/run_development.py --test + python tools/run_development.py --test --silent --yaml > test_results.yaml - name: Upload test results - if: failure() + if: always() uses: actions/upload-artifact@v3 with: name: test-results path: | - tests/**/*.gd + test_results.yaml retention-days: 7 summary: diff --git a/tools/run_development.py b/tools/run_development.py index 0011590..3ca0d01 100644 --- a/tools/run_development.py +++ b/tools/run_development.py @@ -2,19 +2,31 @@ """ Development workflow runner for the Skelly Godot project. -Runs code quality checks (linting, formatting, testing) individually or together. +Runs code quality checks (linting, formatting, testing, validation) individually or together. Provides colored output and error reporting. Usage examples: - python tools/run_development.py # Run all steps - python tools/run_development.py --lint # Only linting - python tools/run_development.py --steps lint test # Custom workflow + python tools/run_development.py # Run all steps + python tools/run_development.py --lint # Only linting + python tools/run_development.py --validate # Only file validation + python tools/run_development.py --validate --silent # Silent validation (only errors) + python tools/run_development.py --test --yaml # Test results in YAML format + python tools/run_development.py --steps lint test # Custom workflow + +Features: +- GDScript formatting and linting +- Test execution +- YAML, TOML, and JSON file validation (respects .gitignore) +- Colored output and comprehensive error reporting +- Machine-readable YAML output for CI/CD integration NOTE: Handles "successful but noisy" linter output such as "Success: no problems found" - treats these as clean instead of warnings. """ import argparse +import asyncio +import json import os import re import subprocess @@ -24,6 +36,24 @@ import warnings from pathlib import Path from typing import Dict, List, Tuple +try: + import aiofiles +except ImportError: + aiofiles = None + +try: + import yaml +except ImportError: + yaml = None + +try: + import tomllib +except ImportError: + try: + import tomli as tomllib + except ImportError: + tomllib = None + # Suppress pkg_resources deprecation warning from gdtoolkit warnings.filterwarnings("ignore", message="pkg_resources is deprecated", category=UserWarning) @@ -54,27 +84,60 @@ class Colors: return f"{color}{text}{Colors.RESET}" -def print_header(title: str) -> None: +def print_header(title: str, silent: bool = False) -> None: """Print a formatted header.""" - separator = Colors.colorize("=" * 48, Colors.CYAN) - colored_title = Colors.colorize(title, Colors.BOLD + Colors.WHITE) - print(separator) - print(colored_title) - print(separator) - print() + if not silent: + separator = Colors.colorize("=" * 48, Colors.CYAN) + colored_title = Colors.colorize(title, Colors.BOLD + Colors.WHITE) + print(separator) + print(colored_title) + print(separator) + print() -def print_summary(title: str, stats: Dict[str, int]) -> None: +def print_summary(title: str, stats: Dict[str, int], silent: bool = False) -> None: """Print a formatted summary.""" - separator = Colors.colorize("=" * 48, Colors.CYAN) - colored_title = Colors.colorize(title, Colors.BOLD + Colors.WHITE) - print(separator) - print(colored_title) - print(separator) - for key, value in stats.items(): - colored_key = Colors.colorize(key, Colors.BLUE) - colored_value = Colors.colorize(str(value), Colors.BOLD + Colors.WHITE) - print(f"{colored_key}: {colored_value}") + if not silent: + separator = Colors.colorize("=" * 48, Colors.CYAN) + colored_title = Colors.colorize(title, Colors.BOLD + Colors.WHITE) + print(separator) + print(colored_title) + print(separator) + for key, value in stats.items(): + colored_key = Colors.colorize(key, Colors.BLUE) + colored_value = Colors.colorize(str(value), Colors.BOLD + Colors.WHITE) + print(f"{colored_key}: {colored_value}") + + +def output_yaml_results(step_name: str, results: Dict, success: bool) -> None: + """Output results in YAML format.""" + if yaml is None: + print("# YAML output requires PyYAML. Install with: pip install PyYAML") + return + + # Convert results to YAML-friendly format + yaml_data = { + "step": step_name, + "success": success, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "statistics": {}, + "failed_items": [] + } + + # Extract statistics + for key, value in results.items(): + if key != "failed_paths" and key != "results": + yaml_data["statistics"][key.lower().replace(" ", "_")] = value + + # Extract failed items + if "failed_paths" in results: + yaml_data["failed_items"] = results["failed_paths"] + elif "results" in results: + # For test results, extract failed tests + failed_tests = [result[0] for result in results["results"] if not result[1]] + yaml_data["failed_items"] = failed_tests + + print(yaml.dump(yaml_data, default_flow_style=False, sort_keys=False)) def run_command(cmd: List[str], cwd: Path, timeout: int = 30) -> subprocess.CompletedProcess: @@ -124,6 +187,71 @@ def run_command(cmd: List[str], cwd: Path, timeout: int = 30) -> subprocess.Comp return result +async def run_command_async(cmd: List[str], cwd: Path, timeout: int = 30) -> Tuple[int, str, str]: + """ + Execute a shell command asynchronously with error handling and output filtering. + + Filters out gdtoolkit's pkg_resources deprecation warnings. + + Args: + cmd: Command and arguments to execute + cwd: Working directory for command execution + timeout: Maximum execution time in seconds (default: 30s) + + Returns: + Tuple of (returncode, stdout, stderr) + """ + # Suppress pkg_resources deprecation warnings in subprocesses + env = os.environ.copy() + env['PYTHONWARNINGS'] = 'ignore::UserWarning:pkg_resources' + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=cwd, + env=env + ) + + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=timeout + ) + + stdout_text = stdout.decode('utf-8', errors='replace') + stderr_text = stderr.decode('utf-8', errors='replace') + + # Filter out pkg_resources deprecation warnings from the output + def filter_warnings(text: str) -> str: + if not text: + return text + lines = text.split('\n') + filtered_lines = [] + skip_next = False + + for line in lines: + if skip_next: + skip_next = False + continue + if 'pkg_resources is deprecated' in line: + skip_next = True # Skip the next line which contains "import pkg_resources" + continue + if 'import pkg_resources' in line: + continue + filtered_lines.append(line) + + return '\n'.join(filtered_lines) + + return process.returncode, filter_warnings(stdout_text), filter_warnings(stderr_text) + + except asyncio.TimeoutError: + if process.returncode is None: + process.kill() + await process.wait() + raise subprocess.TimeoutExpired(cmd, timeout) + + def should_skip_file(file_path: Path) -> bool: """Check if file should be skipped.""" return file_path.name == "TestHelper.gd" @@ -136,17 +264,18 @@ def print_skip_message(tool: str) -> None: print(f" {colored_message}") -def print_result(success: bool, output: str = "") -> None: +def print_result(success: bool, output: str = "", silent: bool = False) -> None: """Print command result.""" if success: if not output: - message = "โœ… Clean" - colored_message = Colors.colorize(message, Colors.GREEN) + if not silent: + message = "โœ… Clean" + colored_message = Colors.colorize(message, Colors.GREEN) + print(f" {colored_message}") else: message = "โš ๏ธ WARNINGS found:" colored_message = Colors.colorize(message, Colors.YELLOW) - print(f" {colored_message}") - if output: + print(f" {colored_message}") # Indent and color the output for line in output.split('\n'): if line.strip(): @@ -169,6 +298,67 @@ def get_gd_files(project_root: Path) -> List[Path]: return list(project_root.rglob("*.gd")) +def read_gitignore(project_root: Path) -> List[str]: + """Read gitignore patterns from .gitignore file.""" + gitignore_path = project_root / ".gitignore" + patterns = [] + + if gitignore_path.exists(): + try: + with open(gitignore_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + patterns.append(line) + except Exception as e: + print(f"Warning: Could not read .gitignore: {e}") + + return patterns + + +def is_ignored_by_gitignore(file_path: Path, project_root: Path, patterns: List[str]) -> bool: + """Check if a file should be ignored based on gitignore patterns.""" + relative_path = file_path.relative_to(project_root) + path_str = str(relative_path).replace('\\', '/') + + for pattern in patterns: + # Handle directory patterns + if pattern.endswith('/'): + if any(part == pattern[:-1] for part in relative_path.parts): + return True + # Handle file patterns + elif pattern in path_str or relative_path.name == pattern: + return True + # Handle glob patterns (simple implementation) + elif '*' in pattern: + import fnmatch + if fnmatch.fnmatch(path_str, pattern) or fnmatch.fnmatch(relative_path.name, pattern): + return True + + return False + + +def get_validation_files(project_root: Path) -> Dict[str, List[Path]]: + """Get all YAML, TOML, and JSON files in the project, respecting gitignore.""" + gitignore_patterns = read_gitignore(project_root) + + file_types = { + 'yaml': ['*.yaml', '*.yml'], + 'toml': ['*.toml'], + 'json': ['*.json'] + } + + files = {file_type: [] for file_type in file_types} + + for file_type, patterns in file_types.items(): + for pattern in patterns: + for file_path in project_root.rglob(pattern): + if file_path.is_file() and not is_ignored_by_gitignore(file_path, project_root, gitignore_patterns): + files[file_type].append(file_path) + + return files + + def format_test_name(filename: str) -> str: """Convert test_filename to readable test name.""" return filename.replace("test_", "").replace("_", " ") @@ -201,28 +391,33 @@ def _is_successful_linter_output(output: str) -> bool: return False -def run_lint(project_root: Path) -> Tuple[bool, Dict]: +def run_lint(project_root: Path, silent: bool = False, yaml_output: bool = False) -> Tuple[bool, Dict]: """Run gdlint on all GDScript files.""" - print_header("๐Ÿ” GDScript Linter") + if not yaml_output: + print_header("๐Ÿ” GDScript Linter", silent) gd_files = get_gd_files(project_root) - count_msg = f"Found {len(gd_files)} GDScript files to lint." - colored_count = Colors.colorize(count_msg, Colors.BLUE) - print(f"{colored_count}\n") + if not silent and not yaml_output: + count_msg = f"Found {len(gd_files)} GDScript files to lint." + colored_count = Colors.colorize(count_msg, Colors.BLUE) + print(f"{colored_count}\n") clean_files = warning_files = error_files = 0 failed_paths = [] for gd_file in gd_files: relative_path = gd_file.relative_to(project_root) - file_msg = f"๐Ÿ“„ Linting: {relative_path.name}" - colored_file = Colors.colorize(file_msg, Colors.CYAN) - print(colored_file) + if not silent and not yaml_output: + file_msg = f"๐Ÿ“„ Linting: {relative_path.name}" + colored_file = Colors.colorize(file_msg, Colors.CYAN) + print(colored_file) if should_skip_file(gd_file): - print_skip_message("gdlint") + if not silent and not yaml_output: + print_skip_message("gdlint") clean_files += 1 - print() + if not silent and not yaml_output: + print() continue try: @@ -233,24 +428,30 @@ def run_lint(project_root: Path) -> Tuple[bool, Dict]: # If output is "no problems" (or similar), treat as clean. if _is_successful_linter_output(output): clean_files += 1 - print_result(True, "") + if not yaml_output: + print_result(True, "", silent) else: warning_files += 1 - print_result(True, output) + if not yaml_output: + print_result(True, output, silent) else: error_files += 1 failed_paths.append(str(relative_path)) - print_result(False, output) + if not yaml_output: + print_result(False, output, silent) except FileNotFoundError: - print(" โŒ ERROR: gdlint not found") + if not silent and not yaml_output: + print(" โŒ ERROR: gdlint not found") return False, {} except Exception as e: - print(f" โŒ ERROR: {e}") + if not silent and not yaml_output: + print(f" โŒ ERROR: {e}") error_files += 1 failed_paths.append(str(relative_path)) - print() + if not silent and not yaml_output: + print() # Summary stats = { @@ -259,80 +460,328 @@ def run_lint(project_root: Path) -> Tuple[bool, Dict]: "Files with warnings": warning_files, "Files with errors": error_files } - print_summary("Linting Summary", stats) success = error_files == 0 - print() - if not success: - msg = "โŒ Linting FAILED - Please fix the errors above" - colored_msg = Colors.colorize(msg, Colors.RED + Colors.BOLD) - print(colored_msg) - elif warning_files > 0: - msg = "โš ๏ธ Linting PASSED with warnings - Consider fixing them" - colored_msg = Colors.colorize(msg, Colors.YELLOW + Colors.BOLD) - print(colored_msg) + + if yaml_output: + output_yaml_results("lint", {**stats, "failed_paths": failed_paths}, success) else: - msg = "โœ… All GDScript files passed linting!" - colored_msg = Colors.colorize(msg, Colors.GREEN + Colors.BOLD) - print(colored_msg) + print_summary("Linting Summary", stats, silent) + if not silent: + print() + if not success: + msg = "โŒ Linting FAILED - Please fix the errors above" + colored_msg = Colors.colorize(msg, Colors.RED + Colors.BOLD) + print(colored_msg) + elif warning_files > 0: + msg = "โš ๏ธ Linting PASSED with warnings - Consider fixing them" + colored_msg = Colors.colorize(msg, Colors.YELLOW + Colors.BOLD) + print(colored_msg) + else: + msg = "โœ… All GDScript files passed linting!" + colored_msg = Colors.colorize(msg, Colors.GREEN + Colors.BOLD) + print(colored_msg) + elif not success: + # In silent mode, still show failed files + for failed_path in failed_paths: + print(f"โŒ {failed_path}") return success, {**stats, "failed_paths": failed_paths} -# rest of file unchanged (format, tests, workflow runner) -- copied verbatim +def validate_yaml_file(file_path: Path) -> Tuple[bool, str]: + """Validate a YAML file.""" + if yaml is None: + return False, "PyYAML not installed. Install with: pip install PyYAML" -def run_format(project_root: Path) -> Tuple[bool, Dict]: - """Run gdformat on all GDScript files.""" - print_header("๐ŸŽจ GDScript Formatter") + try: + with open(file_path, 'r', encoding='utf-8') as f: + yaml.safe_load(f) + return True, "" + except yaml.YAMLError as e: + return False, f"YAML syntax error: {e}" + except Exception as e: + return False, f"Error reading file: {e}" + + +async def validate_yaml_file_async(file_path: Path) -> Tuple[bool, str]: + """Validate a YAML file asynchronously.""" + if yaml is None: + return False, "PyYAML not installed. Install with: pip install PyYAML" + + if aiofiles is None: + return validate_yaml_file(file_path) + + try: + async with aiofiles.open(file_path, 'r', encoding='utf-8') as f: + content = await f.read() + yaml.safe_load(content) + return True, "" + except yaml.YAMLError as e: + return False, f"YAML syntax error: {e}" + except Exception as e: + return False, f"Error reading file: {e}" + + +def validate_toml_file(file_path: Path) -> Tuple[bool, str]: + """Validate a TOML file.""" + if tomllib is None: + return False, "tomllib/tomli not available. For Python 3.11+, it's built-in. For older versions: pip install tomli" + + try: + with open(file_path, 'rb') as f: + tomllib.load(f) + return True, "" + except tomllib.TOMLDecodeError as e: + return False, f"TOML syntax error: {e}" + except Exception as e: + return False, f"Error reading file: {e}" + + +async def validate_toml_file_async(file_path: Path) -> Tuple[bool, str]: + """Validate a TOML file asynchronously.""" + if tomllib is None: + return False, "tomllib/tomli not available. For Python 3.11+, it's built-in. For older versions: pip install tomli" + + if aiofiles is None: + return validate_toml_file(file_path) + + try: + async with aiofiles.open(file_path, 'rb') as f: + content = await f.read() + tomllib.loads(content.decode('utf-8')) + return True, "" + except tomllib.TOMLDecodeError as e: + return False, f"TOML syntax error: {e}" + except Exception as e: + return False, f"Error reading file: {e}" + + +def validate_json_file(file_path: Path) -> Tuple[bool, str]: + """Validate a JSON file.""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + json.load(f) + return True, "" + except json.JSONDecodeError as e: + return False, f"JSON syntax error: {e}" + except Exception as e: + return False, f"Error reading file: {e}" + + +async def validate_json_file_async(file_path: Path) -> Tuple[bool, str]: + """Validate a JSON file asynchronously.""" + if aiofiles is None: + return validate_json_file(file_path) + + try: + async with aiofiles.open(file_path, 'r', encoding='utf-8') as f: + content = await f.read() + json.loads(content) + return True, "" + except json.JSONDecodeError as e: + return False, f"JSON syntax error: {e}" + except Exception as e: + return False, f"Error reading file: {e}" + + +async def process_lint_file_async(gd_file: Path, project_root: Path, semaphore: asyncio.Semaphore, silent: bool = False, yaml_output: bool = False) -> Tuple[bool, str, str]: + """Process a single file for linting asynchronously.""" + async with semaphore: + relative_path = gd_file.relative_to(project_root) + if not silent and not yaml_output: + file_msg = f"๐Ÿ“„ Linting: {relative_path.name}" + colored_file = Colors.colorize(file_msg, Colors.CYAN) + print(colored_file) + + if should_skip_file(gd_file): + if not silent and not yaml_output: + print_skip_message("gdlint") + return True, "", str(relative_path) + + try: + returncode, stdout, stderr = await run_command_async(["gdlint", str(gd_file)], project_root) + output = (stdout + stderr).strip() + + if returncode == 0: + # If output is "no problems" (or similar), treat as clean. + if _is_successful_linter_output(output): + if not yaml_output: + print_result(True, "", silent) + return True, "", str(relative_path) + else: + if not yaml_output: + print_result(True, output, silent) + return True, output, str(relative_path) + else: + if not yaml_output: + print_result(False, output, silent) + return False, output, str(relative_path) + + except FileNotFoundError: + if not silent and not yaml_output: + print(" โŒ ERROR: gdlint not found") + return False, "gdlint not found", str(relative_path) + except Exception as e: + if not silent and not yaml_output: + print(f" โŒ ERROR: {e}") + return False, str(e), str(relative_path) + finally: + if not silent and not yaml_output: + print() + + +async def run_lint_async(project_root: Path, silent: bool = False, yaml_output: bool = False) -> Tuple[bool, Dict]: + """Run gdlint on all GDScript files asynchronously.""" + if not yaml_output: + print_header("๐Ÿ” GDScript Linter (Async)", silent) gd_files = get_gd_files(project_root) - count_msg = f"Found {len(gd_files)} GDScript files to format." - colored_count = Colors.colorize(count_msg, Colors.BLUE) - print(f"{colored_count}\n") + if not silent and not yaml_output: + count_msg = f"Found {len(gd_files)} GDScript files to lint." + colored_count = Colors.colorize(count_msg, Colors.BLUE) + print(f"{colored_count}\n") + + clean_files = warning_files = error_files = 0 + failed_paths = [] + + # Use semaphore to limit concurrent operations + semaphore = asyncio.Semaphore(min(10, len(gd_files))) + + # Process files concurrently + tasks = [process_lint_file_async(gd_file, project_root, semaphore, silent, yaml_output) for gd_file in gd_files] + results = await asyncio.gather(*tasks, return_exceptions=True) + + for result in results: + if isinstance(result, Exception): + error_files += 1 + failed_paths.append("Unknown file - exception occurred") + else: + success, output, relative_path = result + if success: + if not output: + clean_files += 1 + else: + warning_files += 1 + else: + error_files += 1 + failed_paths.append(relative_path) + + # Summary + stats = { + "Total files": len(gd_files), + "Clean files": clean_files, + "Files with warnings": warning_files, + "Files with errors": error_files + } + + success = error_files == 0 + + if yaml_output: + output_yaml_results("lint", {**stats, "failed_paths": failed_paths}, success) + else: + print_summary("Linting Summary", stats, silent) + if not silent: + print() + if not success: + msg = "โŒ Linting FAILED - Please fix the errors above" + colored_msg = Colors.colorize(msg, Colors.RED + Colors.BOLD) + print(colored_msg) + elif warning_files > 0: + msg = "โš ๏ธ Linting PASSED with warnings - Consider fixing them" + colored_msg = Colors.colorize(msg, Colors.YELLOW + Colors.BOLD) + print(colored_msg) + else: + msg = "โœ… All GDScript files passed linting!" + colored_msg = Colors.colorize(msg, Colors.GREEN + Colors.BOLD) + print(colored_msg) + elif not success: + # In silent mode, still show failed files + for failed_path in failed_paths: + print(f"โŒ {failed_path}") + + return success, {**stats, "failed_paths": failed_paths} + + +async def process_format_file_async(gd_file: Path, project_root: Path, semaphore: asyncio.Semaphore, silent: bool = False, yaml_output: bool = False) -> Tuple[bool, str]: + """Process a single file for formatting asynchronously.""" + async with semaphore: + relative_path = gd_file.relative_to(project_root) + if not silent and not yaml_output: + file_msg = f"๐ŸŽฏ Formatting: {relative_path.name}" + colored_file = Colors.colorize(file_msg, Colors.CYAN) + print(colored_file) + + if should_skip_file(gd_file): + if not silent and not yaml_output: + print_skip_message("gdformat") + return True, str(relative_path) + + try: + returncode, stdout, stderr = await run_command_async(["gdformat", str(gd_file)], project_root) + + if returncode == 0: + if not silent and not yaml_output: + success_msg = "โœ… Success" + colored_success = Colors.colorize(success_msg, Colors.GREEN) + print(f" {colored_success}") + return True, str(relative_path) + else: + if not silent and not yaml_output: + fail_msg = f"โŒ FAILED: {relative_path}" + colored_fail = Colors.colorize(fail_msg, Colors.RED) + print(f" {colored_fail}") + output = (stdout + stderr).strip() + if output: + colored_output = Colors.colorize(output, Colors.RED) + print(f" {colored_output}") + return False, str(relative_path) + + except FileNotFoundError: + if not silent and not yaml_output: + print(" โŒ ERROR: gdformat not found") + return False, str(relative_path) + except Exception as e: + if not silent and not yaml_output: + print(f" โŒ ERROR: {e}") + return False, str(relative_path) + finally: + if not silent and not yaml_output: + print() + + +async def run_format_async(project_root: Path, silent: bool = False, yaml_output: bool = False) -> Tuple[bool, Dict]: + """Run gdformat on all GDScript files asynchronously.""" + if not yaml_output: + print_header("๐ŸŽจ GDScript Formatter (Async)", silent) + + gd_files = get_gd_files(project_root) + if not silent and not yaml_output: + count_msg = f"Found {len(gd_files)} GDScript files to format." + colored_count = Colors.colorize(count_msg, Colors.BLUE) + print(f"{colored_count}\n") formatted_files = failed_files = 0 failed_paths = [] - for gd_file in gd_files: - relative_path = gd_file.relative_to(project_root) - file_msg = f"๐ŸŽฏ Formatting: {relative_path.name}" - colored_file = Colors.colorize(file_msg, Colors.CYAN) - print(colored_file) + # Use semaphore to limit concurrent operations + semaphore = asyncio.Semaphore(min(10, len(gd_files))) - if should_skip_file(gd_file): - print_skip_message("gdformat") - formatted_files += 1 - print() - continue + # Process files concurrently + tasks = [process_format_file_async(gd_file, project_root, semaphore, silent, yaml_output) for gd_file in gd_files] + results = await asyncio.gather(*tasks, return_exceptions=True) - try: - result = run_command(["gdformat", str(gd_file)], project_root) - - if result.returncode == 0: - success_msg = "โœ… Success" - colored_success = Colors.colorize(success_msg, Colors.GREEN) - print(f" {colored_success}") + for result in results: + if isinstance(result, Exception): + failed_files += 1 + failed_paths.append("Unknown file - exception occurred") + else: + success, relative_path = result + if success: formatted_files += 1 else: - fail_msg = f"โŒ FAILED: {relative_path}" - colored_fail = Colors.colorize(fail_msg, Colors.RED) - print(f" {colored_fail}") - output = (result.stdout + result.stderr).strip() - if output: - colored_output = Colors.colorize(output, Colors.RED) - print(f" {colored_output}") failed_files += 1 - failed_paths.append(str(relative_path)) - - except FileNotFoundError: - print(" โŒ ERROR: gdformat not found") - return False, {} - except Exception as e: - print(f" โŒ ERROR: {e}") - failed_files += 1 - failed_paths.append(str(relative_path)) - - print() + failed_paths.append(relative_path) # Summary stats = { @@ -340,18 +789,351 @@ def run_format(project_root: Path) -> Tuple[bool, Dict]: "Successfully formatted": formatted_files, "Failed": failed_files } - print_summary("Formatting Summary", stats) success = failed_files == 0 - print() - if not success: - msg = "โš ๏ธ WARNING: Some files failed to format" - colored_msg = Colors.colorize(msg, Colors.YELLOW + Colors.BOLD) - print(colored_msg) + + if yaml_output: + output_yaml_results("format", {**stats, "failed_paths": failed_paths}, success) else: - msg = "โœ… All GDScript files formatted successfully!" - colored_msg = Colors.colorize(msg, Colors.GREEN + Colors.BOLD) - print(colored_msg) + print_summary("Formatting Summary", stats, silent) + if not silent: + print() + if not success: + msg = "โš ๏ธ WARNING: Some files failed to format" + colored_msg = Colors.colorize(msg, Colors.YELLOW + Colors.BOLD) + print(colored_msg) + else: + msg = "โœ… All GDScript files formatted successfully!" + colored_msg = Colors.colorize(msg, Colors.GREEN + Colors.BOLD) + print(colored_msg) + elif not success: + # In silent mode, still show failed files + for failed_path in failed_paths: + print(f"โŒ {failed_path}") + + return success, {**stats, "failed_paths": failed_paths} + + +def run_validate(project_root: Path, silent: bool = False, yaml_output: bool = False) -> Tuple[bool, Dict]: + """Run validation on YAML, TOML, and JSON files.""" + if not silent and not yaml_output: + print_header("๐Ÿ“‹ File Format Validation") + + # Get all validation files + validation_files = get_validation_files(project_root) + total_files = sum(len(files) for files in validation_files.values()) + + if total_files == 0: + if not silent: + msg = "No YAML, TOML, or JSON files found to validate." + colored_msg = Colors.colorize(msg, Colors.YELLOW) + print(colored_msg) + return True, {"Total files": 0, "Valid files": 0, "Invalid files": 0} + + if not silent and not yaml_output: + count_msg = f"Found {total_files} files to validate:" + colored_count = Colors.colorize(count_msg, Colors.BLUE) + print(colored_count) + + for file_type, files in validation_files.items(): + if files: + type_msg = f" {file_type.upper()}: {len(files)} files" + colored_type = Colors.colorize(type_msg, Colors.CYAN) + print(colored_type) + + print() + + valid_files = invalid_files = 0 + failed_paths = [] + + # Validation functions mapping + validators = { + 'yaml': validate_yaml_file, + 'toml': validate_toml_file, + 'json': validate_json_file + } + + # Validate each file type + for file_type, files in validation_files.items(): + if not files: + continue + + validator = validators[file_type] + if not silent and not yaml_output: + type_header = f"๐Ÿ” Validating {file_type.upper()} files" + colored_header = Colors.colorize(type_header, Colors.MAGENTA + Colors.BOLD) + print(colored_header) + + for file_path in files: + relative_path = file_path.relative_to(project_root) + if not silent and not yaml_output: + file_msg = f"๐Ÿ“„ Validating: {relative_path}" + colored_file = Colors.colorize(file_msg, Colors.CYAN) + print(colored_file) + + is_valid, error_msg = validator(file_path) + + if is_valid: + valid_files += 1 + if not yaml_output: + print_result(True, "", silent) + else: + invalid_files += 1 + failed_paths.append(str(relative_path)) + if not yaml_output: + print_result(False, error_msg, silent) + + if not silent and not yaml_output: + print() + + # Summary + stats = { + "Total files": total_files, + "Valid files": valid_files, + "Invalid files": invalid_files + } + + success = invalid_files == 0 + + if yaml_output: + output_yaml_results("validate", {**stats, "failed_paths": failed_paths}, success) + else: + if not silent: + print_summary("Validation Summary", stats) + print() + if not success: + msg = "โŒ File validation FAILED - Please fix the syntax errors above" + colored_msg = Colors.colorize(msg, Colors.RED + Colors.BOLD) + print(colored_msg) + else: + msg = "โœ… All files passed validation!" + colored_msg = Colors.colorize(msg, Colors.GREEN + Colors.BOLD) + print(colored_msg) + elif not success: + # In silent mode, still show errors + for failed_path in failed_paths: + print(f"โŒ {failed_path}") + + return success, {**stats, "failed_paths": failed_paths} + + +async def process_validation_file_async(file_path: Path, file_type: str, project_root: Path, semaphore: asyncio.Semaphore, silent: bool = False, yaml_output: bool = False) -> Tuple[bool, str]: + """Process a single file for validation asynchronously.""" + async with semaphore: + relative_path = file_path.relative_to(project_root) + if not silent and not yaml_output: + file_msg = f"๐Ÿ“„ Validating: {relative_path}" + colored_file = Colors.colorize(file_msg, Colors.CYAN) + print(colored_file) + + # Validation functions mapping + validators = { + 'yaml': validate_yaml_file_async, + 'toml': validate_toml_file_async, + 'json': validate_json_file_async + } + + validator = validators[file_type] + is_valid, error_msg = await validator(file_path) + + if is_valid: + if not yaml_output: + print_result(True, "", silent) + return True, str(relative_path) + else: + if not yaml_output: + print_result(False, error_msg, silent) + return False, str(relative_path) + + +async def run_validate_async(project_root: Path, silent: bool = False, yaml_output: bool = False) -> Tuple[bool, Dict]: + """Run validation on YAML, TOML, and JSON files asynchronously.""" + if not silent and not yaml_output: + print_header("๐Ÿ“‹ File Format Validation (Async)") + + # Get all validation files + validation_files = get_validation_files(project_root) + total_files = sum(len(files) for files in validation_files.values()) + + if total_files == 0: + if not silent: + msg = "No YAML, TOML, or JSON files found to validate." + colored_msg = Colors.colorize(msg, Colors.YELLOW) + print(colored_msg) + return True, {"Total files": 0, "Valid files": 0, "Invalid files": 0} + + if not silent and not yaml_output: + count_msg = f"Found {total_files} files to validate:" + colored_count = Colors.colorize(count_msg, Colors.BLUE) + print(colored_count) + + for file_type, files in validation_files.items(): + if files: + type_msg = f" {file_type.upper()}: {len(files)} files" + colored_type = Colors.colorize(type_msg, Colors.CYAN) + print(colored_type) + + print() + + valid_files = invalid_files = 0 + failed_paths = [] + + # Use semaphore to limit concurrent operations + semaphore = asyncio.Semaphore(min(10, total_files)) + + # Prepare all validation tasks + all_tasks = [] + for file_type, files in validation_files.items(): + if not files: + continue + + if not silent and not yaml_output: + type_header = f"๐Ÿ” Validating {file_type.upper()} files" + colored_header = Colors.colorize(type_header, Colors.MAGENTA + Colors.BOLD) + print(colored_header) + + for file_path in files: + all_tasks.append(process_validation_file_async(file_path, file_type, project_root, semaphore, silent, yaml_output)) + + # Process files concurrently + results = await asyncio.gather(*all_tasks, return_exceptions=True) + + for result in results: + if isinstance(result, Exception): + invalid_files += 1 + failed_paths.append("Unknown file - exception occurred") + else: + success, relative_path = result + if success: + valid_files += 1 + else: + invalid_files += 1 + failed_paths.append(relative_path) + + if not silent and not yaml_output: + print() + + # Summary + stats = { + "Total files": total_files, + "Valid files": valid_files, + "Invalid files": invalid_files + } + + success = invalid_files == 0 + + if yaml_output: + output_yaml_results("validate", {**stats, "failed_paths": failed_paths}, success) + else: + if not silent: + print_summary("Validation Summary", stats) + print() + if not success: + msg = "โŒ File validation FAILED - Please fix the syntax errors above" + colored_msg = Colors.colorize(msg, Colors.RED + Colors.BOLD) + print(colored_msg) + else: + msg = "โœ… All files passed validation!" + colored_msg = Colors.colorize(msg, Colors.GREEN + Colors.BOLD) + print(colored_msg) + elif not success: + # In silent mode, still show errors + for failed_path in failed_paths: + print(f"โŒ {failed_path}") + + return success, {**stats, "failed_paths": failed_paths} + + +def run_format(project_root: Path, silent: bool = False, yaml_output: bool = False) -> Tuple[bool, Dict]: + """Run gdformat on all GDScript files.""" + if not yaml_output: + print_header("๐ŸŽจ GDScript Formatter", silent) + + gd_files = get_gd_files(project_root) + if not silent and not yaml_output: + count_msg = f"Found {len(gd_files)} GDScript files to format." + colored_count = Colors.colorize(count_msg, Colors.BLUE) + print(f"{colored_count}\n") + + formatted_files = failed_files = 0 + failed_paths = [] + + for gd_file in gd_files: + relative_path = gd_file.relative_to(project_root) + if not silent and not yaml_output: + file_msg = f"๐ŸŽฏ Formatting: {relative_path.name}" + colored_file = Colors.colorize(file_msg, Colors.CYAN) + print(colored_file) + + if should_skip_file(gd_file): + if not silent and not yaml_output: + print_skip_message("gdformat") + formatted_files += 1 + if not silent and not yaml_output: + print() + continue + + try: + result = run_command(["gdformat", str(gd_file)], project_root) + + if result.returncode == 0: + if not silent and not yaml_output: + success_msg = "โœ… Success" + colored_success = Colors.colorize(success_msg, Colors.GREEN) + print(f" {colored_success}") + formatted_files += 1 + else: + if not silent and not yaml_output: + fail_msg = f"โŒ FAILED: {relative_path}" + colored_fail = Colors.colorize(fail_msg, Colors.RED) + print(f" {colored_fail}") + output = (result.stdout + result.stderr).strip() + if output: + colored_output = Colors.colorize(output, Colors.RED) + print(f" {colored_output}") + failed_files += 1 + failed_paths.append(str(relative_path)) + + except FileNotFoundError: + if not silent and not yaml_output: + print(" โŒ ERROR: gdformat not found") + return False, {} + except Exception as e: + if not silent and not yaml_output: + print(f" โŒ ERROR: {e}") + failed_files += 1 + failed_paths.append(str(relative_path)) + + if not silent and not yaml_output: + print() + + # Summary + stats = { + "Total files": len(gd_files), + "Successfully formatted": formatted_files, + "Failed": failed_files + } + + success = failed_files == 0 + + if yaml_output: + output_yaml_results("format", {**stats, "failed_paths": failed_paths}, success) + else: + print_summary("Formatting Summary", stats, silent) + if not silent: + print() + if not success: + msg = "โš ๏ธ WARNING: Some files failed to format" + colored_msg = Colors.colorize(msg, Colors.YELLOW + Colors.BOLD) + print(colored_msg) + else: + msg = "โœ… All GDScript files formatted successfully!" + colored_msg = Colors.colorize(msg, Colors.GREEN + Colors.BOLD) + print(colored_msg) + elif not success: + # In silent mode, still show failed files + for failed_path in failed_paths: + print(f"โŒ {failed_path}") return success, {**stats, "failed_paths": failed_paths} @@ -374,29 +1156,31 @@ def discover_test_files(project_root: Path) -> List[Tuple[Path, str]]: return test_files -def run_tests(project_root: Path) -> Tuple[bool, Dict]: +def run_tests(project_root: Path, silent: bool = False, yaml_output: bool = False) -> Tuple[bool, Dict]: """Run Godot tests.""" - print_header("๐Ÿงช GDScript Test Runner") + if not yaml_output: + print_header("๐Ÿงช GDScript Test Runner", silent) test_files = discover_test_files(project_root) - scan_msg = "๐Ÿ” Scanning for test files in tests\\ directory..." - colored_scan = Colors.colorize(scan_msg, Colors.BLUE) - print(colored_scan) + if not silent and not yaml_output: + scan_msg = "๐Ÿ” Scanning for test files in tests\\ directory..." + colored_scan = Colors.colorize(scan_msg, Colors.BLUE) + print(colored_scan) - discover_msg = "\n๐Ÿ“‹ Discovered test files:" - colored_discover = Colors.colorize(discover_msg, Colors.CYAN) - print(colored_discover) + discover_msg = "\n๐Ÿ“‹ Discovered test files:" + colored_discover = Colors.colorize(discover_msg, Colors.CYAN) + print(colored_discover) - for test_file, prefix in test_files: - test_name = format_test_name(test_file.stem) - file_info = f" {prefix}{test_name}: {test_file}" - colored_file_info = Colors.colorize(file_info, Colors.MAGENTA) - print(colored_file_info) + for test_file, prefix in test_files: + test_name = format_test_name(test_file.stem) + file_info = f" {prefix}{test_name}: {test_file}" + colored_file_info = Colors.colorize(file_info, Colors.MAGENTA) + print(colored_file_info) - start_msg = "\n๐Ÿš€ Starting test execution...\n" - colored_start = Colors.colorize(start_msg, Colors.BLUE + Colors.BOLD) - print(colored_start) + start_msg = "\n๐Ÿš€ Starting test execution...\n" + colored_start = Colors.colorize(start_msg, Colors.BLUE + Colors.BOLD) + print(colored_start) total_tests = failed_tests = 0 test_results = [] @@ -405,13 +1189,14 @@ def run_tests(project_root: Path) -> Tuple[bool, Dict]: test_name = format_test_name(test_file.stem) full_test_name = f"{prefix}{test_name}" - header_msg = f"=== {full_test_name} ===" - colored_header = Colors.colorize(header_msg, Colors.CYAN + Colors.BOLD) - print(f"\n{colored_header}") + if not silent and not yaml_output: + header_msg = f"=== {full_test_name} ===" + colored_header = Colors.colorize(header_msg, Colors.CYAN + Colors.BOLD) + print(f"\n{colored_header}") - running_msg = f"๐ŸŽฏ Running: {test_file}" - colored_running = Colors.colorize(running_msg, Colors.BLUE) - print(colored_running) + running_msg = f"๐ŸŽฏ Running: {test_file}" + colored_running = Colors.colorize(running_msg, Colors.BLUE) + print(colored_running) try: result = run_command( @@ -421,14 +1206,18 @@ def run_tests(project_root: Path) -> Tuple[bool, Dict]: ) if result.returncode == 0: - pass_msg = f"โœ… PASSED: {full_test_name}" - colored_pass = Colors.colorize(pass_msg, Colors.GREEN + Colors.BOLD) - print(colored_pass) + if not silent and not yaml_output: + pass_msg = f"โœ… PASSED: {full_test_name}" + colored_pass = Colors.colorize(pass_msg, Colors.GREEN + Colors.BOLD) + print(colored_pass) test_results.append((full_test_name, True, "")) else: - fail_msg = f"โŒ FAILED: {full_test_name}" - colored_fail = Colors.colorize(fail_msg, Colors.RED + Colors.BOLD) - print(colored_fail) + if not silent and not yaml_output: + fail_msg = f"โŒ FAILED: {full_test_name}" + colored_fail = Colors.colorize(fail_msg, Colors.RED + Colors.BOLD) + print(colored_fail) + elif silent and not yaml_output: + print(f"โŒ {full_test_name}") failed_tests += 1 error_msg = (result.stderr + result.stdout).strip() or "Unknown error" test_results.append((full_test_name, False, error_msg)) @@ -436,26 +1225,34 @@ def run_tests(project_root: Path) -> Tuple[bool, Dict]: total_tests += 1 except subprocess.TimeoutExpired: - timeout_msg = f"โฐ FAILED: {full_test_name} (TIMEOUT)" - colored_timeout = Colors.colorize(timeout_msg, Colors.RED + Colors.BOLD) - print(colored_timeout) + if not silent and not yaml_output: + timeout_msg = f"โฐ FAILED: {full_test_name} (TIMEOUT)" + colored_timeout = Colors.colorize(timeout_msg, Colors.RED + Colors.BOLD) + print(colored_timeout) + elif silent and not yaml_output: + print(f"โฐ {full_test_name} (TIMEOUT)") failed_tests += 1 test_results.append((full_test_name, False, "Test timed out")) total_tests += 1 except FileNotFoundError: - error_msg = "โŒ ERROR: Godot not found" - colored_error = Colors.colorize(error_msg, Colors.RED + Colors.BOLD) - print(colored_error) + if not yaml_output and not silent: + error_msg = "โŒ ERROR: Godot not found" + colored_error = Colors.colorize(error_msg, Colors.RED + Colors.BOLD) + print(colored_error) return False, {} except Exception as e: - exc_msg = f"๐Ÿ’ฅ FAILED: {full_test_name} (ERROR: {e})" - colored_exc = Colors.colorize(exc_msg, Colors.RED + Colors.BOLD) - print(colored_exc) + if not silent and not yaml_output: + exc_msg = f"๐Ÿ’ฅ FAILED: {full_test_name} (ERROR: {e})" + colored_exc = Colors.colorize(exc_msg, Colors.RED + Colors.BOLD) + print(colored_exc) + elif silent and not yaml_output: + print(f"๐Ÿ’ฅ {full_test_name} (ERROR: {e})") failed_tests += 1 test_results.append((full_test_name, False, str(e))) total_tests += 1 - print() + if not silent and not yaml_output: + print() # Summary passed_tests = total_tests - failed_tests @@ -464,23 +1261,28 @@ def run_tests(project_root: Path) -> Tuple[bool, Dict]: "Tests Passed": passed_tests, "Tests Failed": failed_tests } - print_summary("Test Execution Summary", stats) success = failed_tests == 0 - print() - if success: - msg = "๐ŸŽ‰ ALL TESTS PASSED!" - colored_msg = Colors.colorize(msg, Colors.GREEN + Colors.BOLD) - print(colored_msg) + + if yaml_output: + output_yaml_results("test", {**stats, "results": test_results}, success) else: - msg = f"๐Ÿ’ฅ {failed_tests} TEST(S) FAILED" - colored_msg = Colors.colorize(msg, Colors.RED + Colors.BOLD) - print(colored_msg) + print_summary("Test Execution Summary", stats, silent) + if not silent: + print() + if success: + msg = "๐ŸŽ‰ ALL TESTS PASSED!" + colored_msg = Colors.colorize(msg, Colors.GREEN + Colors.BOLD) + print(colored_msg) + else: + msg = f"๐Ÿ’ฅ {failed_tests} TEST(S) FAILED" + colored_msg = Colors.colorize(msg, Colors.RED + Colors.BOLD) + print(colored_msg) return success, {**stats, "results": test_results} -def run_workflow(project_root: Path, steps: List[str]) -> bool: +def run_workflow(project_root: Path, steps: List[str], silent: bool = False, yaml_output: bool = False) -> bool: """ Execute development workflow steps in sequence. @@ -493,107 +1295,120 @@ def run_workflow(project_root: Path, steps: List[str]) -> bool: Returns: bool: True if all steps completed successfully, False if any failed """ - print_header("๐Ÿ”„ Development Workflow Runner") + if not silent: + print_header("๐Ÿ”„ Development Workflow Runner") workflow_steps = { - "lint": ("๐Ÿ” Code linting (gdlint)", run_lint), - "format": ("๐ŸŽจ Code formatting (gdformat)", run_format), - "test": ("๐Ÿงช Test execution (godot tests)", run_tests) + "lint": ("๐Ÿ” Code linting (gdlint)", lambda root: run_lint(root, silent, yaml_output)), + "format": ("๐ŸŽจ Code formatting (gdformat)", lambda root: run_format(root, silent, yaml_output)), + "test": ("๐Ÿงช Test execution (godot tests)", lambda root: run_tests(root, silent, yaml_output)), + "validate": ("๐Ÿ“‹ File format validation (yaml/toml/json)", lambda root: run_validate(root, silent, yaml_output)) } - intro_msg = "๐Ÿš€ This script will run the development workflow:" - colored_intro = Colors.colorize(intro_msg, Colors.BLUE + Colors.BOLD) - print(colored_intro) + if not silent: + intro_msg = "๐Ÿš€ This script will run the development workflow:" + colored_intro = Colors.colorize(intro_msg, Colors.BLUE + Colors.BOLD) + print(colored_intro) - for i, step in enumerate(steps, 1): - step_name = workflow_steps[step][0] - step_msg = f"{i}. {step_name}" - colored_step = Colors.colorize(step_msg, Colors.CYAN) - print(colored_step) - print() + for i, step in enumerate(steps, 1): + step_name = workflow_steps[step][0] + step_msg = f"{i}. {step_name}" + colored_step = Colors.colorize(step_msg, Colors.CYAN) + print(colored_step) + print() start_time = time.time() results = {} for step in steps: step_name, step_func = workflow_steps[step] - separator = Colors.colorize("-" * 48, Colors.MAGENTA) - print(separator) + if not silent: + separator = Colors.colorize("-" * 48, Colors.MAGENTA) + print(separator) - running_msg = f"โšก Running {step_name}" - colored_running = Colors.colorize(running_msg, Colors.BLUE + Colors.BOLD) - print(colored_running) - print(separator) + running_msg = f"โšก Running {step_name}" + colored_running = Colors.colorize(running_msg, Colors.BLUE + Colors.BOLD) + print(colored_running) + print(separator) success, step_results = step_func(project_root) results[step] = step_results - if not success: - fail_msg = f"โŒ {step.upper()} FAILED - Continuing with remaining steps" - colored_fail = Colors.colorize(fail_msg, Colors.RED + Colors.BOLD) - print(f"\n{colored_fail}") + if not silent: + if not success: + fail_msg = f"โŒ {step.upper()} FAILED - Continuing with remaining steps" + colored_fail = Colors.colorize(fail_msg, Colors.RED + Colors.BOLD) + print(f"\n{colored_fail}") - warning_msg = "โš ๏ธ Issues found, but continuing to provide complete feedback" - colored_warning = Colors.colorize(warning_msg, Colors.YELLOW) - print(colored_warning) + warning_msg = "โš ๏ธ Issues found, but continuing to provide complete feedback" + colored_warning = Colors.colorize(warning_msg, Colors.YELLOW) + print(colored_warning) - status_msg = f"โœ… {step_name} completed {'successfully' if success else 'with issues'}" - colored_status = Colors.colorize(status_msg, Colors.GREEN if success else Colors.YELLOW) - print(colored_status) - print() + status_msg = f"โœ… {step_name} completed {'successfully' if success else 'with issues'}" + colored_status = Colors.colorize(status_msg, Colors.GREEN if success else Colors.YELLOW) + print(colored_status) + print() # Final summary elapsed_time = time.time() - start_time - print_header("๐Ÿ“Š Workflow Summary") + + if not silent: + print_header("๐Ÿ“Š Workflow Summary") all_success = True any_failures = False for step in steps: step_success = results[step].get("Tests Failed", results[step].get("Failed", 0)) == 0 - status_emoji = "โœ…" if step_success else "โŒ" - status_text = "PASSED" if step_success else "FAILED" - status_color = Colors.GREEN if step_success else Colors.RED + if not silent: + status_emoji = "โœ…" if step_success else "โŒ" + status_text = "PASSED" if step_success else "FAILED" + status_color = Colors.GREEN if step_success else Colors.RED - step_emoji = {"lint": "๐Ÿ”", "format": "๐ŸŽจ", "test": "๐Ÿงช"}.get(step, "๐Ÿ“‹") - colored_status = Colors.colorize(f"{status_text}", status_color + Colors.BOLD) - print(f"{step_emoji} {step.capitalize()}: {status_emoji} {colored_status}") + step_emoji = {"lint": "๐Ÿ”", "format": "๐ŸŽจ", "test": "๐Ÿงช", "validate": "๐Ÿ“‹"}.get(step, "๐Ÿ“‹") + colored_status = Colors.colorize(f"{status_text}", status_color + Colors.BOLD) + print(f"{step_emoji} {step.capitalize()}: {status_emoji} {colored_status}") if not step_success: any_failures = True all_success = False - print() - if all_success: - success_msg = "๐ŸŽ‰ ALL WORKFLOW STEPS COMPLETED SUCCESSFULLY!" - colored_success = Colors.colorize(success_msg, Colors.GREEN + Colors.BOLD) - print(colored_success) + if not silent: + print() + if all_success: + success_msg = "๐ŸŽ‰ ALL WORKFLOW STEPS COMPLETED SUCCESSFULLY!" + colored_success = Colors.colorize(success_msg, Colors.GREEN + Colors.BOLD) + print(colored_success) - commit_msg = "๐Ÿš€ Your code is ready for commit." - colored_commit = Colors.colorize(commit_msg, Colors.CYAN) - print(colored_commit) - else: - fail_msg = "โŒ WORKFLOW COMPLETED WITH FAILURES" - colored_fail = Colors.colorize(fail_msg, Colors.RED + Colors.BOLD) - print(colored_fail) + commit_msg = "๐Ÿš€ Your code is ready for commit." + colored_commit = Colors.colorize(commit_msg, Colors.CYAN) + print(colored_commit) + else: + fail_msg = "โŒ WORKFLOW COMPLETED WITH FAILURES" + colored_fail = Colors.colorize(fail_msg, Colors.RED + Colors.BOLD) + print(colored_fail) - review_msg = "๐Ÿ”ง Please fix the issues above before committing." - colored_review = Colors.colorize(review_msg, Colors.RED) - print(colored_review) + review_msg = "๐Ÿ”ง Please fix the issues above before committing." + colored_review = Colors.colorize(review_msg, Colors.RED) + print(colored_review) + + time_msg = f"โฑ๏ธ Elapsed time: {elapsed_time:.1f} seconds" + colored_time = Colors.colorize(time_msg, Colors.MAGENTA) + print(f"\n{colored_time}") - time_msg = f"โฑ๏ธ Elapsed time: {elapsed_time:.1f} seconds" - colored_time = Colors.colorize(time_msg, Colors.MAGENTA) - print(f"\n{colored_time}") return all_success def main(): """Main entry point.""" parser = argparse.ArgumentParser(description="Run development workflow for Skelly Godot project") - parser.add_argument("--steps", nargs="+", choices=["lint", "format", "test"], - default=["format", "lint", "test"], help="Workflow steps to run") + parser.add_argument("--steps", nargs="+", choices=["lint", "format", "test", "validate"], + default=["format", "lint", "test", "validate"], help="Workflow steps to run") parser.add_argument("--lint", action="store_true", help="Run linting") parser.add_argument("--format", action="store_true", help="Run formatting") parser.add_argument("--test", action="store_true", help="Run tests") + parser.add_argument("--validate", action="store_true", help="Run file format validation") + parser.add_argument("--silent", "-s", action="store_true", help="Silent mode - hide success messages, only show errors") + parser.add_argument("--yaml", action="store_true", help="Output results in machine-readable YAML format") args = parser.parse_args() project_root = Path(__file__).parent.parent @@ -605,15 +1420,22 @@ def main(): steps = ["format"] elif args.test: steps = ["test"] + elif args.validate: + steps = ["validate"] else: steps = args.steps # Run workflow or individual step if len(steps) == 1: - step_funcs = {"lint": run_lint, "format": run_format, "test": run_tests} + step_funcs = { + "lint": lambda root: run_lint(root, args.silent, args.yaml), + "format": lambda root: run_format(root, args.silent, args.yaml), + "test": lambda root: run_tests(root, args.silent, args.yaml), + "validate": lambda root: run_validate(root, args.silent, args.yaml) + } success, _ = step_funcs[steps[0]](project_root) else: - success = run_workflow(project_root, steps) + success = run_workflow(project_root, steps, args.silent, args.yaml) sys.exit(0 if success else 1)