v1.2版本改为通过xbox系列手柄进行遥控,并增加智能模式和手动模式
This commit is contained in:
1 parent
76603d3c31
commit
1a97bc32c3
1839 files changed
+478852
-3
No files matched your search
@@ -0,0 +1,246 @@
|
||||
# FastLED CI/Build System Agent Guidelines
|
||||
|
||||
## 🔧 Python Build System
|
||||
|
||||
### Core Architecture
|
||||
FastLED uses a high-performance Python-based build system:
|
||||
|
||||
**Key Components:**
|
||||
- `ci/compiler/clang_compiler.py` - Core compiler with PCH and fingerprint cache
|
||||
- `ci/ci/fingerprint_cache.py` - 99% faster PCH rebuilds through intelligent caching
|
||||
- `ci/util/build_flags.py` - TOML-based build configuration management
|
||||
- `ci/compiler/test_example_compilation.py` - Example compilation system
|
||||
|
||||
**Performance Features:**
|
||||
- **PCH (Precompiled Headers)** - Dramatically faster compilation
|
||||
- **Fingerprint Cache** - 99% time reduction for unchanged dependencies
|
||||
- **Parallel Compilation** - Automatic CPU-optimized parallel builds
|
||||
- **Smart Rebuilds** - Only rebuild when source files actually change
|
||||
|
||||
### 🚨 Command Execution Rules
|
||||
|
||||
#### FOREGROUND AGENTS (Interactive Development)
|
||||
**Python Code Execution:**
|
||||
- ✅ **Create/modify tmp.py** in project root (NOT subdirectories)
|
||||
- ✅ **Always run: `uv run python tmp.py`** (never just `python`)
|
||||
- ✅ **Correct import paths:** `from ci.ci.module` for ci.ci modules, `from ci.module` for top-level ci modules
|
||||
- ❌ **NEVER cd to subdirectories** - stay in project root
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
# tmp.py (created in project root)
|
||||
from ci.ci.clang_compiler import Compiler # CORRECT import path
|
||||
import subprocess
|
||||
result = subprocess.run(['git', 'status'], capture_output=True, text=True)
|
||||
print(result.stdout)
|
||||
```
|
||||
Then run: `uv run python tmp.py`
|
||||
|
||||
#### BACKGROUND AGENTS (Automated/CI Environments)
|
||||
**Restrictions:**
|
||||
- ❌ **NO tmp.py files** (forbidden for background agents)
|
||||
- ✅ **Use existing scripts:** `uv run python ci/ci-compile.py uno --examples Blink`
|
||||
- ✅ **Use MCP server tools** for programmatic operations
|
||||
- ✅ **Always use `uv run python`** with existing scripts
|
||||
|
||||
## 🤖 MCP Server Configuration
|
||||
|
||||
**Start server:** `uv run mcp_server.py`
|
||||
|
||||
**Available Tools:**
|
||||
- Running tests with various options
|
||||
- Compiling examples for different platforms
|
||||
- Code fingerprinting and change detection
|
||||
- Linting and formatting
|
||||
- **Build info analysis** for platform-specific defines, compiler flags, toolchain information
|
||||
- **Symbol analysis** for binary optimization (all platforms)
|
||||
- Stack trace setup for enhanced debugging
|
||||
- **🌐 FastLED Web Compiler** with Playwright (FOREGROUND AGENTS ONLY)
|
||||
- **🚨 `validate_completion` tool** (MANDATORY for background agents)
|
||||
|
||||
### FastLED Web Compiler (FOREGROUND AGENTS ONLY)
|
||||
|
||||
**Prerequisites:**
|
||||
- `pip install fastled` - FastLED web compiler
|
||||
- `pip install playwright` - Browser automation
|
||||
- Docker (optional, for faster compilation)
|
||||
|
||||
**Usage via MCP Server:**
|
||||
```python
|
||||
# Basic usage
|
||||
use run_fastled_web_compiler tool with:
|
||||
- example_path: "examples/Audio"
|
||||
- capture_duration: 30
|
||||
- headless: false
|
||||
- save_screenshot: true
|
||||
```
|
||||
|
||||
**Key Features:**
|
||||
- Compiles Arduino sketches to WASM for browser execution
|
||||
- Captures console.log output with playwright automation
|
||||
- Takes screenshots of running visualizations
|
||||
- Monitors FastLED_onFrame calls to verify proper initialization
|
||||
|
||||
**🚫 BACKGROUND AGENT RESTRICTION:** This tool is COMPLETELY DISABLED for background agents and CI environments.
|
||||
|
||||
## 🤖 Linting Guidelines
|
||||
|
||||
### FOREGROUND AGENTS
|
||||
**🚨 ALWAYS USE `bash lint` - DO NOT RUN INDIVIDUAL LINTING SCRIPTS**
|
||||
|
||||
- ✅ **CORRECT:** `bash lint`
|
||||
- ❌ **FORBIDDEN:** `./lint-js`, `./check-js`, `python3 scripts/enhance-js-typing.py`
|
||||
- ❌ **FORBIDDEN:** `uv run ruff check`, `uv run black`, individual tools
|
||||
|
||||
**WHY:** `bash lint` provides comprehensive coverage of Python, C++, JavaScript, and enhancement analysis.
|
||||
|
||||
### BACKGROUND AGENTS
|
||||
**CAN USE FINE-GRAINED LINTING FOR SPECIFIC NEEDS**
|
||||
|
||||
Background agents may use individual linting scripts when needed:
|
||||
- `./lint-js` - JavaScript-only linting
|
||||
- `./check-js` - JavaScript type checking
|
||||
- `python3 scripts/enhance-js-typing.py` - JavaScript enhancement analysis
|
||||
- `uv run ruff check` - Python linting only
|
||||
- MCP server `lint_code` tool - Programmatic access
|
||||
|
||||
**BUT STILL PREFER `bash lint` FOR COMPREHENSIVE CHECKING**
|
||||
|
||||
## Build System Commands
|
||||
|
||||
**Unit Test Compilation:**
|
||||
```bash
|
||||
# Fast Python API (default)
|
||||
bash test --unit --verbose # Uses PCH optimization
|
||||
|
||||
# Disable PCH if needed
|
||||
bash test --unit --no-pch --verbose
|
||||
|
||||
# Legacy CMake system (8x slower)
|
||||
bash test --unit --legacy --verbose
|
||||
```
|
||||
|
||||
**Available Build Options:**
|
||||
- `--no-pch` - Disable precompiled headers
|
||||
- `--clang` - Use Clang compiler (recommended for speed)
|
||||
- `--clean` - Force full rebuild
|
||||
- `--verbose` - Show detailed compilation output
|
||||
- `--legacy` - Use old CMake system (discouraged)
|
||||
|
||||
**Platform Compilation:**
|
||||
```bash
|
||||
# Compile examples for specific platforms
|
||||
uv run ci/ci-compile.py uno --examples Blink
|
||||
uv run ci/ci-compile.py esp32dev --examples Blink
|
||||
uv run ci/ci-compile.py teensy31 --examples Blink
|
||||
```
|
||||
|
||||
**WASM Compilation:**
|
||||
```bash
|
||||
# Compile any sketch directory to WASM
|
||||
uv run ci/wasm_compile.py path/to/your/sketch
|
||||
|
||||
# Quick compile test (compile only, no browser)
|
||||
uv run ci/wasm_compile.py examples/Blink --just-compile
|
||||
|
||||
# Compile and open browser
|
||||
uv run ci/wasm_compile.py examples/Blink -b --open
|
||||
```
|
||||
|
||||
## Build Troubleshooting
|
||||
|
||||
**For Linker Issues:**
|
||||
1. Check `tests/cmake/LinkerCompatibility.cmake` first
|
||||
2. Look for lld-link detection and compatibility functions
|
||||
3. Check GNU→MSVC flag translation logic
|
||||
4. Verify warning suppression settings
|
||||
|
||||
**For Compiler Issues:**
|
||||
1. Check `tests/cmake/CompilerDetection.cmake` for detection logic
|
||||
2. Review `tests/cmake/CompilerFlags.cmake` for flag conflicts
|
||||
3. Verify optimization settings in `OptimizationSettings.cmake`
|
||||
|
||||
**For Build Performance:**
|
||||
1. Check `tests/cmake/ParallelBuild.cmake` for parallel settings
|
||||
2. Review LTO configuration in `OptimizationSettings.cmake`
|
||||
3. Verify linker selection (mold, lld, default)
|
||||
|
||||
## 🚨 Critical User Feedback Corrections
|
||||
|
||||
**Always Use `uv run python` - NEVER just `python` or `uv run`:**
|
||||
- ❌ **WRONG**: `python -c "..."`
|
||||
- ❌ **WRONG**: `uv run -c "..."`
|
||||
- ✅ **CORRECT**: `uv run python -c "..."`
|
||||
|
||||
**Correct Import Paths:**
|
||||
- ✅ **CORRECT**: `from ci.ci.clang_compiler import ...` (for ci.ci modules)
|
||||
- ✅ **CORRECT**: `from ci.clang_compiler import ...` (for top-level ci modules, if they exist)
|
||||
|
||||
**Never Change Directory:**
|
||||
- ❌ **WRONG**: `cd ci && python ...`
|
||||
- ❌ **WRONG**: `cd ci/ci && python ...`
|
||||
- ✅ **CORRECT**: Stay in project root, use full paths: `uv run python ci/script.py`
|
||||
|
||||
**Git-Bash Terminal Compatibility:**
|
||||
Use leading space for git-bash compatibility:
|
||||
- ✅ **CORRECT**: ` bash test` (note the leading space)
|
||||
- ❌ **INCORRECT**: `bash test` (may get truncated to `ash test`)
|
||||
|
||||
## Platform Build Information Analysis
|
||||
|
||||
**Generate Build Info:**
|
||||
```bash
|
||||
# Compile a platform to generate build_info.json
|
||||
uv run ci/ci-compile.py uno --examples Blink
|
||||
uv run ci/ci-compile.py esp32dev --examples Blink
|
||||
```
|
||||
|
||||
**Analyze Platform Defines:**
|
||||
```bash
|
||||
# Command line tool
|
||||
python3 ci/ci/build_info_analyzer.py --board uno --show-defines
|
||||
|
||||
# Via MCP server
|
||||
uv run mcp_server.py
|
||||
# Use build_info_analysis tool with: board="uno", show_defines=true
|
||||
```
|
||||
|
||||
**Compare Platforms:**
|
||||
```bash
|
||||
# Command line
|
||||
python3 ci/ci/build_info_analyzer.py --compare uno esp32dev
|
||||
|
||||
# Via MCP
|
||||
# Use build_info_analysis tool with: board="uno", compare_with="esp32dev"
|
||||
```
|
||||
|
||||
## Symbol Analysis for Binary Optimization
|
||||
|
||||
**Analyze specific platform:**
|
||||
```bash
|
||||
uv run ci/ci/symbol_analysis.py --board uno
|
||||
uv run ci/ci/symbol_analysis.py --board esp32dev
|
||||
uv run ci/ci/symbol_analysis.py --board teensy31
|
||||
```
|
||||
|
||||
**Analyze all available platforms:**
|
||||
```bash
|
||||
uv run ci/demo_symbol_analysis.py
|
||||
```
|
||||
|
||||
**Using MCP Server:**
|
||||
```bash
|
||||
# Use MCP server tools
|
||||
uv run mcp_server.py
|
||||
# Then use symbol_analysis tool with board: "uno" or "auto"
|
||||
# Or use symbol_analysis tool with run_all_platforms: true
|
||||
```
|
||||
|
||||
**JSON Output:**
|
||||
```bash
|
||||
uv run symbol_analysis.py --board esp32dev --output-json
|
||||
# Results saved to: .build/esp32dev_symbol_analysis.json
|
||||
```
|
||||
|
||||
## Memory Refresh Rule
|
||||
**🚨 ALL AGENTS: Read ci/AGENTS.md before concluding CI/build work to refresh memory about current Python build system rules and MCP server requirements.**
|
||||
@@ -0,0 +1,228 @@
|
||||
# FastLED Python Build System - Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
Successfully implemented a new Python-based build system to replace the complex CMake approach for FastLED example compilation testing. The new system delivers on all design goals: **Simple, Fast, and Transparent**.
|
||||
|
||||
## Key Achievements
|
||||
|
||||
### ⚡ Performance Results
|
||||
- **4.3 examples/second** compilation rate
|
||||
- **90%+ faster** than CMake-based approach
|
||||
- **Sub-second builds** for incremental changes
|
||||
- **Intelligent caching** with 30%+ hit rates on repeated builds
|
||||
|
||||
### 🏗️ Architecture Implemented
|
||||
|
||||
```
|
||||
FastLED Python Build System
|
||||
├── ci/build_system/
|
||||
│ ├── __init__.py # Package initialization
|
||||
│ ├── build_config.py # Configuration management
|
||||
│ ├── example_scanner.py # .ino file discovery & analysis
|
||||
│ ├── change_detector.py # Fast file change detection
|
||||
│ ├── compiler.py # Direct Clang compilation
|
||||
│ ├── cache_manager.py # Build cache management
|
||||
│ └── fastled_build.py # Main build engine
|
||||
├── ci/fastled_build # CLI entry point
|
||||
├── ci/test_example_compilation_python.py # New test script
|
||||
└── ci/test_example_compilation.py # Updated original (with --use-python-build flag)
|
||||
```
|
||||
|
||||
### 🎯 Core Features Delivered
|
||||
|
||||
#### 1. **Fast Example Discovery**
|
||||
- Scans 80+ examples in milliseconds
|
||||
- Categorizes by FastLED usage and PCH compatibility
|
||||
- Intelligent filtering of problematic examples
|
||||
|
||||
#### 2. **Content-Based Change Detection**
|
||||
- SHA-256 fingerprinting of source files
|
||||
- Dependency tracking for headers
|
||||
- Smart cache invalidation
|
||||
|
||||
#### 3. **Precompiled Header Optimization**
|
||||
- Automatic PCH generation for FastLED headers
|
||||
- PCH reuse across compatible examples
|
||||
- 50%+ compilation speed improvement
|
||||
|
||||
#### 4. **Intelligent Caching**
|
||||
- Content-addressable build cache
|
||||
- sccache integration for compiler-level caching
|
||||
- Automatic cleanup of stale entries
|
||||
|
||||
#### 5. **Parallel Compilation**
|
||||
- Multi-threaded compilation (16 cores utilized)
|
||||
- ThreadPoolExecutor-based job management
|
||||
- Optimal resource utilization
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### CLI Tool
|
||||
```bash
|
||||
# Quick status check
|
||||
./fastled_build status
|
||||
|
||||
# Build all examples
|
||||
./fastled_build build
|
||||
|
||||
# Build specific examples
|
||||
./fastled_build build Blink DemoReel100
|
||||
|
||||
# Clean build
|
||||
./fastled_build clean
|
||||
./fastled_build build
|
||||
|
||||
# System diagnostics
|
||||
./fastled_build diagnostics
|
||||
```
|
||||
|
||||
### Test Scripts
|
||||
```bash
|
||||
# Default Python build system (recommended)
|
||||
python test_example_compilation.py
|
||||
|
||||
# Alternative Python build system script
|
||||
python test_example_compilation_python.py
|
||||
|
||||
# Both scripts now use Python build system by default
|
||||
python test_example_compilation.py Blink DemoReel100 # Python system
|
||||
```
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Example Categorization
|
||||
- **FastLED PCH Compatible**: Examples that can use precompiled headers
|
||||
- **FastLED Full Build**: Examples requiring full compilation
|
||||
- **Basic Examples**: Non-FastLED examples
|
||||
- **Platform Specific**: Hardware-dependent examples
|
||||
- **Problematic**: Known issues (excluded by default)
|
||||
|
||||
### Build Configuration
|
||||
- **Compiler**: Auto-detection (Clang preferred)
|
||||
- **Platform**: STUB implementation for testing
|
||||
- **Flags**: C++17, -O0 for fast compilation
|
||||
- **Arduino Compatibility**: WASM Arduino.h integration
|
||||
|
||||
### Performance Optimizations
|
||||
- **Fast Paths**: "No changes" detection in < 1 second
|
||||
- **PCH Reuse**: Share PCH across compatible examples
|
||||
- **Content Hashing**: Only hash changed files
|
||||
- **Parallel Processing**: All I/O and compilation parallelized
|
||||
|
||||
## Migration Status
|
||||
|
||||
### ✅ Phase 1: Parallel Implementation - COMPLETE
|
||||
- ✅ Implemented Python build system alongside CMake
|
||||
- ✅ Added `--use-python-build` flag to test script
|
||||
- ✅ Validated equivalent functionality
|
||||
- ✅ Performance testing shows 4x+ improvement
|
||||
|
||||
### 🚧 Phase 2: Feature Parity - IN PROGRESS
|
||||
- ✅ All CMake features replicated
|
||||
- ✅ Performance optimization completed
|
||||
- ✅ CLI and API documentation
|
||||
- ⏳ Extended platform testing
|
||||
|
||||
### ✅ Phase 3: Migration - COMPLETE
|
||||
- ✅ Switched default to Python build system
|
||||
- ✅ Removed CMake implementation completely
|
||||
- ✅ Updated `test_example_compilation.py` to use Python build system only
|
||||
- ✅ Maintained backward compatibility with same CLI interface
|
||||
|
||||
### 🧹 Phase 4: Cleanup - ⚠️ **INCOMPLETE**
|
||||
- ❌ Remove misleading "CMake" references in function names and error messages
|
||||
- ❌ Clean up dead code paths that reference non-existent CMake directories
|
||||
- ❌ Fix confusing system names (both systems are Python-based)
|
||||
- ❌ Update all documentation to reflect accurate system architecture
|
||||
- ⚠️ **Status**: Misleading naming caused confusion and bugs; cleanup required
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
| Metric | CMake System | Python System | Improvement |
|
||||
|--------|--------------|---------------|-------------|
|
||||
| Cold start | 15-30s | 1-2s | **15x faster** |
|
||||
| Incremental (5 examples) | 8-15s | 1-3s | **5x faster** |
|
||||
| Full build (80 examples) | 45-60s | 10-15s | **4x faster** |
|
||||
| Cache hit rate | ~60% | ~90% | **50% better** |
|
||||
| Memory usage | 2-4GB | 200-500MB | **80% less** |
|
||||
|
||||
## Error Handling & Diagnostics
|
||||
|
||||
### Comprehensive Error Reporting
|
||||
- Clear, actionable error messages
|
||||
- Compilation error aggregation
|
||||
- Performance timing breakdown
|
||||
- Cache statistics and health
|
||||
|
||||
### Diagnostic Tools
|
||||
```bash
|
||||
./fastled_build diagnostics
|
||||
```
|
||||
Shows:
|
||||
- Compiler information and availability
|
||||
- Cache statistics and hit rates
|
||||
- sccache integration status
|
||||
- File change detection stats
|
||||
|
||||
## Integration with Existing Workflow
|
||||
|
||||
### Backward Compatibility
|
||||
- ✅ Original `test_example_compilation.py` still works with same interface
|
||||
- ✅ Same command-line arguments and exit codes
|
||||
- ✅ Same behavior and output format
|
||||
- ✅ Complete drop-in replacement (CMake removed)
|
||||
- ✅ Performance improvement with no API changes
|
||||
|
||||
### Testing Integration
|
||||
```python
|
||||
# Easy integration in existing test scripts
|
||||
from build_system import BuildConfig, FastLEDBuildEngine
|
||||
|
||||
config = BuildConfig.from_environment()
|
||||
engine = FastLEDBuildEngine(config)
|
||||
result = engine.build(examples=["Blink", "DemoReel100"])
|
||||
assert result.success
|
||||
```
|
||||
|
||||
## Benefits Realized
|
||||
|
||||
### 1. **Simplicity**
|
||||
- ✅ Direct compiler control (no CMake abstraction)
|
||||
- ✅ Python stack traces for easy debugging
|
||||
- ✅ Transparent, understandable build logic
|
||||
|
||||
### 2. **Performance**
|
||||
- ✅ 4x+ faster than CMake approach
|
||||
- ✅ Intelligent caching tailored to FastLED
|
||||
- ✅ Efficient incremental builds
|
||||
|
||||
### 3. **Maintainability**
|
||||
- ✅ Python codebase (team expertise)
|
||||
- ✅ Modular, testable design
|
||||
- ✅ Clear separation of concerns
|
||||
|
||||
### 4. **Flexibility**
|
||||
- ✅ Easy FastLED-specific optimizations
|
||||
- ✅ Simple tool integration
|
||||
- ✅ Consistent cross-platform behavior
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Extended Testing**: Test with more examples and edge cases
|
||||
2. **Documentation**: Update development docs to recommend Python system
|
||||
3. **CI Integration**: Consider switching CI to use Python build system
|
||||
4. **Feature Additions**: Add requested features (linking, size analysis, etc.)
|
||||
|
||||
## Conclusion
|
||||
|
||||
⚠️ **MIGRATION STATUS**: The Python build system has successfully replaced the CMake approach, but **Phase 4 cleanup was never completed**, leaving misleading function names and error messages that reference "CMake" when both systems are actually Python-based.
|
||||
|
||||
### Final Results
|
||||
- **🚀 13.3 examples/second** compilation rate (in cached scenarios)
|
||||
- **⚡ Ultra-fast builds** with intelligent caching
|
||||
- **🔄 100% backward compatibility** with existing workflows
|
||||
- **🧹 Clean codebase** with CMake complexity completely removed
|
||||
- **📊 Superior performance** across all metrics
|
||||
|
||||
The system delivers on all original design goals: **Simple, Fast, and Transparent**. However, **incomplete cleanup has left confusing naming** that makes the system less transparent than intended. The naming issues have been identified and are being addressed.
|
||||
@@ -0,0 +1,11 @@
|
||||
This is the Continuous Integration tool that builds the product in various configurations.
|
||||
|
||||
Running
|
||||
|
||||
Install the python `uv` tool.
|
||||
|
||||
`pip install uv`
|
||||
|
||||
Now run the python in this directory
|
||||
|
||||
`uv run ci-compile.py`
|
||||
@@ -0,0 +1,313 @@
|
||||
# FastLED Symbol Analysis
|
||||
|
||||
This document describes the new generic symbol analysis functionality that works across all supported platforms including UNO, ESP32, and others.
|
||||
|
||||
## Overview
|
||||
|
||||
The symbol analysis tool provides comprehensive analysis of compiled ELF files to help identify optimization opportunities and understand binary size allocation. Unlike the previous ESP32-specific implementation, this tool:
|
||||
|
||||
- ✅ **Works on ALL platforms** (UNO, ESP32, Teensy, STM32, etc.)
|
||||
- ✅ **Pulls ALL symbols** without filtering or classification
|
||||
- ✅ **No FastLED-specific filtering** - analyzes everything
|
||||
- ✅ **Generic and extensible** for any platform with build_info.json
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Analyze a specific platform
|
||||
|
||||
```bash
|
||||
cd ci
|
||||
uv run symbol_analysis.py --board uno
|
||||
uv run symbol_analysis.py --board esp32dev
|
||||
```
|
||||
|
||||
### Run analysis on all available platforms
|
||||
|
||||
```bash
|
||||
uv run demo_symbol_analysis.py
|
||||
```
|
||||
|
||||
### Use in GitHub Actions
|
||||
|
||||
```bash
|
||||
uv run symbol_analysis_runner.py --board uno --example Blink
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- **`symbol_analysis.py`** - Main generic symbol analysis tool
|
||||
- **`symbol_analysis_runner.py`** - GitHub Actions wrapper
|
||||
- **`demo_symbol_analysis.py`** - Demonstration script for multiple platforms
|
||||
- **`SYMBOL_ANALYSIS.md`** - This documentation file
|
||||
|
||||
## Example Output
|
||||
|
||||
### UNO Platform Results
|
||||
|
||||
```
|
||||
================================================================================
|
||||
UNO SYMBOL ANALYSIS REPORT
|
||||
================================================================================
|
||||
|
||||
SUMMARY:
|
||||
Total symbols: 51
|
||||
Total symbol size: 3767 bytes (3.7 KB)
|
||||
|
||||
LARGEST SYMBOLS (all symbols, sorted by size):
|
||||
1. 1204 bytes - ClocklessController<...>::showPixels(...)
|
||||
2. 572 bytes - CFastLED::show(unsigned char) [clone .constprop.18]
|
||||
3. 460 bytes - main
|
||||
4. 204 bytes - CPixelLEDController<...>::show(...)
|
||||
5. 168 bytes - CPixelLEDController<...>::showColor(...)
|
||||
|
||||
SYMBOL TYPE BREAKDOWN:
|
||||
t: 19 symbols, 2710 bytes (2.6 KB)
|
||||
T: 14 symbols, 904 bytes (0.9 KB)
|
||||
b: 15 symbols, 89 bytes (0.1 KB)
|
||||
d: 3 symbols, 64 bytes (0.1 KB)
|
||||
```
|
||||
|
||||
### ESP32 Platform Results
|
||||
|
||||
```
|
||||
================================================================================
|
||||
ESP32DEV SYMBOL ANALYSIS REPORT
|
||||
================================================================================
|
||||
|
||||
SUMMARY:
|
||||
Total symbols: 2503
|
||||
Total symbol size: 237092 bytes (231.5 KB)
|
||||
|
||||
LARGEST SYMBOLS (all symbols, sorted by size):
|
||||
1. 12009 bytes - _vfprintf_r
|
||||
2. 11813 bytes - _svfprintf_r
|
||||
3. 8010 bytes - _vfiprintf_r
|
||||
4. 7854 bytes - _svfiprintf_r
|
||||
5. 4192 bytes - port_IntStack
|
||||
|
||||
SYMBOL TYPE BREAKDOWN:
|
||||
T: 1240 symbols, 156230 bytes (152.6 KB)
|
||||
t: 371 symbols, 45841 bytes (44.8 KB)
|
||||
d: 475 symbols, 13032 bytes (12.7 KB)
|
||||
D: 60 symbols, 9421 bytes (9.2 KB)
|
||||
W: 126 symbols, 5209 bytes (5.1 KB)
|
||||
```
|
||||
|
||||
### ESP32S3 Platform Results
|
||||
|
||||
```
|
||||
================================================================================
|
||||
ESP32S3 SYMBOL ANALYSIS REPORT
|
||||
================================================================================
|
||||
|
||||
SUMMARY:
|
||||
Total symbols: 2831
|
||||
Total symbol size: 260710 bytes (254.6 KB)
|
||||
|
||||
LARGEST SYMBOLS (all symbols, sorted by size):
|
||||
1. 11309 bytes - _vfprintf_r
|
||||
2. 11118 bytes - _svfprintf_r
|
||||
3. 7337 bytes - _svfiprintf_r
|
||||
4. 7265 bytes - _vfiprintf_r
|
||||
5. 4192 bytes - port_IntStack
|
||||
6. 1164 bytes - printBeforeSetupInfo()
|
||||
7. 828 bytes - fl::RmtController5::loadPixelData(PixelIterator&)
|
||||
|
||||
SYMBOL TYPE BREAKDOWN:
|
||||
T: 1416 symbols, 172591 bytes (168.5 KB)
|
||||
t: 429 symbols, 48854 bytes (47.7 KB)
|
||||
d: 529 symbols, 14189 bytes (13.9 KB)
|
||||
D: 72 symbols, 9831 bytes (9.6 KB)
|
||||
W: 122 symbols, 5457 bytes (5.3 KB)
|
||||
```
|
||||
|
||||
## Output Files
|
||||
|
||||
The tool generates JSON files with complete symbol data:
|
||||
|
||||
- `.build/{board}_symbol_analysis.json` - Complete analysis results
|
||||
|
||||
### JSON Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"board": "uno",
|
||||
"total_symbols": 51,
|
||||
"total_size": 3767,
|
||||
"largest_symbols": [...],
|
||||
"type_breakdown": {...},
|
||||
"dependencies": {...}
|
||||
},
|
||||
"all_symbols_sorted_by_size": [
|
||||
{
|
||||
"address": "00001240",
|
||||
"size": 1204,
|
||||
"type": "t",
|
||||
"name": "_ZN19ClocklessController...",
|
||||
"demangled_name": "ClocklessController<...>::showPixels(...)"
|
||||
},
|
||||
...
|
||||
],
|
||||
"dependencies": {
|
||||
"module.cpp.o": ["symbol1", "symbol2", ...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
The tool automatically detects and works with any platform that has:
|
||||
- A `build_info.json` file in `.build/{platform}/`
|
||||
- Standard toolchain tools (nm, c++filt) in the build info aliases
|
||||
|
||||
Successfully tested platforms:
|
||||
- ✅ **UNO** (AVR) - 51 symbols, 3.7 KB
|
||||
- ✅ **ESP32DEV** (Xtensa) - 2503 symbols, 231.5 KB
|
||||
- ✅ **ESP32S3** (Xtensa) - 2831 symbols, 254.6 KB
|
||||
- ✅ **TEENSY31** (ARM Cortex-M4) - 238 symbols, 10.9 KB
|
||||
- ✅ **TEENSYLC** (ARM Cortex-M0+) - 241 symbols, 9.1 KB
|
||||
- ✅ **DIGIX** (ARM Cortex-M3) - 298 symbols, 17.9 KB
|
||||
- ✅ **STM32** (ARM Cortex-M3) - 311 symbols, 15.6 KB
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### From ESP32-specific to Generic
|
||||
|
||||
**Before (ESP32 only):**
|
||||
```python
|
||||
# Hard-coded ESP32 board detection
|
||||
esp32_boards = ["esp32dev", "esp32", "esp32s2", ...]
|
||||
|
||||
# FastLED-specific filtering
|
||||
if any(keyword in search_text for keyword in [
|
||||
"fastled", "cfastled", "crgb", "hsv", ...]):
|
||||
fastled_symbols.append(symbol_info)
|
||||
```
|
||||
|
||||
**After (Generic):**
|
||||
```python
|
||||
# Auto-detect any platform with build_info.json
|
||||
for item in build_dir.iterdir():
|
||||
if item.is_dir() and (item / "build_info.json").exists():
|
||||
available_boards.append((build_info_file, item.name))
|
||||
|
||||
# No filtering - analyze ALL symbols
|
||||
symbols.append(symbol_info) # Everything included
|
||||
```
|
||||
|
||||
### Symbol Analysis Improvements
|
||||
|
||||
1. **No Classification** - All symbols are included without filtering
|
||||
2. **Complete Demangling** - All C++ symbols are properly demangled
|
||||
3. **Platform Agnostic** - Works with any toolchain (AVR, ARM, Xtensa, etc.)
|
||||
4. **Size-based Sorting** - All results sorted by symbol size for easy optimization targeting
|
||||
|
||||
## GitHub Actions Integration
|
||||
|
||||
The `symbol_analysis_runner.py` script provides GitHub Actions compatibility:
|
||||
|
||||
```yaml
|
||||
- name: Run Symbol Analysis
|
||||
run: python ci/symbol_analysis_runner.py --board uno --example Blink
|
||||
```
|
||||
|
||||
This runner:
|
||||
- Validates build_info.json exists
|
||||
- Handles errors gracefully with `--skip-on-failure`
|
||||
- Provides proper exit codes for CI/CD
|
||||
|
||||
## Migration from ESP32 Analysis
|
||||
|
||||
If you were using the old ESP32-specific analysis:
|
||||
|
||||
**Old command:**
|
||||
```bash
|
||||
cd ci/ci && python esp32_symbol_analysis.py
|
||||
```
|
||||
|
||||
**New command:**
|
||||
```bash
|
||||
cd ci && uv run symbol_analysis.py --board esp32dev
|
||||
```
|
||||
|
||||
The new tool provides all the same information plus:
|
||||
- Support for more platforms
|
||||
- ALL symbols instead of just FastLED ones
|
||||
- Better error handling
|
||||
- Cleaner output format
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Analyzing Custom Platforms
|
||||
|
||||
To add support for a new platform:
|
||||
|
||||
1. Ensure the platform builds correctly with PlatformIO
|
||||
2. Verify `build_info.json` is generated in `.build/{platform}/`
|
||||
3. Run: `uv run symbol_analysis.py --board {platform}`
|
||||
|
||||
### Batch Analysis
|
||||
|
||||
```bash
|
||||
# Analyze all available platforms
|
||||
uv run demo_symbol_analysis.py
|
||||
|
||||
# Analyze specific platforms in sequence
|
||||
for board in uno esp32dev teensy31; do
|
||||
uv run symbol_analysis.py --board $board
|
||||
done
|
||||
```
|
||||
|
||||
### Integration with Build Systems
|
||||
|
||||
```python
|
||||
# In your Python build script
|
||||
from ci.ci.symbol_analysis import main as analyze_symbols
|
||||
import sys
|
||||
|
||||
# Override arguments
|
||||
sys.argv = ["symbol_analysis.py", "--board", "uno"]
|
||||
analyze_symbols()
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"build_info.json not found"**
|
||||
- Solution: Compile the platform first: `uv run ci/ci-compile.py {board} --examples Blink`
|
||||
|
||||
2. **"KeyError: 'board_name'"**
|
||||
- Solution: The board name in the directory doesn't match the key in build_info.json
|
||||
- Check: `cat .build/{board}/build_info.json | jq 'keys'`
|
||||
|
||||
3. **"Tool not found"**
|
||||
- Solution: The toolchain isn't installed or path is incorrect in build_info.json
|
||||
|
||||
### Debug Mode
|
||||
|
||||
To debug issues, run with verbose Python output:
|
||||
```bash
|
||||
uv run python -v symbol_analysis.py --board uno
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new features:
|
||||
|
||||
1. Maintain platform independence
|
||||
2. Don't add filtering logic
|
||||
3. Test on multiple platforms
|
||||
4. Update this documentation
|
||||
5. Add tests to verify functionality
|
||||
|
||||
## Performance
|
||||
|
||||
Platform analysis times (approximate):
|
||||
- **UNO**: < 1 second (51 symbols)
|
||||
- **Teensy**: < 2 seconds (238-241 symbols)
|
||||
- **STM32**: < 3 seconds (311 symbols)
|
||||
- **ESP32**: < 10 seconds (2503 symbols)
|
||||
|
||||
The tool scales well with symbol count and provides progress feedback during demangling.
|
||||
@@ -0,0 +1,13 @@
|
||||
"""FastLED CI tools and utilities
|
||||
|
||||
Module-level initialization to ensure consistent runtime behavior across tools.
|
||||
"""
|
||||
|
||||
# Configure UTF-8 console output on Windows globally for CI tools
|
||||
try:
|
||||
from ci.util.console_utf8 import configure_utf8_console
|
||||
|
||||
configure_utf8_console()
|
||||
except Exception:
|
||||
# Never fail import due to console configuration
|
||||
pass
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
|
||||
|
||||
#define FASTLED_USE_ADAFRUIT_NEOPIXEL
|
||||
#include "FastLED.h"
|
||||
|
||||
// Test the non-template Adafruit bridge
|
||||
#define DATA_PIN 2
|
||||
#define NUM_LEDS 144
|
||||
|
||||
// Test LED arrays with different RGB orders
|
||||
CRGB leds_grb[NUM_LEDS];
|
||||
CRGB leds_rgb[NUM_LEDS];
|
||||
CRGB leds_bgr[NUM_LEDS];
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
|
||||
// Test template controllers
|
||||
FastLED.addLeds<WS2812, DATA_PIN, GRB>(leds_grb, NUM_LEDS);
|
||||
FastLED.addLeds<WS2812, DATA_PIN+1, RGB>(leds_rgb, NUM_LEDS);
|
||||
FastLED.addLeds<WS2812, DATA_PIN+2, BGR>(leds_bgr, NUM_LEDS);
|
||||
|
||||
// Fill with test patterns
|
||||
fill_rainbow(leds_grb, NUM_LEDS, 0, 255/NUM_LEDS);
|
||||
fill_solid(leds_rgb, NUM_LEDS, CRGB::Red);
|
||||
fill_solid(leds_bgr, NUM_LEDS, CRGB::Blue);
|
||||
|
||||
FastLED.show();
|
||||
Serial.println("Adafruit bridge test initialized");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
static uint8_t hue = 0;
|
||||
|
||||
// Update colors
|
||||
fill_rainbow(leds_grb, NUM_LEDS, hue, 255/NUM_LEDS);
|
||||
fill_solid(leds_rgb, NUM_LEDS, CHSV(hue, 255, 255));
|
||||
fill_solid(leds_bgr, NUM_LEDS, CHSV(hue + 128, 255, 255));
|
||||
|
||||
FastLED.show();
|
||||
|
||||
hue += 2;
|
||||
delay(50);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[platformio]
|
||||
src_dir = symlink://../../../src
|
||||
|
||||
[env:dev]
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.04/platform-espressif32.zip
|
||||
board = esp32-s3-devkitc-1
|
||||
framework = arduino
|
||||
lib_deps =
|
||||
adafruit/Adafruit NeoPixel
|
||||
|
||||
lib_ldf_mode=deep
|
||||
build_flags=-DFASTLED_STUB_MAIN_INCLUDE_INO="../ci/adafruit_bridge/adafruit_bridge.ino.cpp"
|
||||
-DFASTLED_USE_ADAFRUIT_NEOPIXEL=1
|
||||
@@ -0,0 +1,769 @@
|
||||
# dataclasses
|
||||
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
# An open source version of the esp-idf 5.1 platform for the ESP32 that
|
||||
# gives esp32 boards the same build environment as the Arduino 2.3.1+.
|
||||
|
||||
# Set to a specific release, we may want to update this in the future.
|
||||
ESP32_IDF_5_1_PIOARDUINO = "https://github.com/pioarduino/platform-espressif32/releases/download/51.03.04/platform-espressif32.zip"
|
||||
|
||||
# TODO: Upgrade toolkit to 5.3
|
||||
ESP32_IDF_5_3_PIOARDUINO = "https://github.com/pioarduino/platform-espressif32/releases/download/53.03.10/platform-espressif32.zip"
|
||||
ESP32_IDF_5_4_PIOARDUINO = "https://github.com/pioarduino/platform-espressif32/releases/download/54.03.20/platform-espressif32.zip"
|
||||
ESP32_IDF_5_5_PIOARDUINO = "https://github.com/pioarduino/platform-espressif32/releases/download/55.03.30-2/platform-espressif32.zip"
|
||||
ESP32_IDF_LATEST_PIOARDUINO = ESP32_IDF_5_5_PIOARDUINO
|
||||
|
||||
ESP32_IDF_LATEST_PIOARDUINO = (
|
||||
"https://github.com/pioarduino/platform-espressif32.git#develop"
|
||||
)
|
||||
ESP32_IDF_4_4_LATEST = (
|
||||
"https://github.com/platformio/platform-espressif32/archive/refs/tags/v4.4.0.zip"
|
||||
)
|
||||
APOLLO3_2_2_0 = "https://github.com/nigelb/platform-apollo3blue"
|
||||
# Top of trunk.
|
||||
# ESP32_IDF_5_1_PIOARDUINO = "https://github.com/pioarduino/platform-espressif32"
|
||||
|
||||
# Old fork that we were using
|
||||
# ESP32_IDF_5_1_PIOARDUINO = "https://github.com/zackees/platform-espressif32#Arduino/IDF5"
|
||||
|
||||
# ALL will be auto populated in the Board constructor whenever a
|
||||
# board is defined.
|
||||
ALL: list["Board"] = []
|
||||
|
||||
|
||||
class Auto:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Board:
|
||||
board_name: str
|
||||
real_board_name: str | None = None
|
||||
platform: str | None = None
|
||||
platform_needs_install: bool = False
|
||||
use_pio_run: bool = (
|
||||
False # some platforms like esp32-c2-devkitm-1 will only work with pio run
|
||||
)
|
||||
platform_packages: str | None = None
|
||||
framework: str | None = None
|
||||
board_build_mcu: str | None = None
|
||||
board_build_core: str | None = None
|
||||
board_build_filesystem_size: str | None = None
|
||||
build_flags: list[str] | None = None # Reserved for future use.
|
||||
build_unflags: list[str] | None = None # New: unflag options
|
||||
defines: list[str] | None = None
|
||||
customsdk: str | None = None
|
||||
board_partitions: str | None = None # Reserved for future use.
|
||||
no_board_spec: bool = (
|
||||
False # For platforms like 'native' that don't need a board specification
|
||||
)
|
||||
add_board_to_all: bool = True
|
||||
lib_compat_mode: str | None = (
|
||||
None # Library compatibility mode (e.g., 'off' for native platform)
|
||||
)
|
||||
lib_ldf_mode: str | None = (
|
||||
None # Library Dependency Finder mode (e.g., 'chain+' for enhanced dependency finding)
|
||||
)
|
||||
lib_ignore: list[str] | None = (
|
||||
None # Libraries to ignore during compilation (e.g., ['I2S'] for UNO R4 WiFi)
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Check if framework is set, warn and auto-set to arduino if missing (except for native/stub platforms)
|
||||
if self.framework is None and not self._is_native_or_stub_platform():
|
||||
self.framework = "arduino"
|
||||
|
||||
# Auto-detect and exclude problematic libraries based on environmental signals
|
||||
self._auto_detect_library_exclusions()
|
||||
|
||||
ALL.append(self)
|
||||
|
||||
def _is_native_or_stub_platform(self) -> bool:
|
||||
"""Check if this is a native or stub platform that doesn't need a framework"""
|
||||
# Check for native platform
|
||||
if self.platform and (
|
||||
"native" in self.platform.lower() or "stub" in self.platform.lower()
|
||||
):
|
||||
return True
|
||||
|
||||
# Check for stub build flags
|
||||
if self.build_flags:
|
||||
for flag in self.build_flags:
|
||||
if "FASTLED_STUB" in flag or "PLATFORM_NATIVE" in flag:
|
||||
return True
|
||||
|
||||
# Check no_board_spec flag (typically used for native platforms)
|
||||
if self.no_board_spec:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _auto_detect_library_exclusions(self) -> None:
|
||||
"""Automatically detect and exclude libraries known to be broken on this platform."""
|
||||
|
||||
# Check for I2S library problems based on environmental signals
|
||||
if self._should_ignore_i2s_library():
|
||||
if not self.lib_ignore:
|
||||
self.lib_ignore = []
|
||||
if "I2S" not in self.lib_ignore:
|
||||
self.lib_ignore.append("I2S")
|
||||
|
||||
def _should_ignore_i2s_library(self) -> bool:
|
||||
"""Detect if I2S library should be ignored based on platform characteristics."""
|
||||
|
||||
# Known problematic platforms - Renesas RA family
|
||||
if self.platform == "renesas-ra":
|
||||
return True
|
||||
|
||||
# Known problematic board names
|
||||
problematic_boards = ["uno_r4_wifi", "uno_r4_minima"]
|
||||
if self.board_name in problematic_boards:
|
||||
return True
|
||||
|
||||
# Check for specific defines that indicate I2S problems
|
||||
if self.defines:
|
||||
problematic_defines = [
|
||||
"ARDUINO_UNOR4_WIFI",
|
||||
"ARDUINO_UNOR4_MINIMA",
|
||||
"ARDUINO_ARCH_RENESAS",
|
||||
"ARDUINO_ARCH_RENESAS_UNO",
|
||||
"_RENESAS_RA_",
|
||||
"ARDUINO_FSP",
|
||||
]
|
||||
for define in self.defines:
|
||||
# Check if define contains any problematic patterns
|
||||
if any(prob_define in define for prob_define in problematic_defines):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clone(self) -> "Board":
|
||||
out = Board(
|
||||
board_name=self.board_name,
|
||||
add_board_to_all=False,
|
||||
)
|
||||
for field_name, field_info in self.__dataclass_fields__.items():
|
||||
field_value: Any = getattr(self, field_name)
|
||||
# Create deep copy for mutable types to avoid shared references
|
||||
if isinstance(field_value, (list, dict)):
|
||||
field_value = deepcopy(field_value) # type: ignore[misc]
|
||||
setattr(out, field_name, field_value)
|
||||
return out
|
||||
|
||||
def get_real_board_name(self) -> str:
|
||||
return self.real_board_name if self.real_board_name else self.board_name
|
||||
|
||||
def to_dictionary(self) -> dict[str, list[str]]:
|
||||
out: dict[str, list[str]] = {}
|
||||
if self.real_board_name:
|
||||
out[self.board_name] = [f"board={self.real_board_name}"]
|
||||
options = out.setdefault(self.board_name, [])
|
||||
|
||||
if self.platform:
|
||||
options.append(f"platform={self.platform}")
|
||||
|
||||
if self.platform_needs_install:
|
||||
options.append("platform_needs_install=true")
|
||||
if self.platform_packages:
|
||||
options.append(f"platform_packages={self.platform_packages}")
|
||||
if self.framework:
|
||||
options.append(f"framework={self.framework}")
|
||||
if self.board_build_core:
|
||||
options.append(f"board_build.core={self.board_build_core}")
|
||||
if self.board_build_mcu:
|
||||
options.append(f"board_build.mcu={self.board_build_mcu}")
|
||||
if self.board_build_filesystem_size:
|
||||
options.append(
|
||||
f"board_build.filesystem_size={self.board_build_filesystem_size}"
|
||||
)
|
||||
if self.defines:
|
||||
for define in self.defines:
|
||||
options.append(f"build_flags=-D{define}")
|
||||
|
||||
# Handle build_unflags
|
||||
if self.build_unflags:
|
||||
for uf in self.build_unflags:
|
||||
options.append(f"build_unflags={uf}")
|
||||
|
||||
# Handle explicit build_flags (added for native host compilation and other special cases)
|
||||
if self.build_flags:
|
||||
for bf in self.build_flags:
|
||||
options.append(f"build_flags={bf}")
|
||||
|
||||
if self.customsdk:
|
||||
options.append(f"custom_sdkconfig={self.customsdk}")
|
||||
|
||||
# Add board-specific build cache directory pointing via symlink directive
|
||||
# here = Path(__file__).parent
|
||||
# project_root = here.parent.parent # Move from ci/util/ to project root
|
||||
# cache_dir = project_root / ".pio_cache" / self.board_name
|
||||
# absolute_cache_dir = cache_dir.resolve()
|
||||
# options.append(f"build_cache_dir=symlink://{absolute_cache_dir}")
|
||||
|
||||
return out
|
||||
|
||||
def __repr__(self) -> str:
|
||||
json_str = json.dumps(self.to_dictionary(), indent=4, sort_keys=True)
|
||||
return json_str
|
||||
|
||||
def __hash__(self) -> int:
|
||||
data_str = self.__repr__()
|
||||
return hash(data_str)
|
||||
|
||||
def to_platformio_ini(
|
||||
self,
|
||||
additional_defines: list[str] | None = None,
|
||||
additional_include_dirs: list[str] | None = None,
|
||||
additional_libs: list[str] | None = None,
|
||||
include_platformio_section: bool = False,
|
||||
core_dir: str | None = None,
|
||||
packages_dir: str | None = None,
|
||||
project_root: str | None = None,
|
||||
build_cache_dir: str | None = None,
|
||||
extra_scripts: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Return a `platformio.ini` snippet representing this board.
|
||||
|
||||
The output is suitable for directly appending to a *platformio.ini* file
|
||||
and follows the same semantics used by the PlatformIO CLI. Only
|
||||
parameters understood by PlatformIO are emitted – internal helper
|
||||
fields like ``platform_needs_install`` and ``use_pio_run`` are **not**
|
||||
included because they are consumed exclusively by the build helpers in
|
||||
the *ci/* folder and would be ignored (or flagged as errors) by
|
||||
PlatformIO itself.
|
||||
|
||||
Args:
|
||||
example: Example name for dynamic build flags (currently unused)
|
||||
additional_defines: Additional defines to merge with board defines
|
||||
additional_include_dirs: Additional include directories to merge with build flags
|
||||
include_platformio_section: Whether to include [platformio] section with core_dir/packages_dir
|
||||
core_dir: PlatformIO core directory path
|
||||
packages_dir: PlatformIO packages directory path
|
||||
project_root: FastLED project root for lib_deps symlink
|
||||
"""
|
||||
lines: list[str] = []
|
||||
|
||||
# Optional [platformio] section
|
||||
if include_platformio_section:
|
||||
lines.append("[platformio]")
|
||||
if build_cache_dir:
|
||||
lines.append(f"build_cache_dir = {build_cache_dir}")
|
||||
if core_dir:
|
||||
lines.append(f"core_dir = {core_dir}")
|
||||
if packages_dir:
|
||||
lines.append(f"packages_dir = {packages_dir}")
|
||||
lines.append("")
|
||||
|
||||
# Section header
|
||||
lines.append(f"[env:{self.board_name}]")
|
||||
if extra_scripts:
|
||||
lines.append(f"extra_scripts = {' '.join(extra_scripts)}")
|
||||
|
||||
# Board identifier (skip for platforms that don't need board specification)
|
||||
if not self.no_board_spec:
|
||||
lines.append(f"board = {self.get_real_board_name()}")
|
||||
|
||||
# Optional parameters -------------------------------------------------
|
||||
if self.platform:
|
||||
lines.append(f"platform = {self.platform}")
|
||||
|
||||
if self.platform_packages:
|
||||
lines.append(f"platform_packages = {self.platform_packages}")
|
||||
|
||||
if self.framework:
|
||||
lines.append(f"framework = {self.framework}")
|
||||
|
||||
if self.board_build_core:
|
||||
lines.append(f"board_build.core = {self.board_build_core}")
|
||||
|
||||
if self.board_build_mcu:
|
||||
lines.append(f"board_build.mcu = {self.board_build_mcu}")
|
||||
|
||||
if self.board_build_filesystem_size:
|
||||
lines.append(
|
||||
f"board_build.filesystem_size = {self.board_build_filesystem_size}"
|
||||
)
|
||||
|
||||
if self.board_partitions:
|
||||
lines.append(f"board_partitions = {self.board_partitions}")
|
||||
|
||||
# Build-time flags and unflags ---------------------------------------
|
||||
build_flags_elements: list[str] = []
|
||||
if self.defines:
|
||||
build_flags_elements.extend(f"-D{define}" for define in self.defines)
|
||||
|
||||
# Add additional defines
|
||||
if additional_defines:
|
||||
build_flags_elements.extend(f"-D{define}" for define in additional_defines)
|
||||
|
||||
# Use static build flags
|
||||
if self.build_flags:
|
||||
build_flags_elements.extend(self.build_flags)
|
||||
|
||||
# Add additional include directories
|
||||
if additional_include_dirs:
|
||||
build_flags_elements.extend(
|
||||
f"-I{include_dir}" for include_dir in additional_include_dirs
|
||||
)
|
||||
|
||||
if build_flags_elements:
|
||||
# Put each build flag on a separate line for better readability and debugging
|
||||
lines.append("build_flags =")
|
||||
for flag in build_flags_elements:
|
||||
lines.append(f" {flag}")
|
||||
|
||||
if self.build_unflags:
|
||||
# PlatformIO accepts multiple *build_unflags* separated by spaces.
|
||||
# Emit a single line for readability.
|
||||
lines.append(f"build_unflags = {' '.join(self.build_unflags)}")
|
||||
|
||||
# Custom ESP-IDF sdkconfig override (ESP32-family boards)
|
||||
if self.customsdk:
|
||||
lines.append(f"custom_sdkconfig = {self.customsdk}")
|
||||
|
||||
# Library compatibility mode (for platforms like native that need special handling)
|
||||
if self.lib_compat_mode:
|
||||
lines.append(f"lib_compat_mode = {self.lib_compat_mode}")
|
||||
|
||||
# Library Dependency Finder mode (for enhanced dependency finding)
|
||||
if self.lib_ldf_mode:
|
||||
lines.append(f"lib_ldf_mode = {self.lib_ldf_mode}")
|
||||
|
||||
# Libraries to ignore during compilation
|
||||
if self.lib_ignore:
|
||||
if len(self.lib_ignore) == 1:
|
||||
lines.append(f"lib_ignore = {self.lib_ignore[0]}")
|
||||
else:
|
||||
lines.append("lib_ignore =")
|
||||
for lib in self.lib_ignore:
|
||||
lines.append(f" {lib}")
|
||||
|
||||
# Add FastLED-specific configurations if project_root is provided
|
||||
if project_root:
|
||||
# Only add default lib_ldf_mode if board doesn't specify its own
|
||||
current_config = "\n".join(lines)
|
||||
if "lib_ldf_mode" not in current_config:
|
||||
lines.append("lib_ldf_mode = chain")
|
||||
lines.append("lib_archive = true")
|
||||
|
||||
# Build lib_deps with additional libs only (FastLED is copied to lib/FastLED)
|
||||
# PlatformIO supports multiple lib_deps entries on separate lines
|
||||
if additional_libs:
|
||||
# Format as multi-line lib_deps for proper PlatformIO parsing
|
||||
if len(additional_libs) == 1:
|
||||
lines.append(f"lib_deps = {additional_libs[0]}")
|
||||
else:
|
||||
lines.append("lib_deps =")
|
||||
for entry in additional_libs:
|
||||
lines.append(f" {entry}")
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# [env:sparkfun_xrp_controller]
|
||||
# platform = https://github.com/maxgerhardt/platform-raspberrypi
|
||||
# board = sparkfun_xrp_controller
|
||||
# framework = arduino
|
||||
# lib_deps = fastled/FastLED @ ^3.9.16
|
||||
|
||||
|
||||
WEBTARGET = Board(
|
||||
board_name="web",
|
||||
)
|
||||
|
||||
# Native host compilation target using PlatformIO's "native" platform.
|
||||
# This allows compiling FastLED for the host machine (Linux/macOS/Windows)
|
||||
# which is useful for CI compile-tests and static analysis. We replicate
|
||||
# the build flags present in ci/native/platformio.ini so that the same
|
||||
# stub implementation and main-file inclusion are used.
|
||||
|
||||
NATIVE = Board(
|
||||
board_name="native",
|
||||
platform="platformio/native",
|
||||
no_board_spec=True, # Native platform doesn't need a board specification
|
||||
lib_compat_mode="off", # Disable library compatibility checking for native platform
|
||||
build_flags=[
|
||||
"-DFASTLED_STUB_IMPL", # Enable stub platform implementations
|
||||
"-DFASTLED_USE_STUB_ARDUINO", # Enable Arduino stub implementations
|
||||
"-DPLATFORM_NATIVE", # Enable native platform stub compilation
|
||||
"-std=c++17",
|
||||
"-I../../../src/platforms/stub", # Include path for Arduino.h and other stub headers (relative to project dir)
|
||||
"-I../../../src", # Include path for FastLED.h and other source headers (relative to project dir)
|
||||
],
|
||||
)
|
||||
|
||||
DUE = Board(
|
||||
board_name="due",
|
||||
platform="atmelsam",
|
||||
)
|
||||
|
||||
|
||||
SPARKFUN_XRP_CONTROLLER_2350B = Board(
|
||||
board_name="sparkfun_xrp_controller",
|
||||
platform="https://github.com/maxgerhardt/platform-raspberrypi",
|
||||
platform_needs_install=True,
|
||||
)
|
||||
|
||||
APOLLO3_RED_BOARD = Board(
|
||||
board_name="apollo3_red",
|
||||
real_board_name="SparkFun_RedBoard_Artemis_ATP",
|
||||
platform=APOLLO3_2_2_0,
|
||||
platform_packages="framework-arduinoapollo3@https://github.com/sparkfun/Arduino_Apollo3#v2.2.0",
|
||||
platform_needs_install=True,
|
||||
)
|
||||
|
||||
APOLLO3_SPARKFUN_THING_PLUS_EXPLORABLE = Board(
|
||||
board_name="apollo3_thing_explorable",
|
||||
real_board_name="SparkFun_Thing_Plus_expLoRaBLE",
|
||||
platform=APOLLO3_2_2_0,
|
||||
platform_packages="framework-arduinoapollo3@https://github.com/sparkfun/Arduino_Apollo3#v2.2.0",
|
||||
platform_needs_install=True,
|
||||
)
|
||||
|
||||
ESP32DEV = Board(
|
||||
board_name="esp32dev",
|
||||
platform=ESP32_IDF_5_3_PIOARDUINO,
|
||||
)
|
||||
|
||||
ESP32DEV_IDF3_3 = Board(
|
||||
board_name="esp32dev_idf33",
|
||||
real_board_name="esp32dev",
|
||||
platform="espressif32@1.11.2",
|
||||
)
|
||||
|
||||
ESP32DEV_IDF4_4 = Board(
|
||||
board_name="esp32dev_idf44",
|
||||
real_board_name="esp32dev",
|
||||
platform=ESP32_IDF_4_4_LATEST,
|
||||
)
|
||||
|
||||
GIGA_R1 = Board(
|
||||
board_name="giga_r1",
|
||||
platform="ststm32",
|
||||
framework="arduino",
|
||||
real_board_name="giga_r1_m7",
|
||||
)
|
||||
|
||||
# ESP01 = Board(
|
||||
# board_name="esp01",
|
||||
# platform=ESP32_IDF_5_1_PIOARDUINO,
|
||||
# )
|
||||
|
||||
ESP32_C2_DEVKITM_1 = Board(
|
||||
board_name="esp32c2",
|
||||
real_board_name="esp32-c2-devkitm-1",
|
||||
platform="https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip",
|
||||
framework="arduino, espidf",
|
||||
)
|
||||
|
||||
ESP32_C3_DEVKITM_1 = Board(
|
||||
board_name="esp32c3",
|
||||
real_board_name="esp32-c3-devkitm-1",
|
||||
platform=ESP32_IDF_5_3_PIOARDUINO,
|
||||
)
|
||||
|
||||
ESP32_C5_DEVKITC_1 = Board(
|
||||
board_name="esp32c5",
|
||||
real_board_name="esp32-c5-devkitc-1",
|
||||
platform=ESP32_IDF_5_5_PIOARDUINO,
|
||||
)
|
||||
|
||||
ESP32_C6_DEVKITC_1 = Board(
|
||||
board_name="esp32c6",
|
||||
real_board_name="esp32-c6-devkitc-1",
|
||||
platform=ESP32_IDF_5_3_PIOARDUINO,
|
||||
)
|
||||
|
||||
ESP32_S3_DEVKITC_1 = Board(
|
||||
board_name="esp32s3",
|
||||
real_board_name="esp32-s3-devkitc-1",
|
||||
platform=ESP32_IDF_5_4_PIOARDUINO,
|
||||
framework="arduino",
|
||||
board_partitions="huge_app.csv",
|
||||
build_unflags=["-DFASTLED_RMT5=0", "-DFASTLED_RMT5"],
|
||||
)
|
||||
|
||||
ESP32_S2_DEVKITM_1 = Board(
|
||||
board_name="esp32s2",
|
||||
real_board_name="lolin_s2_mini ",
|
||||
board_build_mcu="esp32s2",
|
||||
platform=ESP32_IDF_5_3_PIOARDUINO,
|
||||
)
|
||||
|
||||
ESP32_UPESY_WROOM = Board(
|
||||
board_name="upesy_wroom",
|
||||
real_board_name="upesy_wroom",
|
||||
platform="espressif32",
|
||||
)
|
||||
|
||||
ESP32H2 = Board(
|
||||
board_name="esp32h2",
|
||||
real_board_name="esp32-h2-devkitm-1",
|
||||
platform_needs_install=True, # Install platform package to get the boards
|
||||
platform=ESP32_IDF_5_3_PIOARDUINO,
|
||||
)
|
||||
|
||||
ESP32_P4 = Board(
|
||||
board_name="esp32p4",
|
||||
real_board_name="esp32-p4-evboard",
|
||||
platform_needs_install=True, # Install platform package to get the boards
|
||||
platform="https://github.com/pioarduino/platform-espressif32/releases/download/stable/platform-espressif32.zip",
|
||||
)
|
||||
|
||||
ADA_FEATHER_NRF52840_SENSE = Board(
|
||||
board_name="adafruit_feather_nrf52840_sense",
|
||||
platform="nordicnrf52",
|
||||
)
|
||||
|
||||
XIAOBLESENSE_ADAFRUIT_NRF52 = Board(
|
||||
board_name="xiaoblesense_adafruit",
|
||||
platform="https://github.com/maxgerhardt/platform-nordicnrf52",
|
||||
platform_needs_install=True, # Install platform package to get the boards
|
||||
)
|
||||
|
||||
# Alias: handle common misspelling without the trailing 't'
|
||||
XIAOBLESENSE_ADAFRUI_ALIAS = Board(
|
||||
board_name="xiaoblesense_adafrui", # missing 't'
|
||||
real_board_name="xiaoblesense_adafruit", # map to the correct board name
|
||||
platform="https://github.com/maxgerhardt/platform-nordicnrf52",
|
||||
platform_needs_install=True,
|
||||
)
|
||||
|
||||
XIAOBLESENSE_NRF52 = Board(
|
||||
board_name="xiaoblesense",
|
||||
real_board_name="xiaoble_adafruit",
|
||||
platform="https://github.com/maxgerhardt/platform-nordicnrf52",
|
||||
platform_needs_install=True,
|
||||
)
|
||||
|
||||
# Correct nRF52840 DK board definition
|
||||
# The Nordic nRF52840 DK is directly supported by the default PlatformIO
|
||||
# `nordicnrf52` platform under the board name `nrf52840_dk`, so we don't
|
||||
# need a custom platform package or extra installation steps. Point the
|
||||
# Board definition at the stock platform and use the canonical board name.
|
||||
# This fixes compilation failures introduced during the build-system
|
||||
# migration where the board was temporarily mapped to the XIAO variant.
|
||||
NRF52840 = Board(
|
||||
board_name="nrf52840_dk",
|
||||
real_board_name="nrf52840_dk_adafruit", # Use Adafruit BSP variant which includes full Nordic SDK headers
|
||||
platform="nordicnrf52",
|
||||
framework="arduino",
|
||||
platform_needs_install=False,
|
||||
platform_packages="framework-arduinoadafruitnrf52@^1.10601.0",
|
||||
defines=[
|
||||
"FASTLED_USE_COMPILE_TESTS=0",
|
||||
],
|
||||
board_build_core="nRF5", # Ensure correct core directory
|
||||
)
|
||||
|
||||
RPI_PICO = Board(
|
||||
board_name="rpipico",
|
||||
platform="https://github.com/maxgerhardt/platform-raspberrypi.git",
|
||||
platform_needs_install=True, # Install platform package to get the boards
|
||||
platform_packages="framework-arduinopico@https://github.com/earlephilhower/arduino-pico.git",
|
||||
framework="arduino",
|
||||
board_build_core="earlephilhower",
|
||||
board_build_filesystem_size="0.5m",
|
||||
)
|
||||
|
||||
RPI_PICO2 = Board(
|
||||
board_name="rpipico2",
|
||||
real_board_name="rpipico", # Use the existing Pico board definition until PlatformIO adds native Pico 2 support
|
||||
platform="https://github.com/maxgerhardt/platform-raspberrypi.git",
|
||||
platform_needs_install=True, # Install platform package to get the boards
|
||||
platform_packages="framework-arduinopico@https://github.com/earlephilhower/arduino-pico.git",
|
||||
framework="arduino",
|
||||
board_build_core="earlephilhower",
|
||||
board_build_filesystem_size="0.5m",
|
||||
)
|
||||
|
||||
BLUEPILL = Board(
|
||||
board_name="bluepill",
|
||||
real_board_name="bluepill_f103c8",
|
||||
platform="ststm32",
|
||||
)
|
||||
|
||||
# maple_mini_b20
|
||||
MAPLE_MINI = Board(
|
||||
board_name="maple_mini",
|
||||
real_board_name="maple_mini_b20",
|
||||
platform="ststm32",
|
||||
)
|
||||
|
||||
HY_TINYSTM103TB = Board(
|
||||
board_name="hy_tinystm103tb",
|
||||
platform="ststm32",
|
||||
)
|
||||
|
||||
ATTINY88 = Board(
|
||||
board_name="attiny88",
|
||||
platform="atmelavr",
|
||||
)
|
||||
|
||||
# ATtiny1604
|
||||
ATTINY1604 = Board(
|
||||
board_name="ATtiny1604",
|
||||
platform="atmelmegaavr",
|
||||
)
|
||||
|
||||
# attiny4313
|
||||
ATTINY4313 = Board(
|
||||
board_name="attiny4313",
|
||||
platform="atmelavr",
|
||||
)
|
||||
|
||||
ATTINY1616 = Board(
|
||||
board_name="ATtiny1616",
|
||||
platform="atmelmegaavr",
|
||||
)
|
||||
|
||||
UNO_R4_WIFI = Board(
|
||||
board_name="uno_r4_wifi",
|
||||
platform="renesas-ra",
|
||||
# I2S library auto-excluded due to missing r_i2s_api.h header in Arduino Renesas framework
|
||||
)
|
||||
|
||||
NANO_EVERY = Board(
|
||||
board_name="nano_every",
|
||||
platform="atmelmegaavr",
|
||||
)
|
||||
|
||||
ESP32DEV_I2S = Board(
|
||||
board_name="esp32dev_i2s",
|
||||
real_board_name="esp32dev",
|
||||
platform=ESP32_IDF_4_4_LATEST,
|
||||
)
|
||||
|
||||
ESP32S3_RMT51 = Board(
|
||||
board_name="esp32rmt_51",
|
||||
real_board_name="esp32-s3-devkitc-1",
|
||||
platform_needs_install=True,
|
||||
platform=ESP32_IDF_5_3_PIOARDUINO,
|
||||
defines=[
|
||||
"FASTLED_RMT5=1",
|
||||
],
|
||||
)
|
||||
|
||||
# Teensy boards
|
||||
TEENSY_LC = Board(
|
||||
board_name="teensylc",
|
||||
platform="teensy",
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
TEENSY30 = Board(
|
||||
board_name="teensy30",
|
||||
platform="teensy",
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
TEENSY31 = Board(
|
||||
board_name="teensy31",
|
||||
platform="teensy",
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
TEENSY40 = Board(
|
||||
board_name="teensy40",
|
||||
platform="teensy",
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
TEENSY41 = Board(
|
||||
board_name="teensy41",
|
||||
platform="teensy",
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
# Basic Arduino boards
|
||||
UNO = Board(
|
||||
board_name="uno",
|
||||
platform="atmelavr",
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
YUN = Board(
|
||||
board_name="yun",
|
||||
platform="atmelavr",
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
DIGIX = Board(
|
||||
board_name="digix",
|
||||
real_board_name="due", # Digix is Arduino Due compatible
|
||||
platform="atmelsam",
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
# ESP8266 boards
|
||||
ESP01 = Board(
|
||||
board_name="esp01",
|
||||
platform="espressif8266",
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
# ATtiny boards
|
||||
ATTINY85 = Board(
|
||||
board_name="attiny85",
|
||||
platform="atmelavr",
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
# Seeed XIAO ESP32S3 board – same platform, needs FASTLED_RMT5 macro removal
|
||||
XIAO_ESP32S3 = Board(
|
||||
board_name="seeed_xiao_esp32s3",
|
||||
real_board_name="seeed_xiao_esp32s3",
|
||||
platform=ESP32_IDF_5_4_PIOARDUINO,
|
||||
board_partitions="huge_app.csv",
|
||||
defines=None,
|
||||
build_unflags=["-DFASTLED_RMT5=0", "-DFASTLED_RMT5"],
|
||||
)
|
||||
|
||||
# STM32F4 Black Pill board - addresses GitHub issue #726
|
||||
BLACKPILL = Board(
|
||||
board_name="blackpill",
|
||||
real_board_name="blackpill_f411ce",
|
||||
platform="ststm32",
|
||||
)
|
||||
|
||||
# Silicon Labs MGM240S boards (Arduino Nano Matter, SparkFun Thing Plus Matter)
|
||||
# Uses Silicon Labs EFM32 platform with Arduino framework support
|
||||
# Based on EFR32MG24 SoC with ARM Cortex-M33 @ 78MHz
|
||||
MGM240S = Board(
|
||||
board_name="mgm240",
|
||||
real_board_name="sparkfun_thingplusmatter",
|
||||
platform="https://github.com/maxgerhardt/platform-siliconlabsefm32/archive/refs/heads/silabs-arduino.zip",
|
||||
platform_needs_install=True,
|
||||
framework="arduino",
|
||||
)
|
||||
|
||||
|
||||
def _make_board_map(boards: list[Board]) -> dict[str, Board]:
|
||||
# make board map, but assert on duplicate board names
|
||||
board_map: dict[str, Board] = {}
|
||||
for board in boards:
|
||||
assert board.board_name not in board_map, (
|
||||
f"Duplicate board name: {board.board_name}"
|
||||
)
|
||||
board_map[board.board_name] = board
|
||||
return board_map
|
||||
|
||||
|
||||
_BOARD_MAP: dict[str, Board] = _make_board_map(ALL)
|
||||
|
||||
|
||||
def create_board(board_name: str, no_project_options: bool = False) -> Board:
|
||||
board: Board
|
||||
if no_project_options:
|
||||
board = Board(board_name=board_name, add_board_to_all=False)
|
||||
if board_name not in _BOARD_MAP:
|
||||
# empty board without any special overrides, assume platformio will know what to do with it.
|
||||
board = Board(board_name=board_name, add_board_to_all=False)
|
||||
else:
|
||||
board = _BOARD_MAP[board_name]
|
||||
return board.clone()
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"build": {
|
||||
"core": "maple",
|
||||
"cpu": "cortex-m3",
|
||||
"extra_flags": "-DSTM32F103C8 -DSTM32F1 -DARDUINO_GENERIC_STM32F103C8 -DARDUINO_ARCH_STM32",
|
||||
"f_cpu": "72000000L",
|
||||
"hwids": [
|
||||
[
|
||||
"0x1EAF",
|
||||
"0x0003"
|
||||
]
|
||||
],
|
||||
"ldscript": "jtag_c8.ld",
|
||||
"mcu": "stm32f103c8",
|
||||
"product_line": "STM32F103xx",
|
||||
"variant": "STM32F103C8"
|
||||
},
|
||||
"connectivity": [
|
||||
"can"
|
||||
],
|
||||
"debug": {
|
||||
"jlink_device": "STM32F103C8",
|
||||
"openocd_board": "stm32f103c8_blue_pill",
|
||||
"openocd_target": "stm32f1x",
|
||||
"svd_path": "STM32F103.svd"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino",
|
||||
"stm32cube",
|
||||
"libopencm3"
|
||||
],
|
||||
"name": "Blue Pill STM32F103C8",
|
||||
"upload": {
|
||||
"maximum_ram_size": 20480,
|
||||
"maximum_size": 65536,
|
||||
"protocol": "stlink",
|
||||
"protocols": [
|
||||
"stlink",
|
||||
"dfu",
|
||||
"serial",
|
||||
"blackmagic",
|
||||
"jlink"
|
||||
]
|
||||
},
|
||||
"url": "https://stm32-base.org/boards/STM32F103C8T6-Blue-Pill",
|
||||
"vendor": "Generic"
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"build": {
|
||||
"core": "esp32",
|
||||
"f_cpu": "120000000L",
|
||||
"f_flash": "60000000L",
|
||||
"flash_mode": "qio",
|
||||
"mcu": "esp32c2",
|
||||
"variant": "esp32c2"
|
||||
},
|
||||
"connectivity": [
|
||||
"wifi"
|
||||
],
|
||||
"debug": {
|
||||
"openocd_target": "esp32c2.cfg"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Espressif ESP32-C2-DevKitM-1",
|
||||
"upload": {
|
||||
"flash_size": "4MB",
|
||||
"maximum_ram_size": 278528,
|
||||
"maximum_size": 4194304,
|
||||
"require_upload_port": true,
|
||||
"speed": 460800
|
||||
},
|
||||
"espidf": {
|
||||
"custom_sdkconfig": [
|
||||
"CONFIG_IDF_TARGET=\"esp32c2\""
|
||||
]
|
||||
},
|
||||
"url": "https://docs.espressif.com/projects/espressif-esp-dev-kits/en/latest/esp8684/esp8684-devkitm-1/user_guide.html",
|
||||
"vendor": "Espressif"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"build": {
|
||||
"core": "esp32",
|
||||
"f_cpu": "160000000L",
|
||||
"f_flash": "80000000L",
|
||||
"flash_mode": "qio",
|
||||
"mcu": "esp32c6",
|
||||
"variant": "esp32c6"
|
||||
},
|
||||
"connectivity": [
|
||||
"bluetooth",
|
||||
"wifi"
|
||||
],
|
||||
"debug": {
|
||||
"openocd_target": "esp32c6.cfg"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Espressif ESP32-C6-DevKitC-1",
|
||||
"upload": {
|
||||
"flash_size": "8MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 8388608,
|
||||
"require_upload_port": true,
|
||||
"speed": 460800
|
||||
},
|
||||
"url": "https://docs.espressif.com/projects/espressif-esp-dev-kits/en/latest/esp32c6/esp32-c6-devkitc-1/index.html",
|
||||
"vendor": "Espressif"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"build": {
|
||||
"core": "esp32",
|
||||
"f_cpu": "96000000L",
|
||||
"f_flash": "64000000L",
|
||||
"f_image": "48000000L",
|
||||
"flash_mode": "qio",
|
||||
"mcu": "esp32h2",
|
||||
"variant": "esp32h2"
|
||||
},
|
||||
"connectivity": [
|
||||
"bluetooth"
|
||||
],
|
||||
"debug": {
|
||||
"openocd_target": "esp32h2.cfg"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino",
|
||||
"espidf"
|
||||
],
|
||||
"name": "Espressif ESP32-H2-DevKit",
|
||||
"upload": {
|
||||
"flash_size": "4MB",
|
||||
"maximum_ram_size": 327680,
|
||||
"maximum_size": 4194304,
|
||||
"require_upload_port": true,
|
||||
"speed": 460800
|
||||
},
|
||||
"url": "https://docs.espressif.com/projects/espressif-esp-dev-kits/en/latest/esp32h2/esp32-h2-devkitm-1/index.html",
|
||||
"vendor": "Espressif"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "linker_script.ld",
|
||||
"flash_layout": "100_0"
|
||||
},
|
||||
"extra_flags": "-DARDUINO_GIGA -DARDUINO_GIGA_PINS -DGIGA_PINS -DGIGA -DCM4_BINARY_START=0x60000000 -DCM4_BINARY_END=0x60040000 -DCM4_RAM_END=0x60080000",
|
||||
"core": "arduino",
|
||||
"cpu": "cortex-m7",
|
||||
"f_cpu": "480000000L",
|
||||
"mcu": "stm32h747xih6",
|
||||
"variant": "GIGA",
|
||||
"product_line": "STM32H747xx",
|
||||
"hwids": [
|
||||
[
|
||||
"0x2341",
|
||||
"0x0266"
|
||||
],
|
||||
[
|
||||
"0x2341",
|
||||
"0x0366"
|
||||
],
|
||||
[
|
||||
"0x2341",
|
||||
"0x0466"
|
||||
]
|
||||
]
|
||||
},
|
||||
"connectivity": [
|
||||
"bluetooth",
|
||||
"wifi"
|
||||
],
|
||||
"debug": {
|
||||
"jlink_device": "STM32H747XI_M7",
|
||||
"openocd_target": "stm32h7x_dual_bank"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino"
|
||||
],
|
||||
"name": "Arduino Giga R1 Wifi",
|
||||
"upload": {
|
||||
"_maximum_ram_size": 294248,
|
||||
"_maximum_size": 1048576,
|
||||
"maximum_ram_size": 523624,
|
||||
"maximum_size": 1966080,
|
||||
"protocol": "dfu",
|
||||
"protocols": [
|
||||
"cmsis-dap",
|
||||
"dfu",
|
||||
"jlink",
|
||||
"stlink",
|
||||
"mbed"
|
||||
],
|
||||
"require_upload_port": true,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": true,
|
||||
"offset_address": "0x08100000"
|
||||
},
|
||||
"url": "https://docs.arduino.cc/hardware/giga-r1-wifi",
|
||||
"vendor": "Arduino"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino":{
|
||||
"ldscript": "nrf52_xxaa.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DARDUINO_NRF52_DK",
|
||||
"f_cpu": "64000000L",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "nRF52DK",
|
||||
"zephyr": {
|
||||
"variant": "nrf52840dk_nrf52840"
|
||||
}
|
||||
},
|
||||
"connectivity": [
|
||||
"bluetooth"
|
||||
],
|
||||
"debug": {
|
||||
"default_tools": [
|
||||
"jlink"
|
||||
],
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"onboard_tools": [
|
||||
"cmsis-dap",
|
||||
"jlink"
|
||||
],
|
||||
"svd_path": "nrf52840.svd"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino",
|
||||
"mbed",
|
||||
"zephyr"
|
||||
],
|
||||
"name": "Nordic nRF52840-DK",
|
||||
"upload": {
|
||||
"maximum_ram_size": 262144,
|
||||
"maximum_size": 1048576,
|
||||
"protocol": "jlink",
|
||||
"protocols": [
|
||||
"jlink",
|
||||
"nrfjprog",
|
||||
"stlink",
|
||||
"blackmagic",
|
||||
"cmsis-dap",
|
||||
"mbed"
|
||||
]
|
||||
},
|
||||
"url": "https://os.mbed.com/platforms/Nordic-nRF52840-DK/",
|
||||
"vendor": "Nordic"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"earlephilhower": {
|
||||
"boot2_source": "boot2_w25q080_2_padded_checksum.S",
|
||||
"usb_vid": "0x2E8A",
|
||||
"usb_pid": "0x000A"
|
||||
}
|
||||
},
|
||||
"core": "earlephilhower",
|
||||
"cpu": "cortex-m0plus",
|
||||
"extra_flags": "-DARDUINO_RASPBERRY_PI_PICO -DARDUINO_ARCH_RP2040 -DUSBD_MAX_POWER_MA=250 ",
|
||||
"f_cpu": "133000000L",
|
||||
"hwids": [
|
||||
[
|
||||
"0x2E8A",
|
||||
"0x00C0"
|
||||
],
|
||||
[
|
||||
"0x2E8A",
|
||||
"0x000A"
|
||||
]
|
||||
],
|
||||
"mcu": "rp2040",
|
||||
"variant": "rpipico"
|
||||
},
|
||||
"debug": {
|
||||
"jlink_device": "RP2040_M0_0",
|
||||
"openocd_target": "rp2040.cfg",
|
||||
"svd_path": "rp2040.svd"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino"
|
||||
],
|
||||
"name": "Pico",
|
||||
"upload": {
|
||||
"maximum_ram_size": 262144,
|
||||
"maximum_size": 2097152,
|
||||
"require_upload_port": true,
|
||||
"native_usb": true,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": false,
|
||||
"protocol": "picotool",
|
||||
"protocols": [
|
||||
"blackmagic",
|
||||
"cmsis-dap",
|
||||
"jlink",
|
||||
"raspberrypi-swd",
|
||||
"picotool",
|
||||
"picoprobe",
|
||||
"pico-debug"
|
||||
]
|
||||
},
|
||||
"url": "https://www.raspberrypi.org/products/raspberry-pi-pico/",
|
||||
"vendor": "Raspberry Pi"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"earlephilhower": {
|
||||
"boot2_source": "boot2_generic_03h_2_padded_checksum.S",
|
||||
"usb_vid": "0x2E8A",
|
||||
"usb_pid": "0x000B"
|
||||
}
|
||||
},
|
||||
"core": "earlephilhower",
|
||||
"cpu": "cortex-m33",
|
||||
"extra_flags": "-D ARDUINO_RASPBERRY_PI_PICO_2 -DARDUINO_ARCH_RP2350 -DUSBD_MAX_POWER_MA=250",
|
||||
"f_cpu": "133000000L",
|
||||
"hwids": [
|
||||
[
|
||||
"0x2E8A",
|
||||
"0x00C0"
|
||||
],
|
||||
[
|
||||
"0x2E8A",
|
||||
"0x000B"
|
||||
]
|
||||
],
|
||||
"mcu": "rp2350",
|
||||
"variant": "rpipico2"
|
||||
},
|
||||
"debug": {
|
||||
"jlink_device": "RP2350_0",
|
||||
"openocd_target": "rp2350.cfg",
|
||||
"svd_path": "rp2350.svd"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino"
|
||||
],
|
||||
"name": "Pico 2",
|
||||
"upload": {
|
||||
"maximum_ram_size": 524288,
|
||||
"maximum_size": 2097152,
|
||||
"require_upload_port": true,
|
||||
"native_usb": true,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": false,
|
||||
"protocol": "picotool",
|
||||
"protocols": [
|
||||
"blackmagic",
|
||||
"cmsis-dap",
|
||||
"jlink",
|
||||
"raspberrypi-swd",
|
||||
"picotool",
|
||||
"picoprobe",
|
||||
"pico-debug"
|
||||
]
|
||||
},
|
||||
"url": "https://www.raspberrypi.org/products/raspberry-pi-pico/",
|
||||
"vendor": "Raspberry Pi"
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"earlephilhower": {
|
||||
"boot2_source": "boot2_w25q080_2_padded_checksum.S",
|
||||
"usb_vid": "0x2E8A",
|
||||
"usb_pid": "0xF00A"
|
||||
}
|
||||
},
|
||||
"core": "earlephilhower",
|
||||
"cpu": "cortex-m0plus",
|
||||
"extra_flags": "-DARDUINO_RASPBERRY_PI_PICO_W -DARDUINO_ARCH_RP2040 -DUSBD_MAX_POWER_MA=250 ",
|
||||
"f_cpu": "133000000L",
|
||||
"hwids": [
|
||||
[
|
||||
"0x2E8A",
|
||||
"0x00C0"
|
||||
],
|
||||
[
|
||||
"0x2E8A",
|
||||
"0xF00A"
|
||||
]
|
||||
],
|
||||
"mcu": "rp2040",
|
||||
"variant": "rpipicow"
|
||||
},
|
||||
"debug": {
|
||||
"jlink_device": "RP2040_M0_0",
|
||||
"openocd_target": "rp2040.cfg",
|
||||
"svd_path": "rp2040.svd"
|
||||
},
|
||||
"frameworks": [
|
||||
"arduino"
|
||||
],
|
||||
"name": "Pico W",
|
||||
"upload": {
|
||||
"maximum_ram_size": 262144,
|
||||
"maximum_size": 2097152,
|
||||
"require_upload_port": true,
|
||||
"native_usb": true,
|
||||
"use_1200bps_touch": true,
|
||||
"wait_for_upload_port": false,
|
||||
"protocol": "picotool",
|
||||
"protocols": [
|
||||
"blackmagic",
|
||||
"cmsis-dap",
|
||||
"jlink",
|
||||
"raspberrypi-swd",
|
||||
"picotool",
|
||||
"picoprobe",
|
||||
"pico-debug"
|
||||
]
|
||||
},
|
||||
"url": "https://www.raspberrypi.org/products/raspberry-pi-pico/",
|
||||
"vendor": "Raspberry Pi"
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FastLED Unified Build API
|
||||
|
||||
This module provides a unified build system for both unit tests and examples,
|
||||
centralizing PCH and library building while providing a simple, elegant API.
|
||||
|
||||
Key Features:
|
||||
- Unified API for both unit tests and examples
|
||||
- Automatic PCH header building and reuse
|
||||
- Automatic libfastled.a building and reuse
|
||||
- Organized build directories under .build/
|
||||
- Efficient multiple target building
|
||||
- Simple, elegant interface
|
||||
|
||||
Usage:
|
||||
# Unit test building
|
||||
unit_builder = BuildAPI(
|
||||
config_file="ci/build_unit.toml",
|
||||
build_dir=".build/unit",
|
||||
build_type=BuildType.UNIT_TEST
|
||||
)
|
||||
unit_builder.build_targets(["test_json.cpp", "test_color.cpp"])
|
||||
|
||||
# Example building
|
||||
example_builder = BuildAPI(
|
||||
config_file="ci/build_example.toml",
|
||||
build_dir=".build/examples",
|
||||
build_type=BuildType.EXAMPLE
|
||||
)
|
||||
example_builder.build_targets(["Blink.ino", "DemoReel100.ino"])
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
|
||||
|
||||
from ci.ci.fingerprint_cache import FingerprintCache
|
||||
from ci.compiler.clang_compiler import (
|
||||
BuildFlags,
|
||||
Compiler,
|
||||
CompilerOptions,
|
||||
LibarchiveOptions,
|
||||
)
|
||||
from ci.compiler.test_example_compilation import get_fastled_core_sources
|
||||
from ci.util.paths import PROJECT_ROOT
|
||||
from ci.util.running_process import subprocess_run
|
||||
|
||||
|
||||
class BuildType(Enum):
|
||||
"""Build type enumeration"""
|
||||
|
||||
UNIT_TEST = "unit_test"
|
||||
EXAMPLE = "example"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildTarget:
|
||||
"""Represents a single build target (test file or example)"""
|
||||
|
||||
source_file: Path
|
||||
name: str
|
||||
output_dir: Path
|
||||
|
||||
def get_output_executable(self) -> Path:
|
||||
"""Get the expected output executable path"""
|
||||
if self.source_file.suffix == ".cpp":
|
||||
# Unit test: test_name -> test_name.exe
|
||||
base_name = self.source_file.stem
|
||||
return self.output_dir / f"{base_name}.exe"
|
||||
else:
|
||||
# Example: Blink.ino -> Blink.exe
|
||||
base_name = self.source_file.stem
|
||||
return self.output_dir / f"{base_name}.exe"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BuildResult:
|
||||
"""Result of a build operation"""
|
||||
|
||||
target: BuildTarget
|
||||
success: bool
|
||||
message: str
|
||||
build_time: float
|
||||
|
||||
|
||||
class BuildAPI:
|
||||
"""
|
||||
Unified build API for both unit tests and examples.
|
||||
|
||||
This class centralizes all build operations including PCH building,
|
||||
library creation, and target compilation. It provides a simple,
|
||||
elegant interface while handling the complexity internally.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
build_flags_toml: str | Path,
|
||||
build_dir: str | Path,
|
||||
build_type: BuildType,
|
||||
use_pch: bool = True,
|
||||
parallel: bool = True,
|
||||
clean: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize the build API.
|
||||
|
||||
Args:
|
||||
build_flags_toml: Path to build_flags.toml file (build_unit.toml or build_example.toml)
|
||||
build_dir: Directory for build outputs (e.g., .build/unit or .build/examples)
|
||||
build_type: Type of build (UNIT_TEST or EXAMPLE)
|
||||
use_pch: Whether to use precompiled headers
|
||||
parallel: Whether to enable parallel compilation
|
||||
clean: Whether to force a clean build
|
||||
"""
|
||||
self.build_flags_toml = Path(build_flags_toml)
|
||||
self.build_dir = Path(build_dir)
|
||||
self.build_type = build_type
|
||||
self.use_pch = use_pch
|
||||
self.parallel = parallel
|
||||
self.clean = clean
|
||||
|
||||
# Ensure build directory exists
|
||||
self.build_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Set up subdirectories for organization
|
||||
self.artifacts_dir = self.build_dir / "artifacts" # PCH, lib files
|
||||
self.targets_dir = self.build_dir / "targets" # Individual target builds
|
||||
self.cache_dir = self.build_dir / "cache" # Fingerprint caches
|
||||
|
||||
for directory in [self.artifacts_dir, self.targets_dir, self.cache_dir]:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Initialize internal state
|
||||
self._compiler: Optional[Compiler] = None
|
||||
self._pch_file: Optional[Path] = None
|
||||
self._library_file: Optional[Path] = None
|
||||
self._fingerprint_cache: Optional[FingerprintCache] = None
|
||||
|
||||
# Parse build flags from TOML file
|
||||
self._parse_build_flags()
|
||||
|
||||
# Create compiler from TOML configuration
|
||||
self._create_compiler()
|
||||
|
||||
def _parse_build_flags(self) -> None:
|
||||
"""Parse build flags from TOML file"""
|
||||
if not self.build_flags_toml.exists():
|
||||
raise RuntimeError(
|
||||
f"Build flags TOML file not found: {self.build_flags_toml}"
|
||||
)
|
||||
|
||||
print(f"[BUILD API] Parsing build flags from {self.build_flags_toml}")
|
||||
|
||||
# Parse build flags from TOML
|
||||
self.build_flags = BuildFlags.from_toml_file(
|
||||
self.build_flags_toml,
|
||||
quick_build=True, # Use quick build mode for faster iteration
|
||||
strict_mode=False,
|
||||
)
|
||||
|
||||
# Initialize fingerprint cache
|
||||
cache_file = self.cache_dir / "fingerprint_cache.json"
|
||||
self._fingerprint_cache = FingerprintCache(cache_file)
|
||||
|
||||
def _create_compiler(self) -> None:
|
||||
"""Create compiler from TOML configuration ONLY - no inline settings allowed"""
|
||||
from ci.compiler.clang_compiler import create_compiler_options_from_toml
|
||||
|
||||
# ALL settings must come from the TOML file - NO hardcoded/inline settings allowed
|
||||
print(f"[BUILD API] Creating compiler from TOML configuration")
|
||||
|
||||
# Set up PCH output path
|
||||
pch_output_path: Optional[str] = None
|
||||
if self.use_pch:
|
||||
pch_output_path = str(
|
||||
self.artifacts_dir / f"{self.build_type.value}_pch.hpp.pch"
|
||||
)
|
||||
|
||||
# Use the proper function to create CompilerOptions from TOML - NO inline settings
|
||||
self.compiler_settings = create_compiler_options_from_toml(
|
||||
toml_path=self.build_flags_toml,
|
||||
include_path=str(PROJECT_ROOT / "src"), # Base include path only
|
||||
quick_build=True, # Use quick build mode for faster iteration
|
||||
strict_mode=False,
|
||||
# Pass PCH and parallel settings from constructor
|
||||
use_pch=self.use_pch,
|
||||
pch_output_path=pch_output_path,
|
||||
parallel=self.parallel,
|
||||
)
|
||||
|
||||
# Create compiler instance with TOML-loaded settings
|
||||
self._compiler = Compiler(self.compiler_settings, self.build_flags)
|
||||
|
||||
def _ensure_pch_built(self) -> bool:
|
||||
"""
|
||||
Ensure PCH header is built and ready.
|
||||
|
||||
Returns:
|
||||
bool: True if PCH is ready, False otherwise
|
||||
"""
|
||||
if not self.use_pch or not self._compiler:
|
||||
return True
|
||||
|
||||
print(f"[BUILD API] Building PCH header for {self.build_type.value}")
|
||||
|
||||
# Check if we need to rebuild PCH
|
||||
if not self.clean and self._pch_file and self._pch_file.exists():
|
||||
# TODO: Add proper dependency checking here
|
||||
print(f"[BUILD API] Reusing existing PCH: {self._pch_file}")
|
||||
return True
|
||||
|
||||
# Build PCH
|
||||
success = self._compiler.create_pch_file()
|
||||
if success and self.compiler_settings.pch_output_path:
|
||||
self._pch_file = Path(self.compiler_settings.pch_output_path)
|
||||
print(f"[BUILD API] PCH built successfully: {self._pch_file}")
|
||||
else:
|
||||
print(f"[BUILD API] PCH build failed")
|
||||
|
||||
return success
|
||||
|
||||
def _ensure_library_built(self) -> bool:
|
||||
"""
|
||||
Ensure libfastled.a is built and ready.
|
||||
|
||||
Returns:
|
||||
bool: True if library is ready, False otherwise
|
||||
"""
|
||||
if not self._compiler:
|
||||
return False
|
||||
|
||||
lib_name = f"libfastled_{self.build_type.value}.a"
|
||||
self._library_file = self.artifacts_dir / lib_name
|
||||
|
||||
print(f"[BUILD API] Building FastLED library for {self.build_type.value}")
|
||||
|
||||
# Check if we need to rebuild library
|
||||
if not self.clean and self._library_file.exists():
|
||||
# TODO: Add proper dependency checking here
|
||||
print(f"[BUILD API] Reusing existing library: {self._library_file}")
|
||||
return True
|
||||
|
||||
# Prefer UNITY chunk build for faster library creation
|
||||
try:
|
||||
unity_dir = self.artifacts_dir / "unity"
|
||||
unity_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Collect FastLED core sources (consistent with example compilation)
|
||||
all_sources = [
|
||||
p for p in get_fastled_core_sources() if p.name != "stub_main.cpp"
|
||||
]
|
||||
if not all_sources:
|
||||
print("[BUILD API] No FastLED sources found for unity build")
|
||||
return False
|
||||
|
||||
# Deterministic sort relative to project root
|
||||
project_root = PROJECT_ROOT
|
||||
|
||||
def sort_key(p: Path) -> str:
|
||||
try:
|
||||
rel = p.relative_to(project_root)
|
||||
except Exception:
|
||||
rel = p
|
||||
return rel.as_posix()
|
||||
|
||||
all_sources = sorted(all_sources, key=sort_key)
|
||||
|
||||
# Partition into chunks (1..4 typical)
|
||||
total = len(all_sources)
|
||||
max_chunks = min(4, max(1, (os.cpu_count() or 2) // 2))
|
||||
chunks = max_chunks if total >= max_chunks else max(1, total)
|
||||
|
||||
# Prepare compile options (PCH off for unity)
|
||||
compile_opts = CompilerOptions(
|
||||
include_path=self._compiler.settings.include_path,
|
||||
compiler=self._compiler.settings.compiler,
|
||||
defines=self._compiler.settings.defines,
|
||||
std_version=self._compiler.settings.std_version,
|
||||
compiler_args=self._compiler.settings.compiler_args,
|
||||
use_pch=False,
|
||||
additional_flags=["-c"],
|
||||
parallel=self.parallel,
|
||||
)
|
||||
|
||||
# Compute chunk sizes
|
||||
base = total // chunks
|
||||
rem = total % chunks
|
||||
start = 0
|
||||
unity_objects: list[Path] = []
|
||||
|
||||
for i in range(chunks):
|
||||
size = base + (1 if i < rem else 0)
|
||||
end = start + size
|
||||
group = all_sources[start:end]
|
||||
start = end
|
||||
|
||||
if not group:
|
||||
continue
|
||||
|
||||
unity_cpp = unity_dir / f"unity{i + 1}.cpp"
|
||||
# Use synchronous path to simplify error handling here
|
||||
result = self._compiler._compile_unity_sync(
|
||||
compile_opts,
|
||||
group,
|
||||
unity_output_path=unity_cpp,
|
||||
)
|
||||
if not result.ok:
|
||||
print(f"[BUILD API] Unity chunk {i + 1} failed: {result.stderr}")
|
||||
return False
|
||||
unity_objects.append(unity_cpp.with_suffix(".o"))
|
||||
|
||||
if not unity_objects:
|
||||
print("[BUILD API] No unity objects produced")
|
||||
return False
|
||||
|
||||
# Archive unity objects into the final library
|
||||
archive_result = self._compiler.create_archive(
|
||||
unity_objects, self._library_file, LibarchiveOptions()
|
||||
).result()
|
||||
if not archive_result.ok:
|
||||
print(f"[BUILD API] Archive creation failed: {archive_result.stderr}")
|
||||
return False
|
||||
|
||||
print(
|
||||
f"[BUILD API] Library built successfully (UNITY): {self._library_file}"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[BUILD API] UNITY library build failed with exception: {e}")
|
||||
return False
|
||||
|
||||
def _create_static_library(
|
||||
self, object_files: List[Path], output_lib: Path
|
||||
) -> bool:
|
||||
"""
|
||||
Create static library from object files.
|
||||
|
||||
Args:
|
||||
object_files: List of object files to archive
|
||||
output_lib: Output library file path
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
if not object_files:
|
||||
return False
|
||||
|
||||
# Use archiver from build flags
|
||||
archiver_cmd = self.build_flags.tools.archiver
|
||||
archive_flags = self.build_flags.archive.flags
|
||||
|
||||
# Build archiver command
|
||||
cmd = (
|
||||
archiver_cmd
|
||||
+ [archive_flags]
|
||||
+ [str(output_lib)]
|
||||
+ [str(f) for f in object_files]
|
||||
)
|
||||
|
||||
print(f"[BUILD API] Creating static library: {' '.join(cmd)}")
|
||||
|
||||
# Execute archiver command using stdout-pumping wrapper
|
||||
try:
|
||||
_completed = subprocess_run(
|
||||
command=cmd,
|
||||
cwd=None,
|
||||
check=True,
|
||||
timeout=900,
|
||||
enable_stack_trace=False,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"[BUILD API] Archiver failed: {e}")
|
||||
print(f"[BUILD API] Archiver stdout: {e.stdout}")
|
||||
print(f"[BUILD API] Archiver stderr: {e.stderr}")
|
||||
return False
|
||||
|
||||
def build_targets(self, target_files: Sequence[str | Path]) -> List[BuildResult]:
|
||||
"""
|
||||
Build multiple targets efficiently.
|
||||
|
||||
Args:
|
||||
target_files: List of source files to build
|
||||
|
||||
Returns:
|
||||
List[BuildResult]: Results for each target
|
||||
"""
|
||||
print(
|
||||
f"[BUILD API] Building {len(target_files)} targets for {self.build_type.value}"
|
||||
)
|
||||
|
||||
# Ensure shared artifacts are built first
|
||||
if not self._ensure_pch_built():
|
||||
return [
|
||||
BuildResult(
|
||||
target=BuildTarget(Path(f), Path(f).stem, self.targets_dir),
|
||||
success=False,
|
||||
message="PCH build failed",
|
||||
build_time=0.0,
|
||||
)
|
||||
for f in target_files
|
||||
]
|
||||
|
||||
if not self._ensure_library_built():
|
||||
return [
|
||||
BuildResult(
|
||||
target=BuildTarget(Path(f), Path(f).stem, self.targets_dir),
|
||||
success=False,
|
||||
message="Library build failed",
|
||||
build_time=0.0,
|
||||
)
|
||||
for f in target_files
|
||||
]
|
||||
|
||||
# Build individual targets
|
||||
results: List[BuildResult] = []
|
||||
for target_file in target_files:
|
||||
result = self._build_single_target(Path(target_file))
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
def _build_single_target(self, target_file: Path) -> BuildResult:
|
||||
"""
|
||||
Build a single target.
|
||||
|
||||
Args:
|
||||
target_file: Source file to build
|
||||
|
||||
Returns:
|
||||
BuildResult: Result of the build
|
||||
"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Create build target
|
||||
target_dir = self.targets_dir / target_file.stem
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target = BuildTarget(
|
||||
source_file=target_file, name=target_file.stem, output_dir=target_dir
|
||||
)
|
||||
|
||||
print(f"[BUILD API] Building target: {target.name}")
|
||||
|
||||
try:
|
||||
# Compile and link the target
|
||||
success = self._compile_and_link_target(target)
|
||||
|
||||
build_time = time.time() - start_time
|
||||
message = "Build successful" if success else "Build failed"
|
||||
|
||||
return BuildResult(
|
||||
target=target, success=success, message=message, build_time=build_time
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
build_time = time.time() - start_time
|
||||
return BuildResult(
|
||||
target=target,
|
||||
success=False,
|
||||
message=f"Build error: {e}",
|
||||
build_time=build_time,
|
||||
)
|
||||
|
||||
def _compile_and_link_target(self, target: BuildTarget) -> bool:
|
||||
"""
|
||||
Compile and link a single target.
|
||||
|
||||
Args:
|
||||
target: Target to build
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
if not self._compiler:
|
||||
return False
|
||||
|
||||
# Compile target source file
|
||||
obj_file = target.output_dir / f"{target.name}.o"
|
||||
if target.source_file.suffix == ".cpp":
|
||||
success = self._compiler.compile_cpp_file(target.source_file, obj_file)
|
||||
else:
|
||||
# For .ino files
|
||||
success = self._compiler.compile_ino_file(target.source_file, obj_file)
|
||||
|
||||
if not success:
|
||||
print(f"[BUILD API] Failed to compile {target.source_file}")
|
||||
return False
|
||||
|
||||
# Link with library to create executable
|
||||
exe_file = target.get_output_executable()
|
||||
success = self._link_executable([obj_file], exe_file)
|
||||
|
||||
if not success:
|
||||
print(f"[BUILD API] Failed to link {exe_file}")
|
||||
return False
|
||||
|
||||
print(f"[BUILD API] Successfully built {exe_file}")
|
||||
return True
|
||||
|
||||
def _link_executable(self, object_files: List[Path], output_exe: Path) -> bool:
|
||||
"""
|
||||
Link object files into an executable.
|
||||
|
||||
Args:
|
||||
object_files: Object files to link
|
||||
output_exe: Output executable path
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
if not self._library_file or not self._library_file.exists():
|
||||
print(f"[BUILD API] Library not available for linking")
|
||||
return False
|
||||
|
||||
# Build linker command
|
||||
linker_cmd = self.build_flags.tools.linker
|
||||
link_flags = self.build_flags.link_flags
|
||||
|
||||
cmd = (
|
||||
linker_cmd
|
||||
+ link_flags
|
||||
+ [str(f) for f in object_files]
|
||||
+ [str(self._library_file)]
|
||||
+ ["-o", str(output_exe)]
|
||||
)
|
||||
cmd_str = subprocess.list2cmdline(cmd)
|
||||
|
||||
print(f"[BUILD API] Linking: {cmd_str}")
|
||||
|
||||
# Execute linker using stdout-pumping wrapper
|
||||
try:
|
||||
_completed = subprocess_run(
|
||||
command=cmd,
|
||||
cwd=None,
|
||||
check=True,
|
||||
timeout=900,
|
||||
enable_stack_trace=False,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"[BUILD API] Linker failed: {e}")
|
||||
print(f"[BUILD API] Linker stdout: {e.stdout}")
|
||||
print(f"[BUILD API] Linker stderr: {e.stderr}")
|
||||
return False
|
||||
|
||||
def get_build_info(self) -> Dict[str, Any]:
|
||||
"""Get information about the current build configuration"""
|
||||
return {
|
||||
"build_flags_toml": str(self.build_flags_toml),
|
||||
"build_dir": str(self.build_dir),
|
||||
"build_type": self.build_type.value,
|
||||
"use_pch": self.use_pch,
|
||||
"parallel": self.parallel,
|
||||
"pch_file": str(self._pch_file) if self._pch_file else None,
|
||||
"library_file": str(self._library_file) if self._library_file else None,
|
||||
}
|
||||
|
||||
def clean_build_artifacts(self) -> None:
|
||||
"""Clean all build artifacts"""
|
||||
print(f"[BUILD API] Cleaning build artifacts in {self.build_dir}")
|
||||
|
||||
if self.build_dir.exists():
|
||||
shutil.rmtree(self.build_dir)
|
||||
self.build_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Recreate subdirectories
|
||||
for directory in [self.artifacts_dir, self.targets_dir, self.cache_dir]:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Reset internal state
|
||||
self._pch_file = None
|
||||
self._library_file = None
|
||||
if self._fingerprint_cache:
|
||||
# Clear the cache by resetting it
|
||||
cache_file = self.cache_dir / "fingerprint_cache.json"
|
||||
self._fingerprint_cache = FingerprintCache(cache_file)
|
||||
|
||||
|
||||
def create_unit_test_builder(
|
||||
build_dir: str | Path = ".build/unit",
|
||||
use_pch: bool = True,
|
||||
parallel: bool = True,
|
||||
clean: bool = False,
|
||||
) -> BuildAPI:
|
||||
"""
|
||||
Create a BuildAPI instance for unit tests.
|
||||
|
||||
Args:
|
||||
build_dir: Build directory for unit test outputs
|
||||
use_pch: Whether to use precompiled headers
|
||||
parallel: Whether to enable parallel compilation
|
||||
clean: Whether to force a clean build
|
||||
|
||||
Returns:
|
||||
BuildAPI: Configured build API for unit tests
|
||||
"""
|
||||
build_flags_toml = PROJECT_ROOT / "ci" / "build_unit.toml"
|
||||
return BuildAPI(
|
||||
build_flags_toml=build_flags_toml,
|
||||
build_dir=build_dir,
|
||||
build_type=BuildType.UNIT_TEST,
|
||||
use_pch=use_pch,
|
||||
parallel=parallel,
|
||||
clean=clean,
|
||||
)
|
||||
|
||||
|
||||
def create_example_builder(
|
||||
build_dir: str | Path = ".build/examples",
|
||||
use_pch: bool = True,
|
||||
parallel: bool = True,
|
||||
clean: bool = False,
|
||||
) -> BuildAPI:
|
||||
"""
|
||||
Create a BuildAPI instance for examples.
|
||||
|
||||
Args:
|
||||
build_dir: Build directory for example outputs
|
||||
use_pch: Whether to use precompiled headers
|
||||
parallel: Whether to enable parallel compilation
|
||||
clean: Whether to force a clean build
|
||||
|
||||
Returns:
|
||||
BuildAPI: Configured build API for examples
|
||||
"""
|
||||
build_flags_toml = PROJECT_ROOT / "ci" / "build_example.toml"
|
||||
return BuildAPI(
|
||||
build_flags_toml=build_flags_toml,
|
||||
build_dir=build_dir,
|
||||
build_type=BuildType.EXAMPLE,
|
||||
use_pch=use_pch,
|
||||
parallel=parallel,
|
||||
clean=clean,
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
[all]
|
||||
# Universal defines and compiler flags for compile-commands generation
|
||||
defines = [
|
||||
"STUB_PLATFORM",
|
||||
"__EMSCRIPTEN__"
|
||||
]
|
||||
compiler_flags = [
|
||||
"-std=gnu++17",
|
||||
"-fpermissive",
|
||||
"-Wall",
|
||||
"-Wextra"
|
||||
]
|
||||
# Include flags are passed verbatim; use -I and -isystem as needed
|
||||
include_flags = [
|
||||
"-Isrc",
|
||||
"-isystemsrc/platforms/stub"
|
||||
]
|
||||
|
||||
[tools]
|
||||
# Tool configuration required by BuildFlags.parse
|
||||
cpp_compiler = ["uv", "run", "python", "-m", "ziglang", "c++"]
|
||||
linker = ["uv", "run", "python", "-m", "ziglang", "c++"]
|
||||
archiver = ["uv", "run", "python", "-m", "ziglang", "ar"]
|
||||
c_compiler = ["uv", "run", "python", "-m", "ziglang", "cc"]
|
||||
objcopy = ["uv", "run", "python", "-m", "ziglang", "objcopy"]
|
||||
nm = ["uv", "run", "python", "-m", "ziglang", "nm"]
|
||||
strip = ["uv", "run", "python", "-m", "ziglang", "strip"]
|
||||
ranlib = ["uv", "run", "python", "-m", "ziglang", "ranlib"]
|
||||
|
||||
[archive]
|
||||
# Required by BuildFlags.parse; not used for compile-commands generation
|
||||
flags = "rcsD"
|
||||
@@ -0,0 +1,181 @@
|
||||
# ================================================================================================
|
||||
# FastLED Example Build Flags Configuration
|
||||
# ================================================================================================
|
||||
#
|
||||
# This file centralizes ALL compilation flags used by example compilation to ensure
|
||||
# they remain synchronized and compatible across the entire build system.
|
||||
#
|
||||
# Examples use the STUB platform for Arduino-compatible functionality without hardware
|
||||
# dependencies, but do NOT need unit test specific flags like FASTLED_FORCE_NAMESPACE.
|
||||
|
||||
[tools]
|
||||
# Build tool configuration - Full compiler command with arguments
|
||||
# CRITICAL: Must use 'uv run python' for subprocess execution (subprocess doesn't inherit uv environment)
|
||||
cpp_compiler = ["uv", "run", "python", "-m", "ziglang", "c++"]
|
||||
linker = ["uv", "run", "python", "-m", "ziglang", "c++"] # Separate linker config for consistency
|
||||
archiver = ["uv", "run", "python", "-m", "ziglang", "ar"] # Ziglang archiver for unified toolchain
|
||||
c_compiler = ["uv", "run", "python", "-m", "ziglang", "cc"]
|
||||
objcopy = ["uv", "run", "python", "-m", "ziglang", "objcopy"]
|
||||
nm = ["uv", "run", "python", "-m", "ziglang", "nm"]
|
||||
strip = ["uv", "run", "python", "-m", "ziglang", "strip"]
|
||||
ranlib = ["uv", "run", "python", "-m", "ziglang", "ranlib"]
|
||||
|
||||
[all]
|
||||
# Universal compilation flags (compiler command handled separately)
|
||||
compiler_flags = [
|
||||
"-std=gnu++17",
|
||||
"-fpermissive",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Wno-deprecated-register", # Suppress 'register' keyword warnings for C++17 compatibility
|
||||
"-Wno-backslash-newline-escape", # Suppress backslash newline escape warnings
|
||||
"-fno-exceptions",
|
||||
"-fno-rtti",
|
||||
]
|
||||
|
||||
defines = [
|
||||
"STUB_PLATFORM",
|
||||
# NOTE: Examples do NOT get FASTLED_FORCE_NAMESPACE=1 - they use global namespace
|
||||
]
|
||||
|
||||
include_flags = [
|
||||
"-I.",
|
||||
"-Isrc",
|
||||
]
|
||||
|
||||
[windows]
|
||||
# ================================================================================================
|
||||
# WINDOWS-SPECIFIC BUILD FLAGS
|
||||
# ================================================================================================
|
||||
# Comprehensive Windows build configuration using Zig's bundled Clang with GNU target
|
||||
|
||||
# C compiler flags for Windows
|
||||
cc_flags = [
|
||||
"--target=x86_64-windows-gnu", # Explicit target for MSYS2/MinGW compatibility
|
||||
"-fuse-ld=lld", # Use Zig's bundled LLD linker
|
||||
"-std=c17", # C17 standard
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
]
|
||||
|
||||
# C++ compiler flags for Windows
|
||||
cpp_flags = [
|
||||
"--target=x86_64-windows-gnu", # Explicit target for MSYS2/MinGW compatibility
|
||||
"-fuse-ld=lld-link", # Use lld-link (Windows MSVC-compatible linker)
|
||||
"-std=c++17", # C++17 standard
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-fno-exceptions",
|
||||
"-fno-rtti",
|
||||
|
||||
# Fix Arduino INPUT macro conflict with Windows headers
|
||||
"-DNOMINMAX", # Prevent Windows min/max macros
|
||||
"-DWIN32_LEAN_AND_MEAN", # Reduce Windows header conflicts
|
||||
]
|
||||
|
||||
# Linker flags for Windows
|
||||
link_flags = [
|
||||
"-mconsole", # Console application
|
||||
|
||||
# Prevent automatic library inclusion to avoid CRT collisions
|
||||
"-nodefaultlibs", # Don't automatically include default libraries
|
||||
|
||||
# Fix CRT collision between Zig/Clang and MSYS2/MinGW runtimes
|
||||
# This prevents "atexit was replaced" errors from conflicting CRT implementations
|
||||
# "-rtlib=compiler-rt", # Removed - was causing conflicts
|
||||
"-unwindlib=libunwind", # Use Clang's libunwind (avoid MinGW unwind)
|
||||
"-nostdlib++", # Don't auto-link standard C++ library
|
||||
"-lc++", # Manually link libc++ (Clang's C++ standard library)
|
||||
|
||||
"-lkernel32", # Windows kernel functions
|
||||
"-luser32", # Windows user interface
|
||||
"-lgdi32", # Graphics device interface
|
||||
"-ladvapi32", # Advanced Windows API
|
||||
]
|
||||
|
||||
[linking.base]
|
||||
# Base linking flags (Unix-like systems)
|
||||
flags = [
|
||||
"-pthread", # POSIX threads (not used on Windows)
|
||||
]
|
||||
|
||||
[build_modes.quick]
|
||||
flags = [
|
||||
"-O1",
|
||||
"-g0",
|
||||
"-fno-inline-functions",
|
||||
"-fno-vectorize",
|
||||
"-fno-unroll-loops",
|
||||
"-fno-strict-aliasing",
|
||||
]
|
||||
|
||||
link_flags = []
|
||||
|
||||
[strict_mode]
|
||||
flags = [
|
||||
"-Werror",
|
||||
"-Wextra",
|
||||
"-Wconversion",
|
||||
"-Wsign-conversion",
|
||||
"-Werror=unused-variable", # Force error on unused variables
|
||||
"-Werror=unused-parameter", # Force error on unused parameters
|
||||
"-Werror=unused-function", # Force error on unused functions
|
||||
"-Werror=unused-but-set-variable", # Force error on variables that are set but never used
|
||||
"-Wuninitialized",
|
||||
"-Wdouble-promotion",
|
||||
"-Wformat=2",
|
||||
"-Wcast-align",
|
||||
"-Wcast-qual",
|
||||
"-Werror=return-type",
|
||||
]
|
||||
|
||||
[stub_platform]
|
||||
# ================================================================================================
|
||||
# STUB PLATFORM DEFINES FOR EXAMPLE COMPILATION
|
||||
# ================================================================================================
|
||||
# These defines are used for compiling examples on the STUB platform
|
||||
# which provides Arduino-compatible functionality without hardware dependencies.
|
||||
|
||||
defines = [
|
||||
"STUB_PLATFORM", # Enable STUB platform
|
||||
"ARDUINO=10808", # Arduino version compatibility
|
||||
"FASTLED_USE_STUB_ARDUINO", # Use stub Arduino implementation
|
||||
"FASTLED_STUB_IMPL", # Enable STUB platform Arduino function implementations
|
||||
"SKETCH_HAS_LOTS_OF_MEMORY=1", # Enable memory-intensive features for STUB platform
|
||||
"FASTLED_HAS_ENGINE_EVENTS=1", # Enable EngineEvents for UI components
|
||||
]
|
||||
|
||||
# Include paths required for stub platform compilation
|
||||
include_paths = [
|
||||
"src/platforms/stub", # Arduino.h stub for examples
|
||||
]
|
||||
|
||||
# Additional compiler arguments for stub platform
|
||||
compiler_args = [
|
||||
# No additional args currently needed - all flags come from [all] and [build_modes.quick] sections
|
||||
]
|
||||
|
||||
[archive]
|
||||
# ================================================================================================
|
||||
# ARCHIVE CREATION FLAGS
|
||||
# ================================================================================================
|
||||
# Flags for creating static libraries (.a files) with deterministic builds.
|
||||
# Using Zig's unified toolchain (ziglang ar) which supports deterministic builds on all platforms.
|
||||
|
||||
flags = "rcsD" # r=insert, c=create if needed, s=write symbol table, D=deterministic (zero timestamps)
|
||||
|
||||
[linking.unix]
|
||||
# ================================================================================================
|
||||
# UNIX-SPECIFIC LINKING FLAGS
|
||||
# ================================================================================================
|
||||
# Linking flags specific to Unix-like systems (Linux, macOS).
|
||||
|
||||
flags = [
|
||||
# Core Libraries
|
||||
"-pthread", # POSIX threads support
|
||||
"-lm", # Math library
|
||||
"-ldl", # Dynamic loading library
|
||||
|
||||
# Platform-specific libraries (added as needed)
|
||||
# Note: Additional platform-specific flags can be added here
|
||||
]
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FastLED Unified Build API - Example Usage
|
||||
|
||||
This script demonstrates how to use the new unified build API to replace
|
||||
the old scattered build logic for both unit tests and examples.
|
||||
|
||||
Before (scattered logic):
|
||||
- PCH building logic in multiple places
|
||||
- libfastled.a building in callers
|
||||
- Build flags mixed between unit tests and examples
|
||||
- Manual directory management
|
||||
|
||||
After (unified API):
|
||||
- Single API for both unit tests and examples
|
||||
- Automatic PCH and library building
|
||||
- Clean separation of build configurations
|
||||
- Organized build directory structure
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ci.build_api import BuildType, create_example_builder, create_unit_test_builder
|
||||
|
||||
|
||||
def demo_unit_test_building():
|
||||
"""Demonstrate unit test building with the unified API"""
|
||||
print("=" * 60)
|
||||
print("UNIT TEST BUILDING DEMO")
|
||||
print("=" * 60)
|
||||
|
||||
# Create unit test builder - automatically uses ci/build_unit.toml
|
||||
unit_builder = create_unit_test_builder(
|
||||
build_dir=".build/unit", use_pch=True, parallel=True, clean=False
|
||||
)
|
||||
|
||||
# Get build info
|
||||
info = unit_builder.get_build_info()
|
||||
print(f"Build configuration: {info}")
|
||||
|
||||
# Build multiple unit test targets efficiently
|
||||
# The API automatically handles:
|
||||
# - PCH building (once, reused by all targets)
|
||||
# - libfastled.a building (once, reused by all targets)
|
||||
# - Individual target compilation and linking
|
||||
test_files = [
|
||||
Path("tests/test_json.cpp"),
|
||||
Path("tests/test_color.cpp"),
|
||||
Path("tests/test_math.cpp"),
|
||||
]
|
||||
|
||||
print(f"\nBuilding {len(test_files)} unit test targets...")
|
||||
results = unit_builder.build_targets(test_files)
|
||||
|
||||
# Report results
|
||||
for result in results:
|
||||
status = "SUCCESS" if result.success else "FAILED"
|
||||
print(f" {result.target.name}: {status} ({result.build_time:.2f}s)")
|
||||
if not result.success:
|
||||
print(f" Error: {result.message}")
|
||||
|
||||
print(f"\nBuild artifacts organized in: {unit_builder.build_dir}")
|
||||
print(f" - PCH file: {unit_builder.artifacts_dir}")
|
||||
print(f" - Library: {unit_builder.artifacts_dir}")
|
||||
print(f" - Targets: {unit_builder.targets_dir}")
|
||||
print(f" - Cache: {unit_builder.cache_dir}")
|
||||
|
||||
|
||||
def demo_example_building():
|
||||
"""Demonstrate example building with the unified API"""
|
||||
print("\n" + "=" * 60)
|
||||
print("EXAMPLE BUILDING DEMO")
|
||||
print("=" * 60)
|
||||
|
||||
# Create example builder - automatically uses ci/build_example.toml
|
||||
example_builder = create_example_builder(
|
||||
build_dir=".build/examples", use_pch=True, parallel=True, clean=False
|
||||
)
|
||||
|
||||
# Get build info
|
||||
info = example_builder.get_build_info()
|
||||
print(f"Build configuration: {info}")
|
||||
|
||||
# Build multiple example targets efficiently
|
||||
# The API automatically handles:
|
||||
# - PCH building (once, reused by all targets)
|
||||
# - libfastled.a building (once, with example-specific flags)
|
||||
# - Individual target compilation and linking
|
||||
example_files = [
|
||||
Path("examples/Blink/Blink.ino"),
|
||||
Path("examples/DemoReel100/DemoReel100.ino"),
|
||||
Path("examples/ColorPalette/ColorPalette.ino"),
|
||||
]
|
||||
|
||||
print(f"\nBuilding {len(example_files)} example targets...")
|
||||
results = example_builder.build_targets(example_files)
|
||||
|
||||
# Report results
|
||||
for result in results:
|
||||
status = "SUCCESS" if result.success else "FAILED"
|
||||
print(f" {result.target.name}: {status} ({result.build_time:.2f}s)")
|
||||
if not result.success:
|
||||
print(f" Error: {result.message}")
|
||||
|
||||
print(f"\nBuild artifacts organized in: {example_builder.build_dir}")
|
||||
print(f" - PCH file: {example_builder.artifacts_dir}")
|
||||
print(f" - Library: {example_builder.artifacts_dir}")
|
||||
print(f" - Targets: {example_builder.targets_dir}")
|
||||
print(f" - Cache: {example_builder.cache_dir}")
|
||||
|
||||
|
||||
def demo_build_configuration_differences():
|
||||
"""Demonstrate the differences between unit test and example configurations"""
|
||||
print("\n" + "=" * 60)
|
||||
print("BUILD CONFIGURATION DIFFERENCES")
|
||||
print("=" * 60)
|
||||
|
||||
unit_builder = create_unit_test_builder()
|
||||
example_builder = create_example_builder()
|
||||
|
||||
unit_info = unit_builder.get_build_info()
|
||||
example_info = example_builder.get_build_info()
|
||||
|
||||
print("Unit Test Configuration:")
|
||||
print(f" - Build flags TOML: {unit_info['build_flags_toml']}")
|
||||
print(f" - Build directory: {unit_info['build_dir']}")
|
||||
print(f" - Build type: {unit_info['build_type']}")
|
||||
print(f" - Includes FASTLED_FORCE_NAMESPACE=1")
|
||||
print(f" - Includes FASTLED_TESTING=1")
|
||||
|
||||
print("\nExample Configuration:")
|
||||
print(f" - Build flags TOML: {example_info['build_flags_toml']}")
|
||||
print(f" - Build directory: {example_info['build_dir']}")
|
||||
print(f" - Build type: {example_info['build_type']}")
|
||||
print(f" - Uses global namespace (no FASTLED_FORCE_NAMESPACE)")
|
||||
print(f" - Arduino compatibility defines")
|
||||
|
||||
|
||||
def demo_clean_builds():
|
||||
"""Demonstrate clean build functionality"""
|
||||
print("\n" + "=" * 60)
|
||||
print("CLEAN BUILD DEMO")
|
||||
print("=" * 60)
|
||||
|
||||
# Create builders with clean=True to force rebuilding of all artifacts
|
||||
unit_builder = create_unit_test_builder(clean=True)
|
||||
example_builder = create_example_builder(clean=True)
|
||||
|
||||
print("Unit test builder:")
|
||||
print(f" - Will clean and rebuild all artifacts in {unit_builder.build_dir}")
|
||||
|
||||
print("Example builder:")
|
||||
print(f" - Will clean and rebuild all artifacts in {example_builder.build_dir}")
|
||||
|
||||
# Demonstrate manual cleaning
|
||||
print("\nManual cleaning:")
|
||||
unit_builder.clean_build_artifacts()
|
||||
print(f" - Cleaned unit test artifacts")
|
||||
|
||||
example_builder.clean_build_artifacts()
|
||||
print(f" - Cleaned example artifacts")
|
||||
|
||||
|
||||
def demo_advanced_usage():
|
||||
"""Demonstrate advanced usage patterns"""
|
||||
print("\n" + "=" * 60)
|
||||
print("ADVANCED USAGE DEMO")
|
||||
print("=" * 60)
|
||||
|
||||
# Custom build directory structure
|
||||
unit_builder = create_unit_test_builder(
|
||||
build_dir=".build/my_custom_unit_tests",
|
||||
use_pch=False, # Disable PCH for debugging
|
||||
parallel=False, # Disable parallel for single-threaded debugging
|
||||
)
|
||||
|
||||
print("Custom configuration:")
|
||||
print(f" - Custom build directory: {unit_builder.build_dir}")
|
||||
print(f" - PCH disabled for easier debugging")
|
||||
print(f" - Parallel compilation disabled for sequential output")
|
||||
|
||||
# Show the organized directory structure
|
||||
print(f"\nOrganized directory structure:")
|
||||
print(f" {unit_builder.build_dir}/")
|
||||
print(f" ├── artifacts/ # PCH files and libfastled.a")
|
||||
print(f" ├── targets/ # Individual target builds")
|
||||
print(f" │ ├── test_json/")
|
||||
print(f" │ │ ├── test_json.o")
|
||||
print(f" │ │ └── test_json.exe")
|
||||
print(f" │ └── test_color/")
|
||||
print(f" │ ├── test_color.o")
|
||||
print(f" │ └── test_color.exe")
|
||||
print(f" └── cache/ # Fingerprint caches")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("FastLED Unified Build API - Example Usage")
|
||||
print("This demonstrates the new unified build system")
|
||||
|
||||
# Run all demos (commented out to avoid actual compilation during demo)
|
||||
|
||||
# Uncomment these to run actual builds:
|
||||
# demo_unit_test_building()
|
||||
# demo_example_building()
|
||||
|
||||
# These can run without actual building:
|
||||
demo_build_configuration_differences()
|
||||
demo_clean_builds()
|
||||
demo_advanced_usage()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("DEMO COMPLETE")
|
||||
print("=" * 60)
|
||||
print("The unified build API provides:")
|
||||
print("✓ Single API for both unit tests and examples")
|
||||
print("✓ Automatic PCH and library building")
|
||||
print("✓ Clean separation of build configurations")
|
||||
print("✓ Organized build directory structure")
|
||||
print("✓ Efficient multiple target building")
|
||||
print("✓ Simple, elegant interface")
|
||||
@@ -0,0 +1,276 @@
|
||||
# ================================================================================================
|
||||
# FastLED Unit Test Build Flags Configuration
|
||||
# ================================================================================================
|
||||
#
|
||||
# This file centralizes ALL compilation flags used by unit test compilation to ensure
|
||||
# they remain synchronized and compatible across the entire build system.
|
||||
#
|
||||
# Unit tests require special flags like FASTLED_FORCE_NAMESPACE=1 to put all symbols
|
||||
# in the fl:: namespace that the tests expect.
|
||||
|
||||
[tools]
|
||||
# Build tool configuration - Full compiler command with arguments
|
||||
# CRITICAL: Must use 'uv run python' for subprocess execution (subprocess doesn't inherit uv environment)
|
||||
cpp_compiler = ["uv", "run", "python", "-m", "ziglang", "c++"]
|
||||
linker = ["uv", "run", "python", "-m", "ziglang", "c++"] # Separate linker config for consistency
|
||||
archiver = ["uv", "run", "python", "-m", "ziglang", "ar"] # Ziglang archiver for unified toolchain
|
||||
c_compiler = ["uv", "run", "python", "-m", "ziglang", "cc"]
|
||||
objcopy = ["uv", "run", "python", "-m", "ziglang", "objcopy"]
|
||||
nm = ["uv", "run", "python", "-m", "ziglang", "nm"]
|
||||
strip = ["uv", "run", "python", "-m", "ziglang", "strip"]
|
||||
ranlib = ["uv", "run", "python", "-m", "ziglang", "ranlib"]
|
||||
|
||||
[all]
|
||||
# Universal compilation flags (compiler command handled separately)
|
||||
compiler_flags = [
|
||||
"-std=gnu++17",
|
||||
"-fpermissive",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Wno-deprecated-register", # Suppress 'register' keyword warnings for C++17 compatibility
|
||||
"-Wno-backslash-newline-escape", # Suppress backslash newline escape warnings
|
||||
"-fno-exceptions",
|
||||
"-fno-rtti",
|
||||
]
|
||||
|
||||
defines = [
|
||||
"STUB_PLATFORM",
|
||||
"FASTLED_UNIT_TEST=1",
|
||||
"FASTLED_FORCE_NAMESPACE=1", # CRITICAL: Required for unit tests to access fl:: namespace
|
||||
"FASTLED_TESTING=1", # Enable test-specific functionality like MockTimeProvider
|
||||
]
|
||||
|
||||
include_flags = [
|
||||
"-I.",
|
||||
"-Isrc",
|
||||
"-Itests",
|
||||
]
|
||||
|
||||
[windows]
|
||||
# ================================================================================================
|
||||
# WINDOWS-SPECIFIC BUILD FLAGS
|
||||
# ================================================================================================
|
||||
# Comprehensive Windows build configuration using Zig's bundled Clang with GNU target
|
||||
|
||||
# C compiler flags for Windows
|
||||
cc_flags = [
|
||||
"--target=x86_64-windows-gnu", # Explicit target for MSYS2/MinGW compatibility
|
||||
"-fuse-ld=lld", # Use Zig's bundled LLD linker
|
||||
"-std=c17", # C17 standard
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
]
|
||||
|
||||
# C++ compiler flags for Windows
|
||||
cpp_flags = [
|
||||
"--target=x86_64-windows-gnu", # Explicit target for MSYS2/MinGW compatibility
|
||||
"-fuse-ld=lld-link", # Use lld-link (Windows MSVC-compatible linker)
|
||||
"-std=c++17", # C++17 standard
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-fno-exceptions",
|
||||
"-fno-rtti",
|
||||
|
||||
# Fix Arduino INPUT macro conflict with Windows headers
|
||||
"-DNOMINMAX", # Prevent Windows min/max macros
|
||||
"-DWIN32_LEAN_AND_MEAN", # Reduce Windows header conflicts
|
||||
]
|
||||
|
||||
# Linker flags for Windows
|
||||
link_flags = [
|
||||
"-mconsole", # Console application
|
||||
|
||||
# Prevent automatic library inclusion to avoid CRT collisions
|
||||
"-nodefaultlibs", # Don't automatically include default libraries
|
||||
|
||||
# Fix CRT collision between Zig/Clang and MSYS2/MinGW runtimes
|
||||
# This prevents "atexit was replaced" errors from conflicting CRT implementations
|
||||
# "-rtlib=compiler-rt", # Removed - was causing conflicts
|
||||
"-unwindlib=libunwind", # Use Clang's libunwind (avoid MinGW unwind)
|
||||
"-nostdlib++", # Don't auto-link standard C++ library
|
||||
"-lc++", # Manually link libc++ (Clang's C++ standard library)
|
||||
|
||||
"-lkernel32", # Windows kernel functions
|
||||
"-luser32", # Windows user interface
|
||||
"-lgdi32", # Graphics device interface
|
||||
"-ladvapi32", # Advanced Windows API
|
||||
]
|
||||
|
||||
[linking.base]
|
||||
# Base linking flags (Unix-like systems)
|
||||
flags = [
|
||||
"-pthread", # POSIX threads (not used on Windows)
|
||||
]
|
||||
|
||||
[linux]
|
||||
# Linux-specific build configuration – libunwind disabled (fallback to execinfo)
|
||||
link_flags = [
|
||||
]
|
||||
|
||||
defines = [
|
||||
]
|
||||
|
||||
[build_modes.quick]
|
||||
flags = [
|
||||
"-O1",
|
||||
"-fno-inline-functions",
|
||||
"-fno-vectorize",
|
||||
"-fno-unroll-loops",
|
||||
"-fno-strict-aliasing",
|
||||
"-fno-omit-frame-pointer", # Preserve stack frames for debugging
|
||||
]
|
||||
|
||||
link_flags = []
|
||||
|
||||
[build_modes.debug_asan]
|
||||
# AddressSanitizer and UndefinedBehaviorSanitizer for debugging memory issues
|
||||
flags = [
|
||||
"-fsanitize=address", # AddressSanitizer for memory error detection
|
||||
"-fsanitize=undefined", # UndefinedBehaviorSanitizer for undefined behavior
|
||||
"-O1", # Light optimization for performance
|
||||
"-fno-omit-frame-pointer", # Preserve stack frames for better debugging
|
||||
"-fno-optimize-sibling-calls", # Better stack traces
|
||||
]
|
||||
|
||||
link_flags = [
|
||||
"-fsanitize=address",
|
||||
"-fsanitize=undefined",
|
||||
]
|
||||
|
||||
[strict_mode]
|
||||
flags = [
|
||||
"-Werror",
|
||||
"-Wextra",
|
||||
"-Wconversion",
|
||||
"-Wsign-conversion",
|
||||
"-Werror=unused-variable", # Force error on unused variables
|
||||
"-Werror=unused-parameter", # Force error on unused parameters
|
||||
"-Werror=unused-function", # Force error on unused functions
|
||||
"-Werror=unused-but-set-variable", # Force error on variables that are set but never used
|
||||
"-Wuninitialized",
|
||||
"-Wdouble-promotion",
|
||||
"-Wformat=2",
|
||||
"-Wcast-align",
|
||||
"-Wcast-qual",
|
||||
"-Werror=return-type",
|
||||
]
|
||||
|
||||
[test]
|
||||
# Test-specific preprocessor defines
|
||||
defines = [
|
||||
# FastLED Test Configuration
|
||||
"-DFASTLED_UNIT_TEST=1", # Identify as unit test build
|
||||
"-DFASTLED_FORCE_NAMESPACE=1", # Force namespace usage to avoid conflicts
|
||||
"-DFASTLED_USE_PROGMEM=0", # Disable PROGMEM usage in tests
|
||||
"-DSTUB_PLATFORM", # Use stub platform for testing
|
||||
"-DARDUINO=10808", # Arduino version compatibility
|
||||
"-DFASTLED_USE_STUB_ARDUINO", # Use stub Arduino implementation
|
||||
"-DSKETCH_HAS_LOTS_OF_MEMORY=1", # Assume lots of memory available
|
||||
"-DFASTLED_STUB_IMPL", # Use stub implementations
|
||||
"-DFASTLED_USE_JSON_UI=1", # Enable JSON UI features
|
||||
"-DFASTLED_TESTING", # Enable testing features
|
||||
"-DFASTLED_NO_AUTO_NAMESPACE", # Disable automatic namespace
|
||||
"-DFASTLED_NO_PINMAP", # Disable pin mapping
|
||||
"-DHAS_HARDWARE_PIN_SUPPORT", # Enable hardware pin support
|
||||
"-DFASTLED_DEBUG_LEVEL=1", # Set debug level
|
||||
"-DFASTLED_NO_ATEXIT=1", # Disable atexit to fix Windows lld-link errors
|
||||
"-DDOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS", # Doctest configuration
|
||||
"-DENABLE_CRASH_HANDLER", # Enable global crash handler in tests
|
||||
]
|
||||
|
||||
# Test-specific compiler flags
|
||||
compiler_flags = [
|
||||
# Language Standard and Compatibility
|
||||
"-std=c++17", # Base C++17 standard
|
||||
"-std=gnu++17", # Use GNU C++17 standard with extensions
|
||||
"-fpermissive", # Allow some non-conforming code patterns
|
||||
|
||||
# Warning Configuration
|
||||
"-Wall", # Enable all warnings
|
||||
"-Wextra", # Enable extra warnings
|
||||
"-Werror=return-type", # Error on missing return statements
|
||||
|
||||
# Debug and Crash Handling
|
||||
"-fno-omit-frame-pointer", # Preserve stack frames for debugging
|
||||
|
||||
# AddressSanitizer and Memory Debugging
|
||||
"-fsanitize=address", # AddressSanitizer for memory error detection
|
||||
"-fsanitize=undefined", # UndefinedBehaviorSanitizer for undefined behavior
|
||||
|
||||
# Thread Safety and Runtime Behavior
|
||||
"-fno-threadsafe-statics", # Disable thread-safe static initialization
|
||||
"-fno-exceptions", # Disable C++ exceptions
|
||||
"-fno-rtti", # Disable RTTI
|
||||
|
||||
# PCH Support
|
||||
"-fpch-instantiate-templates", # Required for proper PCH template handling
|
||||
]
|
||||
|
||||
# Test-specific include flags
|
||||
include_flags = [
|
||||
"-I.", # Current directory
|
||||
"-Isrc", # FastLED source directory
|
||||
"-Itests", # Test directory
|
||||
]
|
||||
|
||||
# Test-specific linking flags for AddressSanitizer and libunwind
|
||||
link_flags = [
|
||||
"-fsanitize=address", # Link AddressSanitizer runtime
|
||||
"-fsanitize=undefined", # Link UndefinedBehaviorSanitizer runtime
|
||||
"-lunwind", # Link libunwind for stack traces
|
||||
"-lunwind-x86_64", # Platform-specific libunwind functions
|
||||
]
|
||||
|
||||
[archive]
|
||||
# ================================================================================================
|
||||
# ARCHIVE CREATION FLAGS
|
||||
# ================================================================================================
|
||||
# Flags for creating static libraries (.a files) with deterministic builds.
|
||||
# Using Zig's unified toolchain (ziglang ar) which supports deterministic builds on all platforms.
|
||||
|
||||
flags = "rcsD" # r=insert, c=create if needed, s=write symbol table, D=deterministic (zero timestamps)
|
||||
|
||||
[stub_platform]
|
||||
# ================================================================================================
|
||||
# STUB PLATFORM DEFINES FOR UNIT TEST COMPILATION
|
||||
# ================================================================================================
|
||||
# These defines are used when unit tests need to compile example code on the STUB platform
|
||||
# which provides Arduino-compatible functionality without hardware dependencies.
|
||||
|
||||
defines = [
|
||||
"STUB_PLATFORM", # Enable STUB platform
|
||||
"ARDUINO=10808", # Arduino version compatibility
|
||||
"FASTLED_USE_STUB_ARDUINO", # Use stub Arduino implementation
|
||||
"FASTLED_STUB_IMPL", # Enable STUB platform Arduino function implementations
|
||||
"SKETCH_HAS_LOTS_OF_MEMORY=1", # Enable memory-intensive features for STUB platform
|
||||
"FASTLED_HAS_ENGINE_EVENTS=1", # Enable EngineEvents for UI components
|
||||
"FASTLED_UNIT_TEST=1", # Identify as unit test build
|
||||
"FASTLED_FORCE_NAMESPACE=1", # Force namespace usage for unit tests
|
||||
"FASTLED_TESTING=1", # Enable testing features
|
||||
]
|
||||
|
||||
# Include paths required for stub platform compilation in unit tests
|
||||
include_paths = [
|
||||
"src/platforms/stub", # Arduino.h stub for examples
|
||||
]
|
||||
|
||||
# Additional compiler arguments for stub platform
|
||||
compiler_args = [
|
||||
# No additional args currently needed - all flags come from [all] and other sections
|
||||
]
|
||||
|
||||
[linking.unix]
|
||||
# ================================================================================================
|
||||
# UNIX-SPECIFIC LINKING FLAGS
|
||||
# ================================================================================================
|
||||
# Linking flags specific to Unix-like systems (Linux, macOS).
|
||||
|
||||
flags = [
|
||||
# Core Libraries
|
||||
"-pthread", # POSIX threads support
|
||||
"-lm", # Math library
|
||||
"-ldl", # Dynamic loading library
|
||||
|
||||
# Platform-specific libraries (added as needed)
|
||||
# Note: Additional platform-specific flags can be added here
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TypeScript checking for FastLED JavaScript/WASM compiler modules.
|
||||
|
||||
This script runs TypeScript compiler with JSDoc checking enabled
|
||||
to catch type errors in JavaScript files that would be caught
|
||||
at compile time in TypeScript.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check_js_types():
|
||||
"""Run TypeScript checking on JavaScript files."""
|
||||
wasm_compiler_dir = (
|
||||
Path(__file__).parent.parent / "src" / "platforms" / "wasm" / "compiler"
|
||||
)
|
||||
|
||||
if not wasm_compiler_dir.exists():
|
||||
print(f"❌ WASM compiler directory not found: {wasm_compiler_dir}")
|
||||
return 1
|
||||
|
||||
jsconfig_path = wasm_compiler_dir / "jsconfig.json"
|
||||
if not jsconfig_path.exists():
|
||||
print(f"❌ jsconfig.json not found: {jsconfig_path}")
|
||||
return 1
|
||||
|
||||
print(f"🔍 Checking JavaScript types in: {wasm_compiler_dir}")
|
||||
|
||||
try:
|
||||
# Run TypeScript compiler with noEmit to only check types
|
||||
result = subprocess.run(
|
||||
["npx", "tsc", "--noEmit", "--project", str(jsconfig_path)],
|
||||
cwd=wasm_compiler_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print("✅ JavaScript type checking passed!")
|
||||
return 0
|
||||
else:
|
||||
print("❌ JavaScript type checking failed:")
|
||||
print(result.stdout)
|
||||
print(result.stderr)
|
||||
return 1
|
||||
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"❌ TypeScript (tsc) not found. Please install: npm install -g typescript"
|
||||
)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"❌ Error running type check: {e}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(check_js_types())
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Match, Optional
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = HERE.parent
|
||||
|
||||
IS_GITHUB = "GITHUB_ACTIONS" in os.environ
|
||||
|
||||
|
||||
def run_command(
|
||||
cmd_list: List[str],
|
||||
capture_output: bool = True,
|
||||
shell: bool = False,
|
||||
check: Optional[bool] = None,
|
||||
) -> Optional[str]:
|
||||
"""Run a command and return its output."""
|
||||
actual_check = check if check is not None else False
|
||||
cmd = cmd_list if not shell else subprocess.list2cmdline(cmd_list)
|
||||
|
||||
result: subprocess.CompletedProcess[str] = subprocess.run(
|
||||
cmd, capture_output=capture_output, text=True, shell=shell, check=actual_check
|
||||
)
|
||||
|
||||
if not capture_output:
|
||||
return None
|
||||
|
||||
stdout: str = result.stdout
|
||||
stdout = stdout.strip()
|
||||
return stdout
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check compiled program size for a board"
|
||||
)
|
||||
parser.add_argument("board", help="Board name")
|
||||
parser.add_argument(
|
||||
"--max-size", type=int, required=False, help="Maximum allowed size"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-build",
|
||||
action="store_true",
|
||||
help="Skip compilation and check existing build",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--example",
|
||||
default="Blink",
|
||||
help="Example to compile (default: Blink)",
|
||||
)
|
||||
|
||||
# Parse known args first
|
||||
args, unknown = parser.parse_known_args()
|
||||
|
||||
# Add remaining arguments as extra_args
|
||||
args.extra_args = unknown
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def main():
|
||||
os.chdir(str(PROJECT_ROOT))
|
||||
args = parse_args()
|
||||
|
||||
if not args.no_build:
|
||||
cmd_list = [
|
||||
"uv",
|
||||
"run",
|
||||
"python",
|
||||
"-m",
|
||||
"ci.ci-compile",
|
||||
args.board,
|
||||
"--examples",
|
||||
args.example,
|
||||
] + args.extra_args
|
||||
try:
|
||||
run_command(cmd_list, shell=True, capture_output=IS_GITHUB, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
run_command(cmd_list, shell=True, capture_output=False, check=True)
|
||||
|
||||
output = run_command(
|
||||
["uv", "run", "-m", "ci.compiled_size", "--board", args.board],
|
||||
capture_output=True,
|
||||
)
|
||||
size_match = re.search(r": *(\d+)", output) # type: ignore
|
||||
|
||||
if not size_match:
|
||||
print("Error: Unable to extract size from output")
|
||||
print(f"Output: {output}")
|
||||
sys.exit(1)
|
||||
|
||||
size_str = size_match.group(1) # type: ignore
|
||||
if not size_str:
|
||||
print("Error: Size group is empty")
|
||||
sys.exit(1)
|
||||
size = int(size_str) # type: ignore
|
||||
|
||||
if args.max_size is not None and args.max_size > 0:
|
||||
max_size = args.max_size
|
||||
if size > max_size:
|
||||
print(f"{args.board} size {size} is greater than max size {max_size}")
|
||||
print("::error::Compiled size exceeds maximum allowed size")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"{args.board} size {size} is within the limit of {max_size}")
|
||||
else:
|
||||
if not args.max_size:
|
||||
print("Warning: No max size specified")
|
||||
elif args.max_size <= 0:
|
||||
print("Warning: max size was <= 0 so no check was performed")
|
||||
print(f"{args.board} size: {size}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# cd to the directory of the script
|
||||
cd "$(dirname "$0")"
|
||||
uv run python ci-compile.py "$@"
|
||||
@@ -0,0 +1,424 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false
|
||||
"""
|
||||
BACKUP: Original concurrent build system - REPLACED by pio ci approach
|
||||
|
||||
This file is a backup of the original ci-compile.py that used concurrent builds.
|
||||
It has been replaced by a simpler pio ci approach in the main ci-compile.py.
|
||||
|
||||
The original system used ThreadPoolExecutor to run multiple board compilations
|
||||
in parallel with build directory creation and artifact recycling.
|
||||
|
||||
The new system uses pio ci command directly for each board/example combination,
|
||||
which is simpler and more straightforward.
|
||||
|
||||
This backup is kept for reference and potential future needs.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import io
|
||||
import multiprocessing
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import List, Set
|
||||
|
||||
from ci.boards import Board, create_board # type: ignore
|
||||
from ci.util.concurrent_run import ConcurrentRunArgs, concurrent_run
|
||||
from ci.util.locked_print import locked_print
|
||||
|
||||
|
||||
HERE = Path(__file__).parent.resolve()
|
||||
|
||||
LIBS = ["src"]
|
||||
EXTRA_LIBS = [
|
||||
"https://github.com/me-no-dev/ESPAsyncWebServer.git",
|
||||
"ArduinoOTA",
|
||||
"SD",
|
||||
"FS",
|
||||
"ESPmDNS",
|
||||
"WiFi",
|
||||
"WebSockets",
|
||||
]
|
||||
BUILD_FLAGS = ["-Wl,-Map,firmware.map", "-fopt-info-all=optimization_report.txt"]
|
||||
|
||||
# Default boards to compile for. You can use boards not defined here but
|
||||
# if the board isn't part of the officially supported platformio boards then
|
||||
# you will need to add the board to the ~/.platformio/platforms directory.
|
||||
# prior to running this script. This happens automatically as of 2024-08-20
|
||||
# with the github workflow scripts.
|
||||
DEFAULT_BOARDS_NAMES = [
|
||||
"apollo3_red",
|
||||
"apollo3_thing_explorable",
|
||||
"web", # work in progress
|
||||
"uno", # Build is faster if this is first, because it's used for global init.
|
||||
"esp32dev",
|
||||
"esp01", # ESP8266
|
||||
"esp32c3",
|
||||
"attiny85",
|
||||
"ATtiny1616",
|
||||
"esp32c6",
|
||||
"esp32s3",
|
||||
"esp32p4",
|
||||
"yun",
|
||||
"digix",
|
||||
"teensylc",
|
||||
"teensy30",
|
||||
"teensy31",
|
||||
"teensy41",
|
||||
"adafruit_feather_nrf52840_sense",
|
||||
"xiaoblesense_adafruit",
|
||||
"rpipico",
|
||||
"rpipico2",
|
||||
"uno_r4_wifi",
|
||||
"esp32rmt_51",
|
||||
"esp32dev_idf44",
|
||||
"bluepill",
|
||||
"esp32rmt_51",
|
||||
"giga_r1",
|
||||
"sparkfun_xrp_controller",
|
||||
]
|
||||
|
||||
OTHER_BOARDS_NAMES = [
|
||||
"nano_every",
|
||||
"esp32-c2-devkitm-1",
|
||||
]
|
||||
|
||||
# Examples to compile.
|
||||
DEFAULT_EXAMPLES = [
|
||||
"Animartrix",
|
||||
"Apa102",
|
||||
"Apa102HD",
|
||||
"Apa102HDOverride",
|
||||
"Audio",
|
||||
"Blink",
|
||||
"Blur",
|
||||
"Chromancer",
|
||||
"ColorPalette",
|
||||
"ColorTemperature",
|
||||
"Corkscrew",
|
||||
"Cylon",
|
||||
"DemoReel100",
|
||||
# "Downscale",
|
||||
"FestivalStick",
|
||||
"FirstLight",
|
||||
"Fire2012",
|
||||
"Multiple/MultipleStripsInOneArray",
|
||||
"Multiple/ArrayOfLedArrays",
|
||||
"Noise",
|
||||
"NoisePlayground",
|
||||
"NoisePlusPalette",
|
||||
# "LuminescentGrand",
|
||||
"Pacifica",
|
||||
"Pride2015",
|
||||
"RGBCalibrate",
|
||||
"RGBSetDemo",
|
||||
"RGBW",
|
||||
"Overclock",
|
||||
"RGBWEmulated",
|
||||
"TwinkleFox",
|
||||
"XYMatrix",
|
||||
"FireMatrix",
|
||||
"FireCylinder",
|
||||
"FxGfx2Video",
|
||||
"FxSdCard",
|
||||
"FxCylon",
|
||||
"FxDemoReel100",
|
||||
"FxTwinkleFox",
|
||||
"FxFire2012",
|
||||
"FxNoisePlusPalette",
|
||||
"FxPacifica",
|
||||
"FxEngine",
|
||||
"WS2816",
|
||||
]
|
||||
|
||||
EXTRA_EXAMPLES: dict[Board, list[str]] = {
|
||||
# ESP32DEV: ["EspI2SDemo"],
|
||||
# ESP32_S3_DEVKITC_1: ["EspS3I2SDemo"],
|
||||
}
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compile FastLED examples for various boards."
|
||||
)
|
||||
# parser.add_argument(
|
||||
# "--boards", type=str, help="Comma-separated list of boards to compile for"
|
||||
# )
|
||||
# needs to be a positional argument instead
|
||||
parser.add_argument(
|
||||
"boards",
|
||||
type=str,
|
||||
help="Comma-separated list of boards to compile for",
|
||||
nargs="?",
|
||||
)
|
||||
parser.add_argument(
|
||||
"positional_examples",
|
||||
type=str,
|
||||
help="Examples to compile (positional arguments after board name)",
|
||||
nargs="*",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--examples", type=str, help="Comma-separated list of examples to compile"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-examples", type=str, help="Examples that should be excluded"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-init", action="store_true", help="Skip the initialization step"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--defines", type=str, help="Comma-separated list of compiler definitions"
|
||||
)
|
||||
parser.add_argument("--customsdk", type=str, help="custom_sdkconfig project option")
|
||||
parser.add_argument(
|
||||
"--extra-packages",
|
||||
type=str,
|
||||
help="Comma-separated list of extra packages to install",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--add-extra-esp32-libs",
|
||||
action="store_true",
|
||||
help="Add extra libraries to the libraries list to check against compiler errors.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build-dir", type=str, help="Override the default build directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-project-options",
|
||||
action="store_true",
|
||||
help="Don't use custom project options",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
help="Enable interactive mode to choose a board",
|
||||
)
|
||||
# Passed by the github action to disable interactive mode.
|
||||
parser.add_argument(
|
||||
"--no-interactive", action="store_true", help="Disable interactive mode"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Enable verbose output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--supported-boards",
|
||||
action="store_true",
|
||||
help="Print the list of supported boards and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--symbols",
|
||||
action="store_true",
|
||||
help="Run symbol analysis on compiled output",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allsrc",
|
||||
action="store_true",
|
||||
help="Enable all-source build (adds FASTLED_ALL_SRC=1 define)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-allsrc",
|
||||
action="store_true",
|
||||
help="Disable all-source build (adds FASTLED_ALL_SRC=0 define)",
|
||||
)
|
||||
try:
|
||||
args = parser.parse_intermixed_args()
|
||||
unknown = []
|
||||
except SystemExit:
|
||||
# If parse_intermixed_args fails, fall back to parse_known_args
|
||||
args, unknown = parser.parse_known_args()
|
||||
|
||||
# Handle unknown arguments intelligently - treat non-flag arguments as examples
|
||||
unknown_examples = []
|
||||
unknown_flags = []
|
||||
for arg in unknown:
|
||||
if arg.startswith("-"):
|
||||
unknown_flags.append(arg)
|
||||
else:
|
||||
unknown_examples.append(arg)
|
||||
|
||||
# Add unknown examples to positional_examples
|
||||
if unknown_examples:
|
||||
if not hasattr(args, "positional_examples") or args.positional_examples is None:
|
||||
args.positional_examples = []
|
||||
args.positional_examples.extend(unknown_examples)
|
||||
|
||||
# Only warn about actual unknown flags, not examples
|
||||
if unknown_flags:
|
||||
warnings.warn(f"Unknown arguments: {unknown_flags}")
|
||||
|
||||
# Check for FASTLED_CI_NO_INTERACTIVE environment variable
|
||||
# This allows test.py and other scripts to force non-interactive mode
|
||||
if os.environ.get("FASTLED_CI_NO_INTERACTIVE") == "true":
|
||||
args.interactive = False
|
||||
args.no_interactive = True
|
||||
|
||||
# if --interactive and --no-interative are both passed, --no-interactive takes precedence.
|
||||
if args.interactive and args.no_interactive:
|
||||
warnings.warn(
|
||||
"Both --interactive and --no-interactive were passed, --no-interactive takes precedence."
|
||||
)
|
||||
args.interactive = False
|
||||
|
||||
# Validate that --allsrc and --no-allsrc are not both specified
|
||||
if args.allsrc and args.no_allsrc:
|
||||
warnings.warn(
|
||||
"Both --allsrc and --no-allsrc were passed, this is contradictory. Please specify only one."
|
||||
)
|
||||
sys.exit(1) # Exit with error
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def remove_duplicates(items: List[str]) -> List[str]:
|
||||
seen: Set[str] = set()
|
||||
out: List[str] = []
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def choose_board_interactively(boards: List[str]) -> List[str]:
|
||||
print("Available boards:")
|
||||
boards = remove_duplicates(sorted(boards))
|
||||
for i, board in enumerate(boards):
|
||||
print(f"[{i}]: {board}")
|
||||
print("[all]: All boards")
|
||||
out: List[str] = []
|
||||
while True:
|
||||
try:
|
||||
# choice = int(input("Enter the number of the board(s) you want to compile to: "))
|
||||
input_str = input(
|
||||
"Enter the number of the board(s) you want to compile to, or it's name(s): "
|
||||
)
|
||||
if "all" in input_str:
|
||||
return boards
|
||||
for board in input_str.split(","):
|
||||
if board == "":
|
||||
continue
|
||||
if not board.isdigit():
|
||||
out.append(board) # Assume it's a board name.
|
||||
else:
|
||||
index = int(board) # Find the board from the index.
|
||||
if 0 <= index < len(boards):
|
||||
out.append(boards[index])
|
||||
else:
|
||||
warnings.warn(f"invalid board index: {index}, skipping")
|
||||
if not out:
|
||||
print("Please try again.")
|
||||
continue
|
||||
return out
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number.")
|
||||
|
||||
|
||||
def resolve_example_path(example: str) -> Path:
|
||||
example_path = HERE.parent / "examples" / example
|
||||
if not example_path.exists():
|
||||
raise FileNotFoundError(f"Example '{example}' not found at '{example_path}'")
|
||||
return example_path
|
||||
|
||||
|
||||
def create_concurrent_run_args(args: argparse.Namespace) -> ConcurrentRunArgs:
|
||||
skip_init = args.skip_init
|
||||
if args.interactive:
|
||||
boards = choose_board_interactively(DEFAULT_BOARDS_NAMES + OTHER_BOARDS_NAMES)
|
||||
else:
|
||||
boards = args.boards.split(",") if args.boards else DEFAULT_BOARDS_NAMES
|
||||
projects: list[Board] = []
|
||||
for board in boards:
|
||||
projects.append(create_board(board, no_project_options=args.no_project_options))
|
||||
extra_examples: dict[Board, list[Path]] = {}
|
||||
# Handle both positional and named examples
|
||||
if args.positional_examples:
|
||||
# Convert positional examples, handling both "examples/Blink" and "Blink" formats
|
||||
examples = []
|
||||
for example in args.positional_examples:
|
||||
# Remove "examples/" prefix if present
|
||||
if example.startswith("examples/"):
|
||||
example = example[len("examples/") :]
|
||||
examples.append(example)
|
||||
elif args.examples:
|
||||
examples = args.examples.split(",")
|
||||
else:
|
||||
examples = DEFAULT_EXAMPLES
|
||||
# Only add extra examples when using defaults
|
||||
for b, _examples in EXTRA_EXAMPLES.items():
|
||||
resolved_examples = [resolve_example_path(example) for example in _examples]
|
||||
extra_examples[b] = resolved_examples
|
||||
examples_paths = [resolve_example_path(example) for example in examples]
|
||||
# now process example exclusions.
|
||||
if args.exclude_examples:
|
||||
exclude_examples = args.exclude_examples.split(",")
|
||||
examples_paths = [
|
||||
example
|
||||
for example in examples_paths
|
||||
if example.name not in exclude_examples
|
||||
]
|
||||
for exclude in exclude_examples:
|
||||
examples.remove(exclude)
|
||||
customsdk = args.customsdk
|
||||
defines: list[str] = []
|
||||
if args.defines:
|
||||
defines.extend(args.defines.split(","))
|
||||
# Add FASTLED_ALL_SRC define when --allsrc or --no-allsrc flag is specified
|
||||
if args.allsrc:
|
||||
defines.append("FASTLED_ALL_SRC=1")
|
||||
elif args.no_allsrc:
|
||||
defines.append("FASTLED_ALL_SRC=0")
|
||||
extra_packages: list[str] = []
|
||||
if args.extra_packages:
|
||||
extra_packages.extend(args.extra_packages.split(","))
|
||||
build_dir = args.build_dir
|
||||
extra_scripts = "pre:lib/ci/util-flags.py"
|
||||
verbose = args.verbose
|
||||
|
||||
out: ConcurrentRunArgs = ConcurrentRunArgs(
|
||||
projects=projects,
|
||||
examples=examples_paths,
|
||||
skip_init=skip_init,
|
||||
defines=defines,
|
||||
customsdk=customsdk,
|
||||
extra_packages=extra_packages,
|
||||
libs=LIBS,
|
||||
build_dir=build_dir,
|
||||
extra_scripts=extra_scripts,
|
||||
cwd=str(HERE.parent),
|
||||
board_dir=(HERE / "boards").absolute().as_posix(),
|
||||
build_flags=BUILD_FLAGS,
|
||||
verbose=verbose,
|
||||
extra_examples=extra_examples,
|
||||
symbols=args.symbols,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main function."""
|
||||
args = parse_args()
|
||||
if args.supported_boards:
|
||||
print(",".join(DEFAULT_BOARDS_NAMES))
|
||||
return 0
|
||||
if args.add_extra_esp32_libs:
|
||||
LIBS.extend(EXTRA_LIBS)
|
||||
|
||||
# Set the working directory to the script's parent directory.
|
||||
run_args = create_concurrent_run_args(args)
|
||||
start_time = time.time()
|
||||
rtn = concurrent_run(args=run_args)
|
||||
time_taken = time.strftime("%Mm:%Ss", time.gmtime(time.time() - start_time))
|
||||
locked_print(f"Compilation finished in {time_taken}.")
|
||||
return rtn
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
# Change to the directory of the script
|
||||
os.chdir(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Change to the 'native' directory and run 'pio run'
|
||||
os.chdir("native")
|
||||
result = subprocess.run(["pio", "run"], check=True)
|
||||
|
||||
# Exit with the same status as the pio run command
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,596 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false
|
||||
"""
|
||||
Runs the compilation process for examples on boards using pio ci command.
|
||||
This replaces the previous concurrent build system with a simpler pio ci approach.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import List, Set
|
||||
|
||||
from ci.boards import Board, create_board # type: ignore
|
||||
from ci.util.locked_print import locked_print
|
||||
|
||||
|
||||
HERE = Path(__file__).parent.resolve()
|
||||
|
||||
# Default boards to compile for
|
||||
DEFAULT_BOARDS_NAMES = [
|
||||
"apollo3_red",
|
||||
"apollo3_thing_explorable",
|
||||
"web", # work in progress
|
||||
"uno", # Build is faster if this is first, because it's used for global init.
|
||||
"esp32dev",
|
||||
"esp01", # ESP8266
|
||||
"esp32c3",
|
||||
"attiny85",
|
||||
"ATtiny1616",
|
||||
"esp32c6",
|
||||
"esp32s3",
|
||||
"esp32p4",
|
||||
"yun",
|
||||
"digix",
|
||||
"teensylc",
|
||||
"teensy30",
|
||||
"teensy31",
|
||||
"teensy41",
|
||||
"adafruit_feather_nrf52840_sense",
|
||||
"xiaoblesense_adafruit",
|
||||
"rpipico",
|
||||
"rpipico2",
|
||||
"uno_r4_wifi",
|
||||
"esp32rmt_51",
|
||||
"esp32dev_idf44",
|
||||
"bluepill",
|
||||
"esp32rmt_51",
|
||||
"giga_r1",
|
||||
"sparkfun_xrp_controller",
|
||||
]
|
||||
|
||||
OTHER_BOARDS_NAMES = [
|
||||
"nano_every",
|
||||
"esp32-c2-devkitm-1",
|
||||
]
|
||||
|
||||
# Examples to compile.
|
||||
DEFAULT_EXAMPLES = [
|
||||
"Animartrix",
|
||||
"Apa102",
|
||||
"Apa102HD",
|
||||
"Apa102HDOverride",
|
||||
"Audio",
|
||||
"Blink",
|
||||
"Blur",
|
||||
"Chromancer",
|
||||
"ColorPalette",
|
||||
"ColorTemperature",
|
||||
"Corkscrew",
|
||||
"Cylon",
|
||||
"DemoReel100",
|
||||
# "Downscale",
|
||||
"FestivalStick",
|
||||
"FirstLight",
|
||||
"Fire2012",
|
||||
"Multiple/MultipleStripsInOneArray",
|
||||
"Multiple/ArrayOfLedArrays",
|
||||
"Noise",
|
||||
"NoisePlayground",
|
||||
"NoisePlusPalette",
|
||||
# "LuminescentGrand",
|
||||
"Pacifica",
|
||||
"Pride2015",
|
||||
"RGBCalibrate",
|
||||
"RGBSetDemo",
|
||||
"RGBW",
|
||||
"Overclock",
|
||||
"RGBWEmulated",
|
||||
"TwinkleFox",
|
||||
"XYMatrix",
|
||||
"FireMatrix",
|
||||
"FireCylinder",
|
||||
"FxGfx2Video",
|
||||
"FxSdCard",
|
||||
"FxCylon",
|
||||
"FxDemoReel100",
|
||||
"FxTwinkleFox",
|
||||
"FxFire2012",
|
||||
"FxNoisePlusPalette",
|
||||
"FxPacifica",
|
||||
"FxEngine",
|
||||
"WS2816",
|
||||
]
|
||||
|
||||
EXTRA_EXAMPLES: dict[Board, list[str]] = {
|
||||
# ESP32DEV: ["EspI2SDemo"],
|
||||
# ESP32_S3_DEVKITC_1: ["EspS3I2SDemo"],
|
||||
}
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compile FastLED examples for various boards using pio ci.util."
|
||||
)
|
||||
parser.add_argument(
|
||||
"boards",
|
||||
type=str,
|
||||
help="Comma-separated list of boards to compile for",
|
||||
nargs="?",
|
||||
)
|
||||
parser.add_argument(
|
||||
"positional_examples",
|
||||
type=str,
|
||||
help="Examples to compile (positional arguments after board name)",
|
||||
nargs="*",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--examples", type=str, help="Comma-separated list of examples to compile"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-examples", type=str, help="Examples that should be excluded"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--defines", type=str, help="Comma-separated list of compiler definitions"
|
||||
)
|
||||
parser.add_argument("--customsdk", type=str, help="custom_sdkconfig project option")
|
||||
parser.add_argument(
|
||||
"--extra-packages",
|
||||
type=str,
|
||||
help="Comma-separated list of extra packages to install",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build-dir", type=str, help="Override the default build directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
help="Enable interactive mode to choose a board",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-interactive", action="store_true", help="Disable interactive mode"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Enable verbose output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--supported-boards",
|
||||
action="store_true",
|
||||
help="Print the list of supported boards and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--symbols",
|
||||
action="store_true",
|
||||
help="Run symbol analysis on compiled output",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allsrc",
|
||||
action="store_true",
|
||||
help="Enable all-source build (adds FASTLED_ALL_SRC=1 define)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-allsrc",
|
||||
action="store_true",
|
||||
help="Disable all-source build (adds FASTLED_ALL_SRC=0 define)",
|
||||
)
|
||||
try:
|
||||
args = parser.parse_intermixed_args()
|
||||
unknown = []
|
||||
except SystemExit:
|
||||
# If parse_intermixed_args fails, fall back to parse_known_args
|
||||
args, unknown = parser.parse_known_args()
|
||||
|
||||
# Handle unknown arguments intelligently - treat non-flag arguments as examples
|
||||
unknown_examples = []
|
||||
unknown_flags = []
|
||||
for arg in unknown:
|
||||
if arg.startswith("-"):
|
||||
unknown_flags.append(arg)
|
||||
else:
|
||||
unknown_examples.append(arg)
|
||||
|
||||
# Add unknown examples to positional_examples
|
||||
if unknown_examples:
|
||||
if not hasattr(args, "positional_examples") or args.positional_examples is None:
|
||||
args.positional_examples = []
|
||||
args.positional_examples.extend(unknown_examples)
|
||||
|
||||
# Only warn about actual unknown flags, not examples
|
||||
if unknown_flags:
|
||||
warnings.warn(f"Unknown arguments: {unknown_flags}")
|
||||
|
||||
# Check for FASTLED_CI_NO_INTERACTIVE environment variable
|
||||
if os.environ.get("FASTLED_CI_NO_INTERACTIVE") == "true":
|
||||
args.interactive = False
|
||||
args.no_interactive = True
|
||||
|
||||
# if --interactive and --no-interative are both passed, --no-interactive takes precedence.
|
||||
if args.interactive and args.no_interactive:
|
||||
warnings.warn(
|
||||
"Both --interactive and --no-interactive were passed, --no-interactive takes precedence."
|
||||
)
|
||||
args.interactive = False
|
||||
|
||||
# Validate that --allsrc and --no-allsrc are not both specified
|
||||
if args.allsrc and args.no_allsrc:
|
||||
warnings.warn(
|
||||
"Both --allsrc and --no-allsrc were passed, this is contradictory. Please specify only one."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def remove_duplicates(items: List[str]) -> List[str]:
|
||||
seen: Set[str] = set()
|
||||
out: List[str] = []
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
def choose_board_interactively(boards: List[str]) -> List[str]:
|
||||
print("Available boards:")
|
||||
boards = remove_duplicates(sorted(boards))
|
||||
for i, board in enumerate(boards):
|
||||
print(f"[{i}]: {board}")
|
||||
print("[all]: All boards")
|
||||
out: list[str] = []
|
||||
while True:
|
||||
try:
|
||||
input_str = input(
|
||||
"Enter the number of the board(s) you want to compile to, or it's name(s): "
|
||||
)
|
||||
if "all" in input_str:
|
||||
return boards
|
||||
for board in input_str.split(","):
|
||||
if board == "":
|
||||
continue
|
||||
if not board.isdigit():
|
||||
out.append(board) # Assume it's a board name.
|
||||
else:
|
||||
index = int(board) # Find the board from the index.
|
||||
if 0 <= index < len(boards):
|
||||
out.append(boards[index])
|
||||
else:
|
||||
warnings.warn(f"invalid board index: {index}, skipping")
|
||||
if not out:
|
||||
print("Please try again.")
|
||||
continue
|
||||
return out
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number.")
|
||||
|
||||
|
||||
def resolve_example_path(example: str) -> Path:
|
||||
example_path = HERE.parent / "examples" / example
|
||||
if not example_path.exists():
|
||||
raise FileNotFoundError(f"Example '{example}' not found at '{example_path}'")
|
||||
return example_path
|
||||
|
||||
|
||||
def compile_with_pio_ci(
|
||||
board: Board,
|
||||
example_paths: list[Path],
|
||||
build_dir: str | None,
|
||||
defines: list[str],
|
||||
verbose: bool,
|
||||
) -> tuple[bool, str]:
|
||||
"""Compile examples for a board using pio ci command."""
|
||||
|
||||
# Skip web boards
|
||||
if board.board_name == "web":
|
||||
locked_print(f"Skipping web target for board {board.board_name}")
|
||||
return True, ""
|
||||
|
||||
board_name = board.board_name
|
||||
real_board_name = board.get_real_board_name()
|
||||
|
||||
# Set up build directory
|
||||
if build_dir:
|
||||
board_build_dir = Path(build_dir) / board_name
|
||||
else:
|
||||
board_build_dir = Path(".build") / board_name
|
||||
|
||||
board_build_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
locked_print(f"*** Compiling examples for board {board_name} using pio ci ***")
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
for example_path in example_paths:
|
||||
locked_print(
|
||||
f"*** Building example {example_path.name} for board {board_name} ***"
|
||||
)
|
||||
|
||||
# Find the .ino file in the example directory
|
||||
ino_files = list(example_path.glob("*.ino"))
|
||||
if not ino_files:
|
||||
error_msg = f"No .ino file found in {example_path}"
|
||||
locked_print(f"ERROR: {error_msg}")
|
||||
errors.append(error_msg)
|
||||
continue
|
||||
|
||||
# Use the first .ino file found
|
||||
ino_file = ino_files[0]
|
||||
|
||||
# Build pio ci command
|
||||
cmd_list = [
|
||||
"pio",
|
||||
"ci",
|
||||
str(ino_file),
|
||||
"--board",
|
||||
real_board_name,
|
||||
"--lib",
|
||||
"src", # FastLED source directory
|
||||
"--keep-build-dir",
|
||||
"--build-dir",
|
||||
str(board_build_dir / example_path.name),
|
||||
]
|
||||
|
||||
# Add platform-specific options
|
||||
if board.platform:
|
||||
cmd_list.extend(["--project-option", f"platform={board.platform}"])
|
||||
|
||||
if board.platform_packages:
|
||||
cmd_list.extend(
|
||||
["--project-option", f"platform_packages={board.platform_packages}"]
|
||||
)
|
||||
|
||||
if board.framework:
|
||||
cmd_list.extend(["--project-option", f"framework={board.framework}"])
|
||||
|
||||
if board.board_build_core:
|
||||
cmd_list.extend(
|
||||
["--project-option", f"board_build.core={board.board_build_core}"]
|
||||
)
|
||||
|
||||
if board.board_build_filesystem_size:
|
||||
cmd_list.extend(
|
||||
[
|
||||
"--project-option",
|
||||
f"board_build.filesystem_size={board.board_build_filesystem_size}",
|
||||
]
|
||||
)
|
||||
|
||||
# Add defines
|
||||
all_defines = defines.copy()
|
||||
if board.defines:
|
||||
all_defines.extend(board.defines)
|
||||
|
||||
if all_defines:
|
||||
build_flags = " ".join(f"-D{define}" for define in all_defines)
|
||||
cmd_list.extend(["--project-option", f"build_flags={build_flags}"])
|
||||
|
||||
# Add custom SDK config if specified
|
||||
if board.customsdk:
|
||||
cmd_list.extend(["--project-option", f"custom_sdkconfig={board.customsdk}"])
|
||||
|
||||
# Add verbose flag if requested
|
||||
if verbose:
|
||||
cmd_list.append("--verbose")
|
||||
|
||||
# Execute the command
|
||||
cmd_str = subprocess.list2cmdline(cmd_list)
|
||||
locked_print(f"Running command: {cmd_str}")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd_list,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(HERE.parent),
|
||||
timeout=300, # 5 minute timeout per example
|
||||
)
|
||||
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
if result.returncode == 0:
|
||||
locked_print(
|
||||
f"*** Successfully built {example_path.name} for {board_name} in {elapsed_time:.2f}s ***"
|
||||
)
|
||||
if verbose and result.stdout:
|
||||
locked_print(f"Build output:\n{result.stdout}")
|
||||
else:
|
||||
error_msg = f"Failed to build {example_path.name} for {board_name}"
|
||||
locked_print(f"ERROR: {error_msg}")
|
||||
locked_print(f"Command: {cmd_str}")
|
||||
if result.stdout:
|
||||
locked_print(f"STDOUT:\n{result.stdout}")
|
||||
if result.stderr:
|
||||
locked_print(f"STDERR:\n{result.stderr}")
|
||||
errors.append(f"{error_msg}: {result.stderr}")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
error_msg = f"Timeout building {example_path.name} for {board_name}"
|
||||
locked_print(f"ERROR: {error_msg}")
|
||||
errors.append(error_msg)
|
||||
except Exception as e:
|
||||
error_msg = f"Exception building {example_path.name} for {board_name}: {e}"
|
||||
locked_print(f"ERROR: {error_msg}")
|
||||
errors.append(error_msg)
|
||||
|
||||
if errors:
|
||||
return False, "\n".join(errors)
|
||||
|
||||
return True, f"Successfully compiled all examples for {board_name}"
|
||||
|
||||
|
||||
def run_symbol_analysis(boards: list[Board]) -> None:
|
||||
"""Run symbol analysis on compiled outputs if requested."""
|
||||
locked_print("\nRunning symbol analysis on compiled outputs...")
|
||||
|
||||
for board in boards:
|
||||
if board.board_name == "web":
|
||||
continue
|
||||
|
||||
try:
|
||||
locked_print(f"Running symbol analysis for board: {board.board_name}")
|
||||
|
||||
cmd = [
|
||||
"uv",
|
||||
"run",
|
||||
"ci/util/symbol_analysis.py",
|
||||
"--board",
|
||||
board.board_name,
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, cwd=str(HERE.parent)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
locked_print(
|
||||
f"ERROR: Symbol analysis failed for board {board.board_name}: {result.stderr}"
|
||||
)
|
||||
else:
|
||||
locked_print(f"Symbol analysis completed for board: {board.board_name}")
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
|
||||
except Exception as e:
|
||||
locked_print(
|
||||
f"ERROR: Exception during symbol analysis for board {board.board_name}: {e}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main function."""
|
||||
args = parse_args()
|
||||
|
||||
if args.supported_boards:
|
||||
print(",".join(DEFAULT_BOARDS_NAMES))
|
||||
return 0
|
||||
|
||||
# Determine which boards to compile for
|
||||
if args.interactive:
|
||||
boards_names = choose_board_interactively(
|
||||
DEFAULT_BOARDS_NAMES + OTHER_BOARDS_NAMES
|
||||
)
|
||||
else:
|
||||
boards_names = args.boards.split(",") if args.boards else DEFAULT_BOARDS_NAMES
|
||||
|
||||
# Get board objects
|
||||
boards: list[Board] = []
|
||||
for board_name in boards_names:
|
||||
try:
|
||||
board = create_board(board_name, no_project_options=False)
|
||||
boards.append(board)
|
||||
except Exception as e:
|
||||
locked_print(f"ERROR: Failed to get board '{board_name}': {e}")
|
||||
return 1
|
||||
|
||||
# Determine which examples to compile
|
||||
if args.positional_examples:
|
||||
# Convert positional examples, handling both "examples/Blink" and "Blink" formats
|
||||
examples = []
|
||||
for example in args.positional_examples:
|
||||
# Remove "examples/" prefix if present
|
||||
if example.startswith("examples/"):
|
||||
example = example[len("examples/") :]
|
||||
examples.append(example)
|
||||
elif args.examples:
|
||||
examples = args.examples.split(",")
|
||||
else:
|
||||
examples = DEFAULT_EXAMPLES
|
||||
|
||||
# Process example exclusions
|
||||
if args.exclude_examples:
|
||||
exclude_examples = args.exclude_examples.split(",")
|
||||
examples = [ex for ex in examples if ex not in exclude_examples]
|
||||
|
||||
# Resolve example paths
|
||||
example_paths: list[Path] = []
|
||||
for example in examples:
|
||||
try:
|
||||
example_path = resolve_example_path(example)
|
||||
example_paths.append(example_path)
|
||||
except FileNotFoundError as e:
|
||||
locked_print(f"ERROR: {e}")
|
||||
return 1
|
||||
|
||||
# Add extra examples for specific boards
|
||||
extra_examples: dict[Board, list[Path]] = {}
|
||||
for board in boards:
|
||||
if board in EXTRA_EXAMPLES:
|
||||
board_examples = []
|
||||
for example in EXTRA_EXAMPLES[board]:
|
||||
try:
|
||||
board_examples.append(resolve_example_path(example))
|
||||
except FileNotFoundError as e:
|
||||
locked_print(f"WARNING: {e}")
|
||||
if board_examples:
|
||||
extra_examples[board] = board_examples
|
||||
|
||||
# Set up defines
|
||||
defines: list[str] = []
|
||||
if args.defines:
|
||||
defines.extend(args.defines.split(","))
|
||||
|
||||
# Add FASTLED_ALL_SRC define when --allsrc or --no-allsrc flag is specified
|
||||
if args.allsrc:
|
||||
defines.append("FASTLED_ALL_SRC=1")
|
||||
elif args.no_allsrc:
|
||||
defines.append("FASTLED_ALL_SRC=0")
|
||||
|
||||
# Start compilation
|
||||
start_time = time.time()
|
||||
locked_print(
|
||||
f"Starting compilation for {len(boards)} boards with {len(example_paths)} examples"
|
||||
)
|
||||
|
||||
compilation_errors: List[str] = []
|
||||
|
||||
# Compile for each board
|
||||
for board in boards:
|
||||
board_examples = example_paths.copy()
|
||||
if board in extra_examples:
|
||||
board_examples.extend(extra_examples[board])
|
||||
|
||||
success, message = compile_with_pio_ci(
|
||||
board=board,
|
||||
example_paths=board_examples,
|
||||
build_dir=args.build_dir,
|
||||
defines=defines,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
if not success:
|
||||
compilation_errors.append(f"Board {board.board_name}: {message}")
|
||||
locked_print(f"ERROR: Compilation failed for board {board.board_name}")
|
||||
# Continue with other boards instead of stopping
|
||||
|
||||
# Run symbol analysis if requested
|
||||
if args.symbols:
|
||||
run_symbol_analysis(boards)
|
||||
|
||||
# Report results
|
||||
elapsed_time = time.time() - start_time
|
||||
time_str = time.strftime("%Mm:%Ss", time.gmtime(elapsed_time))
|
||||
|
||||
if compilation_errors:
|
||||
locked_print(
|
||||
f"\nCompilation finished in {time_str} with {len(compilation_errors)} error(s):"
|
||||
)
|
||||
for error in compilation_errors:
|
||||
locked_print(f" - {error}")
|
||||
return 1
|
||||
else:
|
||||
locked_print(f"\nAll compilations completed successfully in {time_str}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
locked_print("\nInterrupted by user")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,657 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FastLED Example Compiler
|
||||
|
||||
Streamlined compiler that uses the PioCompiler to build FastLED examples for various boards.
|
||||
This replaces the previous complex compilation system with a simpler approach using the Pio compiler.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, cast
|
||||
|
||||
from typeguard import typechecked
|
||||
|
||||
from ci.boards import ALL, Board, create_board
|
||||
from ci.compiler.compiler import CacheType, SketchResult
|
||||
from ci.compiler.pio import PioCompiler
|
||||
|
||||
|
||||
def green_text(text: str) -> str:
|
||||
"""Return text in green color."""
|
||||
return f"\033[32m{text}\033[0m"
|
||||
|
||||
|
||||
def red_text(text: str) -> str:
|
||||
"""Return text in red color."""
|
||||
return f"\033[31m{text}\033[0m"
|
||||
|
||||
|
||||
def get_default_boards() -> List[str]:
|
||||
"""Get all board names from the ALL boards list, with preferred boards first."""
|
||||
# These are the boards we want to compile first (preferred order)
|
||||
# Order matters: UNO first because it's used for global init and builds faster
|
||||
preferred_board_names = [
|
||||
"uno", # Build is faster if this is first, because it's used for global init.
|
||||
"esp32dev",
|
||||
"esp01", # ESP8266
|
||||
"esp32c3",
|
||||
"esp32c6",
|
||||
"esp32s3",
|
||||
"teensylc",
|
||||
"teensy31",
|
||||
"teensy41",
|
||||
"digix",
|
||||
"rpipico",
|
||||
"rpipico2",
|
||||
]
|
||||
|
||||
# Get all available board names from the ALL list
|
||||
available_board_names = {board.board_name for board in ALL}
|
||||
|
||||
# Start with preferred boards that exist, warn about missing ones
|
||||
default_boards: List[str] = []
|
||||
for board_name in preferred_board_names:
|
||||
if board_name in available_board_names:
|
||||
default_boards.append(board_name)
|
||||
else:
|
||||
print(
|
||||
f"WARNING: Preferred board '{board_name}' not found in available boards"
|
||||
)
|
||||
|
||||
# Add all remaining boards (sorted for consistency)
|
||||
remaining_boards = sorted(available_board_names - set(default_boards))
|
||||
default_boards.extend(remaining_boards)
|
||||
|
||||
return default_boards
|
||||
|
||||
|
||||
def get_all_examples() -> List[str]:
|
||||
"""Get all available example names from the examples directory."""
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
examples_dir = project_root / "examples"
|
||||
|
||||
if not examples_dir.exists():
|
||||
return []
|
||||
|
||||
examples: List[str] = []
|
||||
for item in examples_dir.iterdir():
|
||||
if item.is_dir():
|
||||
# Check if it contains a .ino file with the same name
|
||||
ino_file = item / f"{item.name}.ino"
|
||||
if ino_file.exists():
|
||||
examples.append(item.name)
|
||||
|
||||
# Sort for consistent ordering
|
||||
examples.sort()
|
||||
return examples
|
||||
|
||||
|
||||
def parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compile FastLED examples for various boards using PioCompiler"
|
||||
)
|
||||
parser.add_argument(
|
||||
"boards",
|
||||
type=str,
|
||||
help="Comma-separated list of boards to compile for",
|
||||
nargs="?",
|
||||
)
|
||||
parser.add_argument(
|
||||
"positional_examples",
|
||||
type=str,
|
||||
help="Examples to compile (positional arguments after board name)",
|
||||
nargs="*",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--examples", type=str, help="Comma-separated list of examples to compile"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-examples", type=str, help="Examples that should be excluded"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--defines", type=str, help="Comma-separated list of compiler definitions"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Enable verbose output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-cache",
|
||||
action="store_true",
|
||||
help="Disable sccache for faster compilation (default is already disabled)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-cache",
|
||||
action="store_true",
|
||||
help="Enable sccache for faster compilation",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cache",
|
||||
action="store_true",
|
||||
help="(Deprecated) Enable sccache for faster compilation - use --enable-cache instead",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--supported-boards",
|
||||
action="store_true",
|
||||
help="Print the list of supported boards and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-interactive",
|
||||
action="store_true",
|
||||
help="Disables interactive mode (deprecated)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-failures",
|
||||
type=str,
|
||||
help="Directory to write per-example failure logs (created on first failure)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--global-cache",
|
||||
type=str,
|
||||
help="Override global PlatformIO cache directory path (for testing)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--out",
|
||||
type=str,
|
||||
help="Output path for build artifact. Requires exactly one sketch. "
|
||||
"If path ends with '/', treated as directory. If has suffix, treated as file. "
|
||||
"Use '-o .' to save in current directory with sketch name.",
|
||||
)
|
||||
|
||||
try:
|
||||
parsed_args = parser.parse_intermixed_args(args)
|
||||
unknown = []
|
||||
except SystemExit:
|
||||
# If parse_intermixed_args fails, fall back to parse_known_args
|
||||
parsed_args, unknown = parser.parse_known_args(args)
|
||||
|
||||
# Handle unknown arguments intelligently - treat non-flag arguments as examples
|
||||
unknown_examples: List[str] = []
|
||||
for arg in unknown:
|
||||
if not arg.startswith("-"):
|
||||
unknown_examples.append(arg)
|
||||
|
||||
# Add unknown examples to positional_examples
|
||||
if unknown_examples:
|
||||
if (
|
||||
not hasattr(parsed_args, "positional_examples")
|
||||
or parsed_args.positional_examples is None
|
||||
):
|
||||
parsed_args.positional_examples = []
|
||||
# Type assertion to help the type checker - argparse returns Any but we know it's List[str]
|
||||
positional_examples: List[str] = cast(
|
||||
List[str], getattr(parsed_args, "positional_examples", [])
|
||||
)
|
||||
positional_examples.extend(unknown_examples)
|
||||
parsed_args.positional_examples = positional_examples
|
||||
|
||||
return parsed_args
|
||||
|
||||
|
||||
def resolve_example_path(example: str) -> str:
|
||||
"""Resolve example name to path, ensuring it exists."""
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
examples_dir = project_root / "examples"
|
||||
|
||||
# Handle both "Blink" and "examples/Blink" formats
|
||||
if example.startswith("examples/"):
|
||||
example = example[len("examples/") :]
|
||||
|
||||
example_path = examples_dir / example
|
||||
if not example_path.exists():
|
||||
raise FileNotFoundError(f"Example not found: {example_path}")
|
||||
|
||||
return example
|
||||
|
||||
|
||||
@typechecked
|
||||
@dataclass
|
||||
class BoardCompilationResult:
|
||||
"""Aggregated result for compiling a set of examples on a single board."""
|
||||
|
||||
ok: bool
|
||||
sketch_results: List[SketchResult]
|
||||
|
||||
|
||||
def compile_board_examples(
|
||||
board: Board,
|
||||
examples: List[str],
|
||||
defines: List[str],
|
||||
verbose: bool,
|
||||
enable_cache: bool,
|
||||
global_cache_dir: Optional[Path] = None,
|
||||
) -> BoardCompilationResult:
|
||||
"""Compile examples for a single board using PioCompiler."""
|
||||
|
||||
# Resolve global cache directory immediately for display
|
||||
resolved_cache_dir = None
|
||||
if global_cache_dir is not None:
|
||||
# User specified a path - use it exactly as provided
|
||||
resolved_cache_dir = global_cache_dir.resolve()
|
||||
else:
|
||||
# Default path ends with 'global_cache'
|
||||
resolved_cache_dir = Path.home() / ".fastled" / "global_cache"
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"COMPILING BOARD: {board.board_name}")
|
||||
print(f"EXAMPLES: {', '.join(examples)}")
|
||||
print(f"GLOBAL CACHE: {resolved_cache_dir}")
|
||||
|
||||
# Show other cache directories
|
||||
from ci.compiler.pio import FastLEDPaths
|
||||
|
||||
paths = FastLEDPaths(board.board_name)
|
||||
|
||||
print(f"BUILD CACHE: {paths.build_cache_dir}")
|
||||
print(f"CORE DIR: {paths.core_dir}")
|
||||
print(f"PACKAGES DIR: {paths.packages_dir}")
|
||||
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
try:
|
||||
# Determine cache type based on flag and board frameworks
|
||||
frameworks = [f.strip().lower() for f in (board.framework or "").split(",")]
|
||||
mixed_frameworks = "arduino" in frameworks and "espidf" in frameworks
|
||||
cache_type = (
|
||||
CacheType.SCCACHE
|
||||
if enable_cache and not mixed_frameworks
|
||||
else CacheType.NO_CACHE
|
||||
)
|
||||
|
||||
# Create PioCompiler instance
|
||||
compiler = PioCompiler(
|
||||
board=board,
|
||||
verbose=verbose,
|
||||
global_cache_dir=resolved_cache_dir,
|
||||
additional_defines=defines,
|
||||
cache_type=cache_type,
|
||||
)
|
||||
|
||||
# Build all examples
|
||||
futures: List[Future[SketchResult]] = compiler.build(examples)
|
||||
|
||||
# Wait for completion and collect results
|
||||
results: List[SketchResult] = []
|
||||
|
||||
for future in futures:
|
||||
try:
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
# SUCCESS/FAILED messages are printed by worker threads
|
||||
except KeyboardInterrupt:
|
||||
print("Keyboard interrupt detected, cancelling builds")
|
||||
compiler.cancel_all()
|
||||
import _thread
|
||||
|
||||
for future in futures:
|
||||
future.cancel()
|
||||
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
# Represent unexpected exception as a failed SketchResult for consistency
|
||||
from pathlib import Path as _Path
|
||||
|
||||
results.append(
|
||||
SketchResult(
|
||||
success=False,
|
||||
output=f"Build exception: {str(e)}",
|
||||
build_dir=_Path("."),
|
||||
example="<exception>",
|
||||
)
|
||||
)
|
||||
print(f"EXCEPTION during build: {e}")
|
||||
# Cleanup
|
||||
compiler.cancel_all()
|
||||
|
||||
# Show compiler statistics after all builds complete
|
||||
try:
|
||||
stats = compiler.get_cache_stats()
|
||||
if stats:
|
||||
print("\n" + "=" * 60)
|
||||
print("COMPILER STATISTICS")
|
||||
print("=" * 60)
|
||||
print(stats)
|
||||
print("=" * 60)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not retrieve compiler statistics: {e}")
|
||||
|
||||
any_failures = False
|
||||
for r in results:
|
||||
if not r.success:
|
||||
any_failures = True
|
||||
break
|
||||
return BoardCompilationResult(ok=not any_failures, sketch_results=results)
|
||||
except KeyboardInterrupt:
|
||||
print("Keyboard interrupt detected, cancelling builds")
|
||||
import _thread
|
||||
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
# Compiler could not be set up; return a single failed result to carry message
|
||||
from pathlib import Path as _Path
|
||||
|
||||
return BoardCompilationResult(
|
||||
ok=False,
|
||||
sketch_results=[
|
||||
SketchResult(
|
||||
success=False,
|
||||
output=f"Compiler setup failed: {str(e)}",
|
||||
build_dir=_Path("."),
|
||||
example="<setup>",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def get_board_artifact_extension(board: Board) -> str:
|
||||
"""Get the primary artifact extension for a board."""
|
||||
# ESP32/ESP8266 boards always produce .bin files
|
||||
if board.board_name.startswith("esp"):
|
||||
return ".bin"
|
||||
|
||||
# Most Arduino-based boards produce .hex files
|
||||
if board.framework and "arduino" in board.framework.lower():
|
||||
return ".hex"
|
||||
|
||||
# Default to .hex for most microcontroller boards
|
||||
return ".hex"
|
||||
|
||||
|
||||
def validate_output_path(
|
||||
output_path: str, sketch_name: str, board: Board
|
||||
) -> tuple[bool, str, str]:
|
||||
"""Validate output path and return (is_valid, resolved_path, error_message).
|
||||
|
||||
Args:
|
||||
output_path: The user-specified output path
|
||||
sketch_name: Name of the sketch being built
|
||||
board: Board configuration
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, resolved_output_path, error_message)
|
||||
"""
|
||||
import os
|
||||
|
||||
expected_ext = get_board_artifact_extension(board)
|
||||
|
||||
# Handle special case: -o .
|
||||
if output_path == ".":
|
||||
resolved_path = f"{sketch_name}{expected_ext}"
|
||||
return True, resolved_path, ""
|
||||
|
||||
# If path ends with /, it's a directory
|
||||
if output_path.endswith("/") or output_path.endswith("\\"):
|
||||
resolved_path = os.path.join(output_path, f"{sketch_name}{expected_ext}")
|
||||
return True, resolved_path, ""
|
||||
|
||||
# If path has an extension, it's a file - validate the extension
|
||||
if "." in os.path.basename(output_path):
|
||||
_, ext = os.path.splitext(output_path)
|
||||
if ext != expected_ext:
|
||||
return (
|
||||
False,
|
||||
"",
|
||||
f"Output file extension '{ext}' doesn't match expected '{expected_ext}' for board '{board.board_name}'",
|
||||
)
|
||||
return True, output_path, ""
|
||||
|
||||
# Path doesn't end with / and has no extension - treat as directory
|
||||
resolved_path = os.path.join(output_path, f"{sketch_name}{expected_ext}")
|
||||
return True, resolved_path, ""
|
||||
|
||||
|
||||
def copy_build_artifact(
|
||||
build_dir: Path, board: Board, sketch_name: str, output_path: str
|
||||
) -> bool:
|
||||
"""Copy the build artifact to the specified output path.
|
||||
|
||||
Args:
|
||||
build_dir: Build directory path
|
||||
board: Board configuration
|
||||
sketch_name: Name of the sketch
|
||||
output_path: Target output path
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
|
||||
expected_ext = get_board_artifact_extension(board)
|
||||
|
||||
# Find the source artifact
|
||||
# PlatformIO builds are in .build/pio/{board}/.pio/build/{board}/firmware.{ext}
|
||||
artifact_dir = build_dir / ".pio" / "build" / board.board_name
|
||||
source_artifact = artifact_dir / f"firmware{expected_ext}"
|
||||
|
||||
if not source_artifact.exists():
|
||||
print(f"ERROR: Build artifact not found: {source_artifact}")
|
||||
return False
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path_obj = Path(output_path)
|
||||
output_path_obj.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
print(f"Copying {source_artifact} to {output_path}")
|
||||
shutil.copy2(source_artifact, output_path)
|
||||
print(f"✅ Build artifact saved to: {output_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to copy build artifact: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main function."""
|
||||
args = parse_args()
|
||||
if args.verbose:
|
||||
os.environ["VERBOSE"] = "1"
|
||||
|
||||
if args.supported_boards:
|
||||
print(",".join(get_default_boards()))
|
||||
return 0
|
||||
|
||||
# Determine which boards to compile for
|
||||
if args.boards:
|
||||
boards_names = args.boards.split(",")
|
||||
else:
|
||||
boards_names = get_default_boards()
|
||||
|
||||
# Get board objects
|
||||
boards: List[Board] = []
|
||||
for board_name in boards_names:
|
||||
try:
|
||||
board = create_board(board_name, no_project_options=False)
|
||||
boards.append(board)
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to get board '{board_name}': {e}")
|
||||
return 1
|
||||
|
||||
# Determine which examples to compile
|
||||
if args.positional_examples:
|
||||
# Convert positional examples, handling both "examples/Blink" and "Blink" formats
|
||||
examples: List[str] = []
|
||||
for example in args.positional_examples:
|
||||
# Remove "examples/" prefix if present
|
||||
if example.startswith("examples/"):
|
||||
example = example[len("examples/") :]
|
||||
examples.append(example)
|
||||
elif args.examples:
|
||||
examples = args.examples.split(",")
|
||||
else:
|
||||
# Compile all available examples since builds are fast now!
|
||||
examples = get_all_examples()
|
||||
|
||||
# Process example exclusions
|
||||
if args.exclude_examples:
|
||||
exclude_examples = args.exclude_examples.split(",")
|
||||
examples = [ex for ex in examples if ex not in exclude_examples]
|
||||
|
||||
# Validate examples exist
|
||||
for example in examples:
|
||||
try:
|
||||
resolve_example_path(example)
|
||||
except FileNotFoundError as e:
|
||||
print(f"ERROR: {e}")
|
||||
return 1
|
||||
|
||||
# Validate -o/--out option requirements
|
||||
if args.out:
|
||||
if len(examples) != 1:
|
||||
print(
|
||||
f"ERROR: -o/--out option requires exactly one sketch, got {len(examples)}: {examples}"
|
||||
)
|
||||
return 1
|
||||
|
||||
if len(boards) != 1:
|
||||
print(
|
||||
f"ERROR: -o/--out option requires exactly one board, got {len(boards)}: {[b.board_name for b in boards]}"
|
||||
)
|
||||
return 1
|
||||
|
||||
# Validate the output path
|
||||
sketch_name = examples[0]
|
||||
board = boards[0]
|
||||
is_valid, resolved_output_path, error_msg = validate_output_path(
|
||||
args.out, sketch_name, board
|
||||
)
|
||||
if not is_valid:
|
||||
print(f"ERROR: {error_msg}")
|
||||
return 1
|
||||
|
||||
# Set up defines
|
||||
defines: List[str] = []
|
||||
if args.defines:
|
||||
defines.extend(args.defines.split(","))
|
||||
|
||||
# Start compilation
|
||||
start_time = time.time()
|
||||
print(
|
||||
f"Starting compilation for {len(boards)} boards with {len(examples)} examples"
|
||||
)
|
||||
|
||||
compilation_errors: List[str] = []
|
||||
failed_example_names: List[str] = []
|
||||
failure_logs_dir: Optional[Path] = (
|
||||
Path(args.log_failures) if getattr(args, "log_failures", None) else None
|
||||
)
|
||||
|
||||
# Compile for each board
|
||||
for board in boards:
|
||||
# Parse global cache directory if provided
|
||||
global_cache_dir = None
|
||||
if args.global_cache:
|
||||
global_cache_dir = Path(args.global_cache)
|
||||
|
||||
result = compile_board_examples(
|
||||
board=board,
|
||||
examples=examples,
|
||||
defines=defines,
|
||||
verbose=args.verbose,
|
||||
enable_cache=(args.enable_cache or args.cache) and not args.no_cache,
|
||||
global_cache_dir=global_cache_dir,
|
||||
)
|
||||
|
||||
if not result.ok:
|
||||
# Record board-level error
|
||||
compilation_errors.append(f"Board {board.board_name} failed")
|
||||
print(red_text(f"ERROR: Compilation failed for board {board.board_name}"))
|
||||
# Print each failing sketch's stdout and collect names for summary
|
||||
for sketch in result.sketch_results:
|
||||
if not sketch.success:
|
||||
if sketch.example and sketch.example not in failed_example_names:
|
||||
failed_example_names.append(sketch.example)
|
||||
# Write per-example failure logs when requested
|
||||
if failure_logs_dir is not None:
|
||||
try:
|
||||
failure_logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = f"{sketch.example}.log"
|
||||
log_path = failure_logs_dir / safe_name
|
||||
with log_path.open(
|
||||
"a", encoding="utf-8", errors="ignore"
|
||||
) as f:
|
||||
f.write(sketch.output)
|
||||
f.write("\n")
|
||||
except KeyboardInterrupt:
|
||||
print("Keyboard interrupt detected, cancelling builds")
|
||||
import _thread
|
||||
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Warning: Could not write failure log for {sketch.example}: {e}"
|
||||
)
|
||||
print(f"\n{'-' * 60}")
|
||||
print(f"Sketch: {sketch.example}")
|
||||
print(f"{'-' * 60}")
|
||||
# Print the collected output for this sketch
|
||||
print(sketch.output)
|
||||
# Continue with other boards instead of stopping
|
||||
else:
|
||||
# Compilation succeeded - handle -o/--out option if specified
|
||||
if args.out:
|
||||
sketch_name = examples[0] # We already validated there's exactly one
|
||||
is_valid, resolved_output_path, _ = validate_output_path(
|
||||
args.out, sketch_name, board
|
||||
)
|
||||
if is_valid:
|
||||
# Find the build directory for this board
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
build_dir = project_root / ".build" / "pio" / board.board_name
|
||||
|
||||
if not copy_build_artifact(
|
||||
build_dir, board, sketch_name, resolved_output_path
|
||||
):
|
||||
compilation_errors.append(
|
||||
f"Failed to copy artifact for {board.board_name}"
|
||||
)
|
||||
print(
|
||||
red_text(
|
||||
f"ERROR: Failed to copy build artifact for {board.board_name}"
|
||||
)
|
||||
)
|
||||
return 1
|
||||
|
||||
# Report results
|
||||
elapsed_time = time.time() - start_time
|
||||
time_str = time.strftime("%Mm:%Ss", time.gmtime(elapsed_time))
|
||||
|
||||
if compilation_errors:
|
||||
print(
|
||||
f"\nCompilation finished in {time_str} with {len(compilation_errors)} error(s):"
|
||||
)
|
||||
for error in compilation_errors:
|
||||
print(f" - {error}")
|
||||
if failed_example_names:
|
||||
print("")
|
||||
print(red_text(f"ERROR! There were {len(failed_example_names)} failures:"))
|
||||
# Sort for stable output
|
||||
for name in sorted(failed_example_names):
|
||||
# print(f" {name}")
|
||||
# same but in red
|
||||
print(red_text(f" {name}"))
|
||||
print("")
|
||||
return 1
|
||||
else:
|
||||
print(f"\nAll compilations completed successfully in {time_str}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user")
|
||||
sys.exit(1)
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,134 @@
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MINIMUM_REPORT_SEVERTIY = "medium"
|
||||
MINIMUM_FAIL_SEVERTIY = "high"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Run cppcheck on the project")
|
||||
parser.add_argument("board", nargs="?", help="Board to check, optional")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def find_platformio_project_dir(build_root: Path, board_name: str) -> Path | None:
|
||||
"""Find a directory containing platformio.ini file for the specified board.
|
||||
|
||||
Handles both old and new build directory structures:
|
||||
Old: .build/uno/Blink/platformio.ini
|
||||
New: .build/fled/examples/uno/Blink/platformio.ini
|
||||
|
||||
Returns the directory containing platformio.ini, or None if not found.
|
||||
"""
|
||||
if not build_root.exists():
|
||||
return None
|
||||
|
||||
# Search patterns for different build structures
|
||||
search_patterns = [
|
||||
# New structure: .build/fled/examples/{board}/**/*.ini
|
||||
build_root / "fled" / "examples" / board_name,
|
||||
# Old structure: .build/{board}/**/*.ini
|
||||
build_root / board_name,
|
||||
# Fallback: search all subdirectories for board name
|
||||
]
|
||||
|
||||
# Try each search pattern
|
||||
for base_dir in search_patterns:
|
||||
if not base_dir.exists():
|
||||
continue
|
||||
|
||||
# Look for platformio.ini in this directory and its subdirectories
|
||||
project_dir = find_platformio_ini_in_tree(base_dir)
|
||||
if project_dir:
|
||||
return project_dir
|
||||
|
||||
# Last resort: search the entire build directory for board-related platformio.ini
|
||||
for platformio_ini in build_root.rglob("platformio.ini"):
|
||||
# Check if this platformio.ini is for our board by looking at the path
|
||||
if board_name in str(platformio_ini):
|
||||
print(f"Found platformio.ini in {platformio_ini.parent}")
|
||||
return platformio_ini.parent
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_platformio_ini_in_tree(base_dir: Path) -> Path | None:
|
||||
"""Recursively search for platformio.ini in a directory tree."""
|
||||
if not base_dir.exists():
|
||||
return None
|
||||
|
||||
# Check direct subdirectories first (most common case)
|
||||
for subdir in base_dir.iterdir():
|
||||
if subdir.is_dir():
|
||||
platformio_ini = subdir / "platformio.ini"
|
||||
if platformio_ini.exists():
|
||||
print(f"Found platformio.ini in {subdir}")
|
||||
return subdir
|
||||
|
||||
# Check if there's a platformio.ini directly in the base directory
|
||||
if (base_dir / "platformio.ini").exists():
|
||||
print(f"Found platformio.ini directly in {base_dir}")
|
||||
return base_dir
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
here = Path(__file__).parent
|
||||
project_root = here.parent
|
||||
build = project_root / ".build"
|
||||
|
||||
if not build.exists():
|
||||
print(f"Build directory {build} not found")
|
||||
return 1
|
||||
|
||||
if args.board:
|
||||
# Search for the specified board
|
||||
project_dir = find_platformio_project_dir(build, args.board)
|
||||
if not project_dir:
|
||||
print(f"No platformio.ini found for board '{args.board}'")
|
||||
print("This usually means the board hasn't been compiled yet.")
|
||||
print(f"Try running: uv run ci/ci-compile.py {args.board} --examples Blink")
|
||||
return 1
|
||||
else:
|
||||
# Auto-detect available board by searching for any platformio.ini
|
||||
project_dir = None
|
||||
|
||||
# Try to find any platformio.ini file in the build directory
|
||||
for platformio_ini in build.rglob("platformio.ini"):
|
||||
project_dir = platformio_ini.parent
|
||||
print(f"Auto-detected project directory: {project_dir}")
|
||||
break
|
||||
|
||||
if not project_dir:
|
||||
print(f"No platformio.ini files found in {build}")
|
||||
print("This usually means no boards have been compiled yet.")
|
||||
print("Try running: uv run ci/ci-compile.py uno --examples Blink")
|
||||
return 1
|
||||
|
||||
print(f"Running pio check in {project_dir}")
|
||||
os.chdir(str(project_dir))
|
||||
|
||||
# Run pio check command
|
||||
cp = subprocess.run(
|
||||
[
|
||||
"pio",
|
||||
"check",
|
||||
"--skip-packages",
|
||||
"--src-filters=+<src/>",
|
||||
f"--severity={MINIMUM_REPORT_SEVERTIY}",
|
||||
f"--fail-on-defect={MINIMUM_FAIL_SEVERTIY}",
|
||||
"--flags",
|
||||
"--inline-suppr",
|
||||
],
|
||||
)
|
||||
return cp.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,6 @@
|
||||
# flake8: noqa
|
||||
# ruff: skip
|
||||
|
||||
Import("env") # type: ignore
|
||||
|
||||
env.Append(CXXFLAGS=["-Wno-register"]) # type: ignore
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run include-what-you-use on the project"
|
||||
)
|
||||
parser.add_argument("board", nargs="?", help="Board to check, optional")
|
||||
parser.add_argument(
|
||||
"--fix",
|
||||
action="store_true",
|
||||
help="Automatically apply suggested fixes using fix_includes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mapping-file",
|
||||
action="append",
|
||||
help="Additional mapping file to use (can be specified multiple times)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-line-length",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Maximum line length for suggestions (default: 100)",
|
||||
)
|
||||
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def find_platformio_project_dir(board_dir: Path) -> Path | None:
|
||||
"""Find a directory containing platformio.ini file in the board's build directory.
|
||||
|
||||
With the new pio ci build system, the structure is:
|
||||
.build/uno/Blink/platformio.ini
|
||||
.build/uno/SomeExample/platformio.ini
|
||||
|
||||
We need to find one of these example directories that has a platformio.ini file.
|
||||
"""
|
||||
if not board_dir.exists():
|
||||
return None
|
||||
|
||||
# Look for subdirectories containing platformio.ini
|
||||
for subdir in board_dir.iterdir():
|
||||
if subdir.is_dir():
|
||||
platformio_ini = subdir / "platformio.ini"
|
||||
if platformio_ini.exists():
|
||||
print(f"Found platformio.ini in {subdir}")
|
||||
return subdir
|
||||
|
||||
# Fallback: check if there's a platformio.ini directly in the board directory (old system)
|
||||
if (board_dir / "platformio.ini").exists():
|
||||
print(f"Found platformio.ini directly in {board_dir}")
|
||||
return board_dir
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def check_iwyu_available() -> bool:
|
||||
"""Check if include-what-you-use is available in the system"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["include-what-you-use", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (
|
||||
subprocess.CalledProcessError,
|
||||
FileNotFoundError,
|
||||
subprocess.TimeoutExpired,
|
||||
):
|
||||
return False
|
||||
|
||||
|
||||
def run_iwyu_on_cpp_tests(args: argparse.Namespace) -> int:
|
||||
"""Run IWYU on the C++ test suite using CMake integration"""
|
||||
here = Path(__file__).parent
|
||||
project_root = here.parent
|
||||
|
||||
print("Running include-what-you-use on C++ test suite...")
|
||||
|
||||
# Use the existing test infrastructure with --check flag
|
||||
cmd = [
|
||||
"uv",
|
||||
"run",
|
||||
"test.py",
|
||||
"--cpp",
|
||||
"--check", # This enables IWYU via CMake
|
||||
"--clang",
|
||||
"--no-interactive",
|
||||
]
|
||||
|
||||
if args.verbose:
|
||||
cmd.append("--verbose")
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, cwd=project_root)
|
||||
return result.returncode
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"IWYU analysis failed with return code {e.returncode}")
|
||||
return e.returncode
|
||||
|
||||
|
||||
def run_iwyu_on_platformio_project(project_dir: Path, args: argparse.Namespace) -> int:
|
||||
"""Run IWYU on a PlatformIO project"""
|
||||
print(f"Running include-what-you-use in {project_dir}")
|
||||
os.chdir(str(project_dir))
|
||||
|
||||
# Build mapping file arguments
|
||||
mapping_args: List[str] = []
|
||||
|
||||
# Add FastLED mapping files if they exist
|
||||
project_root = project_dir
|
||||
while project_root.parent != project_root: # Find project root
|
||||
if (project_root / "ci" / "iwyu").exists():
|
||||
break
|
||||
project_root = project_root.parent
|
||||
|
||||
fastled_mapping = project_root / "ci" / "iwyu" / "fastled.imp"
|
||||
stdlib_mapping = project_root / "ci" / "iwyu" / "stdlib.imp"
|
||||
|
||||
if fastled_mapping.exists():
|
||||
mapping_args.extend(["--mapping_file", str(fastled_mapping)])
|
||||
|
||||
if stdlib_mapping.exists():
|
||||
mapping_args.extend(["--mapping_file", str(stdlib_mapping)])
|
||||
|
||||
# Add user-specified mapping files
|
||||
if args.mapping_file:
|
||||
for mapping in args.mapping_file:
|
||||
mapping_args.extend(["--mapping_file", mapping])
|
||||
|
||||
# Build IWYU command
|
||||
iwyu_cmd = [
|
||||
"include-what-you-use",
|
||||
f"--max_line_length={args.max_line_length}",
|
||||
"--quoted_includes_first",
|
||||
"--no_comments",
|
||||
]
|
||||
|
||||
if args.verbose:
|
||||
iwyu_cmd.append("--verbose=3")
|
||||
else:
|
||||
iwyu_cmd.append("--verbose=1")
|
||||
|
||||
iwyu_cmd.extend(mapping_args)
|
||||
|
||||
# Run through PlatformIO's check system with IWYU
|
||||
pio_cmd = [
|
||||
"pio",
|
||||
"check",
|
||||
"--skip-packages",
|
||||
"--src-filters=+<src/>",
|
||||
"--tool=include-what-you-use",
|
||||
"--flags",
|
||||
] + iwyu_cmd[1:] # Skip the include-what-you-use binary name
|
||||
|
||||
try:
|
||||
result = subprocess.run(pio_cmd)
|
||||
return result.returncode
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"PlatformIO IWYU check failed with return code {e.returncode}")
|
||||
return e.returncode
|
||||
|
||||
|
||||
def apply_iwyu_fixes(source_dir: Path) -> int:
|
||||
"""Apply IWYU fixes using fix_includes tool"""
|
||||
try:
|
||||
# Check if fix_includes is available
|
||||
subprocess.run(["fix_includes", "--help"], capture_output=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print(
|
||||
"Warning: fix_includes tool not found. Install IWYU tools to use --fix option."
|
||||
)
|
||||
return 1
|
||||
|
||||
# Find all .h and .cpp files
|
||||
cpp_files: List[Path] = []
|
||||
for pattern in ["**/*.cpp", "**/*.h", "**/*.hpp"]:
|
||||
cpp_files.extend(source_dir.glob(pattern))
|
||||
|
||||
if not cpp_files:
|
||||
print("No C++ files found to fix")
|
||||
return 0
|
||||
|
||||
print(f"Applying IWYU fixes to {len(cpp_files)} files...")
|
||||
|
||||
# Run fix_includes on the source directory
|
||||
cmd = ["fix_includes", "--update_comments", str(source_dir)]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode == 0:
|
||||
print("IWYU fixes applied successfully")
|
||||
else:
|
||||
print(f"fix_includes failed with return code {result.returncode}")
|
||||
return result.returncode
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Failed to apply IWYU fixes: {e}")
|
||||
return e.returncode
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
here = Path(__file__).parent
|
||||
project_root = here.parent
|
||||
|
||||
# Check if IWYU is available
|
||||
if not check_iwyu_available():
|
||||
print("Error: include-what-you-use not found in PATH")
|
||||
print("Install it with:")
|
||||
print(" Ubuntu/Debian: sudo apt install iwyu")
|
||||
print(" macOS: brew install include-what-you-use")
|
||||
print(" Or build from source: https://include-what-you-use.org/")
|
||||
return 1
|
||||
|
||||
print("Found include-what-you-use")
|
||||
|
||||
# If no board specified, run on C++ test suite
|
||||
if not args.board:
|
||||
print("No board specified, running IWYU on C++ test suite")
|
||||
return run_iwyu_on_cpp_tests(args)
|
||||
|
||||
# Run on specific board
|
||||
build = project_root / ".build"
|
||||
|
||||
if not build.exists():
|
||||
print(f"Build directory {build} not found")
|
||||
print("Run a compilation first: ./compile [board] --examples [example]")
|
||||
return 1
|
||||
|
||||
board_dir = build / args.board
|
||||
if not board_dir.exists():
|
||||
print(f"Board {args.board} not found in {build}")
|
||||
print("Available boards:")
|
||||
for d in build.iterdir():
|
||||
if d.is_dir():
|
||||
print(f" {d.name}")
|
||||
return 1
|
||||
|
||||
project_dir = find_platformio_project_dir(board_dir)
|
||||
if not project_dir:
|
||||
print(f"No platformio.ini found in {board_dir} or its subdirectories")
|
||||
print("This usually means the board hasn't been compiled yet.")
|
||||
print(f"Try running: ./compile {args.board} --examples Blink")
|
||||
return 1
|
||||
|
||||
# Run IWYU on the PlatformIO project
|
||||
result = run_iwyu_on_platformio_project(project_dir, args)
|
||||
|
||||
# Apply fixes if requested and analysis succeeded
|
||||
if args.fix and result == 0:
|
||||
src_dir = project_dir / "src"
|
||||
if src_dir.exists():
|
||||
fix_result = apply_iwyu_fixes(src_dir)
|
||||
if fix_result != 0:
|
||||
result = fix_result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Fingerprint Cache Feature
|
||||
|
||||
A two-layer file change detection system that efficiently determines if source files
|
||||
have been modified by combining fast modification time checks with slower but accurate
|
||||
MD5 hash verification.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheEntry:
|
||||
"""Cache entry storing file modification time and content hash."""
|
||||
|
||||
modification_time: float
|
||||
md5_hash: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FingerprintCacheConfig:
|
||||
"""Configuration for fingerprint cache behavior."""
|
||||
|
||||
cache_file: Path
|
||||
hash_algorithm: str
|
||||
ignore_patterns: list[str]
|
||||
max_cache_size: int
|
||||
|
||||
|
||||
class FingerprintCache:
|
||||
"""
|
||||
Two-layer file change detection using modification time and content hashing.
|
||||
|
||||
Provides efficient change detection by:
|
||||
1. Fast modification time comparison (microsecond performance)
|
||||
2. Accurate content verification via MD5 hashing when needed
|
||||
"""
|
||||
|
||||
def __init__(self, cache_file: Path, modtime_only: bool = False):
|
||||
"""
|
||||
Initialize fingerprint cache.
|
||||
|
||||
Args:
|
||||
cache_file: Path to JSON cache file
|
||||
modtime_only: When True, disable hashing and rely solely on modtime.
|
||||
"""
|
||||
self.cache_file = cache_file
|
||||
self._modtime_only: bool = modtime_only
|
||||
self.cache = self._load_cache()
|
||||
|
||||
def _load_cache(self) -> Dict[str, CacheEntry]:
|
||||
"""
|
||||
Load cache from JSON file, return empty dict if file doesn't exist.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping file paths to cache entries
|
||||
"""
|
||||
if not self.cache_file.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(self.cache_file, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Convert JSON dict to CacheEntry objects
|
||||
cache: Dict[str, CacheEntry] = {}
|
||||
for file_path, entry_data in data.items():
|
||||
cache[file_path] = CacheEntry(
|
||||
modification_time=entry_data["modification_time"],
|
||||
md5_hash=entry_data["md5_hash"],
|
||||
)
|
||||
return cache
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
# Cache corrupted - start fresh
|
||||
return {}
|
||||
|
||||
def _save_cache(self) -> None:
|
||||
"""Save current cache state to JSON file."""
|
||||
# Ensure cache directory exists
|
||||
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Convert CacheEntry objects to JSON-serializable dict
|
||||
data = {}
|
||||
for file_path, entry in self.cache.items():
|
||||
data[file_path] = {
|
||||
"modification_time": entry.modification_time,
|
||||
"md5_hash": entry.md5_hash,
|
||||
}
|
||||
|
||||
with open(self.cache_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def _compute_md5(self, file_path: Path) -> str:
|
||||
"""
|
||||
Compute MD5 hash of file content.
|
||||
|
||||
Args:
|
||||
file_path: Path to file to hash
|
||||
|
||||
Returns:
|
||||
MD5 hash as hexadecimal string
|
||||
|
||||
Raises:
|
||||
IOError: If file cannot be read
|
||||
"""
|
||||
try:
|
||||
hasher = hashlib.md5()
|
||||
with open(file_path, "rb") as f:
|
||||
# Read in chunks to handle large files efficiently
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
except IOError as e:
|
||||
raise IOError(f"Cannot read file {file_path}: {e}")
|
||||
|
||||
def has_changed(self, src_path: Path, previous_modtime: float) -> bool:
|
||||
"""
|
||||
Determine if a source file has changed since the last known modification time.
|
||||
|
||||
Two-layer verification process:
|
||||
1. Fast modification time check - if times match, return False immediately
|
||||
2. Content verification - compute/use cached MD5 hash for accurate comparison
|
||||
|
||||
Args:
|
||||
src_path: Path to the source file to check
|
||||
previous_modtime: Previously known modification time (Unix timestamp)
|
||||
|
||||
Returns:
|
||||
True if the file has changed, False if unchanged
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If source file doesn't exist
|
||||
"""
|
||||
if not src_path.exists():
|
||||
raise FileNotFoundError(f"Source file not found: {src_path}")
|
||||
|
||||
current_modtime = os.path.getmtime(src_path)
|
||||
|
||||
# Optional mode: strictly use modification time only (no hashing)
|
||||
# This is required for toolchains that invalidate PCH on any newer
|
||||
# dependency regardless of content changes (e.g., Clang).
|
||||
if self._modtime_only:
|
||||
# Treat as changed only when file is newer than the reference time
|
||||
# (keeps behavior stable when reference is an external artifact's mtime)
|
||||
return current_modtime > previous_modtime
|
||||
|
||||
# Layer 1: Quick modification time check
|
||||
if current_modtime == previous_modtime:
|
||||
return False # No change detected
|
||||
|
||||
file_key = str(src_path.resolve()) # Use absolute path as key
|
||||
|
||||
# Layer 2: Content verification via hash comparison
|
||||
if file_key in self.cache:
|
||||
cached_entry = self.cache[file_key]
|
||||
previous_hash = (
|
||||
cached_entry.md5_hash
|
||||
) # Store previous hash before potential update
|
||||
|
||||
if current_modtime == cached_entry.modification_time:
|
||||
# File is cached with current modtime - use cached hash
|
||||
current_hash = cached_entry.md5_hash
|
||||
else:
|
||||
# Cache is stale - compute new hash
|
||||
current_hash = self._compute_md5(src_path)
|
||||
self._update_cache_entry(src_path, current_modtime, current_hash)
|
||||
|
||||
# Compare current hash with previous cached hash
|
||||
# If they match, content hasn't actually changed despite modtime difference
|
||||
return current_hash != previous_hash
|
||||
|
||||
else:
|
||||
# File not in cache - compute hash and cache it
|
||||
current_hash = self._compute_md5(src_path)
|
||||
self._update_cache_entry(src_path, current_modtime, current_hash)
|
||||
return True # Assume changed since we have no previous state to compare
|
||||
|
||||
def _update_cache_entry(
|
||||
self, file_path: Path, modification_time: float, md5_hash: str
|
||||
) -> None:
|
||||
"""
|
||||
Update cache with new entry and save to disk.
|
||||
|
||||
Args:
|
||||
file_path: Path to file being cached
|
||||
modification_time: File modification time
|
||||
md5_hash: File content hash
|
||||
"""
|
||||
file_key = str(file_path.resolve())
|
||||
self.cache[file_key] = CacheEntry(
|
||||
modification_time=modification_time, md5_hash=md5_hash
|
||||
)
|
||||
self._save_cache()
|
||||
|
||||
def get_cache_stats(self) -> dict[str, int]:
|
||||
"""
|
||||
Get cache statistics.
|
||||
|
||||
Returns:
|
||||
Dictionary with cache size and other metrics
|
||||
"""
|
||||
return {
|
||||
"total_entries": len(self.cache),
|
||||
"cache_file_exists": self.cache_file.exists(),
|
||||
"cache_file_size_bytes": self.cache_file.stat().st_size
|
||||
if self.cache_file.exists()
|
||||
else 0,
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear all cache entries and remove cache file."""
|
||||
self.cache.clear()
|
||||
if self.cache_file.exists():
|
||||
self.cache_file.unlink()
|
||||
|
||||
|
||||
def has_changed(src_path: Path, previous_modtime: float, cache_file: Path) -> bool:
|
||||
"""
|
||||
Convenience function implementing the main API.
|
||||
|
||||
Args:
|
||||
src_path: Path to the source file to check
|
||||
previous_modtime: Previously known modification time (Unix timestamp)
|
||||
cache_file: Path to the fingerprint cache file
|
||||
|
||||
Returns:
|
||||
True if the file has changed, False if unchanged
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If source file doesn't exist
|
||||
"""
|
||||
cache = FingerprintCache(cache_file)
|
||||
return cache.has_changed(src_path, previous_modtime)
|
||||
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Compile Commands Generator
|
||||
|
||||
Generates a compile_commands.json for clangd/clang-tidy based on explicit
|
||||
tools/flags configuration. See FEATURE.md for usage and rationale.
|
||||
|
||||
Key behaviors:
|
||||
- Reads configuration from ci/build_commands.toml
|
||||
- Enumerates library source files under src/ (respecting optional filters.exclude_dirs)
|
||||
- Emits one entry per translation unit with accurate arguments/command fields
|
||||
|
||||
Notes:
|
||||
- This script intentionally avoids project-unit-test driven flags to keep
|
||||
IntelliSense consistent with the core FastLED library build.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import _thread
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Require Python 3.11+ environment for TOML parsing
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, cast
|
||||
|
||||
# Require typeguard; no try-import fallbacks
|
||||
from typeguard import typechecked
|
||||
|
||||
# Use the canonical BuildFlags/Compiler tooling
|
||||
from ci.compiler.clang_compiler import commands_json
|
||||
|
||||
|
||||
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
|
||||
# Default to bespoke config for compile-commands generation
|
||||
DEFAULT_CONFIG_PATH: Path = PROJECT_ROOT / "ci" / "build_commands.toml"
|
||||
|
||||
|
||||
@typechecked
|
||||
@dataclass
|
||||
class CliConfig:
|
||||
build_flags_toml: Path
|
||||
exclude_dirs: list[str]
|
||||
|
||||
|
||||
@typechecked
|
||||
@dataclass
|
||||
class Args:
|
||||
output: Path
|
||||
clean: bool
|
||||
verbose: bool
|
||||
config: Path
|
||||
exclude: list[str]
|
||||
|
||||
|
||||
def parse_args(args: Optional[list[str]] = None) -> Args:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Generate compile_commands.json for clangd/clang-tidy based on explicit build configuration."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
dest="output",
|
||||
metavar="PATH",
|
||||
help="Output path for compile_commands.json (default: project root)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
dest="config",
|
||||
metavar="PATH",
|
||||
help=(
|
||||
"Path to BuildFlags-style TOML (default: ci/build_example.toml). "
|
||||
"Must be parseable by BuildFlags.parse()."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean",
|
||||
dest="clean",
|
||||
action="store_true",
|
||||
help="Rebuild command set from scratch (ignored currently; reserved)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
dest="verbose",
|
||||
action="store_true",
|
||||
help="Print collected flags/files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exclude-dir",
|
||||
dest="exclude",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Relative directory under project root to exclude (can be used multiple times)",
|
||||
)
|
||||
|
||||
parsed = parser.parse_args(args)
|
||||
|
||||
output_path_str: Optional[str] = None
|
||||
if hasattr(parsed, "output"):
|
||||
output_path_str = parsed.output
|
||||
|
||||
if output_path_str is None or len(output_path_str) == 0:
|
||||
# Default to project root compile_commands.json
|
||||
output_path = PROJECT_ROOT / "compile_commands.json"
|
||||
else:
|
||||
output_path = Path(output_path_str).resolve()
|
||||
|
||||
clean_value: bool = False
|
||||
if hasattr(parsed, "clean"):
|
||||
clean_value = bool(parsed.clean)
|
||||
|
||||
verbose_value: bool = False
|
||||
if hasattr(parsed, "verbose"):
|
||||
verbose_value = bool(parsed.verbose)
|
||||
|
||||
# Config path
|
||||
config_path: Path
|
||||
if getattr(parsed, "config", None):
|
||||
config_path = Path(parsed.config).resolve()
|
||||
else:
|
||||
config_path = DEFAULT_CONFIG_PATH
|
||||
|
||||
# Excluded directories
|
||||
exclude_list: list[str] = list(getattr(parsed, "exclude", []) or [])
|
||||
|
||||
return Args(
|
||||
output=output_path,
|
||||
clean=clean_value,
|
||||
verbose=verbose_value,
|
||||
config=config_path,
|
||||
exclude=exclude_list,
|
||||
)
|
||||
|
||||
|
||||
def _assert_config_exists(config_path: Path) -> None:
|
||||
if not config_path.exists():
|
||||
raise RuntimeError(
|
||||
f"CRITICAL: Required BuildFlags TOML not found at {config_path}. "
|
||||
f"Please provide a valid configuration parseable by BuildFlags.parse()."
|
||||
)
|
||||
|
||||
|
||||
def _create_cli_config(build_flags_toml: Path, exclude: list[str]) -> CliConfig:
|
||||
return CliConfig(build_flags_toml=build_flags_toml, exclude_dirs=exclude)
|
||||
|
||||
|
||||
def _collect_source_files(root: Path, exclude: list[str], verbose: bool) -> list[Path]:
|
||||
source_files: list[Path] = []
|
||||
|
||||
# Compute excluded directories as absolute paths for quick prefix checks
|
||||
excluded_dirs_abs: list[Path] = []
|
||||
for ex in exclude:
|
||||
p = (PROJECT_ROOT / ex).resolve()
|
||||
excluded_dirs_abs.append(p)
|
||||
|
||||
src_root: Path = PROJECT_ROOT / "src"
|
||||
if not src_root.exists():
|
||||
raise RuntimeError(
|
||||
f"CRITICAL: Expected source root at {src_root} not found. "
|
||||
f"This path is required to enumerate translation units."
|
||||
)
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(src_root):
|
||||
current_dir = Path(dirpath).resolve()
|
||||
|
||||
# Apply directory exclusions
|
||||
skip_dir: bool = False
|
||||
for ex in excluded_dirs_abs:
|
||||
try:
|
||||
current_dir.relative_to(ex)
|
||||
skip_dir = True
|
||||
break
|
||||
except ValueError:
|
||||
# Not under this excluded path
|
||||
pass
|
||||
|
||||
if skip_dir:
|
||||
# Prune search by clearing dirnames to avoid descending further
|
||||
del dirnames[:]
|
||||
continue
|
||||
|
||||
for fname in filenames:
|
||||
if fname.endswith(".cpp") or fname.endswith(".c"):
|
||||
source_files.append(current_dir / fname)
|
||||
|
||||
if verbose:
|
||||
print(f"Discovered {len(source_files)} source files under {src_root}")
|
||||
|
||||
if len(source_files) == 0:
|
||||
raise RuntimeError(
|
||||
"CRITICAL: No source files discovered under 'src/'. "
|
||||
"Please verify the repository layout and filters.exclude_dirs configuration."
|
||||
)
|
||||
|
||||
return source_files
|
||||
|
||||
# No per-file argument composition here; delegated to commands_json()
|
||||
# This function intentionally removed to rely on canonical toolchain
|
||||
|
||||
# Emission handled by commands_json()
|
||||
|
||||
|
||||
def main(entry_args: Optional[list[str]] = None) -> int:
|
||||
args = parse_args(entry_args)
|
||||
|
||||
# Log startup context
|
||||
print("[compile-commands] Starting generation")
|
||||
print(f"[compile-commands] Project root: {PROJECT_ROOT}")
|
||||
print(f"[compile-commands] Config (BuildFlags TOML): {args.config}")
|
||||
print(f"[compile-commands] Output path: {args.output}")
|
||||
if args.exclude:
|
||||
print(f"[compile-commands] Excluding directories: {args.exclude}")
|
||||
else:
|
||||
print("[compile-commands] No excluded directories configured")
|
||||
|
||||
# Determine BuildFlags TOML path
|
||||
_assert_config_exists(args.config)
|
||||
|
||||
# Collect source files
|
||||
source_root = PROJECT_ROOT / "src"
|
||||
print(f"[compile-commands] Source root: {source_root}")
|
||||
sources = _collect_source_files(
|
||||
source_root,
|
||||
exclude=args.exclude,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
print(f"[compile-commands] Discovered {len(sources)} source files")
|
||||
|
||||
# Delegate to canonical commands_json() using BuildFlags toolchain
|
||||
print("[compile-commands] Generating compile_commands.json entries...")
|
||||
commands_json(
|
||||
config_path=args.config,
|
||||
include_root=source_root,
|
||||
sources=sources,
|
||||
output_json=args.output,
|
||||
quick_build=True,
|
||||
strict_mode=False,
|
||||
)
|
||||
|
||||
# Post-generation verification
|
||||
if not args.output.exists():
|
||||
print(
|
||||
f"CRITICAL: Generation completed but output file was not found at {args.output}"
|
||||
)
|
||||
return 2
|
||||
|
||||
try:
|
||||
text = args.output.read_text(encoding="utf-8")
|
||||
# Best-effort count of entries to report status
|
||||
import json as _json
|
||||
from typing import cast
|
||||
|
||||
entries = cast(list[dict[str, Any]], _json.loads(text))
|
||||
num_entries = len(entries) if isinstance(entries, list) else 0
|
||||
print(
|
||||
f"[compile-commands] Wrote {num_entries} entries to {args.output} (size: {args.output.stat().st_size} bytes)"
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(
|
||||
f"WARNING: Output written to {args.output} but could not parse JSON for summary: {e}"
|
||||
)
|
||||
|
||||
if args.verbose:
|
||||
print("[compile-commands] Done.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
print("Operation interrupted by user")
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except FileNotFoundError as e:
|
||||
print(f"CRITICAL: Required file not found: {e}")
|
||||
sys.exit(1)
|
||||
except ValueError as e:
|
||||
print(f"CRITICAL: Invalid configuration value: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"CRITICAL: Unexpected error: {e}")
|
||||
raise
|
||||
@@ -0,0 +1,115 @@
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
|
||||
def _create_board_info(path: Path) -> Dict[str, Any]:
|
||||
build_info = json.loads(path.read_text())
|
||||
assert build_info.keys(), f"No boards found in {build_info}"
|
||||
assert len(build_info.keys()) == 1, (
|
||||
f"Multiple boards found in {build_info}, so correct board should be specified"
|
||||
)
|
||||
return build_info[next(iter(build_info))]
|
||||
|
||||
|
||||
def _find_build_info(board: str) -> Path:
|
||||
# Prefer PIO layout
|
||||
candidate = Path(".build") / "pio" / board / "build_info.json"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
# Fallback legacy layout
|
||||
candidate = Path(".build") / board / "build_info.json"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
raise FileNotFoundError(
|
||||
f"build_info.json not found for board '{board}' in .build/pio/{board} or .build/{board}"
|
||||
)
|
||||
|
||||
|
||||
def _run_pio_size(build_dir: Path) -> int | None:
|
||||
try:
|
||||
# Try to compute size without building first
|
||||
result = subprocess.run(
|
||||
["pio", "run", "-d", str(build_dir), "-t", "size"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
output = (result.stdout or "") + "\n" + (result.stderr or "")
|
||||
m = re.search(r"Program:\s*(\d+)\s*bytes", output)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
# If size target did not yield, try a full build then retry size
|
||||
subprocess.run(
|
||||
["pio", "run", "-d", str(build_dir)], capture_output=False, check=False
|
||||
)
|
||||
result = subprocess.run(
|
||||
["pio", "run", "-d", str(build_dir), "-t", "size"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
output = (result.stdout or "") + "\n" + (result.stderr or "")
|
||||
m = re.search(r"Program:\s*(\d+)\s*bytes", output)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def check_firmware_size(board: str) -> int:
|
||||
build_info_json = _find_build_info(board)
|
||||
board_info = _create_board_info(build_info_json)
|
||||
assert board_info, f"Board {board} not found in {build_info_json}"
|
||||
prog_path = Path(board_info["prog_path"]) # absolute or relative path
|
||||
base_path = prog_path.parent
|
||||
suffixes = [".bin", ".hex", ".uf2"]
|
||||
firmware: Path | None = None
|
||||
for suffix in suffixes:
|
||||
candidate = base_path / f"firmware{suffix}"
|
||||
if candidate.exists():
|
||||
firmware = candidate
|
||||
break
|
||||
if firmware is None:
|
||||
# As a fallback, if firmware.* doesn't exist, use ELF size (filesize) if present
|
||||
if prog_path.exists():
|
||||
return prog_path.stat().st_size
|
||||
# As a last resort, run PlatformIO size target in the build directory
|
||||
build_dir = build_info_json.parent
|
||||
size = _run_pio_size(build_dir)
|
||||
if size is not None:
|
||||
return size
|
||||
msg = (
|
||||
", ".join([f"firmware{suffix}" for suffix in suffixes])
|
||||
+ f" not found in {base_path}"
|
||||
)
|
||||
raise FileNotFoundError(msg)
|
||||
return firmware.stat().st_size
|
||||
|
||||
|
||||
def main(board: str):
|
||||
try:
|
||||
size = check_firmware_size(board)
|
||||
print(f"Firmware size for {board}: {size} bytes")
|
||||
except FileNotFoundError as e:
|
||||
print(f"Error: {e}")
|
||||
except json.JSONDecodeError:
|
||||
print(f"Error: Unable to parse build_info.json for {board}")
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check FastLED firmware size for the specified board."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--board", type=str, required=True, help="Board to check firmware size for"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
main(args.board)
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import dateutil.parser # type: ignore
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def run_command(command: str):
|
||||
process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True
|
||||
)
|
||||
output, error = process.communicate()
|
||||
return output.decode("utf-8"), error.decode("utf-8")
|
||||
|
||||
|
||||
def step_back_commits(steps: int) -> bool:
|
||||
step_back_command = f"git reset --hard HEAD~{steps}"
|
||||
output, error = run_command(step_back_command)
|
||||
if error:
|
||||
print(f"Error stepping back {steps} commit(s): {error}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_firmware_size(board: str) -> int:
|
||||
root_build_dir = Path(".build") / board
|
||||
build_info_json = root_build_dir / "build_info.json"
|
||||
build_info = json.loads(build_info_json.read_text())
|
||||
board_info = build_info.get(board)
|
||||
assert board_info, f"Board {board} not found in {build_info_json}"
|
||||
prog_path = Path(board_info["prog_path"])
|
||||
base_path = prog_path.parent
|
||||
suffixes = [".bin", ".hex", ".uf2"]
|
||||
firmware: Path
|
||||
for suffix in suffixes:
|
||||
firmware = base_path / f"firmware{suffix}"
|
||||
if firmware.exists():
|
||||
break
|
||||
else:
|
||||
msg = (
|
||||
", ".join([f"firmware{suffix}" for suffix in suffixes])
|
||||
+ f" not found in {base_path}"
|
||||
)
|
||||
raise FileNotFoundError(msg)
|
||||
size_command = f"du -b {firmware}"
|
||||
output, error = run_command(size_command)
|
||||
if error:
|
||||
print(f"Error checking firmware size: {error}")
|
||||
return -1
|
||||
size_in_bytes = output.strip().split()[0]
|
||||
return int(size_in_bytes)
|
||||
|
||||
|
||||
def get_commit_hash() -> str | None:
|
||||
hash_command = "git rev-parse HEAD"
|
||||
output, error = run_command(hash_command)
|
||||
if error:
|
||||
print(f"Error getting commit hash: {error}")
|
||||
return None
|
||||
return output.strip()
|
||||
|
||||
|
||||
def get_commit_date(commit_hash: str) -> str | None:
|
||||
date_command = f"git show -s --format=%ci {commit_hash}"
|
||||
output, error = run_command(date_command)
|
||||
if error:
|
||||
print(f"Error getting commit date: {error}")
|
||||
return None
|
||||
return dateutil.parser.parse(output.strip()).isoformat()
|
||||
|
||||
|
||||
def main(
|
||||
board: str,
|
||||
num_commits: int,
|
||||
skip_step: int,
|
||||
start_commit: str | None = None,
|
||||
end_commit: str | None = None,
|
||||
):
|
||||
# change to the script dir
|
||||
os.chdir(str(HERE))
|
||||
# Create tmp directory if it doesn't exist
|
||||
|
||||
if os.path.exists("tmp"):
|
||||
shutil.rmtree("tmp")
|
||||
os.makedirs("tmp", exist_ok=True)
|
||||
|
||||
# Change to the tmp directory
|
||||
os.chdir("tmp")
|
||||
|
||||
# 1. Git clone FastLED repository
|
||||
print("Cloning FastLED repository...")
|
||||
clone_command = "git clone https://github.com/FastLED/FastLED.git"
|
||||
output, error = run_command(clone_command)
|
||||
# if error:
|
||||
# print(f"Error cloning repository: {error}")
|
||||
# os.chdir("..")
|
||||
# return
|
||||
|
||||
# Change to the FastLED directory
|
||||
os.chdir("FastLED")
|
||||
|
||||
# Checkout the latest commit
|
||||
run_command("git checkout master")
|
||||
|
||||
# If end_commit is specified, checkout that commit
|
||||
# if end_commit:
|
||||
# print(f"Checking out end commit: {end_commit}")
|
||||
# checkout_command = f"git checkout {end_commit}"
|
||||
# output, error = run_command(checkout_command)
|
||||
# #if error:
|
||||
# # print(f"Error checking out end commit: {error}")
|
||||
# # return
|
||||
|
||||
# Prepare CSV file
|
||||
csv_filename = "../../firmware_sizes.csv"
|
||||
with open(csv_filename, "w", newline="") as csvfile:
|
||||
csvwriter = csv.writer(csvfile)
|
||||
csvwriter.writerow(["datetime", "commit_hash", "binary_size"])
|
||||
|
||||
commits_checked = 0
|
||||
first_iteration = True
|
||||
while True:
|
||||
current_commit = get_commit_hash()
|
||||
|
||||
if first_iteration and start_commit:
|
||||
first_iteration = False
|
||||
while True:
|
||||
if current_commit == start_commit:
|
||||
break
|
||||
if not step_back_commits(1):
|
||||
break
|
||||
current_commit = get_commit_hash()
|
||||
|
||||
if num_commits and commits_checked >= num_commits:
|
||||
print(f"Checked {num_commits} commits")
|
||||
break
|
||||
|
||||
if end_commit and current_commit == end_commit:
|
||||
print(f"Checked until end commit: {end_commit}")
|
||||
break
|
||||
|
||||
# 2. Run ci-compile.py for current commit
|
||||
print(f"\nChecking commit {commits_checked + 1}")
|
||||
|
||||
# remove .build/esp32dev/pio/build/esp32dev/ directory
|
||||
board_files = Path(".build") / board / ".pio" / "build" / board
|
||||
if board_files.exists():
|
||||
shutil.rmtree(str(board_files), ignore_errors=True)
|
||||
compile_command = f"uv run -m ci.ci-compile {board} --examples Blink"
|
||||
output, error = run_command(compile_command)
|
||||
if error:
|
||||
print(f"Error running ci-compile.py: {error}")
|
||||
if not step_back_commits(skip_step):
|
||||
break
|
||||
continue
|
||||
|
||||
# 3. Check firmware size and get commit hash
|
||||
print("Checking firmware size...")
|
||||
try:
|
||||
size = check_firmware_size(board)
|
||||
except FileNotFoundError as e:
|
||||
print(f"Error checking firmware size: {e}")
|
||||
if not step_back_commits(skip_step):
|
||||
break
|
||||
continue
|
||||
except AssertionError as e:
|
||||
print(f"Error: {e}")
|
||||
if not step_back_commits(skip_step):
|
||||
break
|
||||
continue
|
||||
commit_hash = get_commit_hash()
|
||||
if size and commit_hash:
|
||||
commit_date = get_commit_date(commit_hash)
|
||||
print(f"Firmware size: {size} bytes")
|
||||
|
||||
# Write to CSV incrementally
|
||||
with open(csv_filename, "a", newline="") as csvfile:
|
||||
csvwriter = csv.writer(csvfile)
|
||||
csvwriter.writerow([commit_date, commit_hash, size])
|
||||
|
||||
print(f"Result appended to {csv_filename}")
|
||||
|
||||
commits_checked += 1
|
||||
|
||||
# 4. Step back one commit
|
||||
print("Stepping back 1 commit...")
|
||||
if not step_back_commits(1):
|
||||
break
|
||||
|
||||
# Don't remove the tmp directory
|
||||
print("\nTemporary directory 'tmp' has been left intact for inspection.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Check FastLED firmware size for multiple commits."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-commits", type=int, default=1, help="Number of commits to check"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-step",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of commits to skip between checks",
|
||||
)
|
||||
parser.add_argument("--start-commit", type=str, help="Starting commit hash")
|
||||
parser.add_argument("--end-commit", type=str, help="Ending commit hash")
|
||||
parser.add_argument(
|
||||
"--board", type=str, required=True, help="Board to check firmware size for"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.start_commit or args.end_commit:
|
||||
if not (args.start_commit and not args.end_commit):
|
||||
print("Both start commit and end commit must be specified.")
|
||||
exit(1)
|
||||
# if start_commit is specified, end_commit must be specified
|
||||
|
||||
num_commits = args.num_commits
|
||||
if args.start_commit and args.end_commit:
|
||||
if args.start_commit == args.end_commit:
|
||||
print("Start commit and end commit are the same.")
|
||||
exit(1)
|
||||
num_commits = 999999
|
||||
|
||||
main(args.board, num_commits, args.skip_step, args.start_commit, args.end_commit)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""
|
||||
FastLED Compiler Module
|
||||
|
||||
High-performance compilation system built on proven Python compiler API.
|
||||
"""
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FastLED Dynamic Library Builder
|
||||
Builds FastLED as a shared/dynamic library for unit testing
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from ci.compiler.clang_compiler import (
|
||||
BuildFlags,
|
||||
Compiler,
|
||||
CompilerOptions,
|
||||
Result,
|
||||
)
|
||||
from ci.util.paths import PROJECT_ROOT
|
||||
|
||||
|
||||
def build_fastled_dynamic_library(build_dir: Path) -> Path:
|
||||
"""Build FastLED as a dynamic library"""
|
||||
print("Building FastLED dynamic library...")
|
||||
|
||||
# Define library path with appropriate extension
|
||||
if sys.platform == "win32":
|
||||
fastled_lib_path = build_dir / "fastled.dll"
|
||||
else:
|
||||
fastled_lib_path = build_dir / "libfastled.so"
|
||||
|
||||
# Configure compiler with shared library flags
|
||||
settings = CompilerOptions(
|
||||
include_path=str(Path(PROJECT_ROOT) / "src"),
|
||||
defines=[
|
||||
"STUB_PLATFORM",
|
||||
"ARDUINO=10808",
|
||||
"FASTLED_USE_STUB_ARDUINO",
|
||||
"SKETCH_HAS_LOTS_OF_MEMORY=1",
|
||||
"FASTLED_STUB_IMPL",
|
||||
"FASTLED_FORCE_NAMESPACE=1",
|
||||
"FASTLED_TESTING",
|
||||
"FASTLED_NO_AUTO_NAMESPACE",
|
||||
"FASTLED_NO_PINMAP",
|
||||
"HAS_HARDWARE_PIN_SUPPORT",
|
||||
# "FASTLED_DEBUG_LEVEL=1",
|
||||
# "BUILDING_FASTLED_DLL", # New define for DLL exports
|
||||
# "FASTLED_EXPORT=__declspec(dllexport)" if sys.platform == "win32" else "FASTLED_EXPORT=", # DLL exports for Windows
|
||||
],
|
||||
std_version="c++17",
|
||||
compiler="clang++",
|
||||
compiler_args=[
|
||||
# NOTE: All compiler flags should come from build_unit.toml
|
||||
# Keep only platform-specific include paths and shared library flags
|
||||
"-I" + str(Path(PROJECT_ROOT) / "src/platforms/stub"),
|
||||
"-I" + str(Path(PROJECT_ROOT) / "tests"),
|
||||
# Add shared library flags - platform specific
|
||||
"-shared" if sys.platform != "win32" else "/DLL",
|
||||
"-fPIC" if sys.platform != "win32" else "",
|
||||
],
|
||||
use_pch=True,
|
||||
parallel=True,
|
||||
)
|
||||
|
||||
# Load build flags from TOML
|
||||
build_flags_path = Path(PROJECT_ROOT) / "ci" / "build_unit.toml"
|
||||
build_flags = BuildFlags.parse(
|
||||
build_flags_path, quick_build=False, strict_mode=False
|
||||
)
|
||||
|
||||
compiler = Compiler(settings, build_flags)
|
||||
|
||||
# Compile all FastLED source files
|
||||
fastled_src_dir = Path(PROJECT_ROOT) / "src"
|
||||
all_cpp_files = list(fastled_src_dir.rglob("*.cpp"))
|
||||
|
||||
print(f"Found {len(all_cpp_files)} FastLED source files")
|
||||
|
||||
# Compile to object files
|
||||
object_files: List[Path] = []
|
||||
for src_file in all_cpp_files:
|
||||
relative_path = src_file.relative_to(fastled_src_dir)
|
||||
safe_name = (
|
||||
str(relative_path.with_suffix("")).replace("/", "_").replace("\\", "_")
|
||||
)
|
||||
obj_path = build_dir / f"{safe_name}_fastled.o"
|
||||
|
||||
future = compiler.compile_cpp_file(
|
||||
src_file,
|
||||
output_path=obj_path,
|
||||
additional_flags=[
|
||||
"-c",
|
||||
"-DFASTLED_STUB_IMPL",
|
||||
"-DFASTLED_FORCE_NAMESPACE=1",
|
||||
"-DFASTLED_NO_AUTO_NAMESPACE",
|
||||
"-DFASTLED_NO_PINMAP",
|
||||
"-DPROGMEM=",
|
||||
"-DHAS_HARDWARE_PIN_SUPPORT",
|
||||
"-DFASTLED_ENABLE_JSON=1",
|
||||
"-fno-exceptions",
|
||||
"-fno-rtti",
|
||||
"-fPIC", # Position Independent Code for shared library
|
||||
],
|
||||
)
|
||||
result: Result = future.result()
|
||||
if not result.ok:
|
||||
print(f"ERROR: Failed to compile {src_file}: {result.stderr}")
|
||||
continue
|
||||
|
||||
object_files.append(obj_path)
|
||||
|
||||
print(f"Linking {len(object_files)} object files into dynamic library...")
|
||||
|
||||
# Link as shared library
|
||||
if sys.platform == "win32":
|
||||
# Use lld-link for Windows DLL creation
|
||||
link_cmd = [
|
||||
"lld-link",
|
||||
"/DLL",
|
||||
"/NOLOGO",
|
||||
"/OUT:" + str(fastled_lib_path),
|
||||
"/LIBPATH:C:/Program Files (x86)/Windows Kits/10/Lib/10.0.19041.0/um/x64",
|
||||
"/LIBPATH:C:/Program Files (x86)/Windows Kits/10/Lib/10.0.19041.0/ucrt/x64",
|
||||
"/LIBPATH:C:/Program Files/Microsoft Visual Studio/2022/Community/VC/Tools/MSVC/14.37.32822/lib/x64",
|
||||
*[str(obj) for obj in object_files],
|
||||
"msvcrt.lib",
|
||||
"legacy_stdio_definitions.lib",
|
||||
"kernel32.lib",
|
||||
"user32.lib",
|
||||
]
|
||||
else:
|
||||
link_cmd = [
|
||||
"clang++",
|
||||
"-shared",
|
||||
"-o",
|
||||
str(fastled_lib_path),
|
||||
*[str(obj) for obj in object_files],
|
||||
]
|
||||
|
||||
try:
|
||||
# Use streaming to prevent buffer overflow
|
||||
process = subprocess.Popen(
|
||||
link_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
stdout_lines: list[str] = []
|
||||
stderr_lines: list[str] = []
|
||||
|
||||
while True:
|
||||
stdout_line = process.stdout.readline() if process.stdout else ""
|
||||
stderr_line = process.stderr.readline() if process.stderr else ""
|
||||
|
||||
if stdout_line:
|
||||
stdout_lines.append(stdout_line.rstrip())
|
||||
if stderr_line:
|
||||
stderr_lines.append(stderr_line.rstrip())
|
||||
|
||||
if process.poll() is not None:
|
||||
remaining_stdout = process.stdout.read() if process.stdout else ""
|
||||
remaining_stderr = process.stderr.read() if process.stderr else ""
|
||||
|
||||
if remaining_stdout:
|
||||
for line in remaining_stdout.splitlines():
|
||||
stdout_lines.append(line.rstrip())
|
||||
if remaining_stderr:
|
||||
for line in remaining_stderr.splitlines():
|
||||
stderr_lines.append(line.rstrip())
|
||||
break
|
||||
|
||||
stderr_result = "\n".join(stderr_lines)
|
||||
if process.returncode != 0:
|
||||
raise Exception(f"Failed to create dynamic library: {stderr_result}")
|
||||
|
||||
print(f"Successfully created dynamic library: {fastled_lib_path}")
|
||||
return fastled_lib_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Exception during library creation: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Create build directory in temp
|
||||
import tempfile
|
||||
|
||||
build_dir = Path(tempfile.gettempdir()) / "fastled_test_build"
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build the dynamic library
|
||||
build_fastled_dynamic_library(build_dir)
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Clean Cache-Enhanced Compilation
|
||||
|
||||
Simple wrapper that adds fingerprint cache to FastLED compilation.
|
||||
Dramatically speeds up incremental builds by skipping unchanged files.
|
||||
"""
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
from ci.ci.fingerprint_cache import FingerprintCache
|
||||
from ci.compiler.clang_compiler import Compiler
|
||||
from ci.compiler.test_example_compilation import CompilationResult
|
||||
|
||||
|
||||
class CacheAwareCompiler:
|
||||
"""Simple wrapper that adds cache checking to compilation."""
|
||||
|
||||
def __init__(self, compiler: Compiler, cache_file: Path, verbose: bool = False):
|
||||
self.compiler = compiler
|
||||
self.cache = FingerprintCache(cache_file)
|
||||
self.verbose = verbose
|
||||
self.stats = {
|
||||
"files_checked": 0,
|
||||
"files_skipped": 0,
|
||||
"files_compiled": 0,
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
}
|
||||
|
||||
def should_compile(self, file_path: Path, baseline_time: float) -> bool:
|
||||
"""Check if file needs compilation using cache."""
|
||||
self.stats["files_checked"] += 1
|
||||
|
||||
try:
|
||||
needs_compile = self.cache.has_changed(file_path, baseline_time)
|
||||
|
||||
if needs_compile:
|
||||
self.stats["cache_misses"] += 1
|
||||
self.stats["files_compiled"] += 1
|
||||
return True
|
||||
else:
|
||||
self.stats["cache_hits"] += 1
|
||||
self.stats["files_skipped"] += 1
|
||||
if self.verbose:
|
||||
print(f"[CACHE] Skipping unchanged: {file_path.name}")
|
||||
return False
|
||||
except KeyboardInterrupt:
|
||||
import _thread
|
||||
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f"[CACHE] Error checking {file_path.name}: {e}")
|
||||
self.stats["cache_misses"] += 1
|
||||
self.stats["files_compiled"] += 1
|
||||
return True
|
||||
|
||||
def _headers_changed(self, baseline_time: float) -> bool:
|
||||
"""Detect if any relevant header dependency has changed since last run.
|
||||
|
||||
Uses the compiler's PCH dependency discovery to collect a conservative
|
||||
set of headers that impact example compilation, then queries the
|
||||
fingerprint cache to see if any actually changed content.
|
||||
"""
|
||||
try:
|
||||
# Reuse the compiler's dependency discovery (covers src/** and platforms/**)
|
||||
dependencies: List[Path] = []
|
||||
if hasattr(self.compiler, "_get_pch_dependencies"):
|
||||
dependencies = self.compiler._get_pch_dependencies() # type: ignore[attr-defined]
|
||||
else:
|
||||
# Fallback: hash all headers under the compiler's include path
|
||||
include_root = Path(self.compiler.settings.include_path)
|
||||
for pattern in ("**/*.h", "**/*.hpp"):
|
||||
dependencies.extend(include_root.glob(pattern))
|
||||
|
||||
changed_any = False
|
||||
for dep in dependencies:
|
||||
try:
|
||||
if self.cache.has_changed(dep, baseline_time):
|
||||
if self.verbose:
|
||||
print(f"[CACHE] Header changed: {dep}")
|
||||
changed_any = True
|
||||
break
|
||||
except FileNotFoundError:
|
||||
# Missing dependency means we must rebuild conservatively
|
||||
if self.verbose:
|
||||
print(f"[CACHE] Header missing (forces rebuild): {dep}")
|
||||
changed_any = True
|
||||
break
|
||||
return changed_any
|
||||
except KeyboardInterrupt:
|
||||
import _thread
|
||||
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
print(f"[CACHE] Header scan failed, forcing rebuild: {e}")
|
||||
return True
|
||||
|
||||
def compile_with_cache(
|
||||
self,
|
||||
ino_files: List[Path],
|
||||
pch_compatible_files: Set[Path],
|
||||
log_fn: Callable[[str], None],
|
||||
full_compilation: bool,
|
||||
verbose: bool = False,
|
||||
baseline_time: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Compile only files that have changed."""
|
||||
|
||||
if baseline_time is None:
|
||||
baseline_time = time.time() - 3600 # Default: 1 hour ago
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# If any header dependency changed, conservatively rebuild all example files
|
||||
force_recompile_due_to_headers = self._headers_changed(baseline_time)
|
||||
if force_recompile_due_to_headers and self.verbose:
|
||||
print(
|
||||
"[CACHE] Header dependency changes detected - forcing recompilation of example sources"
|
||||
)
|
||||
|
||||
# Check which files need compilation
|
||||
files_to_compile: List[Path] = []
|
||||
for ino_file in ino_files:
|
||||
if force_recompile_due_to_headers or self.should_compile(
|
||||
ino_file, baseline_time
|
||||
):
|
||||
files_to_compile.append(ino_file)
|
||||
|
||||
# Check .cpp files too
|
||||
cpp_files_to_compile: List[Path] = []
|
||||
for ino_file in ino_files:
|
||||
cpp_files = self.compiler.find_cpp_files_for_example(ino_file)
|
||||
for cpp_file in cpp_files:
|
||||
if force_recompile_due_to_headers or self.should_compile(
|
||||
cpp_file, baseline_time
|
||||
):
|
||||
cpp_files_to_compile.append(cpp_file)
|
||||
|
||||
# Log cache results
|
||||
total_files = len(ino_files) + sum(
|
||||
len(self.compiler.find_cpp_files_for_example(f)) for f in ino_files
|
||||
)
|
||||
log_fn(
|
||||
f"[CACHE] {self.stats['files_skipped']}/{total_files} files unchanged, {self.stats['files_compiled']}/{total_files} need compilation"
|
||||
)
|
||||
|
||||
if self.stats["files_skipped"] > 0:
|
||||
time_saved = self.stats["files_skipped"] * 0.5 # Estimate
|
||||
log_fn(f"[CACHE] Estimated time saved: {time_saved:.1f}s")
|
||||
|
||||
# Compile files that changed
|
||||
if files_to_compile or cpp_files_to_compile:
|
||||
result = self._run_actual_compilation(
|
||||
files_to_compile,
|
||||
cpp_files_to_compile,
|
||||
pch_compatible_files,
|
||||
log_fn,
|
||||
full_compilation,
|
||||
verbose,
|
||||
)
|
||||
else:
|
||||
log_fn("[CACHE] All files unchanged - no compilation needed!")
|
||||
result = self._create_success_result(len(ino_files), ino_files)
|
||||
|
||||
# Log final stats
|
||||
if total_files > 0:
|
||||
hit_rate = (
|
||||
(
|
||||
self.stats["cache_hits"]
|
||||
/ (self.stats["cache_hits"] + self.stats["cache_misses"])
|
||||
* 100
|
||||
)
|
||||
if (self.stats["cache_hits"] + self.stats["cache_misses"]) > 0
|
||||
else 0
|
||||
)
|
||||
skip_rate = self.stats["files_skipped"] / total_files * 100
|
||||
log_fn(
|
||||
f"[CACHE] {hit_rate:.1f}% cache hit rate, {skip_rate:.1f}% files skipped"
|
||||
)
|
||||
|
||||
return {
|
||||
"compilation_result": result,
|
||||
"cache_stats": self.stats,
|
||||
"total_time": time.time() - start_time,
|
||||
}
|
||||
|
||||
def _run_actual_compilation(
|
||||
self,
|
||||
ino_files: List[Path],
|
||||
cpp_files_to_compile: List[Path],
|
||||
pch_compatible_files: Set[Path],
|
||||
log_fn: Callable[[str], None],
|
||||
full_compilation: bool,
|
||||
verbose: bool,
|
||||
):
|
||||
"""Run the actual compilation for changed files."""
|
||||
from ci.compiler.test_example_compilation import compile_examples_simple
|
||||
|
||||
# Temporarily filter .cpp files to only compile changed ones
|
||||
original_method = self.compiler.find_cpp_files_for_example
|
||||
|
||||
def filtered_cpp_files(ino_file: Path) -> List[Path]:
|
||||
all_cpp = original_method(ino_file)
|
||||
return [cpp for cpp in all_cpp if cpp in cpp_files_to_compile]
|
||||
|
||||
self.compiler.find_cpp_files_for_example = filtered_cpp_files
|
||||
|
||||
try:
|
||||
return compile_examples_simple(
|
||||
self.compiler,
|
||||
ino_files,
|
||||
pch_compatible_files,
|
||||
log_fn,
|
||||
full_compilation,
|
||||
verbose,
|
||||
)
|
||||
finally:
|
||||
self.compiler.find_cpp_files_for_example = original_method
|
||||
|
||||
def _create_success_result(
|
||||
self, file_count: int, ino_files: Optional[List[Path]] = None
|
||||
) -> CompilationResult:
|
||||
"""Create a successful compilation result for cached files."""
|
||||
|
||||
# For cached files, we need to populate object_file_map with existing object files
|
||||
# so that linking can proceed properly
|
||||
object_file_map: Dict[Path, List[Path]] = {}
|
||||
if ino_files:
|
||||
for ino_file in ino_files:
|
||||
# Find existing object files for this example
|
||||
example_name = ino_file.parent.name
|
||||
build_dir = Path(".build/examples") / example_name
|
||||
|
||||
obj_files: List[Path] = []
|
||||
if build_dir.exists():
|
||||
# Look for the main .ino object file
|
||||
ino_obj = build_dir / f"{ino_file.stem}.o"
|
||||
if ino_obj.exists():
|
||||
obj_files.append(ino_obj)
|
||||
|
||||
# Look for additional .cpp files in the same directory as the .ino
|
||||
cpp_files = self.compiler.find_cpp_files_for_example(ino_file)
|
||||
for cpp_file in cpp_files:
|
||||
cpp_obj = build_dir / f"{cpp_file.stem}.o"
|
||||
if cpp_obj.exists():
|
||||
obj_files.append(cpp_obj)
|
||||
|
||||
if obj_files:
|
||||
object_file_map[ino_file] = obj_files
|
||||
|
||||
return CompilationResult(
|
||||
successful_count=file_count,
|
||||
failed_count=0,
|
||||
compile_time=0.0,
|
||||
failed_examples=[],
|
||||
object_file_map=object_file_map,
|
||||
)
|
||||
|
||||
|
||||
def create_cache_compiler(
|
||||
compiler: Compiler, cache_file: Optional[Path] = None, verbose: bool = False
|
||||
) -> CacheAwareCompiler:
|
||||
"""Create a cache-aware compiler. Simple factory function."""
|
||||
if cache_file is None:
|
||||
cache_file = Path(".build/fingerprint_cache.json")
|
||||
return CacheAwareCompiler(compiler, cache_file, verbose)
|
||||
@@ -0,0 +1,514 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone cache setup script for PlatformIO builds with Python fake compilers.
|
||||
This script is executed by PlatformIO as a post-build extra_script.
|
||||
|
||||
Configuration is passed through environment variables to avoid template string issues.
|
||||
"""
|
||||
|
||||
# ruff: noqa: F405, F821 # Suppress SCons-specific import and undefined name warnings
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
|
||||
# Import env and try to import projenv (SCons-specific imports)
|
||||
# These are dynamically available in PlatformIO/SCons environment
|
||||
try:
|
||||
Import("env") # type: ignore[name-defined] # SCons-specific import
|
||||
env: Any # SCons environment object
|
||||
except NameError:
|
||||
env = None # For type checking when not in SCons context
|
||||
|
||||
_VERBOSE = os.environ.get("VERBOSE", "0") in ("1", "true", "True", "yes", "y")
|
||||
|
||||
# Try to import projenv if it exists
|
||||
has_projenv: bool = False
|
||||
projenv: Optional[Any] = None
|
||||
try:
|
||||
Import("projenv") # type: ignore[name-defined] # SCons-specific import
|
||||
# projenv is now available in scope from Import
|
||||
has_projenv = True
|
||||
except (NameError, Exception):
|
||||
has_projenv = False
|
||||
projenv = None
|
||||
|
||||
# Import cached compiler functions with type stubs
|
||||
create_cached_toolchain: Optional[Any] = None
|
||||
get_platform_packages_paths: Optional[Any] = None
|
||||
cached_compiler_available: bool = False
|
||||
|
||||
try:
|
||||
from ci.util.cached_compiler import (
|
||||
create_cached_toolchain,
|
||||
get_platform_packages_paths,
|
||||
)
|
||||
|
||||
cached_compiler_available = True
|
||||
except ImportError as e:
|
||||
print("WARNING: Could not import cached compiler module: " + str(e))
|
||||
cached_compiler_available = False
|
||||
|
||||
# Debug: Dump the environment state to disk for inspection
|
||||
env_dump: Dict[str, str] = {}
|
||||
try:
|
||||
if env is not None and hasattr(env, "Dictionary"): # type: ignore[has-type]
|
||||
for key in env.Dictionary(): # type: ignore[union-attr]
|
||||
try:
|
||||
value = env[key] # type: ignore[index]
|
||||
# Convert to string to avoid JSON serialization issues
|
||||
env_dump[key] = str(value) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
env_dump[key] = "<error getting value>"
|
||||
else:
|
||||
env_dump["error"] = "env not available or missing Dictionary method"
|
||||
except Exception as e:
|
||||
env_dump["error"] = f"Failed to access env: {e}"
|
||||
|
||||
# Write environment dump to disk
|
||||
env_dump_path = "env_dump.json"
|
||||
with open(env_dump_path, "w") as f:
|
||||
json.dump(env_dump, f, indent=2)
|
||||
print("Environment state dumped to: " + env_dump_path)
|
||||
|
||||
# Also dump projenv if available
|
||||
if has_projenv and projenv is not None:
|
||||
projenv_dump: Dict[str, str] = {}
|
||||
for key in projenv.Dictionary(): # type: ignore
|
||||
try:
|
||||
value = projenv[key] # type: ignore
|
||||
projenv_dump[key] = str(value)
|
||||
except Exception:
|
||||
projenv_dump[key] = "<error getting value>"
|
||||
|
||||
projenv_dump_path = "projenv_dump.json"
|
||||
with open(projenv_dump_path, "w") as f:
|
||||
json.dump(projenv_dump, f, indent=2)
|
||||
print("Projenv state dumped to: " + projenv_dump_path)
|
||||
|
||||
# Read cache configuration from environment variables
|
||||
cache_type = os.environ.get("FASTLED_CACHE_TYPE", "no_cache")
|
||||
cache_executable = os.environ.get("FASTLED_CACHE_EXECUTABLE", "")
|
||||
sccache_path = os.environ.get("FASTLED_SCCACHE_PATH", "")
|
||||
sccache_dir = os.environ.get("FASTLED_SCCACHE_DIR", "")
|
||||
sccache_cache_size = os.environ.get("FASTLED_SCCACHE_CACHE_SIZE", "2G")
|
||||
xcache_path = os.environ.get("FASTLED_XCACHE_PATH", "")
|
||||
debug_enabled = os.environ.get("FASTLED_CACHE_DEBUG", "0") == "1"
|
||||
|
||||
print("Cache configuration from environment:")
|
||||
print(" Cache type: " + cache_type)
|
||||
print(" Cache executable: " + cache_executable)
|
||||
print(" SCCACHE path: " + sccache_path)
|
||||
print(" SCCACHE dir: " + sccache_dir)
|
||||
print(" Debug enabled: " + str(debug_enabled))
|
||||
|
||||
# Set up cache environment variables for subprocess execution
|
||||
try:
|
||||
if env is not None and hasattr(env, "Append"): # type: ignore[has-type]
|
||||
if sccache_dir:
|
||||
env.Append(ENV={"SCCACHE_DIR": sccache_dir}) # type: ignore[union-attr]
|
||||
os.environ["SCCACHE_DIR"] = sccache_dir
|
||||
|
||||
if sccache_cache_size:
|
||||
env.Append(ENV={"SCCACHE_CACHE_SIZE": sccache_cache_size}) # type: ignore[union-attr]
|
||||
os.environ["SCCACHE_CACHE_SIZE"] = sccache_cache_size
|
||||
|
||||
# Ensure sccache binary directory is on PATH so xcache can find it even
|
||||
# inside ESP-IDF's virtual environments where PATH is heavily modified.
|
||||
if sccache_path:
|
||||
sccache_dir_path = str(Path(sccache_path).parent)
|
||||
env.PrependENVPath("PATH", sccache_dir_path) # type: ignore[union-attr]
|
||||
if has_projenv and projenv is not None and hasattr(projenv, "PrependENVPath"):
|
||||
projenv.PrependENVPath("PATH", sccache_dir_path) # type: ignore[union-attr]
|
||||
os.environ["PATH"] = sccache_dir_path + os.pathsep + os.environ.get("PATH", "")
|
||||
|
||||
if debug_enabled:
|
||||
env.Append(ENV={"XCACHE_DEBUG": "1", "SCCACHE_DEBUG": "1"}) # type: ignore[union-attr]
|
||||
if has_projenv and projenv is not None and hasattr(projenv, "Append"):
|
||||
projenv.Append(ENV={"XCACHE_DEBUG": "1", "SCCACHE_DEBUG": "1"}) # type: ignore[union-attr]
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to set up cache environment: {e}")
|
||||
|
||||
# Check if cache is available and cached compiler system can be used
|
||||
USE_CACHE = False
|
||||
|
||||
if cached_compiler_available and cache_executable:
|
||||
if cache_type == "xcache":
|
||||
# For xcache, check if the Python script exists and sccache is available
|
||||
USE_CACHE = (
|
||||
cache_executable
|
||||
and xcache_path
|
||||
and Path(xcache_path).exists()
|
||||
and sccache_path
|
||||
and shutil.which(sccache_path)
|
||||
)
|
||||
|
||||
if USE_CACHE:
|
||||
print("xcache wrapper detected and configured for Python fake compilers")
|
||||
print(" xcache path: " + str(xcache_path))
|
||||
print(" cache executable: " + str(cache_executable))
|
||||
else:
|
||||
# For sccache/ccache, check if executable is in PATH
|
||||
USE_CACHE = shutil.which(cache_executable) is not None
|
||||
|
||||
if USE_CACHE:
|
||||
print(
|
||||
str(cache_type) + " detected and configured for Python cached compilers"
|
||||
)
|
||||
print(" cache executable: " + str(cache_executable))
|
||||
elif not cached_compiler_available:
|
||||
print(
|
||||
"WARNING: Python cached compiler system not available, cache will be disabled"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"Cache executable not found: "
|
||||
+ str(cache_executable)
|
||||
+ ", cache will be disabled"
|
||||
)
|
||||
|
||||
if USE_CACHE and env is not None and hasattr(env, "get"): # type: ignore[has-type]
|
||||
# Get current compilers from environment
|
||||
original_cc = env.get("CC") # type: ignore[union-attr]
|
||||
original_cxx = env.get("CXX") # type: ignore[union-attr]
|
||||
|
||||
print("DEBUG: Found compilers in env:")
|
||||
# Use repr for safer type conversion
|
||||
cc_str = repr(original_cc)
|
||||
cxx_str = repr(original_cxx)
|
||||
cc_type_str = type(original_cc).__name__
|
||||
cxx_type_str = type(original_cxx).__name__
|
||||
print(" CC: " + cc_str + " (type: " + cc_type_str + ")")
|
||||
print(" CXX: " + cxx_str + " (type: " + cxx_type_str + ")")
|
||||
|
||||
# Extract compiler information for fake compiler generation
|
||||
def extract_compiler_info(compiler_env_var: Any) -> Optional[str]:
|
||||
"""Extract compiler name from environment variable value."""
|
||||
if not compiler_env_var:
|
||||
return None
|
||||
|
||||
if isinstance(compiler_env_var, list):
|
||||
return str(compiler_env_var[0]) if compiler_env_var else None # type: ignore[arg-type]
|
||||
else:
|
||||
# Handle string values like "arm-none-eabi-gcc" or "gcc"
|
||||
return str(compiler_env_var).split()[0] # type: ignore[arg-type]
|
||||
|
||||
cc_name = extract_compiler_info(original_cc) or "gcc"
|
||||
cxx_name = extract_compiler_info(original_cxx) or "g++"
|
||||
|
||||
print("Extracted compiler names:")
|
||||
print(" CC name: " + str(cc_name))
|
||||
print(" CXX name: " + str(cxx_name))
|
||||
|
||||
# Create toolchain info for fake compiler generation
|
||||
toolchain_info: Dict[str, str] = {
|
||||
"CC": cc_name,
|
||||
"CXX": cxx_name,
|
||||
}
|
||||
|
||||
# Create cache config for fake compiler system
|
||||
cache_config: Dict[str, str] = {
|
||||
"CACHE_TYPE": cache_type,
|
||||
"CACHE_EXECUTABLE": cache_executable,
|
||||
"SCCACHE_PATH": sccache_path,
|
||||
"SCCACHE_DIR": sccache_dir,
|
||||
"XCACHE_PATH": xcache_path,
|
||||
}
|
||||
|
||||
# Check if toolset exists and is valid
|
||||
# Create a cache key based on the configuration to invalidate cache when config changes
|
||||
cache_key = f"{cc_name}_{cxx_name}_{cache_type}_{cache_executable}"
|
||||
|
||||
# Cache in the local build directory (e.g., .build/pio/uno/)
|
||||
cache_file = Path(current_dir) / "compiler_cache.json"
|
||||
|
||||
cached_tools: Optional[Dict[str, str]] = None
|
||||
|
||||
# Try to load from persistent cache file
|
||||
if cache_file.exists():
|
||||
try:
|
||||
import json
|
||||
|
||||
with open(cache_file, "r") as f:
|
||||
cache_data = json.load(f)
|
||||
|
||||
cached_real_cc = cache_data.get("real_cc")
|
||||
cached_real_cxx = cache_data.get("real_cxx")
|
||||
cached_fake_cc = cache_data.get("fake_cc")
|
||||
cached_fake_cxx = cache_data.get("fake_cxx")
|
||||
|
||||
if (
|
||||
cached_real_cc
|
||||
and cached_real_cxx
|
||||
and cached_fake_cc
|
||||
and cached_fake_cxx
|
||||
):
|
||||
print("Found local compiler cache:")
|
||||
print(f" Cache file: {cache_file}")
|
||||
print(f" Real CC: {cached_real_cc}")
|
||||
print(f" Real CXX: {cached_real_cxx}")
|
||||
|
||||
# Check if fake compiler scripts still exist, recreate if needed
|
||||
fake_cc_path = Path(cached_fake_cc.replace("python ", ""))
|
||||
fake_cxx_path = Path(cached_fake_cxx.replace("python ", ""))
|
||||
|
||||
if fake_cc_path.exists() and fake_cxx_path.exists():
|
||||
# Fast path: use existing cached compilers
|
||||
# On Windows, ensure we have .cmd shims to avoid 'python script.py @file'
|
||||
# causing Python to treat @file as the script name.
|
||||
import sys as _sys
|
||||
import os as _os
|
||||
|
||||
is_win = (
|
||||
_os.name == "nt"
|
||||
or _sys.platform.startswith("win")
|
||||
or _sys.platform.startswith("cygwin")
|
||||
or _sys.platform.startswith("msys")
|
||||
)
|
||||
|
||||
def _ensure_cmd_wrapper(fake_str: str, real_path: str) -> str:
|
||||
p = Path(fake_str.replace("python ", ""))
|
||||
if is_win and p.suffix.lower() == ".py":
|
||||
cmd = p.with_suffix(".cmd")
|
||||
if not cmd.exists():
|
||||
# Create missing .cmd wrapper now
|
||||
from ci.util.cached_compiler import create_cached_compiler_script
|
||||
out_dir = p.parent
|
||||
created = create_cached_compiler_script(
|
||||
compiler_name=p.stem.split("cached_")[-1],
|
||||
cache_executable=cache_config.get("CACHE_EXECUTABLE", "sccache"),
|
||||
real_compiler_path=real_path,
|
||||
output_dir=out_dir,
|
||||
debug=debug_enabled,
|
||||
)
|
||||
cmd = Path(str(created).replace("python ", ""))
|
||||
if cmd.exists():
|
||||
return str(cmd)
|
||||
return fake_str
|
||||
|
||||
# Create/choose proper wrapper paths
|
||||
new_cc = _ensure_cmd_wrapper(cached_fake_cc, cached_real_cc)
|
||||
new_cxx = _ensure_cmd_wrapper(cached_fake_cxx, cached_real_cxx)
|
||||
|
||||
cached_tools = {"CC": new_cc, "CXX": new_cxx}
|
||||
|
||||
# Update cache file if we switched to .cmd
|
||||
try:
|
||||
if new_cc != cached_fake_cc or new_cxx != cached_fake_cxx:
|
||||
cache_data["fake_cc"] = new_cc
|
||||
cache_data["fake_cxx"] = new_cxx
|
||||
with open(cache_file, "w") as f:
|
||||
json.dump(cache_data, f, indent=2)
|
||||
except KeyboardInterrupt as ke:
|
||||
import _thread
|
||||
_thread.interrupt_main()
|
||||
raise ke
|
||||
except Exception:
|
||||
pass
|
||||
print(
|
||||
"SUCCESS: Using cached compilers (instant, no platform search needed):"
|
||||
)
|
||||
print(f" CC: {cached_tools['CC']}")
|
||||
print(f" CXX: {cached_tools['CXX']}")
|
||||
print(" Platform search skipped - using cached toolset")
|
||||
else:
|
||||
print(
|
||||
"Cached compiler scripts missing, recreating with cached real paths..."
|
||||
)
|
||||
# Recreate cached compilers using cached real paths (fast)
|
||||
cached_compilers_dir = Path(current_dir) / "cached_compilers"
|
||||
cached_compilers_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from ci.util.cached_compiler import create_cached_compiler_script
|
||||
|
||||
cache_executable = cache_config.get("CACHE_EXECUTABLE", "sccache")
|
||||
|
||||
# Create cached CC script using cached real path
|
||||
cached_cc_script = create_cached_compiler_script(
|
||||
compiler_name="CC",
|
||||
cache_executable=cache_executable,
|
||||
real_compiler_path=cached_real_cc,
|
||||
output_dir=cached_compilers_dir,
|
||||
debug=debug_enabled,
|
||||
)
|
||||
|
||||
# Create cached CXX script using cached real path
|
||||
cached_cxx_script = create_cached_compiler_script(
|
||||
compiler_name="CXX",
|
||||
cache_executable=cache_executable,
|
||||
real_compiler_path=cached_real_cxx,
|
||||
output_dir=cached_compilers_dir,
|
||||
debug=debug_enabled,
|
||||
)
|
||||
|
||||
cached_tools = {
|
||||
"CC": str(cached_cc_script),
|
||||
"CXX": str(cached_cxx_script),
|
||||
}
|
||||
|
||||
# Update cache file with new script paths
|
||||
cache_data["fake_cc"] = cached_tools["CC"]
|
||||
cache_data["fake_cxx"] = cached_tools["CXX"]
|
||||
|
||||
with open(cache_file, "w") as f:
|
||||
json.dump(cache_data, f, indent=2)
|
||||
|
||||
print("Recreated cached compilers using cached real paths:")
|
||||
print(f" CC: {cached_tools['CC']}")
|
||||
print(f" CXX: {cached_tools['CXX']}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load cache file {cache_file}: {e}")
|
||||
# Fall through to full recreation
|
||||
|
||||
# If no valid cache found, create the toolset from scratch
|
||||
if cached_tools is None:
|
||||
print("No valid cache found, creating compiler toolset from scratch...")
|
||||
print(" This is the first compile or configuration changed")
|
||||
|
||||
# Get platform packages paths for toolchain resolution (expensive operation)
|
||||
platform_packages: List[str] = []
|
||||
if get_platform_packages_paths is not None:
|
||||
print("Searching platform packages (this may take ~10 seconds)...")
|
||||
platform_packages = get_platform_packages_paths()
|
||||
print(f"Found {len(platform_packages)} platform package directories")
|
||||
|
||||
if create_cached_toolchain is not None:
|
||||
try:
|
||||
# Find the real compiler paths (expensive operation, done only once)
|
||||
from ci.util.cached_compiler import find_toolchain_compiler
|
||||
|
||||
print("Resolving real compiler paths...")
|
||||
|
||||
real_cc_path = find_toolchain_compiler(cc_name, platform_packages)
|
||||
real_cxx_path = find_toolchain_compiler(cxx_name, platform_packages)
|
||||
|
||||
if not real_cc_path or not real_cxx_path:
|
||||
print(f"ERROR: Could not find real compilers:")
|
||||
print(f" CC '{cc_name}': {real_cc_path}")
|
||||
print(f" CXX '{cxx_name}': {real_cxx_path}")
|
||||
cached_tools = None
|
||||
else:
|
||||
print(f"Found real compilers:")
|
||||
print(f" Real CC: {real_cc_path}")
|
||||
print(f" Real CXX: {real_cxx_path}")
|
||||
|
||||
# Create cached compiler scripts
|
||||
cached_compilers_dir = Path(current_dir) / "cached_compilers"
|
||||
cached_compilers_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from ci.util.cached_compiler import create_cached_compiler_script
|
||||
|
||||
cache_executable = cache_config.get("CACHE_EXECUTABLE", "sccache")
|
||||
|
||||
# Create cached CC script
|
||||
cached_cc_script = create_cached_compiler_script(
|
||||
compiler_name="CC",
|
||||
cache_executable=cache_executable,
|
||||
real_compiler_path=real_cc_path,
|
||||
output_dir=cached_compilers_dir,
|
||||
debug=debug_enabled,
|
||||
)
|
||||
|
||||
# Create cached CXX script
|
||||
cached_cxx_script = create_cached_compiler_script(
|
||||
compiler_name="CXX",
|
||||
cache_executable=cache_executable,
|
||||
real_compiler_path=real_cxx_path,
|
||||
output_dir=cached_compilers_dir,
|
||||
debug=debug_enabled,
|
||||
)
|
||||
|
||||
cached_tools = {
|
||||
"CC": str(cached_cc_script),
|
||||
"CXX": str(cached_cxx_script),
|
||||
}
|
||||
|
||||
print("Created new compiler toolset:")
|
||||
print(f" CC: {cached_tools['CC']}")
|
||||
print(f" CXX: {cached_tools['CXX']}")
|
||||
|
||||
# Save to local build directory cache file
|
||||
cache_data = {
|
||||
"cache_key": cache_key,
|
||||
"real_cc": real_cc_path,
|
||||
"real_cxx": real_cxx_path,
|
||||
"fake_cc": cached_tools["CC"],
|
||||
"fake_cxx": cached_tools["CXX"],
|
||||
"build_dir": str(current_dir),
|
||||
"platform_packages_count": len(platform_packages),
|
||||
}
|
||||
|
||||
with open(cache_file, "w") as f:
|
||||
json.dump(cache_data, f, indent=2)
|
||||
|
||||
print(f"Saved compiler toolset to local cache: {cache_file}")
|
||||
print(" This cache will persist across builds for this platform")
|
||||
|
||||
except Exception as e:
|
||||
print("ERROR: Toolset creation failed with exception: " + str(e))
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
cached_tools = None
|
||||
else:
|
||||
print("ERROR: create_cached_toolchain function is None")
|
||||
|
||||
if cached_tools:
|
||||
# Use Python cached compilers instead of batch scripts
|
||||
new_cc = cached_tools.get("CC")
|
||||
new_cxx = cached_tools.get("CXX")
|
||||
|
||||
if new_cc and new_cxx:
|
||||
print("Created Python cached compilers:")
|
||||
print(" CC: " + str(new_cc))
|
||||
print(" CXX: " + str(new_cxx))
|
||||
|
||||
# Apply to both environments
|
||||
env.Replace(CC=new_cc, CXX=new_cxx) # type: ignore
|
||||
if has_projenv and projenv is not None:
|
||||
projenv.Replace(CC=new_cc, CXX=new_cxx) # type: ignore
|
||||
print("Applied Python fake compilers to both env and projenv")
|
||||
else:
|
||||
print("Applied Python fake compilers to env (projenv not available)")
|
||||
|
||||
# Apply to library builders (critical for framework caching)
|
||||
try:
|
||||
for lib_builder in env.GetLibBuilders(): # type: ignore
|
||||
lib_builder.env.Replace(CC=new_cc, CXX=new_cxx) # type: ignore
|
||||
if _VERBOSE:
|
||||
print(
|
||||
"Applied Python fake compilers to library builder: "
|
||||
+ str(getattr(lib_builder, "name", "unnamed"))
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
import _thread
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
print("WARNING: Could not apply to library builders: " + str(e))
|
||||
|
||||
print("Python fake compiler cache enabled: " + str(cache_type))
|
||||
print(" Original CC: " + str(original_cc))
|
||||
print(" Original CXX: " + str(original_cxx))
|
||||
print(" Fake CC: " + str(new_cc))
|
||||
print(" Fake CXX: " + str(new_cxx))
|
||||
else:
|
||||
print(
|
||||
"ERROR: Failed to create Python fake compilers, falling back to no cache"
|
||||
)
|
||||
USE_CACHE = False
|
||||
else:
|
||||
print("ERROR: Python fake compiler creation failed, falling back to no cache")
|
||||
USE_CACHE = False
|
||||
|
||||
if not USE_CACHE:
|
||||
if cache_executable:
|
||||
print("Warning: " + str(cache_type) + " setup failed; using default compilers")
|
||||
else:
|
||||
print("No cache executable configured; using default compilers")
|
||||
|
||||
print("Python fake compiler cache environment configured successfully")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,261 @@
|
||||
# pyright: reportUnknownMemberType=false
|
||||
"""
|
||||
Compilation support for individual boards.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import List
|
||||
|
||||
from ci.boards import Board # type: ignore
|
||||
from ci.util.locked_print import locked_print
|
||||
|
||||
|
||||
ERROR_HAPPENED = False
|
||||
|
||||
|
||||
IS_GITHUB = "GITHUB_ACTIONS" in os.environ
|
||||
FIRST_BUILD_LOCK = Lock()
|
||||
USE_FIRST_BUILD_LOCK = IS_GITHUB
|
||||
|
||||
|
||||
def errors_happened() -> bool:
|
||||
"""Return whether any errors happened during the build."""
|
||||
return ERROR_HAPPENED
|
||||
|
||||
|
||||
def _fastled_js_is_parent_directory(p: Path) -> bool:
|
||||
"""Check if fastled_js is a parent directory of the given path."""
|
||||
# Check if fastled_js is a parent directory of p
|
||||
return "fastled_js" in str(p.absolute())
|
||||
|
||||
|
||||
def compile_for_board_and_example(
|
||||
board: Board,
|
||||
example: Path,
|
||||
build_dir: str | None,
|
||||
verbose_on_failure: bool,
|
||||
libs: list[str] | None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Compile the given example for the given board."""
|
||||
global ERROR_HAPPENED # pylint: disable=global-statement
|
||||
if board.board_name == "web":
|
||||
locked_print(f"Skipping web target for example {example}")
|
||||
return True, ""
|
||||
board_name = board.board_name
|
||||
use_pio_run = board.use_pio_run
|
||||
real_board_name = board.get_real_board_name()
|
||||
libs = libs or []
|
||||
builddir = (
|
||||
Path(build_dir) / board_name if build_dir else Path(".build") / board_name
|
||||
)
|
||||
builddir.mkdir(parents=True, exist_ok=True)
|
||||
srcdir = builddir / "src"
|
||||
# Remove the previous *.ino file if it exists, everything else is recycled
|
||||
# to speed up the next build.
|
||||
if srcdir.exists():
|
||||
shutil.rmtree(srcdir, ignore_errors=False)
|
||||
locked_print(f"*** Building example {example} for board {board_name} ***")
|
||||
cwd: str | None = None
|
||||
shell: bool = False
|
||||
# Copy all files from the example directory to the "src" directory
|
||||
for src_file in example.rglob("*"):
|
||||
if src_file.is_file():
|
||||
if _fastled_js_is_parent_directory(src_file):
|
||||
# Skip the fastled_js folder, it's not needed for the build.
|
||||
continue
|
||||
src_dir = src_file.parent
|
||||
path = src_dir.relative_to(example)
|
||||
dst_dir = srcdir / path
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
locked_print(f"Copying {src_file} to {dst_dir / src_file.name}")
|
||||
os.makedirs(srcdir, exist_ok=True)
|
||||
shutil.copy(src_file, dst_dir / src_file.name)
|
||||
# libs = ["src", "ci"]
|
||||
if use_pio_run:
|
||||
# we have to copy a few folders of pio ci in order to get this to work.
|
||||
for lib in libs:
|
||||
project_libdir = Path(lib)
|
||||
assert project_libdir.exists()
|
||||
build_lib = builddir / "lib" / lib
|
||||
shutil.rmtree(build_lib, ignore_errors=True)
|
||||
shutil.copytree(project_libdir, build_lib)
|
||||
|
||||
cwd = str(builddir)
|
||||
cmd_list = [
|
||||
"pio",
|
||||
"run",
|
||||
]
|
||||
# in this case we need to manually copy the example to the src directory
|
||||
# because platformio doesn't support building a single file.
|
||||
# ino_file = example / f"{example.name}.ino"
|
||||
else:
|
||||
cmd_list = [
|
||||
"pio",
|
||||
"ci",
|
||||
"--board",
|
||||
real_board_name,
|
||||
*[f"--lib={lib}" for lib in libs],
|
||||
"--keep-build-dir",
|
||||
f"--build-dir={builddir.as_posix()}",
|
||||
]
|
||||
cmd_list.append(f"{example.as_posix()}/*ino")
|
||||
cmd_str = subprocess.list2cmdline(cmd_list)
|
||||
msg_lsit = [
|
||||
"\n\n******************************",
|
||||
f"* Running command in cwd: {cwd if cwd else os.getcwd()}",
|
||||
f"* {cmd_str}",
|
||||
"******************************\n",
|
||||
]
|
||||
msg = "\n".join(msg_lsit)
|
||||
locked_print(msg)
|
||||
|
||||
# Start timing for the process
|
||||
start_time = time.time()
|
||||
|
||||
# Run the process with real-time output capture and timing
|
||||
result = subprocess.Popen(
|
||||
cmd_list,
|
||||
cwd=cwd,
|
||||
shell=shell,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Capture output lines in real-time with timing
|
||||
stdout_lines: List[str] = []
|
||||
if result.stdout:
|
||||
for line in iter(result.stdout.readline, ""):
|
||||
if line:
|
||||
elapsed = time.time() - start_time
|
||||
# Format timing as seconds with 2 decimal places
|
||||
timing_prefix = f"{elapsed:5.2f} "
|
||||
timed_line = timing_prefix + line.rstrip()
|
||||
stdout_lines.append(
|
||||
line.rstrip()
|
||||
) # Store original line for return value
|
||||
locked_print(timed_line)
|
||||
|
||||
# Wait for process to complete
|
||||
result.wait()
|
||||
|
||||
# Join all stdout lines for the return value
|
||||
stdout = "\n".join(stdout_lines)
|
||||
|
||||
# replace all instances of "lib/src" => "src" so intellisense can find the files
|
||||
# with one click.
|
||||
stdout = stdout.replace("lib/src", "src").replace("lib\\src", "src")
|
||||
if result.returncode != 0:
|
||||
if not verbose_on_failure:
|
||||
ERROR_HAPPENED = True
|
||||
return False, stdout
|
||||
if ERROR_HAPPENED:
|
||||
return False, ""
|
||||
ERROR_HAPPENED = True
|
||||
locked_print(
|
||||
f"*** Error compiling example {example} for board {board_name} ***"
|
||||
)
|
||||
# re-running command with verbose output to see what the defines are.
|
||||
cmd_list.append("-v")
|
||||
cmd_str = subprocess.list2cmdline(cmd_list)
|
||||
msg_lsit = [
|
||||
"\n\n******************************",
|
||||
"* Re-running failed command but with verbose output:",
|
||||
f"* {cmd_str}",
|
||||
"******************************\n",
|
||||
]
|
||||
msg = "\n".join(msg_lsit)
|
||||
locked_print(msg)
|
||||
|
||||
# Start timing for the verbose re-run
|
||||
start_time_verbose = time.time()
|
||||
|
||||
# Run the verbose process with real-time output capture and timing
|
||||
result = subprocess.Popen(
|
||||
cmd_list,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Capture output lines in real-time with timing for verbose run
|
||||
stdout_lines_verbose: List[str] = []
|
||||
if result.stdout:
|
||||
for line in iter(result.stdout.readline, ""):
|
||||
if line:
|
||||
elapsed = time.time() - start_time_verbose
|
||||
# Format timing as seconds with 2 decimal places
|
||||
timing_prefix = f"{elapsed:5.2f} "
|
||||
timed_line = timing_prefix + line.rstrip()
|
||||
stdout_lines_verbose.append(
|
||||
line.rstrip()
|
||||
) # Store original line for return value
|
||||
locked_print(timed_line)
|
||||
|
||||
# Wait for verbose process to complete
|
||||
result.wait()
|
||||
|
||||
# Join all stdout lines for the return value
|
||||
stdout = "\n".join(stdout_lines_verbose)
|
||||
stdout = (
|
||||
stdout
|
||||
+ "\n\nThis is a second attempt, but with verbose output, look above for compiler errors.\n"
|
||||
)
|
||||
return False, stdout
|
||||
locked_print(f"*** Finished building example {example} for board {board_name} ***")
|
||||
return True, stdout
|
||||
|
||||
|
||||
# Function to process task queues for each board
|
||||
def compile_examples(
|
||||
board: Board,
|
||||
examples: list[Path],
|
||||
build_dir: str | None,
|
||||
verbose_on_failure: bool,
|
||||
libs: list[str] | None,
|
||||
) -> tuple[bool, str]:
|
||||
"""Process the task queue for the given board."""
|
||||
global ERROR_HAPPENED # pylint: disable=global-statement
|
||||
board_name = board.board_name
|
||||
is_first = True
|
||||
for example in examples:
|
||||
example = example.relative_to(Path(".").resolve())
|
||||
if ERROR_HAPPENED:
|
||||
return True, ""
|
||||
locked_print(f"\n*** Building {example} for board {board_name} ***")
|
||||
if is_first:
|
||||
locked_print(
|
||||
f"*** Building for first example {example} board {board_name} ***"
|
||||
)
|
||||
if is_first and USE_FIRST_BUILD_LOCK:
|
||||
with FIRST_BUILD_LOCK:
|
||||
# Github runners are memory limited and the first job is the most
|
||||
# memory intensive since all the artifacts are being generated in parallel.
|
||||
success, message = compile_for_board_and_example(
|
||||
board=board,
|
||||
example=example,
|
||||
build_dir=build_dir,
|
||||
verbose_on_failure=verbose_on_failure,
|
||||
libs=libs,
|
||||
)
|
||||
else:
|
||||
success, message = compile_for_board_and_example(
|
||||
board=board,
|
||||
example=example,
|
||||
build_dir=build_dir,
|
||||
verbose_on_failure=verbose_on_failure,
|
||||
libs=libs,
|
||||
)
|
||||
is_first = False
|
||||
if not success:
|
||||
ERROR_HAPPENED = True
|
||||
return (
|
||||
False,
|
||||
f"Error building {example} for board {board_name}. stdout:\n{message}",
|
||||
)
|
||||
return True, ""
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Abstract Base Class for FastLED Compilers
|
||||
|
||||
Defines the interface that all FastLED compiler implementations must follow.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from concurrent.futures import Future
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CacheType(Enum):
|
||||
"""Compiler cache type options."""
|
||||
|
||||
NO_CACHE = "no_cache"
|
||||
SCCACHE = "sccache"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompilerResult:
|
||||
"""Base result class for compiler operations."""
|
||||
|
||||
success: bool
|
||||
output: str
|
||||
build_dir: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class InitResult(CompilerResult):
|
||||
"""Result from compiler initialization."""
|
||||
|
||||
@property
|
||||
def platformio_ini(self) -> Path:
|
||||
return self.build_dir / "platformio.ini"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SketchResult(CompilerResult):
|
||||
"""Result from sketch compilation."""
|
||||
|
||||
example: str
|
||||
|
||||
|
||||
class Compiler(ABC):
|
||||
"""Abstract base class defining the interface for FastLED compilers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the compiler."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build(self, examples: list[str]) -> list[Future[SketchResult]]:
|
||||
"""Build a list of examples with proper resource management.
|
||||
|
||||
Args:
|
||||
examples: List of example names or paths to compile
|
||||
|
||||
Returns:
|
||||
List of Future objects containing SketchResult for each example
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def clean(self) -> None:
|
||||
"""Clean build artifacts for this platform."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def clean_all(self) -> None:
|
||||
"""Clean all build artifacts (local and global) for this platform."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def deploy(
|
||||
self, example: str, upload_port: str | None = None, monitor: bool = False
|
||||
) -> SketchResult:
|
||||
"""Deploy (upload) a specific example to the target device.
|
||||
|
||||
Args:
|
||||
example: Name of the example to deploy
|
||||
upload_port: Optional specific port for upload
|
||||
monitor: If True, attach to device monitor after successful upload
|
||||
|
||||
Returns:
|
||||
SketchResult indicating success/failure of deployment
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def cancel_all(self) -> None:
|
||||
"""Cancel all currently running builds."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def check_usb_permissions(self) -> tuple[bool, str]:
|
||||
"""Check if USB device access is properly configured.
|
||||
|
||||
Returns:
|
||||
Tuple of (has_access, status_message)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def install_usb_permissions(self) -> bool:
|
||||
"""Install platform-specific USB permissions or equivalent.
|
||||
|
||||
Returns:
|
||||
True if installation succeeded, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_cache_stats(self) -> str:
|
||||
"""Get compiler statistics as a formatted string.
|
||||
|
||||
This can include cache statistics, build metrics, performance data,
|
||||
or any other relevant compiler statistics.
|
||||
|
||||
Returns:
|
||||
Formatted string containing compiler statistics, or empty string if none available
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,993 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false, reportReturnType=false, reportMissingParameterType=false
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import Future
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
from ci.util.paths import PROJECT_ROOT
|
||||
from ci.util.running_process import RunningProcess
|
||||
from ci.util.test_args import parse_args as parse_global_test_args
|
||||
|
||||
from .clang_compiler import (
|
||||
BuildFlags,
|
||||
Compiler,
|
||||
CompilerOptions,
|
||||
LinkOptions,
|
||||
test_clang_accessibility,
|
||||
)
|
||||
from .test_example_compilation import create_fastled_compiler
|
||||
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BUILD_DIR = PROJECT_ROOT / "tests" / ".build"
|
||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
CACHE_DIR = PROJECT_ROOT / ".cache"
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
TEST_FILES_LIST = CACHE_DIR / "test_files_list.txt"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HASH-BASED LINKING CACHE (same optimization as examples)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def calculate_file_hash(file_path: Path) -> str:
|
||||
"""Calculate SHA256 hash of a file (same as FastLEDTestCompiler)"""
|
||||
if not file_path.exists():
|
||||
return "no_file"
|
||||
|
||||
hash_sha256 = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hash_sha256.update(chunk)
|
||||
return hash_sha256.hexdigest()
|
||||
|
||||
|
||||
def calculate_linker_args_hash(linker_args: list[str]) -> str:
|
||||
"""Calculate SHA256 hash of linker arguments (same as FastLEDTestCompiler)"""
|
||||
args_str = "|".join(sorted(linker_args))
|
||||
hash_sha256 = hashlib.sha256()
|
||||
hash_sha256.update(args_str.encode("utf-8"))
|
||||
return hash_sha256.hexdigest()
|
||||
|
||||
|
||||
def calculate_link_cache_key(
|
||||
object_files: list[str | Path], fastled_lib_path: Path, linker_args: list[str]
|
||||
) -> str:
|
||||
"""Calculate comprehensive cache key for linking (same as examples)"""
|
||||
# Calculate hash for each object file
|
||||
obj_hashes: list[str] = []
|
||||
for obj_file in object_files:
|
||||
obj_hashes.append(calculate_file_hash(Path(obj_file)))
|
||||
|
||||
# Combine object file hashes in a stable way (sorted by path for consistency)
|
||||
sorted_obj_paths = sorted(str(obj) for obj in object_files)
|
||||
sorted_obj_hashes: list[str] = []
|
||||
for obj_path in sorted_obj_paths:
|
||||
obj_file = Path(obj_path)
|
||||
sorted_obj_hashes.append(calculate_file_hash(obj_file))
|
||||
|
||||
combined_obj_hash = hashlib.sha256(
|
||||
"|".join(sorted_obj_hashes).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
# Calculate other hashes
|
||||
fastled_hash = calculate_file_hash(fastled_lib_path)
|
||||
linker_hash = calculate_linker_args_hash(linker_args)
|
||||
|
||||
# Combine all components (same format as FastLEDTestCompiler)
|
||||
combined = f"fastled:{fastled_hash}|objects:{combined_obj_hash}|flags:{linker_hash}"
|
||||
final_hash = hashlib.sha256(combined.encode("utf-8")).hexdigest()
|
||||
|
||||
return final_hash[:16] # Use first 16 chars for readability
|
||||
|
||||
|
||||
def get_link_cache_dir() -> Path:
|
||||
"""Get the link cache directory (same as examples)"""
|
||||
cache_dir = Path(".build/link_cache")
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
|
||||
def get_cached_executable(test_name: str, cache_key: str) -> Optional[Path]:
|
||||
"""Check if cached executable exists (same as examples)"""
|
||||
cache_dir = get_link_cache_dir()
|
||||
cached_exe = cache_dir / f"{test_name}_{cache_key}.exe"
|
||||
return cached_exe if cached_exe.exists() else None
|
||||
|
||||
|
||||
def cache_executable(test_name: str, cache_key: str, exe_path: Path) -> None:
|
||||
"""Cache an executable for future use (same as examples)"""
|
||||
cache_dir = get_link_cache_dir()
|
||||
cached_exe = cache_dir / f"{test_name}_{cache_key}.exe"
|
||||
try:
|
||||
shutil.copy2(exe_path, cached_exe)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to cache {test_name}: {e}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# END HASH-BASED LINKING CACHE
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_test_files() -> Set[str]:
|
||||
"""Get a set of all test files"""
|
||||
test_files: Set[str] = set()
|
||||
tests_dir = PROJECT_ROOT / "tests"
|
||||
if tests_dir.exists():
|
||||
for file_path in tests_dir.rglob("test_*.cpp"):
|
||||
# Store relative paths for consistency
|
||||
test_files.add(str(file_path.relative_to(PROJECT_ROOT)))
|
||||
return test_files
|
||||
|
||||
|
||||
def check_test_files_changed() -> bool:
|
||||
"""Check if test files have changed since last run"""
|
||||
try:
|
||||
current_files = get_test_files()
|
||||
|
||||
if TEST_FILES_LIST.exists():
|
||||
# Read previous file list
|
||||
with open(TEST_FILES_LIST, "r") as f:
|
||||
previous_files = set(line.strip() for line in f if line.strip())
|
||||
|
||||
# Compare file sets
|
||||
if current_files == previous_files:
|
||||
return False # No changes
|
||||
else:
|
||||
print("Test files have changed, cleaning build directory...")
|
||||
return True # Files changed
|
||||
else:
|
||||
# No previous file list, need to clean
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Warning: Error checking test file changes: {e}")
|
||||
return True # Default to cleaning on error
|
||||
|
||||
|
||||
def save_test_files_list() -> None:
|
||||
"""Save current test file list"""
|
||||
try:
|
||||
current_files = get_test_files()
|
||||
with open(TEST_FILES_LIST, "w") as f:
|
||||
for file_path in sorted(current_files):
|
||||
f.write(f"{file_path}\n")
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to save test file list: {e}")
|
||||
|
||||
|
||||
def clean_build_directory():
|
||||
print("Cleaning build directory...")
|
||||
shutil.rmtree(BUILD_DIR, ignore_errors=True)
|
||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
print("Build directory cleaned.")
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
WASM_BUILD = False
|
||||
USE_ZIG = False
|
||||
USE_CLANG = False
|
||||
|
||||
|
||||
# Legacy CMake helper functions removed - using optimized Python API
|
||||
|
||||
|
||||
def get_unit_test_fastled_sources() -> list[Path]:
|
||||
"""Get essential FastLED .cpp files for unit test library creation (optimized paradigm)."""
|
||||
# Always work from project root
|
||||
project_root = Path(
|
||||
__file__
|
||||
).parent.parent.parent # Go up from ci/compiler/ to project root
|
||||
src_dir = project_root / "src"
|
||||
|
||||
# Core FastLED files that must be included for unit tests
|
||||
core_files: list[Path] = [
|
||||
src_dir / "FastLED.cpp",
|
||||
src_dir / "colorutils.cpp",
|
||||
src_dir / "hsv2rgb.cpp",
|
||||
]
|
||||
|
||||
# Find all .cpp files in key directories
|
||||
additional_sources: list[Path] = []
|
||||
for pattern in ["*.cpp", "lib8tion/*.cpp", "platforms/stub/*.cpp"]:
|
||||
additional_sources.extend(list(src_dir.glob(pattern)))
|
||||
|
||||
# Include essential .cpp files from nested directories
|
||||
additional_sources.extend(list(src_dir.rglob("*.cpp")))
|
||||
|
||||
# Filter out duplicates and ensure files exist
|
||||
all_sources: list[Path] = []
|
||||
seen_files: set[Path] = set()
|
||||
|
||||
for cpp_file in core_files + additional_sources:
|
||||
# Skip stub_main.cpp since unit tests have their own main
|
||||
if cpp_file.name == "stub_main.cpp":
|
||||
continue
|
||||
|
||||
# Skip platform-specific files that aren't needed for unit tests
|
||||
rel_path_str = str(cpp_file)
|
||||
if any(
|
||||
skip in rel_path_str for skip in ["wasm", "esp", "avr", "arm", "teensy"]
|
||||
):
|
||||
continue
|
||||
|
||||
if cpp_file.exists() and cpp_file not in seen_files:
|
||||
all_sources.append(cpp_file)
|
||||
seen_files.add(cpp_file)
|
||||
|
||||
return all_sources
|
||||
|
||||
|
||||
def create_unit_test_fastled_library(
|
||||
clean: bool = False, use_pch: bool = True
|
||||
) -> Path | None:
|
||||
"""Create libfastled.a static library specifically for unit tests with FASTLED_FORCE_NAMESPACE=1.
|
||||
|
||||
CRITICAL: Unit tests need their own separate library from examples because they use
|
||||
different compilation flags. Unit tests require FASTLED_FORCE_NAMESPACE=1 to put
|
||||
all symbols in the fl:: namespace that the tests expect.
|
||||
"""
|
||||
|
||||
# Unit tests get their own separate library directory under .build/fastled/unit/
|
||||
fastled_build_dir = BUILD_DIR.parent / ".build" / "fastled" / "unit"
|
||||
fastled_build_dir.mkdir(parents=True, exist_ok=True)
|
||||
lib_file = fastled_build_dir / "libfastled.a"
|
||||
|
||||
if lib_file.exists() and not clean:
|
||||
print(f"[LIBRARY] Using existing FastLED library: {lib_file}")
|
||||
return lib_file
|
||||
|
||||
print("[LIBRARY] Creating FastLED static library with proper compiler flags...")
|
||||
|
||||
# Create a proper FastLED compiler (NOT unit test compiler) for the library
|
||||
# This ensures the library is compiled without FASTLED_FORCE_NAMESPACE=1
|
||||
print("[LIBRARY] Creating FastLED library compiler (without unit test flags)...")
|
||||
|
||||
# Save current directory and change to project root for create_fastled_compiler
|
||||
import os
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
original_cwd = os.getcwd()
|
||||
os.chdir(str(project_root))
|
||||
|
||||
try:
|
||||
library_compiler = create_fastled_compiler(
|
||||
use_pch=use_pch,
|
||||
parallel=True,
|
||||
)
|
||||
|
||||
# CRITICAL: Add required defines for unit test library compilation
|
||||
if library_compiler.settings.defines is None:
|
||||
library_compiler.settings.defines = []
|
||||
|
||||
# Add FASTLED_FORCE_NAMESPACE=1 to export symbols in fl:: namespace
|
||||
library_compiler.settings.defines.append("FASTLED_FORCE_NAMESPACE=1")
|
||||
print("[LIBRARY] Added FASTLED_FORCE_NAMESPACE=1 to library compiler")
|
||||
|
||||
# Add FASTLED_TESTING=1 to include MockTimeProvider and test utility functions
|
||||
library_compiler.settings.defines.append("FASTLED_TESTING=1")
|
||||
print("[LIBRARY] Added FASTLED_TESTING=1 to library compiler")
|
||||
|
||||
finally:
|
||||
# Restore original working directory
|
||||
os.chdir(original_cwd)
|
||||
|
||||
# Get FastLED sources using optimized selection
|
||||
fastled_sources = get_unit_test_fastled_sources()
|
||||
fastled_objects: list[Path] = []
|
||||
|
||||
obj_dir = fastled_build_dir / "obj"
|
||||
obj_dir.mkdir(exist_ok=True)
|
||||
|
||||
print(f"[LIBRARY] Compiling {len(fastled_sources)} FastLED source files...")
|
||||
|
||||
# Compile each source file with optimized naming (same as examples)
|
||||
futures: List[Tuple[Any, ...]] = []
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
src_dir = project_root / "src"
|
||||
|
||||
for cpp_file in fastled_sources:
|
||||
# Create unique object file name by including relative path to prevent collisions
|
||||
# Convert path separators to underscores to create valid filename
|
||||
if cpp_file.is_relative_to(src_dir):
|
||||
rel_path = cpp_file.relative_to(src_dir)
|
||||
else:
|
||||
rel_path = cpp_file
|
||||
|
||||
# Replace path separators with underscores for unique object file names
|
||||
obj_name = str(rel_path.with_suffix(".o")).replace("/", "_").replace("\\", "_")
|
||||
obj_file = obj_dir / obj_name
|
||||
future = library_compiler.compile_cpp_file(cpp_file, obj_file)
|
||||
futures.append((future, obj_file, cpp_file))
|
||||
|
||||
# Wait for compilation to complete
|
||||
compiled_count = 0
|
||||
for future, obj_file, cpp_file in futures:
|
||||
try:
|
||||
result = future.result()
|
||||
if result.ok:
|
||||
fastled_objects.append(obj_file)
|
||||
compiled_count += 1
|
||||
else:
|
||||
print(
|
||||
f"[LIBRARY] WARNING: Failed to compile {cpp_file.relative_to(src_dir)}: {result.stderr[:100]}..."
|
||||
)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[LIBRARY] WARNING: Exception compiling {cpp_file.relative_to(src_dir)}: {e}"
|
||||
)
|
||||
|
||||
print(
|
||||
f"[LIBRARY] Successfully compiled {compiled_count}/{len(fastled_sources)} FastLED sources"
|
||||
)
|
||||
|
||||
if not fastled_objects:
|
||||
print("[LIBRARY] ERROR: No FastLED source files compiled successfully")
|
||||
return None
|
||||
|
||||
# Create static library using the same approach as examples
|
||||
print(f"[LIBRARY] Creating static library: {lib_file}")
|
||||
|
||||
archive_future = library_compiler.create_archive(fastled_objects, lib_file)
|
||||
archive_result = archive_future.result()
|
||||
|
||||
if not archive_result.ok:
|
||||
print(f"[LIBRARY] ERROR: Library creation failed: {archive_result.stderr}")
|
||||
return None
|
||||
|
||||
print(f"[LIBRARY] SUCCESS: FastLED library created: {lib_file}")
|
||||
return lib_file
|
||||
|
||||
|
||||
def create_unit_test_compiler(
|
||||
use_pch: bool = True, enable_static_analysis: bool = False, debug: bool = False
|
||||
) -> Compiler:
|
||||
"""Create compiler optimized for unit test compilation with PCH support."""
|
||||
|
||||
# Always work from the project root, not from ci/compiler
|
||||
project_root = Path(
|
||||
__file__
|
||||
).parent.parent.parent # Go up from ci/compiler/ to project root
|
||||
current_dir = project_root
|
||||
src_path = current_dir / "src"
|
||||
|
||||
# Load build flags configuration
|
||||
build_flags_path = current_dir / "ci" / "build_unit.toml"
|
||||
build_flags = BuildFlags.parse(
|
||||
build_flags_path, quick_build=True, strict_mode=False
|
||||
)
|
||||
|
||||
# Unit test specific defines
|
||||
unit_test_defines = [
|
||||
"FASTLED_UNIT_TEST=1",
|
||||
"FASTLED_FORCE_NAMESPACE=1",
|
||||
"FASTLED_USE_PROGMEM=0",
|
||||
"STUB_PLATFORM",
|
||||
"ARDUINO=10808",
|
||||
"FASTLED_USE_STUB_ARDUINO",
|
||||
"SKETCH_HAS_LOTS_OF_MEMORY=1",
|
||||
"FASTLED_STUB_IMPL",
|
||||
"FASTLED_USE_JSON_UI=1",
|
||||
"FASTLED_TESTING",
|
||||
"FASTLED_NO_AUTO_NAMESPACE",
|
||||
"FASTLED_NO_PINMAP",
|
||||
"HAS_HARDWARE_PIN_SUPPORT",
|
||||
"FASTLED_DEBUG_LEVEL=1",
|
||||
"FASTLED_NO_ATEXIT=1",
|
||||
"DOCTEST_CONFIG_NO_EXCEPTIONS_BUT_WITH_ALL_ASSERTS",
|
||||
"ENABLE_CRASH_HANDLER",
|
||||
"RELEASE=1", # Disable FASTLED_FORCE_DBG to avoid fl::println dependency
|
||||
]
|
||||
|
||||
# Unit test specific compiler args
|
||||
unit_test_args = [
|
||||
"-std=gnu++17",
|
||||
"-fpermissive",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
"-Wno-deprecated-register",
|
||||
"-Wno-backslash-newline-escape",
|
||||
"-fno-exceptions",
|
||||
"-fno-rtti",
|
||||
# Optimization/debug controls set below based on debug flag
|
||||
"-fno-omit-frame-pointer",
|
||||
"-fno-inline-functions",
|
||||
"-fno-vectorize",
|
||||
"-fno-unroll-loops",
|
||||
"-fno-strict-aliasing",
|
||||
f"-I{current_dir}",
|
||||
f"-I{src_path}",
|
||||
f"-I{current_dir / 'tests'}",
|
||||
f"-I{src_path / 'platforms' / 'stub'}",
|
||||
]
|
||||
|
||||
# Apply quick vs debug modes
|
||||
if debug:
|
||||
unit_test_args.extend(
|
||||
[
|
||||
"-O0",
|
||||
"-g3",
|
||||
"-fstandalone-debug",
|
||||
]
|
||||
)
|
||||
if os.name == "nt":
|
||||
unit_test_args.extend(
|
||||
["-gdwarf-4"]
|
||||
) # GNU debug info for Windows GNU toolchain
|
||||
else:
|
||||
unit_test_args.extend(
|
||||
[
|
||||
"-O0",
|
||||
"-g0",
|
||||
]
|
||||
)
|
||||
|
||||
# Note: DWARF flags are added above conditionally when debug=True
|
||||
|
||||
# PCH configuration with unit test specific headers
|
||||
pch_output_path = None
|
||||
pch_header_content = None
|
||||
if use_pch:
|
||||
cache_dir = current_dir / ".build" / "cache"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
pch_output_path = str(cache_dir / "fastled_unit_test_pch.hpp.pch")
|
||||
|
||||
# Unit test specific PCH header content
|
||||
pch_header_content = """// FastLED Unit Test PCH - Common headers for faster test compilation
|
||||
#pragma once
|
||||
|
||||
// Core test framework
|
||||
#include "test.h"
|
||||
|
||||
// Core FastLED headers that are used in nearly all unit tests
|
||||
#include "FastLED.h"
|
||||
|
||||
// Common C++ standard library headers used in tests
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <stdio.h>
|
||||
#include <cstdint>
|
||||
#include <cmath>
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
|
||||
// Platform headers for stub environment
|
||||
#include "platforms/stub/fastled_stub.h"
|
||||
|
||||
// Commonly tested FastLED components
|
||||
#include "lib8tion.h"
|
||||
#include "colorutils.h"
|
||||
#include "hsv2rgb.h"
|
||||
#include "fl/math.h"
|
||||
#include "fl/vector.h"
|
||||
|
||||
// Using namespace to match test files
|
||||
using namespace fl;
|
||||
"""
|
||||
print(f"[PCH] Unit tests will use precompiled headers: {pch_output_path}")
|
||||
print(
|
||||
f"[PCH] PCH includes: test.h, FastLED.h, lib8tion.h, colorutils.h, and more"
|
||||
)
|
||||
else:
|
||||
print("[PCH] Precompiled headers disabled for unit tests")
|
||||
|
||||
# Determine compiler
|
||||
compiler_cmd = "python -m ziglang c++"
|
||||
if USE_CLANG:
|
||||
compiler_cmd = "clang++"
|
||||
print("USING CLANG COMPILER FOR UNIT TESTS")
|
||||
elif USE_ZIG:
|
||||
print("USING ZIG COMPILER FOR UNIT TESTS")
|
||||
else:
|
||||
print("USING DEFAULT COMPILER FOR UNIT TESTS")
|
||||
|
||||
settings = CompilerOptions(
|
||||
include_path=str(src_path),
|
||||
defines=unit_test_defines,
|
||||
std_version="c++17",
|
||||
compiler=compiler_cmd,
|
||||
compiler_args=unit_test_args,
|
||||
use_pch=use_pch,
|
||||
pch_output_path=pch_output_path,
|
||||
pch_header_content=pch_header_content,
|
||||
parallel=True,
|
||||
)
|
||||
|
||||
return Compiler(settings, build_flags)
|
||||
|
||||
|
||||
def compile_unit_tests_python_api(
|
||||
specific_test: str | None = None,
|
||||
enable_static_analysis: bool = False,
|
||||
use_pch: bool = True,
|
||||
clean: bool = False,
|
||||
debug: bool = False,
|
||||
) -> bool:
|
||||
"""Compile unit tests using the fast Python API instead of CMake.
|
||||
|
||||
Returns:
|
||||
bool: True if all tests compiled and linked successfully, False otherwise
|
||||
"""
|
||||
from .clang_compiler import Compiler, LinkOptions
|
||||
|
||||
print("=" * 60)
|
||||
print("COMPILING UNIT TESTS WITH PYTHON API")
|
||||
print("=" * 60)
|
||||
|
||||
if clean:
|
||||
print("Cleaning build directory...")
|
||||
shutil.rmtree(BUILD_DIR, ignore_errors=True)
|
||||
BUILD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create optimized compiler for unit tests
|
||||
compiler = create_unit_test_compiler(
|
||||
use_pch=use_pch, enable_static_analysis=enable_static_analysis, debug=debug
|
||||
)
|
||||
|
||||
# Find all test files - work from project root
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
tests_dir = project_root / "tests"
|
||||
test_files = []
|
||||
|
||||
if specific_test:
|
||||
# Handle specific test with case-insensitive matching
|
||||
test_name = (
|
||||
specific_test
|
||||
if specific_test.startswith("test_")
|
||||
else f"test_{specific_test}"
|
||||
)
|
||||
test_file = tests_dir / f"{test_name}.cpp"
|
||||
|
||||
# First try exact case match
|
||||
if test_file.exists():
|
||||
test_files = [test_file]
|
||||
print(f"Compiling specific test: {test_file.name}")
|
||||
else:
|
||||
# Try case-insensitive matching for all test files
|
||||
found_match = False
|
||||
for existing_file in tests_dir.glob("test_*.cpp"):
|
||||
# Check if the file matches case-insensitively
|
||||
existing_stem = existing_file.stem
|
||||
existing_name = existing_stem.replace("test_", "")
|
||||
|
||||
if (
|
||||
existing_stem.lower() == test_name.lower()
|
||||
or existing_name.lower() == specific_test.lower()
|
||||
):
|
||||
test_files = [existing_file]
|
||||
print(
|
||||
f"Compiling specific test (case-insensitive match): {existing_file.name}"
|
||||
)
|
||||
found_match = True
|
||||
break
|
||||
|
||||
if not found_match:
|
||||
raise RuntimeError(f"Test file not found: {test_file}")
|
||||
else:
|
||||
# Find all test files
|
||||
test_files = list(tests_dir.glob("test_*.cpp"))
|
||||
print(f"Found {len(test_files)} unit test files")
|
||||
|
||||
if not test_files:
|
||||
print("No test files found")
|
||||
return
|
||||
|
||||
# Ensure output directory exists
|
||||
bin_dir = BUILD_DIR / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create clean execution directory as specified in requirements
|
||||
clean_bin_dir = PROJECT_ROOT / "tests" / "bin"
|
||||
clean_bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Step 1: Compile doctest main once
|
||||
print("Compiling doctest main...")
|
||||
doctest_main_path = tests_dir / "doctest_main.cpp"
|
||||
doctest_main_obj = bin_dir / "doctest_main.o"
|
||||
|
||||
if not doctest_main_obj.exists() or clean:
|
||||
doctest_compile_future = compiler.compile_cpp_file(
|
||||
cpp_path=str(doctest_main_path), output_path=str(doctest_main_obj)
|
||||
)
|
||||
doctest_result = doctest_compile_future.result()
|
||||
|
||||
if doctest_result.return_code != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to compile doctest main: {doctest_result.stderr}"
|
||||
)
|
||||
|
||||
# Step 2: Build FastLED library using optimized examples paradigm
|
||||
print("Building FastLED library...")
|
||||
|
||||
fastled_lib_path = create_unit_test_fastled_library(clean, use_pch=use_pch)
|
||||
|
||||
# Step 3: Compile and link each test (PARALLEL OPTIMIZATION)
|
||||
print(f"Compiling {len(test_files)} tests...")
|
||||
start_time = time.time()
|
||||
|
||||
# Phase 1: Start all compilations in parallel (NON-BLOCKING)
|
||||
print("🚀 Starting parallel compilation...")
|
||||
compile_start = time.time()
|
||||
compile_futures: Dict[str, Future[Any]] = {}
|
||||
test_info: Dict[str, Any] = {}
|
||||
|
||||
for test_file in test_files:
|
||||
test_name = test_file.stem
|
||||
executable_path = bin_dir / test_name
|
||||
object_path = bin_dir / f"{test_name}.o"
|
||||
|
||||
test_info[test_name] = {
|
||||
"test_file": test_file,
|
||||
"executable_path": executable_path,
|
||||
"object_path": object_path,
|
||||
}
|
||||
|
||||
print(f" Compiling {test_name}...")
|
||||
|
||||
# Start compilation (NON-BLOCKING)
|
||||
compile_future = compiler.compile_cpp_file(
|
||||
cpp_path=str(test_file), output_path=str(object_path)
|
||||
)
|
||||
compile_futures[test_name] = compile_future
|
||||
|
||||
# Phase 2: Wait for all compilations to complete and check cache before linking
|
||||
compile_dispatch_time = time.time() - compile_start
|
||||
print(
|
||||
f"⏳ Waiting for compilations to complete... (dispatch took {compile_dispatch_time:.2f}s)"
|
||||
)
|
||||
compile_wait_start = time.time()
|
||||
link_futures: Dict[str, Dict[str, Any]] = {}
|
||||
cache_hits = 0
|
||||
success_count = 0
|
||||
|
||||
for test_name, compile_future in compile_futures.items():
|
||||
try:
|
||||
compile_result = (
|
||||
compile_future.result()
|
||||
) # BLOCKING WAIT for this specific test
|
||||
|
||||
if compile_result.return_code != 0:
|
||||
print(f"❌ Compilation failed for {test_name}: {compile_result.stderr}")
|
||||
continue
|
||||
|
||||
# Prepare linking info (same logic as before)
|
||||
info = test_info[test_name]
|
||||
executable_path = info["executable_path"]
|
||||
object_path = info["object_path"]
|
||||
|
||||
tests_with_own_main = ["test_example_compilation"]
|
||||
if test_name in tests_with_own_main:
|
||||
object_files: list[str | Path] = [object_path]
|
||||
else:
|
||||
object_files: list[str | Path] = [object_path, doctest_main_obj]
|
||||
|
||||
# Platform-specific linker arguments for crash handler support
|
||||
if os.name == "nt": # Windows
|
||||
linker_args = ["-ldbghelp", "-lpsapi"]
|
||||
else: # Linux/macOS
|
||||
linker_args = ["-pthread"]
|
||||
|
||||
# HASH-BASED CACHE CHECK (same as examples)
|
||||
if not fastled_lib_path:
|
||||
print(f"⚠️ No FastLED library found, skipping cache for {test_name}")
|
||||
cache_key = "no_fastled_lib"
|
||||
cached_exe = None
|
||||
else:
|
||||
cache_key = calculate_link_cache_key(
|
||||
object_files, fastled_lib_path, linker_args
|
||||
)
|
||||
cached_exe = get_cached_executable(test_name, cache_key)
|
||||
|
||||
if cached_exe:
|
||||
# Cache hit! Copy cached executable to target location
|
||||
try:
|
||||
shutil.copy2(cached_exe, executable_path)
|
||||
# Also copy to clean execution directory
|
||||
clean_executable_path = clean_bin_dir / f"{test_name}.exe"
|
||||
shutil.copy2(cached_exe, clean_executable_path)
|
||||
cache_hits += 1
|
||||
success_count += 1
|
||||
print(f" ⚡ {test_name}: Using cached executable (cache hit)")
|
||||
continue # Skip linking entirely
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Failed to copy cached {test_name}, will relink: {e}")
|
||||
# Fall through to actual linking
|
||||
|
||||
# Cache miss - proceed with actual linking
|
||||
static_libraries: List[Union[str, Path]] = []
|
||||
if fastled_lib_path and fastled_lib_path.exists():
|
||||
static_libraries.append(fastled_lib_path)
|
||||
|
||||
link_options = LinkOptions(
|
||||
output_executable=str(executable_path),
|
||||
object_files=object_files,
|
||||
static_libraries=static_libraries,
|
||||
linker_args=linker_args,
|
||||
)
|
||||
|
||||
# Start linking (NON-BLOCKING) and store cache info for later
|
||||
link_future = compiler.link_program(link_options)
|
||||
link_futures[test_name] = {
|
||||
"future": link_future,
|
||||
"cache_key": cache_key,
|
||||
"executable_path": executable_path,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ ERROR compiling {test_name}: {e}")
|
||||
continue
|
||||
|
||||
# Phase 3: Wait for all linking to complete and cache successful results
|
||||
compile_wait_time = time.time() - compile_wait_start
|
||||
print(
|
||||
f"🔗 Waiting for linking to complete... (compilation took {compile_wait_time:.2f}s)"
|
||||
)
|
||||
link_start = time.time()
|
||||
cache_misses = 0
|
||||
|
||||
for test_name, link_info in link_futures.items():
|
||||
try:
|
||||
link_future = link_info["future"]
|
||||
cache_key = link_info["cache_key"]
|
||||
executable_path = link_info["executable_path"]
|
||||
|
||||
link_result = link_future.result() # BLOCKING WAIT for this specific test
|
||||
|
||||
if link_result.return_code != 0:
|
||||
print(f"⚠️ Linking failed for {test_name}: {link_result.stderr}")
|
||||
cache_misses += 1
|
||||
continue
|
||||
else:
|
||||
success_count += 1
|
||||
cache_misses += 1 # This was a fresh link, not from cache
|
||||
|
||||
# Cache the successful executable for future use (same as examples)
|
||||
cache_executable(test_name, cache_key, executable_path)
|
||||
|
||||
# Also copy to clean execution directory
|
||||
clean_executable_path = clean_bin_dir / f"{test_name}.exe"
|
||||
shutil.copy2(executable_path, clean_executable_path)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ ERROR linking {test_name}: {e}")
|
||||
cache_misses += 1
|
||||
continue
|
||||
|
||||
link_time = time.time() - link_start
|
||||
compilation_time = time.time() - start_time
|
||||
|
||||
print(f"✅ Unit test compilation completed in {compilation_time:.2f}s")
|
||||
print(f" 📊 Time breakdown:")
|
||||
print(f" • Dispatch: {compile_dispatch_time:.2f}s")
|
||||
print(f" • Compilation: {compile_wait_time:.2f}s")
|
||||
print(f" • Linking: {link_time:.2f}s")
|
||||
print(f" 🎯 Cache statistics (archive + hash linking optimization):")
|
||||
print(f" • Cache hits: {cache_hits} (skipped linking)")
|
||||
print(f" • Cache misses: {cache_misses} (fresh linking)")
|
||||
print(
|
||||
f" • Cache hit ratio: {cache_hits / max(1, cache_hits + cache_misses) * 100:.1f}%"
|
||||
)
|
||||
print(f" Successfully compiled: {success_count}/{len(test_files)} tests")
|
||||
print(f" Average: {compilation_time / len(test_files):.2f}s per test")
|
||||
print(f" Output directory: {bin_dir}")
|
||||
print(f" Cache directory: {get_link_cache_dir()}")
|
||||
|
||||
# Return success only if ALL tests compiled and linked successfully
|
||||
all_tests_successful = success_count == len(test_files)
|
||||
if not all_tests_successful:
|
||||
failed_count = len(test_files) - success_count
|
||||
print(f"❌ {failed_count} test(s) failed to compile or link")
|
||||
|
||||
return all_tests_successful
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compile FastLED library with different compiler options."
|
||||
)
|
||||
parser.add_argument("--use-zig", action="store_true", help="Use Zig compiler")
|
||||
parser.add_argument("--use-clang", action="store_true", help="Use Clang compiler")
|
||||
parser.add_argument("--wasm", action="store_true", help="Build for WebAssembly")
|
||||
parser.add_argument(
|
||||
"--clean",
|
||||
action="store_true",
|
||||
help="Clean the build directory before compiling",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
help="Specific test to compile (without test_ prefix)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Enable static analysis (IWYU, clang-tidy)",
|
||||
)
|
||||
parser.add_argument("--no-unity", action="store_true", help="Disable unity build")
|
||||
parser.add_argument(
|
||||
"--no-pch", action="store_true", help="Disable precompiled headers (PCH)"
|
||||
)
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug symbols")
|
||||
|
||||
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def get_build_info(args: argparse.Namespace) -> dict[str, str | dict[str, str]]:
|
||||
return {
|
||||
"USE_ZIG": str(USE_ZIG),
|
||||
"USE_CLANG": str(USE_CLANG),
|
||||
"WASM_BUILD": str(WASM_BUILD),
|
||||
"CC": os.environ.get("CC", ""),
|
||||
"CXX": os.environ.get("CXX", ""),
|
||||
"AR": os.environ.get("AR", ""),
|
||||
"CFLAGS": os.environ.get("CFLAGS", ""),
|
||||
"CXXFLAGS": os.environ.get("CXXFLAGS", ""),
|
||||
"ARGS": {
|
||||
"use_zig": str(args.use_zig),
|
||||
"use_clang": str(args.use_clang),
|
||||
"wasm": str(args.wasm),
|
||||
"specific_test": str(args.test) if args.test else "all",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def should_clean_build(build_info: dict[str, str | dict[str, str]]) -> bool:
|
||||
build_info_file = BUILD_DIR / "build_info.json"
|
||||
if not build_info_file.exists():
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(build_info_file, "r") as f:
|
||||
old_build_info = json.load(f)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
# If JSON is corrupted or empty, remove it and force clean build
|
||||
logger.warning(
|
||||
f"Corrupted build_info.json detected ({e}), removing and forcing clean build"
|
||||
)
|
||||
try:
|
||||
build_info_file.unlink()
|
||||
except OSError:
|
||||
pass # Ignore if we can't remove it
|
||||
return True
|
||||
|
||||
# If build parameters have changed, we need to rebuild
|
||||
if old_build_info != build_info:
|
||||
# Check if this is just a change in specific test target
|
||||
old_args_raw = old_build_info.get("ARGS", {})
|
||||
new_args_raw = build_info.get("ARGS", {})
|
||||
|
||||
# Ensure ARGS is a dictionary
|
||||
if not isinstance(old_args_raw, dict) or not isinstance(new_args_raw, dict):
|
||||
return True
|
||||
|
||||
old_args: Dict[str, Any] = old_args_raw # type: ignore
|
||||
new_args: Dict[str, Any] = new_args_raw # type: ignore
|
||||
|
||||
# If only the specific test changed and everything else is the same,
|
||||
# we don't need to clean the build directory
|
||||
old_test = old_args.get("specific_test", "all")
|
||||
new_test = new_args.get("specific_test", "all")
|
||||
|
||||
# Create copies without the specific_test field for comparison
|
||||
old_args_no_test: Dict[str, Any] = {
|
||||
k: v for k, v in old_args.items() if k != "specific_test"
|
||||
}
|
||||
new_args_no_test: Dict[str, Any] = {
|
||||
k: v for k, v in new_args.items() if k != "specific_test"
|
||||
}
|
||||
|
||||
# If only the test target changed, don't clean
|
||||
if (
|
||||
old_args_no_test == new_args_no_test
|
||||
and old_build_info.get("USE_ZIG") == build_info.get("USE_ZIG")
|
||||
and old_build_info.get("USE_CLANG") == build_info.get("USE_CLANG")
|
||||
and old_build_info.get("WASM_BUILD") == build_info.get("WASM_BUILD")
|
||||
and old_build_info.get("CC") == build_info.get("CC")
|
||||
and old_build_info.get("CXX") == build_info.get("CXX")
|
||||
and old_build_info.get("AR") == build_info.get("AR")
|
||||
and old_build_info.get("CFLAGS") == build_info.get("CFLAGS")
|
||||
and old_build_info.get("CXXFLAGS") == build_info.get("CXXFLAGS")
|
||||
):
|
||||
print(
|
||||
f"Build parameters unchanged, only test target changed: {old_test} -> {new_test}"
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def update_build_info(build_info: dict[str, str | dict[str, str]]):
|
||||
build_info_file = BUILD_DIR / "build_info.json"
|
||||
with open(build_info_file, "w") as f:
|
||||
json.dump(build_info, f, indent=2)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
global USE_ZIG, USE_CLANG, WASM_BUILD
|
||||
|
||||
args = parse_arguments()
|
||||
USE_ZIG = args.use_zig # use Zig's clang compiler
|
||||
USE_CLANG = args.use_clang # Use pure Clang for WASM builds
|
||||
WASM_BUILD = args.wasm
|
||||
|
||||
using_gcc = not USE_ZIG and not USE_CLANG and not WASM_BUILD
|
||||
if using_gcc:
|
||||
if not shutil.which("g++"):
|
||||
print(
|
||||
"gcc compiler not found in PATH, falling back zig's built in clang compiler"
|
||||
)
|
||||
USE_ZIG = True
|
||||
USE_CLANG = False
|
||||
|
||||
if USE_CLANG:
|
||||
if not test_clang_accessibility():
|
||||
print(
|
||||
"Clang compiler not found in PATH, falling back to Zig-clang compiler"
|
||||
)
|
||||
USE_ZIG = True
|
||||
USE_CLANG = False
|
||||
|
||||
os.chdir(str(HERE))
|
||||
print(f"Current directory: {Path('.').absolute()}")
|
||||
|
||||
# Auto-detection for --clean based on test file changes
|
||||
need_clean = args.clean
|
||||
if not need_clean:
|
||||
# Only check for changes if --clean wasn't explicitly specified
|
||||
need_clean = check_test_files_changed()
|
||||
|
||||
build_info = get_build_info(args)
|
||||
if need_clean or should_clean_build(build_info):
|
||||
clean_build_directory()
|
||||
# Save the file list after cleaning
|
||||
save_test_files_list()
|
||||
elif args.clean:
|
||||
# If --clean was explicitly specified but not needed according to build info,
|
||||
# still clean and save file list
|
||||
clean_build_directory()
|
||||
save_test_files_list()
|
||||
|
||||
# Unit tests now use optimized Python API by default (same as examples)
|
||||
use_pch = not getattr(
|
||||
args, "no_pch", False
|
||||
) # Default to PCH enabled unless --no-pch specified
|
||||
|
||||
# Use the optimized Python API with PCH optimization (now default for unit tests)
|
||||
compilation_successful = compile_unit_tests_python_api(
|
||||
specific_test=args.test,
|
||||
enable_static_analysis=args.check,
|
||||
use_pch=use_pch,
|
||||
clean=need_clean,
|
||||
debug=args.debug,
|
||||
)
|
||||
|
||||
if compilation_successful:
|
||||
update_build_info(build_info)
|
||||
print("FastLED library compiled successfully.")
|
||||
else:
|
||||
print("❌ FastLED unit test compilation failed!")
|
||||
import sys
|
||||
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,906 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false, reportMissingParameterType=false
|
||||
import argparse
|
||||
import multiprocessing
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time # Added for timing test execution
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from queue import Empty, PriorityQueue
|
||||
from threading import Event, Lock, Thread
|
||||
from typing import List
|
||||
|
||||
import psutil
|
||||
|
||||
from ci.util.paths import PROJECT_ROOT
|
||||
|
||||
|
||||
def optimize_python_command(cmd: list[str]) -> list[str]:
|
||||
"""
|
||||
Optimize command list for subprocess execution in uv environment.
|
||||
|
||||
For python commands, we need to use 'uv run python' to ensure access to
|
||||
installed packages like ziglang. Direct sys.executable bypasses uv environment.
|
||||
|
||||
Args:
|
||||
cmd: Command list that may contain 'python' as first element
|
||||
|
||||
Returns:
|
||||
list[str]: Optimized command with 'python' prefixed by 'uv run'
|
||||
"""
|
||||
if cmd and (cmd[0] == "python" or cmd[0] == "python3"):
|
||||
# Use uv run python to ensure access to uv-managed packages
|
||||
optimized_cmd = ["uv", "run", "python"] + cmd[1:]
|
||||
return optimized_cmd
|
||||
return cmd
|
||||
|
||||
|
||||
from ci.util.test_exceptions import (
|
||||
CompilationFailedException,
|
||||
TestExecutionFailedException,
|
||||
TestFailureInfo,
|
||||
TestTimeoutException,
|
||||
)
|
||||
|
||||
|
||||
class OutputBuffer:
|
||||
"""Thread-safe output buffer with ordered output display"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.output_queue: PriorityQueue[tuple[int, int, str]] = PriorityQueue()
|
||||
self.next_sequence: int = 0
|
||||
self.sequence_lock: Lock = Lock()
|
||||
self.stop_event: Event = Event()
|
||||
self.output_thread: Thread = Thread(target=self._output_worker, daemon=True)
|
||||
self.output_thread.start()
|
||||
|
||||
def write(self, test_index: int, message: str) -> None:
|
||||
"""Write a message to the buffer with test index for ordering"""
|
||||
with self.sequence_lock:
|
||||
sequence = self.next_sequence
|
||||
self.next_sequence += 1
|
||||
self.output_queue.put((test_index, sequence, message))
|
||||
|
||||
def _output_worker(self) -> None:
|
||||
"""Worker thread that processes output in order"""
|
||||
while not self.stop_event.is_set() or not self.output_queue.empty():
|
||||
try:
|
||||
item: tuple[int, int, str] = self.output_queue.get(timeout=0.1)
|
||||
_, _, message = item
|
||||
print(message, flush=True)
|
||||
self.output_queue.task_done()
|
||||
except Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
print(f"Error in output worker: {e}")
|
||||
continue
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the output worker thread"""
|
||||
self.stop_event.set()
|
||||
if self.output_thread.is_alive():
|
||||
self.output_thread.join()
|
||||
|
||||
|
||||
# Configure console for UTF-8 output on Windows
|
||||
if os.name == "nt": # Windows
|
||||
# Try to set console to UTF-8 mode
|
||||
try:
|
||||
# Set stdout and stderr to UTF-8 encoding
|
||||
# Note: reconfigure() was added in Python 3.7
|
||||
if hasattr(sys.stdout, "reconfigure") and callable(
|
||||
getattr(sys.stdout, "reconfigure", None)
|
||||
):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||
if hasattr(sys.stderr, "reconfigure") and callable(
|
||||
getattr(sys.stderr, "reconfigure", None)
|
||||
):
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
||||
except (AttributeError, OSError):
|
||||
# Fallback for older Python versions or if reconfigure fails
|
||||
pass
|
||||
|
||||
|
||||
# Environment flags for backward compatibility
|
||||
_SHOW_COMPILE = os.environ.get("FASTLED_TEST_SHOW_COMPILE", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
_SHOW_LINK = os.environ.get("FASTLED_TEST_SHOW_LINK", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FailedTest:
|
||||
name: str
|
||||
return_code: int
|
||||
stdout: str
|
||||
|
||||
|
||||
def check_iwyu_available() -> bool:
|
||||
"""Check if include-what-you-use is available in the system"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["include-what-you-use", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (
|
||||
subprocess.CalledProcessError,
|
||||
FileNotFoundError,
|
||||
subprocess.TimeoutExpired,
|
||||
):
|
||||
return False
|
||||
|
||||
|
||||
def run_command(
|
||||
command: str | list[str],
|
||||
use_gdb: bool = False,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
show_compile: bool = False,
|
||||
show_link: bool = False,
|
||||
) -> tuple[int, str]:
|
||||
captured_lines: list[str] = []
|
||||
|
||||
# Determine command type
|
||||
is_test_execution = False
|
||||
is_compile = False
|
||||
is_link = False
|
||||
if isinstance(command, str):
|
||||
cmd_lower = command.replace("\\", "/").lower()
|
||||
# Check if running test executable
|
||||
is_test_execution = (
|
||||
"/test_" in cmd_lower
|
||||
or ".build/bin/test_" in cmd_lower
|
||||
or cmd_lower.endswith(".exe")
|
||||
)
|
||||
# Check if compiling
|
||||
is_compile = "-c" in cmd_lower and (".cpp" in cmd_lower or ".c" in cmd_lower)
|
||||
# Check if linking
|
||||
is_link = (
|
||||
not is_compile
|
||||
and not is_test_execution
|
||||
and ("-o" in cmd_lower or "lib" in cmd_lower)
|
||||
)
|
||||
|
||||
if use_gdb:
|
||||
with tempfile.NamedTemporaryFile(mode="w+", delete=False) as gdb_script:
|
||||
gdb_script.write("set pagination off\n")
|
||||
gdb_script.write("run\n")
|
||||
gdb_script.write("bt full\n")
|
||||
gdb_script.write("info registers\n")
|
||||
gdb_script.write("x/16i $pc\n")
|
||||
gdb_script.write("thread apply all bt full\n")
|
||||
gdb_script.write("quit\n")
|
||||
|
||||
gdb_command = (
|
||||
f"gdb -return-child-result -batch -x {gdb_script.name} --args {command}"
|
||||
)
|
||||
process = subprocess.Popen(
|
||||
gdb_command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, # Merge stderr into stdout
|
||||
shell=True,
|
||||
text=False,
|
||||
)
|
||||
assert process.stdout is not None
|
||||
# Stream and capture output
|
||||
while True:
|
||||
line_bytes = process.stdout.readline()
|
||||
line = line_bytes.decode("utf-8", errors="ignore")
|
||||
if not line and process.poll() is not None:
|
||||
break
|
||||
if line:
|
||||
captured_lines.append(line.rstrip())
|
||||
# Always print GDB output (it's only used for crashes anyway)
|
||||
try:
|
||||
print(line, end="", flush=True)
|
||||
except UnicodeEncodeError:
|
||||
# Fallback: replace problematic characters
|
||||
print(
|
||||
line.encode("utf-8", errors="replace").decode(
|
||||
"utf-8", errors="replace"
|
||||
),
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
os.unlink(gdb_script.name)
|
||||
output = "\n".join(captured_lines)
|
||||
return process.returncode, output
|
||||
else:
|
||||
# Optimize list commands to avoid shell overhead
|
||||
if isinstance(command, list):
|
||||
# Optimize python commands and use shell=False for better performance
|
||||
python_exe = optimize_python_command(command)
|
||||
process = subprocess.Popen(
|
||||
python_exe,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, # Merge stderr into stdout
|
||||
shell=False, # Use shell=False for better performance with list commands
|
||||
text=False,
|
||||
)
|
||||
else:
|
||||
# String commands still need shell=True
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, # Merge stderr into stdout
|
||||
shell=True,
|
||||
text=False,
|
||||
)
|
||||
assert process.stdout is not None
|
||||
# Stream and capture output
|
||||
while True:
|
||||
line_bytes = process.stdout.readline()
|
||||
line = line_bytes.decode("utf-8", errors="ignore")
|
||||
if not line and process.poll() is not None:
|
||||
break
|
||||
if line:
|
||||
captured_lines.append(line.rstrip())
|
||||
# Determine if we should print this line
|
||||
should_print = (
|
||||
verbose # Always print in verbose mode
|
||||
or (is_compile and show_compile) # Print compilation if enabled
|
||||
or (is_link and show_link) # Print linking if enabled
|
||||
or (not is_test_execution) # Print non-test output
|
||||
or (
|
||||
is_test_execution and process.returncode != 0
|
||||
) # Print failed test output
|
||||
or (
|
||||
is_test_execution
|
||||
and any(
|
||||
marker in line
|
||||
for marker in [
|
||||
"Running test:",
|
||||
"Test passed",
|
||||
"Test FAILED",
|
||||
"passed with return code",
|
||||
"Test output:",
|
||||
]
|
||||
)
|
||||
) # Print test status
|
||||
)
|
||||
if should_print:
|
||||
try:
|
||||
# Add prefix for compile/link commands
|
||||
if is_compile and show_compile:
|
||||
print("[COMPILE] ", end="", flush=True)
|
||||
elif is_link and show_link:
|
||||
print("[LINK] ", end="", flush=True)
|
||||
print(line, end="", flush=True)
|
||||
except UnicodeEncodeError:
|
||||
# Fallback: replace problematic characters
|
||||
print(
|
||||
line.encode("utf-8", errors="replace").decode(
|
||||
"utf-8", errors="replace"
|
||||
),
|
||||
end="",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
output = "\n".join(captured_lines)
|
||||
return process.returncode, output
|
||||
|
||||
|
||||
def compile_tests(
|
||||
clean: bool = False,
|
||||
unknown_args: list[str] = [],
|
||||
specific_test: str | None = None,
|
||||
quick_build: bool = True,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
show_compile: bool = False,
|
||||
show_link: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Compile C++ tests using the Python build system.
|
||||
"""
|
||||
os.chdir(str(PROJECT_ROOT))
|
||||
print("🔧 Compiling tests using Python build system")
|
||||
|
||||
_compile_tests_python(
|
||||
clean,
|
||||
unknown_args,
|
||||
specific_test,
|
||||
quick_build=quick_build,
|
||||
verbose=verbose,
|
||||
show_compile=show_compile,
|
||||
show_link=show_link,
|
||||
)
|
||||
|
||||
|
||||
def _compile_tests_python(
|
||||
clean: bool = False,
|
||||
unknown_args: list[str] = [],
|
||||
specific_test: str | None = None,
|
||||
quick_build: bool = True,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
show_compile: bool = False,
|
||||
show_link: bool = False,
|
||||
) -> None:
|
||||
"""Python build system with PCH optimization"""
|
||||
# Use the optimized cpp_test_compile system directly
|
||||
import subprocess
|
||||
|
||||
cmd = ["uv", "run", "python", "-m", "ci.compiler.cpp_test_compile"]
|
||||
|
||||
if specific_test:
|
||||
cmd.extend(["--test", specific_test])
|
||||
if clean:
|
||||
cmd.append("--clean")
|
||||
if verbose:
|
||||
cmd.append("--verbose")
|
||||
if "--check" in unknown_args:
|
||||
cmd.append("--check")
|
||||
if "--no-pch" in unknown_args:
|
||||
cmd.append("--no-pch")
|
||||
# Forward debug mode to the compiler when quick_build is disabled
|
||||
if not quick_build:
|
||||
cmd.append("--debug")
|
||||
|
||||
print("🚀 Using Python build system with PCH optimization")
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"Unit test compilation failed with return code {result.returncode}"
|
||||
)
|
||||
|
||||
|
||||
def run_tests(
|
||||
specific_test: str | None = None,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
show_compile: bool = False,
|
||||
show_link: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Run compiled tests with GDB crash analysis support.
|
||||
"""
|
||||
_run_tests_python(specific_test)
|
||||
|
||||
|
||||
def _run_tests_python(
|
||||
specific_test: str | None = None,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
show_compile: bool = False,
|
||||
show_link: bool = False,
|
||||
) -> None:
|
||||
"""Run tests from Python build system"""
|
||||
# Import the test compiler system
|
||||
from ci.compiler.test_compiler import FastLEDTestCompiler
|
||||
|
||||
# Get test executables from Python build system
|
||||
test_compiler = FastLEDTestCompiler.get_existing_instance()
|
||||
if not test_compiler:
|
||||
print("No compiled tests found. Run compilation first.")
|
||||
sys.exit(1)
|
||||
|
||||
test_executables = test_compiler.get_test_executables(specific_test)
|
||||
if not test_executables:
|
||||
test_name = specific_test or "any tests"
|
||||
print(f"No test executables found for: {test_name}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Running {len(test_executables)} tests from Python build...")
|
||||
|
||||
# Print list of tests that will be executed
|
||||
print("Tests to execute:")
|
||||
for i, test_exec in enumerate(test_executables, 1):
|
||||
# Convert absolute path to relative for display
|
||||
rel_path = os.path.relpath(test_exec.executable_path)
|
||||
print(f" {i}. {test_exec.name} ({rel_path})")
|
||||
print("")
|
||||
|
||||
failed_tests: list[FailedTest] = []
|
||||
|
||||
# Convert to file list format for compatibility with existing logic
|
||||
files: list[str] = []
|
||||
test_paths: dict[str, str] = {}
|
||||
for test_exec in test_executables:
|
||||
file_name = test_exec.name
|
||||
if os.name == "nt" and not file_name.endswith(".exe"):
|
||||
file_name += ".exe"
|
||||
files.append(file_name)
|
||||
test_paths[file_name] = str(test_exec.executable_path)
|
||||
|
||||
print(f"Starting test execution for {len(files)} test files...")
|
||||
_execute_test_files(files, "", failed_tests, specific_test, test_paths)
|
||||
_handle_test_results(failed_tests)
|
||||
|
||||
|
||||
def _execute_test_files(
|
||||
files: list[str],
|
||||
test_dir: str,
|
||||
failed_tests: list[FailedTest],
|
||||
specific_test: str | None,
|
||||
test_paths: dict[str, str] | None = None,
|
||||
*,
|
||||
verbose: bool = False,
|
||||
show_compile: bool = False,
|
||||
show_link: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Execute test files in parallel with full GDB crash analysis.
|
||||
|
||||
Args:
|
||||
files: List of test file names
|
||||
test_dir: Directory containing tests (for CMake) or empty string (for Python API)
|
||||
failed_tests: List to collect failed tests
|
||||
specific_test: Specific test name if filtering
|
||||
test_paths: Dict mapping file names to full paths (for Python API)
|
||||
"""
|
||||
total_tests = len(files)
|
||||
successful_tests = 0
|
||||
completed_tests = 0
|
||||
|
||||
# Initialize output buffer for ordered output
|
||||
output_buffer = OutputBuffer()
|
||||
output_buffer.write(0, f"Executing {total_tests} test files in parallel...")
|
||||
|
||||
# Determine number of workers based on configuration
|
||||
|
||||
# Get configuration from args
|
||||
args = parse_args()
|
||||
|
||||
# Force sequential execution if NO_PARALLEL is set
|
||||
if os.environ.get("NO_PARALLEL"):
|
||||
max_workers = 1
|
||||
output_buffer.write(
|
||||
0, "NO_PARALLEL environment variable set - forcing sequential execution"
|
||||
)
|
||||
elif args.sequential:
|
||||
max_workers = 1
|
||||
elif args.parallel:
|
||||
max_workers = args.parallel
|
||||
else:
|
||||
max_workers = max(1, multiprocessing.cpu_count() - 1) # Leave one core free
|
||||
|
||||
# Check memory limit
|
||||
if args.max_memory:
|
||||
memory_limit = args.max_memory * 1024 * 1024 # Convert MB to bytes
|
||||
available_memory = psutil.virtual_memory().available
|
||||
if memory_limit > available_memory:
|
||||
output_buffer.write(
|
||||
0,
|
||||
f"Warning: Requested memory limit {args.max_memory}MB exceeds available memory {available_memory / (1024 * 1024):.0f}MB",
|
||||
)
|
||||
output_buffer.write(
|
||||
0, "Reducing number of parallel workers to stay within memory limits"
|
||||
)
|
||||
# Estimate memory per test based on previous runs or default to 100MB
|
||||
memory_per_test = 100 * 1024 * 1024 # 100MB per test
|
||||
max_parallel_by_memory = max(1, memory_limit // memory_per_test)
|
||||
max_workers = min(max_workers, max_parallel_by_memory)
|
||||
|
||||
output_buffer.write(0, f"Using {max_workers} parallel workers")
|
||||
|
||||
# Thread-safe counter
|
||||
counter_lock = Lock()
|
||||
|
||||
def run_single_test(
|
||||
test_file: str, test_index: int
|
||||
) -> tuple[bool, float, str, int]:
|
||||
"""Run a single test and return its results"""
|
||||
nonlocal completed_tests
|
||||
|
||||
if test_paths:
|
||||
test_path = test_paths[test_file]
|
||||
else:
|
||||
test_path = os.path.join(test_dir, test_file)
|
||||
|
||||
# For .cpp files, compile them first
|
||||
if test_path.endswith(".cpp"):
|
||||
# Create a temporary directory for compilation
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Compile the test file
|
||||
output_buffer.write(
|
||||
test_index,
|
||||
f"[{test_index}/{total_tests}] Compiling test: {test_file}",
|
||||
)
|
||||
compile_cmd = [
|
||||
"python",
|
||||
"-m",
|
||||
"ziglang",
|
||||
"c++",
|
||||
"-o",
|
||||
os.path.join(temp_dir, "test.exe"),
|
||||
test_path,
|
||||
"-I",
|
||||
os.path.join(PROJECT_ROOT, "src"),
|
||||
"-I",
|
||||
os.path.join(PROJECT_ROOT, "tests"),
|
||||
# NOTE: Compiler flags now come from build configuration TOML
|
||||
]
|
||||
return_code, stdout = run_command(compile_cmd)
|
||||
if return_code != 0:
|
||||
output_buffer.write(
|
||||
test_index,
|
||||
f"[{test_index}/{total_tests}] ERROR: Failed to compile test: {test_file}",
|
||||
)
|
||||
return False, 0.0, f"Failed to compile test: {stdout}", return_code
|
||||
|
||||
# Update test_path to point to the compiled executable
|
||||
test_path = os.path.join(temp_dir, "test.exe")
|
||||
|
||||
if not (os.path.isfile(test_path) and os.access(test_path, os.X_OK)):
|
||||
output_buffer.write(
|
||||
test_index,
|
||||
f"[{test_index}/{total_tests}] ERROR: Test file not found or not executable: {test_path}",
|
||||
)
|
||||
return False, 0.0, f"Test file not found or not executable: {test_path}", 1
|
||||
|
||||
output_buffer.write(
|
||||
test_index, f"[{test_index}/{total_tests}] Running test: {test_file}"
|
||||
)
|
||||
if verbose:
|
||||
output_buffer.write(test_index, f" Command: {test_path}")
|
||||
|
||||
start_time = time.time()
|
||||
# Pass --minimal flag to doctest when not in verbose mode to suppress output unless tests fail
|
||||
cmd = [test_path]
|
||||
if not verbose:
|
||||
cmd.append("--minimal")
|
||||
return_code, stdout = run_command(cmd)
|
||||
elapsed_time = time.time() - start_time
|
||||
|
||||
output = stdout
|
||||
failure_pattern = re.compile(r"Test .+ failed with return code (\d+)")
|
||||
failure_match = failure_pattern.search(output)
|
||||
is_crash = failure_match is not None
|
||||
|
||||
# Handle crashes with GDB (must be done synchronously)
|
||||
if is_crash:
|
||||
output_buffer.write(
|
||||
test_index, f"Test crashed. Re-running with GDB to get stack trace..."
|
||||
)
|
||||
_, gdb_stdout = run_command(test_path, use_gdb=True)
|
||||
stdout += "\n--- GDB Output ---\n" + gdb_stdout
|
||||
|
||||
# Extract crash information
|
||||
crash_info = extract_crash_info(gdb_stdout)
|
||||
output_buffer.write(
|
||||
test_index, f"Crash occurred at: {crash_info.file}:{crash_info.line}"
|
||||
)
|
||||
output_buffer.write(test_index, f"Cause: {crash_info.cause}")
|
||||
output_buffer.write(test_index, f"Stack: {crash_info.stack}")
|
||||
|
||||
# Print output based on verbosity and status
|
||||
if verbose or return_code != 0:
|
||||
output_buffer.write(test_index, "Test output:")
|
||||
output_buffer.write(test_index, stdout)
|
||||
|
||||
if return_code == 0:
|
||||
output_buffer.write(
|
||||
test_index, f" Test {test_file} passed in {elapsed_time:.2f}s"
|
||||
)
|
||||
else:
|
||||
output_buffer.write(
|
||||
test_index,
|
||||
f" Test {test_file} FAILED with return code {return_code} in {elapsed_time:.2f}s",
|
||||
)
|
||||
|
||||
with counter_lock:
|
||||
completed_tests += 1
|
||||
|
||||
return return_code == 0, elapsed_time, stdout, return_code
|
||||
|
||||
try:
|
||||
# Run tests in parallel using ThreadPoolExecutor
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
# Submit all tests
|
||||
future_to_test = {
|
||||
executor.submit(run_single_test, test_file, i + 1): test_file
|
||||
for i, test_file in enumerate(files)
|
||||
}
|
||||
|
||||
# Process results as they complete
|
||||
for future in as_completed(future_to_test):
|
||||
test_file = future_to_test[future]
|
||||
try:
|
||||
success, _, stdout, return_code = future.result()
|
||||
if success:
|
||||
with counter_lock:
|
||||
successful_tests += 1
|
||||
else:
|
||||
failed_tests.append(
|
||||
FailedTest(
|
||||
name=test_file, return_code=return_code, stdout=stdout
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
output_buffer.write(
|
||||
0, f"ERROR: Test {test_file} failed with exception: {e}"
|
||||
)
|
||||
failed_tests.append(
|
||||
FailedTest(name=test_file, return_code=1, stdout=str(e))
|
||||
)
|
||||
|
||||
# Print final summary
|
||||
output_buffer.write(
|
||||
0,
|
||||
f"Test execution complete: {successful_tests} passed, {len(failed_tests)} failed",
|
||||
)
|
||||
if successful_tests == total_tests:
|
||||
output_buffer.write(0, "All tests passed successfully!")
|
||||
else:
|
||||
output_buffer.write(
|
||||
0, f"Some tests failed ({len(failed_tests)} of {total_tests})"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Ensure output buffer is stopped
|
||||
output_buffer.stop()
|
||||
|
||||
|
||||
def _handle_test_results(
|
||||
failed_tests: list[FailedTest], *, verbose: bool = False
|
||||
) -> None:
|
||||
"""Handle test results and exit appropriately (preserving existing logic)"""
|
||||
if failed_tests:
|
||||
print("Failed tests summary:")
|
||||
failures: List[TestFailureInfo] = []
|
||||
for failed_test in failed_tests:
|
||||
print(
|
||||
f"Test {failed_test.name} failed with return code {failed_test.return_code}"
|
||||
)
|
||||
# Always show output on failure
|
||||
print("Output:")
|
||||
# Show indented output for better readability
|
||||
for line in failed_test.stdout.splitlines():
|
||||
print(f" {line}")
|
||||
print() # Add spacing between failed tests
|
||||
|
||||
failures.append(
|
||||
TestFailureInfo(
|
||||
test_name=failed_test.name,
|
||||
command=f"test_{failed_test.name}",
|
||||
return_code=failed_test.return_code,
|
||||
output=failed_test.stdout,
|
||||
error_type="test_execution_failure",
|
||||
)
|
||||
)
|
||||
|
||||
tests_failed = len(failed_tests)
|
||||
failed_test_names = [test.name for test in failed_tests]
|
||||
print(
|
||||
f"{tests_failed} test{'s' if tests_failed != 1 else ''} failed: {', '.join(failed_test_names)}"
|
||||
)
|
||||
raise TestExecutionFailedException(f"{tests_failed} test(s) failed", failures)
|
||||
if verbose:
|
||||
print("All tests passed.")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CrashInfo:
|
||||
cause: str = "Unknown"
|
||||
stack: str = "Unknown"
|
||||
file: str = "Unknown"
|
||||
line: str = "Unknown"
|
||||
|
||||
|
||||
def extract_crash_info(gdb_output: str) -> CrashInfo:
|
||||
lines = gdb_output.split("\n")
|
||||
crash_info = CrashInfo()
|
||||
|
||||
try:
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("Program received signal"):
|
||||
try:
|
||||
crash_info.cause = line.split(":", 1)[1].strip()
|
||||
except IndexError:
|
||||
crash_info.cause = line.strip()
|
||||
elif line.startswith("#0"):
|
||||
crash_info.stack = line
|
||||
for j in range(i, len(lines)):
|
||||
if "at" in lines[j]:
|
||||
try:
|
||||
_, location = lines[j].split("at", 1)
|
||||
location = location.strip()
|
||||
if ":" in location:
|
||||
crash_info.file, crash_info.line = location.rsplit(
|
||||
":", 1
|
||||
)
|
||||
else:
|
||||
crash_info.file = location
|
||||
except ValueError:
|
||||
pass # If split fails, we keep the default values
|
||||
break
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"Error parsing GDB output: {e}")
|
||||
|
||||
return crash_info
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Compile and run C++ tests")
|
||||
parser.add_argument(
|
||||
"--compile-only",
|
||||
action="store_true",
|
||||
help="Only compile the tests without running them",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--run-only",
|
||||
action="store_true",
|
||||
help="Only run the tests without compiling them",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--only-run-failed-test",
|
||||
action="store_true",
|
||||
help="Only run the tests that failed in the previous run",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean", action="store_true", help="Clean build before compiling"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
help="Specific test to run (without extension)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Enable verbose output",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show-compile",
|
||||
action="store_true",
|
||||
help="Show compilation commands and output",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show-link",
|
||||
action="store_true",
|
||||
help="Show linking commands and output",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--parallel",
|
||||
type=int,
|
||||
help="Number of parallel test processes to run (default: CPU count - 1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sequential",
|
||||
action="store_true",
|
||||
help="Run tests sequentially (disables parallel execution)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-memory",
|
||||
type=int,
|
||||
help="Maximum memory usage in MB for parallel test execution",
|
||||
)
|
||||
|
||||
# Create mutually exclusive group for compiler selection
|
||||
compiler_group = parser.add_mutually_exclusive_group()
|
||||
compiler_group.add_argument(
|
||||
"--clang",
|
||||
help="Use Clang compiler",
|
||||
action="store_true",
|
||||
)
|
||||
compiler_group.add_argument(
|
||||
"--gcc",
|
||||
help="Use GCC compiler (default on non-Windows)",
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Enable static analysis (IWYU, clang-tidy)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-unity",
|
||||
action="store_true",
|
||||
help="Disable unity builds for cpp tests",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-pch",
|
||||
action="store_true",
|
||||
help="Disable precompiled headers (PCH) for unit tests",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--debug",
|
||||
action="store_true",
|
||||
help="Use debug build mode with full debug symbols (default is quick mode with -g0)",
|
||||
)
|
||||
|
||||
args, unknown = parser.parse_known_args()
|
||||
args.unknown = unknown
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
args = parse_args()
|
||||
|
||||
# Get verbosity flags from args
|
||||
|
||||
run_only = args.run_only
|
||||
compile_only = args.compile_only
|
||||
specific_test = args.test
|
||||
# only_run_failed_test feature to be implemented in future
|
||||
_ = args.only_run_failed_test
|
||||
use_clang = args.clang
|
||||
|
||||
no_unity = args.no_unity
|
||||
quick_build = (
|
||||
not args.debug
|
||||
) # Default to quick mode unless --debug is specified
|
||||
# use_gcc = args.gcc
|
||||
|
||||
if not run_only:
|
||||
passthrough_args = args.unknown
|
||||
if use_clang:
|
||||
passthrough_args.append("--use-clang")
|
||||
if args.check:
|
||||
passthrough_args.append("--check")
|
||||
if no_unity:
|
||||
passthrough_args.append("--no-unity")
|
||||
if args.no_pch:
|
||||
passthrough_args.append("--no-pch")
|
||||
# Note: --gcc is handled by not passing --use-clang (GCC is the default in compiler/cpp_test_compile.py)
|
||||
compile_tests(
|
||||
clean=args.clean,
|
||||
unknown_args=passthrough_args,
|
||||
specific_test=specific_test,
|
||||
quick_build=quick_build,
|
||||
verbose=args.verbose,
|
||||
show_compile=args.show_compile,
|
||||
show_link=args.show_link,
|
||||
)
|
||||
|
||||
if not compile_only:
|
||||
if specific_test:
|
||||
run_tests(
|
||||
specific_test,
|
||||
verbose=args.verbose,
|
||||
show_compile=args.show_compile,
|
||||
show_link=args.show_link,
|
||||
)
|
||||
else:
|
||||
# Use our own test runner instead of CTest since CTest integration is broken
|
||||
run_tests(
|
||||
None,
|
||||
verbose=args.verbose,
|
||||
show_compile=args.show_compile,
|
||||
show_link=args.show_link,
|
||||
)
|
||||
except (
|
||||
CompilationFailedException,
|
||||
TestExecutionFailedException,
|
||||
TestTimeoutException,
|
||||
) as e:
|
||||
# Print detailed failure information
|
||||
print("\n" + "=" * 60)
|
||||
print("FASTLED TEST FAILURE DETAILS")
|
||||
print("=" * 60)
|
||||
print(e.get_detailed_failure_info())
|
||||
print("=" * 60)
|
||||
|
||||
# Exit with appropriate code
|
||||
if e.failures:
|
||||
# Use the return code from the first failure, or 1 if none available
|
||||
exit_code = (
|
||||
e.failures[0].return_code if e.failures[0].return_code != 0 else 1
|
||||
)
|
||||
else:
|
||||
exit_code = 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,784 @@
|
||||
"""
|
||||
Enhanced Arduino Package Index Implementation with Pydantic
|
||||
|
||||
This module provides a robust, production-ready Arduino package management system
|
||||
that fully complies with the Arduino CLI Package Index JSON Specification.
|
||||
|
||||
Key Features:
|
||||
- Pydantic models with comprehensive validation
|
||||
- Multi-source package index support
|
||||
- Caching and persistence
|
||||
- Package installation and dependency resolution
|
||||
- Search and filtering capabilities
|
||||
- Checksum validation and error handling
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
import tarfile
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import urlopen
|
||||
|
||||
import httpx
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
EmailStr,
|
||||
Field,
|
||||
HttpUrl,
|
||||
ValidationError,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
|
||||
# Custom Exceptions
|
||||
class PackageParsingError(Exception):
|
||||
"""Error parsing package index data"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PackageInstallationError(Exception):
|
||||
"""Error during package installation"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PackageValidationError(Exception):
|
||||
"""Error validating package data"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Core Pydantic Models with Enhanced Validation
|
||||
|
||||
|
||||
class Help(BaseModel):
|
||||
"""Help information with online resources"""
|
||||
|
||||
online: HttpUrl = Field(description="URL to online help resources")
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {"online": "https://github.com/espressif/arduino-esp32"}
|
||||
}
|
||||
|
||||
|
||||
class Board(BaseModel):
|
||||
"""Board information with complete properties"""
|
||||
|
||||
name: str = Field(min_length=1, max_length=200, description="Board display name")
|
||||
properties: Dict[str, Any] = Field(
|
||||
default_factory=dict, description="Board configuration properties"
|
||||
)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_board_name(cls, v: str) -> str:
|
||||
"""Validate board name format"""
|
||||
if not v.strip():
|
||||
raise ValueError("Board name cannot be empty or whitespace")
|
||||
return v.strip()
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"name": "ESP32 Dev Module",
|
||||
"properties": {
|
||||
"upload.tool": "esptool_py",
|
||||
"upload.maximum_size": "1310720",
|
||||
"build.mcu": "esp32",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ToolDependency(BaseModel):
|
||||
"""Tool dependency specification with validation"""
|
||||
|
||||
packager: str = Field(
|
||||
min_length=1, max_length=100, description="Tool packager name"
|
||||
)
|
||||
name: str = Field(pattern=r"^[a-zA-Z0-9_.-]+$", description="Tool name")
|
||||
version: str = Field(min_length=1, description="Required tool version")
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def validate_version_format(cls, v: str) -> str:
|
||||
"""Validate version format - supports semantic versioning and Arduino versioning"""
|
||||
if not v.strip():
|
||||
raise ValueError("Version cannot be empty")
|
||||
# Allow flexible versioning (semantic, date-based, etc.)
|
||||
return v.strip()
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"packager": "esp32",
|
||||
"name": "xtensa-esp32-elf-gcc",
|
||||
"version": "esp-2021r2-patch5-8.4.0",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Platform(BaseModel):
|
||||
"""Platform specification with comprehensive validation"""
|
||||
|
||||
name: str = Field(min_length=1, max_length=200, description="Platform display name")
|
||||
architecture: str = Field(
|
||||
pattern=r"^[a-zA-Z0-9_-]+$", description="Target architecture"
|
||||
)
|
||||
version: str = Field(description="Platform version")
|
||||
category: str = Field(min_length=1, description="Platform category")
|
||||
url: HttpUrl = Field(description="Download URL for platform archive")
|
||||
archive_filename: str = Field(
|
||||
alias="archiveFileName", description="Archive file name"
|
||||
)
|
||||
checksum: str = Field(
|
||||
pattern=r"^SHA-256:[a-fA-F0-9]{64}$", description="SHA-256 checksum"
|
||||
)
|
||||
size_mb: float = Field(gt=0, alias="size", description="Archive size in megabytes")
|
||||
boards: List[Board] = Field(
|
||||
default_factory=lambda: [], description="Supported boards"
|
||||
)
|
||||
tool_dependencies: List[ToolDependency] = Field(
|
||||
default_factory=lambda: [],
|
||||
alias="toolsDependencies",
|
||||
description="Required tool dependencies",
|
||||
)
|
||||
help: Help = Field(description="Help and documentation links")
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def validate_version_format(cls, v: str) -> str:
|
||||
"""Validate version format"""
|
||||
if not v.strip():
|
||||
raise ValueError("Version cannot be empty")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("size_mb", mode="before")
|
||||
@classmethod
|
||||
def convert_size_from_bytes(cls, v: Union[str, int, float]) -> float:
|
||||
"""Convert size from bytes to megabytes if needed"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
size_bytes = int(v)
|
||||
return size_bytes / (1024 * 1024)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid size format: {v}")
|
||||
elif isinstance(v, (int, float)):
|
||||
if v > 1024 * 1024: # Assume bytes if > 1MB
|
||||
return v / (1024 * 1024)
|
||||
return v # Already in MB
|
||||
return v
|
||||
|
||||
@field_validator("archive_filename")
|
||||
@classmethod
|
||||
def validate_archive_filename(cls, v: str) -> str:
|
||||
"""Validate archive filename format"""
|
||||
valid_extensions = [".zip", ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst"]
|
||||
if not any(v.lower().endswith(ext) for ext in valid_extensions):
|
||||
raise ValueError(
|
||||
f"Archive must have one of these extensions: {valid_extensions}"
|
||||
)
|
||||
return v
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
schema_extra: Dict[str, Any] = {
|
||||
"example": {
|
||||
"name": "ESP32 Arduino",
|
||||
"architecture": "esp32",
|
||||
"version": "2.0.5",
|
||||
"category": "ESP32",
|
||||
"url": "https://github.com/espressif/arduino-esp32/releases/download/2.0.5/esp32-2.0.5.zip",
|
||||
"archiveFileName": "esp32-2.0.5.zip",
|
||||
"checksum": "SHA-256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"size": "50000000",
|
||||
"boards": [],
|
||||
"toolsDependencies": [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SystemDownload(BaseModel):
|
||||
"""System-specific download information for tools"""
|
||||
|
||||
host: str = Field(min_length=1, description="Target host system identifier")
|
||||
url: HttpUrl = Field(description="Download URL for this system")
|
||||
archive_filename: str = Field(
|
||||
alias="archiveFileName", description="Archive file name"
|
||||
)
|
||||
checksum: str = Field(
|
||||
pattern=r"^SHA-256:[a-fA-F0-9]{64}$", description="SHA-256 checksum"
|
||||
)
|
||||
size_mb: float = Field(gt=0, alias="size", description="Archive size in megabytes")
|
||||
|
||||
@field_validator("size_mb", mode="before")
|
||||
@classmethod
|
||||
def convert_size_from_bytes(cls, v: Union[str, int, float]) -> float:
|
||||
"""Convert size from bytes to megabytes if needed"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
size_bytes = int(v)
|
||||
return size_bytes / (1024 * 1024)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid size format: {v}")
|
||||
elif isinstance(v, (int, float)):
|
||||
if v > 1024 * 1024: # Assume bytes if > 1MB
|
||||
return v / (1024 * 1024)
|
||||
return v # Already in MB
|
||||
return v
|
||||
|
||||
@field_validator("host")
|
||||
@classmethod
|
||||
def validate_host_format(cls, v: str) -> str:
|
||||
"""Validate host system identifier"""
|
||||
# Common host patterns: i686-pc-linux-gnu, x86_64-apple-darwin, etc.
|
||||
if not v.strip():
|
||||
raise ValueError("Host identifier cannot be empty")
|
||||
return v.strip()
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"host": "x86_64-pc-linux-gnu",
|
||||
"url": "https://github.com/espressif/crosstool-NG/releases/download/esp-2021r2-patch5/xtensa-esp32-elf-gcc8_4_0-esp-2021r2-patch5-linux-amd64.tar.gz",
|
||||
"archiveFileName": "xtensa-esp32-elf-gcc8_4_0-esp-2021r2-patch5-linux-amd64.tar.gz",
|
||||
"checksum": "SHA-256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
|
||||
"size": "150000000",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Tool(BaseModel):
|
||||
"""Tool with system-specific downloads and enhanced functionality"""
|
||||
|
||||
name: str = Field(pattern=r"^[a-zA-Z0-9_.-]+$", description="Tool name")
|
||||
version: str = Field(min_length=1, description="Tool version")
|
||||
systems: List[SystemDownload] = Field(
|
||||
min_length=1, description="System-specific downloads"
|
||||
)
|
||||
|
||||
@field_validator("systems")
|
||||
@classmethod
|
||||
def validate_unique_systems(cls, v: List[SystemDownload]) -> List[SystemDownload]:
|
||||
"""Ensure no duplicate host systems"""
|
||||
hosts = [system.host for system in v]
|
||||
if len(hosts) != len(set(hosts)):
|
||||
raise ValueError("Duplicate host systems found in tool downloads")
|
||||
return v
|
||||
|
||||
def get_system_download(self, host_pattern: str) -> Optional[SystemDownload]:
|
||||
"""Get system download matching host pattern"""
|
||||
for system in self.systems:
|
||||
if host_pattern in system.host:
|
||||
return system
|
||||
return None
|
||||
|
||||
def get_compatible_systems(self) -> List[str]:
|
||||
"""Get list of compatible host systems"""
|
||||
return [system.host for system in self.systems]
|
||||
|
||||
class Config:
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"name": "xtensa-esp32-elf-gcc",
|
||||
"version": "esp-2021r2-patch5-8.4.0",
|
||||
"systems": [
|
||||
{
|
||||
"host": "x86_64-pc-linux-gnu",
|
||||
"url": "https://github.com/espressif/crosstool-NG/releases/download/esp-2021r2-patch5/xtensa-esp32-elf-gcc8_4_0-esp-2021r2-patch5-linux-amd64.tar.gz",
|
||||
"archiveFileName": "xtensa-esp32-elf-gcc8_4_0-esp-2021r2-patch5-linux-amd64.tar.gz",
|
||||
"checksum": "SHA-256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"size": "150000000",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Package(BaseModel):
|
||||
"""Package containing platforms and tools with comprehensive validation"""
|
||||
|
||||
name: str = Field(pattern=r"^[A-Za-z0-9_.-]+$", description="Package identifier")
|
||||
maintainer: str = Field(
|
||||
min_length=1, max_length=200, description="Package maintainer"
|
||||
)
|
||||
website_url: HttpUrl = Field(alias="websiteURL", description="Package website URL")
|
||||
email: EmailStr = Field(description="Maintainer contact email")
|
||||
help: Help = Field(description="Help and documentation links")
|
||||
platforms: List[Platform] = Field(
|
||||
default_factory=lambda: [], description="Available platforms"
|
||||
)
|
||||
tools: List[Tool] = Field(default_factory=lambda: [], description="Available tools")
|
||||
|
||||
@field_validator("platforms")
|
||||
@classmethod
|
||||
def validate_unique_platforms(cls, v: List[Platform]) -> List[Platform]:
|
||||
"""Ensure no duplicate platform architecture/version combinations"""
|
||||
seen: Set[tuple[str, str]] = set()
|
||||
for platform in v:
|
||||
key = (platform.architecture, platform.version)
|
||||
if key in seen:
|
||||
raise ValueError(
|
||||
f"Duplicate platform found: {platform.architecture} v{platform.version}"
|
||||
)
|
||||
seen.add(key)
|
||||
return v
|
||||
|
||||
@field_validator("tools")
|
||||
@classmethod
|
||||
def validate_unique_tools(cls, v: List[Tool]) -> List[Tool]:
|
||||
"""Ensure no duplicate tool name/version combinations"""
|
||||
seen: Set[tuple[str, str]] = set()
|
||||
for tool in v:
|
||||
key = (tool.name, tool.version)
|
||||
if key in seen:
|
||||
raise ValueError(f"Duplicate tool found: {tool.name} v{tool.version}")
|
||||
seen.add(key)
|
||||
return v
|
||||
|
||||
def find_platform(
|
||||
self, architecture: str, version: Optional[str] = None
|
||||
) -> Optional[Platform]:
|
||||
"""Find platform by architecture and optionally version"""
|
||||
for platform in self.platforms:
|
||||
if platform.architecture == architecture:
|
||||
if version is None or platform.version == version:
|
||||
return platform
|
||||
return None
|
||||
|
||||
def find_tool(self, name: str, version: Optional[str] = None) -> Optional[Tool]:
|
||||
"""Find tool by name and optionally version"""
|
||||
for tool in self.tools:
|
||||
if tool.name == name:
|
||||
if version is None or tool.version == version:
|
||||
return tool
|
||||
return None
|
||||
|
||||
def get_latest_platform_version(self, architecture: str) -> Optional[str]:
|
||||
"""Get the latest version for a given architecture"""
|
||||
versions = [p.version for p in self.platforms if p.architecture == architecture]
|
||||
if not versions:
|
||||
return None
|
||||
# Simple version sorting - can be enhanced with proper semver parsing
|
||||
return sorted(versions)[-1]
|
||||
|
||||
class Config:
|
||||
allow_population_by_field_name = True
|
||||
schema_extra: Dict[str, Any] = {
|
||||
"example": {
|
||||
"name": "esp32",
|
||||
"maintainer": "Espressif Systems",
|
||||
"websiteURL": "https://github.com/espressif/arduino-esp32",
|
||||
"email": "hr@espressif.com",
|
||||
"help": {"online": "https://github.com/espressif/arduino-esp32"},
|
||||
"platforms": [],
|
||||
"tools": [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class PackageIndex(BaseModel):
|
||||
"""Root package index containing multiple packages"""
|
||||
|
||||
packages: List[Package] = Field(min_length=1, description="Available packages")
|
||||
|
||||
@field_validator("packages")
|
||||
@classmethod
|
||||
def validate_unique_packages(cls, v: List[Package]) -> List[Package]:
|
||||
"""Ensure no duplicate package names"""
|
||||
names = [pkg.name for pkg in v]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("Duplicate package names found in index")
|
||||
return v
|
||||
|
||||
def find_package(self, name: str) -> Optional[Package]:
|
||||
"""Find package by name"""
|
||||
for package in self.packages:
|
||||
if package.name == name:
|
||||
return package
|
||||
return None
|
||||
|
||||
def get_all_platforms(self) -> List[Platform]:
|
||||
"""Get all platforms from all packages"""
|
||||
platforms: List[Platform] = []
|
||||
for package in self.packages:
|
||||
platforms.extend(package.platforms)
|
||||
return platforms
|
||||
|
||||
def get_all_tools(self) -> List[Tool]:
|
||||
"""Get all tools from all packages"""
|
||||
tools: List[Tool] = []
|
||||
for package in self.packages:
|
||||
tools.extend(package.tools)
|
||||
return tools
|
||||
|
||||
|
||||
# Enhanced Parser with Validation and Error Handling
|
||||
|
||||
|
||||
class PackageIndexParser:
|
||||
"""Enhanced parser with comprehensive validation and error handling"""
|
||||
|
||||
def __init__(self, timeout: int = 30):
|
||||
"""Initialize parser with timeout configuration"""
|
||||
self.timeout = timeout
|
||||
|
||||
def parse_package_index(self, json_str: str) -> PackageIndex:
|
||||
"""Parse and validate package index JSON"""
|
||||
try:
|
||||
raw_data = json.loads(json_str)
|
||||
return PackageIndex(**raw_data)
|
||||
except ValidationError as e:
|
||||
raise PackageParsingError(f"Invalid package index format: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
raise PackageParsingError(f"Invalid JSON format: {e}")
|
||||
|
||||
def parse_from_url(self, url: str) -> PackageIndex:
|
||||
"""Fetch and parse package index from URL with validation"""
|
||||
try:
|
||||
print(f"Fetching package index from: {url}")
|
||||
with urlopen(url, timeout=self.timeout) as response:
|
||||
content = response.read()
|
||||
json_str = content.decode("utf-8")
|
||||
|
||||
return self.parse_package_index(json_str)
|
||||
|
||||
except Exception as e:
|
||||
raise PackageParsingError(f"Error fetching package index from {url}: {e}")
|
||||
|
||||
async def parse_from_url_async(self, url: str) -> PackageIndex:
|
||||
"""Async version of URL parsing"""
|
||||
try:
|
||||
print(f"Fetching package index from: {url}")
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
json_str = response.text
|
||||
|
||||
return self.parse_package_index(json_str)
|
||||
|
||||
except Exception as e:
|
||||
raise PackageParsingError(f"Error fetching package index from {url}: {e}")
|
||||
|
||||
|
||||
# Package Manager Configuration
|
||||
|
||||
|
||||
class PackageManagerConfig(BaseModel):
|
||||
"""Configuration for package manager with validation"""
|
||||
|
||||
cache_dir: Path = Field(default_factory=lambda: Path.home() / ".arduino_packages")
|
||||
sources: List[HttpUrl] = Field(
|
||||
default_factory=lambda: [], description="Package index URLs"
|
||||
)
|
||||
timeout: int = Field(
|
||||
default=30, gt=0, le=300, description="Request timeout in seconds"
|
||||
)
|
||||
max_retries: int = Field(
|
||||
default=3, ge=0, le=10, description="Maximum retry attempts"
|
||||
)
|
||||
verify_checksums: bool = Field(
|
||||
default=True, description="Verify download checksums"
|
||||
)
|
||||
allow_insecure: bool = Field(default=False, description="Allow insecure downloads")
|
||||
|
||||
@field_validator("cache_dir")
|
||||
@classmethod
|
||||
def validate_cache_dir(cls, v: Path) -> Path:
|
||||
"""Ensure cache directory is valid"""
|
||||
if v.exists() and not v.is_dir():
|
||||
raise ValueError(f"Cache path exists but is not a directory: {v}")
|
||||
return v
|
||||
|
||||
class Config:
|
||||
validate_assignment = True
|
||||
schema_extra = {
|
||||
"example": {
|
||||
"cache_dir": "~/.arduino_packages",
|
||||
"sources": [
|
||||
"https://espressif.github.io/arduino-esp32/package_esp32_index.json"
|
||||
],
|
||||
"timeout": 30,
|
||||
"max_retries": 3,
|
||||
"verify_checksums": True,
|
||||
"allow_insecure": False,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Utility Functions for Enhanced Functionality
|
||||
|
||||
|
||||
def verify_checksum(file_path: Path, expected_checksum: str) -> bool:
|
||||
"""Verify file checksum against expected SHA-256 value"""
|
||||
if not expected_checksum.startswith("SHA-256:"):
|
||||
raise ValueError(f"Invalid checksum format: {expected_checksum}")
|
||||
|
||||
expected_hash = expected_checksum[8:] # Remove 'SHA-256:' prefix
|
||||
|
||||
sha256_hash = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(chunk)
|
||||
|
||||
actual_hash = sha256_hash.hexdigest()
|
||||
return actual_hash.lower() == expected_hash.lower()
|
||||
|
||||
|
||||
def extract_archive(archive_path: Path, extract_to: Path) -> bool:
|
||||
"""Extract archive to specified directory with format detection"""
|
||||
try:
|
||||
archive_str = str(archive_path)
|
||||
|
||||
if archive_path.suffix == ".zip":
|
||||
with zipfile.ZipFile(archive_path, "r") as zip_ref:
|
||||
zip_ref.extractall(extract_to)
|
||||
elif archive_str.endswith((".tar.gz", ".tgz")):
|
||||
with tarfile.open(archive_path, "r:gz") as tar_ref:
|
||||
tar_ref.extractall(extract_to)
|
||||
elif archive_str.endswith(".tar.bz2"):
|
||||
with tarfile.open(archive_path, "r:bz2") as tar_ref:
|
||||
tar_ref.extractall(extract_to)
|
||||
elif archive_str.endswith(".tar.xz"):
|
||||
with tarfile.open(archive_path, "r:xz") as tar_ref:
|
||||
tar_ref.extractall(extract_to)
|
||||
else:
|
||||
raise ValueError(f"Unsupported archive format: {archive_path}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting archive {archive_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def format_size(size_mb: float) -> str:
|
||||
"""Format size in a human-readable way"""
|
||||
if size_mb < 1:
|
||||
return f"{size_mb * 1024:.1f} KB"
|
||||
elif size_mb < 1024:
|
||||
return f"{size_mb:.1f} MB"
|
||||
else:
|
||||
return f"{size_mb / 1024:.1f} GB"
|
||||
|
||||
|
||||
# Display Functions with Enhanced Formatting
|
||||
|
||||
|
||||
def display_package_info(package: Package) -> None:
|
||||
"""Display package information with enhanced formatting"""
|
||||
print(f"\n📦 Package: {package.name}")
|
||||
print(f"👤 Maintainer: {package.maintainer}")
|
||||
print(f"🌐 Website: {package.website_url}")
|
||||
print(f"📧 Email: {package.email}")
|
||||
print(f"📚 Help: {package.help.online}")
|
||||
print(f"🛠️ Platforms: {len(package.platforms)}")
|
||||
print(f"🔧 Tools: {len(package.tools)}")
|
||||
|
||||
# Show platform information
|
||||
for i, platform in enumerate(package.platforms[:3]): # Show first 3 platforms
|
||||
print(f"\n 📋 Platform {i + 1}: {platform.name} v{platform.version}")
|
||||
print(f" Architecture: {platform.architecture}")
|
||||
print(f" Category: {platform.category}")
|
||||
print(f" Size: {format_size(platform.size_mb)}")
|
||||
print(f" Archive: {platform.archive_filename}")
|
||||
print(f" Checksum: {platform.checksum[:24]}...")
|
||||
print(f" Help: {platform.help.online}")
|
||||
print(f" Boards: {len(platform.boards)}")
|
||||
|
||||
# Show first 5 boards
|
||||
for board in platform.boards[:5]:
|
||||
print(f" • {board.name}")
|
||||
if len(platform.boards) > 5:
|
||||
print(f" ... and {len(platform.boards) - 5} more")
|
||||
|
||||
print(f" Tool Dependencies: {len(platform.tool_dependencies)}")
|
||||
for dep in platform.tool_dependencies[:3]: # Show first 3 dependencies
|
||||
print(f" • {dep.name} v{dep.version} ({dep.packager})")
|
||||
if len(platform.tool_dependencies) > 3:
|
||||
print(f" ... and {len(platform.tool_dependencies) - 3} more")
|
||||
|
||||
if len(package.platforms) > 3:
|
||||
print(f"\n ... and {len(package.platforms) - 3} more platforms")
|
||||
|
||||
|
||||
def display_validation_summary(package_index: PackageIndex) -> None:
|
||||
"""Display validation summary for the package index"""
|
||||
print(f"\n✅ VALIDATION SUMMARY")
|
||||
print(f" 📦 Total packages: {len(package_index.packages)}")
|
||||
|
||||
total_platforms = sum(len(pkg.platforms) for pkg in package_index.packages)
|
||||
total_tools = sum(len(pkg.tools) for pkg in package_index.packages)
|
||||
total_boards = sum(
|
||||
len(platform.boards)
|
||||
for pkg in package_index.packages
|
||||
for platform in pkg.platforms
|
||||
)
|
||||
|
||||
print(f" 🛠️ Total platforms: {total_platforms}")
|
||||
print(f" 🔧 Total tools: {total_tools}")
|
||||
print(f" 💾 Total boards: {total_boards}")
|
||||
|
||||
# Show architectures
|
||||
architectures: Set[str] = set()
|
||||
for pkg in package_index.packages:
|
||||
for platform in pkg.platforms:
|
||||
architectures.add(platform.architecture)
|
||||
|
||||
print(f" 🏗️ Architectures: {', '.join(sorted(architectures))}")
|
||||
|
||||
|
||||
# Demonstration Functions
|
||||
|
||||
|
||||
def demo_esp32_parsing() -> Optional[PackageIndex]:
|
||||
"""Demonstrate parsing ESP32 package index with enhanced validation"""
|
||||
ESP32_URL = "https://espressif.github.io/arduino-esp32/package_esp32_index.json"
|
||||
|
||||
try:
|
||||
parser = PackageIndexParser(timeout=30)
|
||||
package_index = parser.parse_from_url(ESP32_URL)
|
||||
|
||||
print("🎉 Successfully parsed ESP32 package index with Pydantic validation!")
|
||||
display_validation_summary(package_index)
|
||||
|
||||
# Display first package
|
||||
if package_index.packages:
|
||||
display_package_info(package_index.packages[0])
|
||||
|
||||
return package_index
|
||||
|
||||
except PackageParsingError as e:
|
||||
print(f"❌ Package parsing error: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def demo_model_validation() -> None:
|
||||
"""Demonstrate Pydantic model validation capabilities"""
|
||||
print("\n🧪 TESTING PYDANTIC MODEL VALIDATION")
|
||||
|
||||
# Define valid platform data
|
||||
valid_platform_data: Dict[str, Any] = {
|
||||
"name": "ESP32 Arduino",
|
||||
"architecture": "esp32",
|
||||
"version": "2.0.5",
|
||||
"category": "ESP32",
|
||||
"url": "https://github.com/espressif/arduino-esp32/releases/download/2.0.5/esp32-2.0.5.zip",
|
||||
"archiveFileName": "esp32-2.0.5.zip",
|
||||
"checksum": "SHA-256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||
"size": "50000000", # Should convert to MB
|
||||
"boards": [],
|
||||
"toolsDependencies": [],
|
||||
"help": {"online": "https://github.com/espressif/arduino-esp32"},
|
||||
}
|
||||
|
||||
# Test valid platform
|
||||
try:
|
||||
platform = Platform(**valid_platform_data)
|
||||
print(f"✅ Valid platform created: {platform.name} v{platform.version}")
|
||||
print(f" Size converted: {platform.size_mb:.1f} MB")
|
||||
|
||||
except ValidationError as e:
|
||||
print(f"❌ Unexpected validation error: {e}")
|
||||
|
||||
# Test invalid platform
|
||||
try:
|
||||
invalid_platform_data = valid_platform_data.copy()
|
||||
invalid_platform_data["checksum"] = "invalid-checksum-format"
|
||||
|
||||
platform = Platform(**invalid_platform_data)
|
||||
print("❌ Should have failed validation!")
|
||||
|
||||
except ValidationError as e:
|
||||
print(f"✅ Correctly caught invalid checksum: {e.errors()[0]['msg']}")
|
||||
|
||||
# Test invalid URL
|
||||
try:
|
||||
invalid_url_data = valid_platform_data.copy()
|
||||
invalid_url_data["url"] = "not-a-valid-url"
|
||||
|
||||
platform = Platform(**invalid_url_data)
|
||||
print("❌ Should have failed URL validation!")
|
||||
|
||||
except ValidationError as e:
|
||||
print(f"✅ Correctly caught invalid URL: {e.errors()[0]['msg']}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main function demonstrating enhanced package index functionality"""
|
||||
print("🚀 ENHANCED ARDUINO PACKAGE INDEX WITH PYDANTIC")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# Demo model validation
|
||||
demo_model_validation()
|
||||
|
||||
# Demo ESP32 parsing
|
||||
package_index = demo_esp32_parsing()
|
||||
|
||||
# Interactive options
|
||||
try:
|
||||
print(f"\n📋 AVAILABLE OPTIONS:")
|
||||
print("1. Show detailed tools information")
|
||||
print("2. Search for specific architecture")
|
||||
print("3. Exit")
|
||||
|
||||
choice = input("\nEnter your choice (1-3): ").strip()
|
||||
|
||||
if choice == "1" and package_index and package_index.packages:
|
||||
# Simple tools info display (without importing original)
|
||||
pkg = package_index.packages[0]
|
||||
print(f"\n🔧 TOOLS INFORMATION for {pkg.name}")
|
||||
print(f"Total tools: {len(pkg.tools)}")
|
||||
for tool in pkg.tools[:3]: # Show first 3 tools
|
||||
print(
|
||||
f" • {tool.name} v{tool.version} ({len(tool.systems)} systems)"
|
||||
)
|
||||
if len(pkg.tools) > 3:
|
||||
print(f" ... and {len(pkg.tools) - 3} more tools")
|
||||
|
||||
elif choice == "2" and package_index:
|
||||
arch = input("Enter architecture to search for: ").strip()
|
||||
platforms = [
|
||||
p
|
||||
for pkg in package_index.packages
|
||||
for p in pkg.platforms
|
||||
if p.architecture == arch
|
||||
]
|
||||
if platforms:
|
||||
print(
|
||||
f"\n🔍 Found {len(platforms)} platforms for architecture '{arch}':"
|
||||
)
|
||||
for platform in platforms:
|
||||
print(
|
||||
f" • {platform.name} v{platform.version} ({format_size(platform.size_mb)})"
|
||||
)
|
||||
else:
|
||||
print(f"❌ No platforms found for architecture '{arch}'")
|
||||
else:
|
||||
print("👋 Goodbye!")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Interrupted by user")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 Interrupted by user")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,937 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PlatformIO artifact cache implementation for speeding up CI builds.
|
||||
|
||||
This module implements the cache mechanism described in FEATURE_PIO_SPEEDUP.md,
|
||||
providing functionality to:
|
||||
1. Parse platformio.ini files with zip URLs
|
||||
2. Download and cache platform/framework artifacts
|
||||
3. Install artifacts via PlatformIO CLI
|
||||
4. Modify platformio.ini in-place with resolved local paths
|
||||
"""
|
||||
|
||||
import _thread
|
||||
import configparser
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import TracebackType
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union, cast
|
||||
|
||||
import fasteners
|
||||
import httpx
|
||||
|
||||
from ci.compiler.platformio_ini import PlatformIOIni
|
||||
from ci.util.running_process import RunningProcess
|
||||
from ci.util.url_utils import sanitize_url_for_path
|
||||
|
||||
|
||||
@dataclass
|
||||
class DownloadResult:
|
||||
"""Result of a download operation."""
|
||||
|
||||
url: str
|
||||
temp_path: Path
|
||||
exception: Optional[BaseException] = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""True if download succeeded."""
|
||||
return self.exception is None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtifactProcessingResult:
|
||||
"""Result of processing an artifact."""
|
||||
|
||||
url: str
|
||||
is_framework: bool
|
||||
env_section: str
|
||||
resolved_path: Optional[str] = None
|
||||
exception: Optional[BaseException] = None
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""True if processing succeeded."""
|
||||
return self.exception is None and self.resolved_path is not None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZipUrlInfo:
|
||||
"""Information about a zip URL found in platformio.ini."""
|
||||
|
||||
url: str
|
||||
section_name: str
|
||||
option_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManifestResult:
|
||||
"""Result of manifest validation and type detection."""
|
||||
|
||||
is_valid: bool
|
||||
manifest_path: Optional[Path]
|
||||
is_framework: bool
|
||||
|
||||
|
||||
def _get_remote_file_size(url: str) -> Optional[int]:
|
||||
"""Get file size from URL using HEAD request."""
|
||||
try:
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
if parsed_url.scheme in ("http", "https"):
|
||||
with httpx.Client(follow_redirects=True) as client:
|
||||
response = client.head(
|
||||
url, headers={"User-Agent": "PlatformIO-Cache/1.0"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length:
|
||||
return int(content_length)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get file size for {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _format_file_size(size_bytes: Optional[int]) -> str:
|
||||
"""Format file size in human-readable format."""
|
||||
if size_bytes is None:
|
||||
return "unknown size"
|
||||
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
elif size_bytes < 1024 * 1024:
|
||||
return f"{size_bytes / 1024:.1f} KB"
|
||||
elif size_bytes < 1024 * 1024 * 1024:
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
else:
|
||||
return f"{size_bytes / (1024 * 1024 * 1024):.1f} GB"
|
||||
|
||||
|
||||
# Global cache to track PlatformIO installations in this session
|
||||
_session_installation_cache: set[str] = set()
|
||||
|
||||
# Global cancellation event for handling keyboard interrupts
|
||||
_global_cancel_event = threading.Event()
|
||||
|
||||
# Lock for thread-safe operations
|
||||
_download_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_status_file(artifact_dir: Path, cache_key: str) -> Path:
|
||||
"""Get the JSON status file path for an artifact."""
|
||||
# Use simple descriptive filename since artifacts are already in unique directories
|
||||
return artifact_dir / "info.json"
|
||||
|
||||
|
||||
def _read_status(status_file: Path) -> Optional[Dict[str, Any]]:
|
||||
"""Read status from JSON file."""
|
||||
if not status_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(status_file, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_status(status_file: Path, status: Dict[str, Any]) -> None:
|
||||
"""Write status to JSON file."""
|
||||
try:
|
||||
with open(status_file, "w") as f:
|
||||
json.dump(status, f, indent=2)
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to write status file {status_file}: {e}")
|
||||
|
||||
|
||||
def _is_processing_complete(status_file: Path) -> bool:
|
||||
"""Check if processing is complete based on status file."""
|
||||
status = _read_status(status_file)
|
||||
return status is not None and status.get("status") == "complete"
|
||||
|
||||
|
||||
def _download_with_progress(
|
||||
url: str, temp_path: Path, cancel_event: threading.Event
|
||||
) -> DownloadResult:
|
||||
"""Download HTTP/HTTPS file with cancellation support."""
|
||||
try:
|
||||
# HTTP/HTTPS download using httpx with streaming
|
||||
with httpx.Client(follow_redirects=True, timeout=30.0) as client:
|
||||
with client.stream(
|
||||
"GET", url, headers={"User-Agent": "PlatformIO-Cache/1.0"}
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
|
||||
with open(temp_path, "wb") as f:
|
||||
for chunk in response.iter_bytes(chunk_size=8192):
|
||||
# Check for cancellation periodically
|
||||
if cancel_event.is_set():
|
||||
cancelled_error = RuntimeError(
|
||||
f"Download cancelled for {url}"
|
||||
)
|
||||
logger.warning(
|
||||
f"Download cancelled for {url}",
|
||||
exc_info=cancelled_error,
|
||||
)
|
||||
return DownloadResult(url, temp_path, cancelled_error)
|
||||
f.write(chunk)
|
||||
|
||||
return DownloadResult(url, temp_path) # Success
|
||||
|
||||
except KeyboardInterrupt as e:
|
||||
# Set cancel event and interrupt main thread
|
||||
cancel_event.set()
|
||||
_thread.interrupt_main()
|
||||
logger.warning(
|
||||
f"Download interrupted by KeyboardInterrupt for {url}", exc_info=e
|
||||
)
|
||||
return DownloadResult(url, temp_path, e)
|
||||
except Exception as e:
|
||||
if not cancel_event.is_set():
|
||||
logger.error(f"Download failed for {url}: {e}", exc_info=e)
|
||||
else:
|
||||
logger.warning(f"Download failed for {url} (cancelled): {e}", exc_info=e)
|
||||
return DownloadResult(url, temp_path, e)
|
||||
|
||||
|
||||
def _copy_file_with_progress(
|
||||
url: str, temp_path: Path, cancel_event: threading.Event
|
||||
) -> DownloadResult:
|
||||
"""Copy local file with cancellation support."""
|
||||
try:
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
|
||||
# File URL - copy local file
|
||||
logger.debug(f"Parsing file URL: {url}")
|
||||
logger.debug(f"Parsed URL path: {parsed_url.path}")
|
||||
|
||||
# Handle both Unix and Windows file URLs
|
||||
if os.name == "nt": # Windows
|
||||
# On Windows, file:///C:/path becomes /C:/path, so remove leading slash
|
||||
if (
|
||||
parsed_url.path.startswith("/")
|
||||
and len(parsed_url.path) > 3
|
||||
and parsed_url.path[2] == ":"
|
||||
):
|
||||
source_path = Path(parsed_url.path[1:])
|
||||
else:
|
||||
source_path = Path(parsed_url.path)
|
||||
else: # Unix-like
|
||||
source_path = Path(parsed_url.path)
|
||||
|
||||
logger.debug(f"Resolved file path: {source_path}")
|
||||
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"Source file not found: {source_path}")
|
||||
|
||||
# Check for cancellation before copy
|
||||
if cancel_event.is_set():
|
||||
cancelled_error = RuntimeError(f"Copy cancelled for {url}")
|
||||
logger.warning(f"Copy cancelled for {url}", exc_info=cancelled_error)
|
||||
return DownloadResult(url, temp_path, cancelled_error)
|
||||
|
||||
shutil.copy2(source_path, temp_path)
|
||||
return DownloadResult(url, temp_path) # Success
|
||||
|
||||
except KeyboardInterrupt as e:
|
||||
# Set cancel event and interrupt main thread
|
||||
cancel_event.set()
|
||||
_thread.interrupt_main()
|
||||
logger.warning(f"Copy interrupted by KeyboardInterrupt for {url}", exc_info=e)
|
||||
return DownloadResult(url, temp_path, e)
|
||||
except Exception as e:
|
||||
if not cancel_event.is_set():
|
||||
logger.error(f"Copy failed for {url}: {e}", exc_info=e)
|
||||
else:
|
||||
logger.warning(f"Copy failed for {url} (cancelled): {e}", exc_info=e)
|
||||
return DownloadResult(url, temp_path, e)
|
||||
|
||||
|
||||
def clear_session_cache() -> None:
|
||||
"""Clear the session installation cache. Useful for testing."""
|
||||
global _session_installation_cache
|
||||
_session_installation_cache.clear()
|
||||
logger.debug("Cleared session installation cache")
|
||||
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reduce httpx verbosity to avoid log spew from HTTP requests
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class PlatformIOCache:
|
||||
"""Enhanced cache manager for PlatformIO artifacts."""
|
||||
|
||||
def __init__(self, cache_dir: Path):
|
||||
"""Initialize cache manager with directory structure."""
|
||||
self.cache_dir = cache_dir
|
||||
# Simplified structure: each artifact gets its own directory directly in cache root
|
||||
# containing both the .zip and extracted/ folder
|
||||
|
||||
# Create directory structure
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_cache_key(self, url: str) -> str:
|
||||
"""Generate cache key from URL - sanitized for filesystem use."""
|
||||
return str(sanitize_url_for_path(url))
|
||||
|
||||
def download_artifact(self, url: str) -> str:
|
||||
"""
|
||||
Download and cache an artifact from the given URL.
|
||||
Returns the absolute path to the cached zip file.
|
||||
"""
|
||||
cache_key = self._get_cache_key(url)
|
||||
# Each artifact gets its own directory
|
||||
artifact_dir = self.cache_dir / cache_key
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
cached_path = artifact_dir / "artifact.zip"
|
||||
|
||||
# Use read-write locking for concurrent safety
|
||||
# Start with write lock for downloading, can upgrade to read if cache hit
|
||||
lock_path = str(artifact_dir / "artifact.lock")
|
||||
rw_lock = fasteners.InterProcessReaderWriterLock(lock_path)
|
||||
with rw_lock.write_lock():
|
||||
if cached_path.exists():
|
||||
# Check if processing was completed successfully
|
||||
status_file = _get_status_file(artifact_dir, cache_key)
|
||||
if not _is_processing_complete(status_file):
|
||||
logger.warning(
|
||||
f"Cache incomplete (no completion status), re-downloading: {url}"
|
||||
)
|
||||
# Remove incomplete zip file
|
||||
cached_path.unlink()
|
||||
# Also remove any partial extraction
|
||||
extracted_dir = artifact_dir / "extracted"
|
||||
if extracted_dir.exists():
|
||||
shutil.rmtree(extracted_dir)
|
||||
else:
|
||||
print(f"Using cached artifact: {cached_path}")
|
||||
# Cache hit - return the cached path (write lock will be released)
|
||||
return str(cached_path)
|
||||
|
||||
# Check URL scheme to determine action
|
||||
parsed_url = urllib.parse.urlparse(url)
|
||||
|
||||
if parsed_url.scheme in ("http", "https"):
|
||||
# Download to temporary file first (atomic operation)
|
||||
file_size = _get_remote_file_size(url)
|
||||
size_str = _format_file_size(file_size)
|
||||
print(f"Downloading: {url} ({size_str})")
|
||||
temp_file_handle = tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".zip"
|
||||
)
|
||||
temp_path = Path(temp_file_handle.name)
|
||||
temp_file_handle.close() # Close immediately to avoid Windows file locking issues
|
||||
|
||||
# Create a thread-local cancel event
|
||||
thread_cancel_event = threading.Event()
|
||||
|
||||
# Download directly (we're already in a thread from the main pool)
|
||||
download_result = _download_with_progress(
|
||||
url, temp_path, thread_cancel_event
|
||||
)
|
||||
elif parsed_url.scheme == "file":
|
||||
# File URL - copy from local file
|
||||
print(f"Copying from local file: {url}")
|
||||
temp_file_handle = tempfile.NamedTemporaryFile(
|
||||
delete=False, suffix=".zip"
|
||||
)
|
||||
temp_path = Path(temp_file_handle.name)
|
||||
temp_file_handle.close() # Close immediately to avoid Windows file locking issues
|
||||
|
||||
# Create a thread-local cancel event
|
||||
thread_cancel_event = threading.Event()
|
||||
|
||||
# Copy directly (we're already in a thread from the main pool)
|
||||
download_result = _copy_file_with_progress(
|
||||
url, temp_path, thread_cancel_event
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported URL scheme: {parsed_url.scheme}")
|
||||
|
||||
try:
|
||||
# Check if global cancellation was requested
|
||||
if _global_cancel_event.is_set():
|
||||
raise KeyboardInterrupt(
|
||||
"Download cancelled due to global interrupt"
|
||||
)
|
||||
|
||||
# Process the download result
|
||||
if not download_result.success:
|
||||
if download_result.exception is not None:
|
||||
raise download_result.exception
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Download failed for unknown reason: {download_result.url}"
|
||||
)
|
||||
else:
|
||||
print(f"Download completed successfully: {download_result.url}")
|
||||
|
||||
# Atomic move to final location
|
||||
shutil.move(str(temp_path), str(cached_path))
|
||||
print(f"Successfully cached: {cached_path}")
|
||||
return str(cached_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Download failed: {e}")
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
raise
|
||||
|
||||
|
||||
def _is_zip_web_url(value: str) -> bool:
|
||||
"""Enhanced URL detection for zip artifacts."""
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
|
||||
parsed = urllib.parse.urlparse(value)
|
||||
# Direct zip URLs
|
||||
if value.endswith(".zip"):
|
||||
return parsed.scheme in ("http", "https")
|
||||
return False
|
||||
|
||||
|
||||
def validate_and_detect_manifest(
|
||||
content_path: Path,
|
||||
) -> ManifestResult:
|
||||
"""
|
||||
Validate manifest files and auto-detect artifact type.
|
||||
"""
|
||||
# Try framework manifests first
|
||||
framework_manifests = ["framework.json", "package.json"]
|
||||
for manifest in framework_manifests:
|
||||
manifest_path = content_path / manifest
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
with open(manifest_path, "r") as f:
|
||||
json.load(f) # Validate JSON syntax
|
||||
print(f"Found valid framework manifest: {manifest_path}")
|
||||
return ManifestResult(
|
||||
is_valid=True, manifest_path=manifest_path, is_framework=True
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Invalid JSON in {manifest_path}: {e}")
|
||||
|
||||
# Try platform manifest
|
||||
platform_manifest = content_path / "platform.json"
|
||||
if platform_manifest.exists():
|
||||
try:
|
||||
with open(platform_manifest, "r") as f:
|
||||
json.load(f) # Validate JSON syntax
|
||||
print(f"Found valid platform manifest: {platform_manifest}")
|
||||
return ManifestResult(
|
||||
is_valid=True, manifest_path=platform_manifest, is_framework=False
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Invalid JSON in {platform_manifest}: {e}")
|
||||
|
||||
return ManifestResult(is_valid=False, manifest_path=None, is_framework=False)
|
||||
|
||||
|
||||
def get_platformio_command_path(path: Path) -> str:
|
||||
"""Get path format for PlatformIO command line usage."""
|
||||
import platform
|
||||
|
||||
resolved_path = path.resolve()
|
||||
|
||||
# On Windows, PlatformIO has issues with file:// URLs for local directories
|
||||
# Use native Windows paths instead
|
||||
if platform.system() == "Windows":
|
||||
return str(resolved_path)
|
||||
else:
|
||||
# On Unix systems, use proper file:// URLs
|
||||
posix_path = resolved_path.as_posix()
|
||||
return f"file://{posix_path}"
|
||||
|
||||
|
||||
def get_proper_file_url(path: Path) -> str:
|
||||
"""Convert a path to a proper file:// URL for platformio.ini files."""
|
||||
# For PlatformIO on Windows, use the Windows path directly (not file:// URL)
|
||||
# This avoids issues with path parsing in PlatformIO
|
||||
import platform
|
||||
|
||||
resolved_path = path.resolve()
|
||||
|
||||
if platform.system() == "Windows":
|
||||
# On Windows, PlatformIO handles Windows paths directly better than file:// URLs
|
||||
# Convert to Windows path format
|
||||
return str(resolved_path)
|
||||
else:
|
||||
# On Unix systems, use proper file:// URL format
|
||||
posix_path = resolved_path.as_posix()
|
||||
return f"file://{posix_path}"
|
||||
|
||||
|
||||
def unzip_and_install(
|
||||
cached_zip_path: Path,
|
||||
cache_manager: PlatformIOCache,
|
||||
is_framework: bool,
|
||||
env_section: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Enhanced unzip and install with manifest validation and cleanup.
|
||||
"""
|
||||
cached_zip_path_obj = cached_zip_path
|
||||
# Extract to a directory alongside the zip file
|
||||
artifact_dir = cached_zip_path_obj.parent
|
||||
extracted_dir = artifact_dir / "extracted"
|
||||
temp_unzip_dir = artifact_dir / "temp_extract"
|
||||
|
||||
try:
|
||||
# Clean extraction directory
|
||||
if temp_unzip_dir.exists():
|
||||
shutil.rmtree(temp_unzip_dir)
|
||||
temp_unzip_dir.mkdir(parents=True)
|
||||
|
||||
print(f"Extracting {cached_zip_path_obj} to {temp_unzip_dir}")
|
||||
|
||||
# Extract with better error handling
|
||||
try:
|
||||
with zipfile.ZipFile(cached_zip_path_obj, "r") as zip_ref:
|
||||
# Check for zip bombs (basic protection)
|
||||
total_size = sum(info.file_size for info in zip_ref.infolist())
|
||||
if total_size > 500 * 1024 * 1024: # 500MB limit
|
||||
raise ValueError("Archive too large, possible zip bomb")
|
||||
|
||||
zip_ref.extractall(temp_unzip_dir)
|
||||
print("Extraction completed successfully")
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
logger.error(f"Invalid zip file: {cached_zip_path_obj}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Extraction failed: {e}")
|
||||
return False
|
||||
|
||||
# Find content directory (handle nested structures like GitHub archives)
|
||||
content_items = list(temp_unzip_dir.iterdir())
|
||||
if len(content_items) == 1 and content_items[0].is_dir():
|
||||
# Single root directory (common with GitHub/GitLab archives) - use it
|
||||
unzipped_content_path = content_items[0]
|
||||
print(f"Found nested directory structure: {content_items[0].name}")
|
||||
else:
|
||||
# Multiple items at root - use temp dir
|
||||
unzipped_content_path = temp_unzip_dir
|
||||
print("Using flat directory structure")
|
||||
|
||||
# Validate manifest files and auto-detect type
|
||||
manifest_result = validate_and_detect_manifest(unzipped_content_path)
|
||||
if not manifest_result.is_valid:
|
||||
logger.error(f"No valid manifest found in {unzipped_content_path}")
|
||||
return False
|
||||
|
||||
# Move to final location
|
||||
if extracted_dir.exists():
|
||||
shutil.rmtree(extracted_dir)
|
||||
shutil.move(unzipped_content_path, extracted_dir)
|
||||
|
||||
# Install via PlatformIO
|
||||
return install_with_platformio(
|
||||
extracted_dir, manifest_result.is_framework, env_section
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup temporary extraction
|
||||
if temp_unzip_dir.exists():
|
||||
print(f"Cleaning up extraction directory: {temp_unzip_dir}")
|
||||
shutil.rmtree(temp_unzip_dir)
|
||||
|
||||
|
||||
def install_with_platformio(
|
||||
content_path: Path, is_framework: bool, env_section: str
|
||||
) -> bool:
|
||||
"""Install extracted content using appropriate PlatformIO command."""
|
||||
content_path_obj = Path(content_path)
|
||||
command_path = get_platformio_command_path(content_path_obj)
|
||||
|
||||
# Create a cache key based on the installation type and path
|
||||
cache_key = f"{'framework' if is_framework else 'platform'}:{command_path}"
|
||||
|
||||
# Check if we've already installed this in this session
|
||||
if cache_key in _session_installation_cache:
|
||||
print(
|
||||
f"Skipping installation for {env_section}: already installed in this session ({command_path})"
|
||||
)
|
||||
return True
|
||||
|
||||
if is_framework:
|
||||
# Framework installation - use pkg install
|
||||
command = ["pio", "pkg", "install", "--global", command_path]
|
||||
else:
|
||||
# Platform installation - also use pkg install (new recommended way)
|
||||
command = ["pio", "pkg", "install", "--global", "--platform", command_path]
|
||||
|
||||
try:
|
||||
print(f"Installing for {env_section}: {' '.join(command)}")
|
||||
|
||||
# Use RunningProcess for streaming output
|
||||
process = RunningProcess(
|
||||
command,
|
||||
check=False, # We'll handle errors ourselves
|
||||
timeout=300, # 5 minute timeout
|
||||
)
|
||||
|
||||
# Stream output in real-time
|
||||
for line in process.line_iter(timeout=300):
|
||||
line_str = cast(str, line)
|
||||
print(f"PIO: {line_str}")
|
||||
|
||||
# Wait for completion and check result
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
print("PlatformIO installation successful")
|
||||
# Add to session cache to avoid redundant installations
|
||||
_session_installation_cache.add(cache_key)
|
||||
return True
|
||||
else:
|
||||
logger.error(
|
||||
f"PlatformIO installation failed with return code: {process.returncode}"
|
||||
)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
if "No such file or directory" in str(e) or "not found" in str(e).lower():
|
||||
logger.error("PlatformIO CLI not found. Is it installed and in PATH?")
|
||||
else:
|
||||
logger.error(f"PlatformIO installation failed: {e}")
|
||||
logger.error(f"Command: {' '.join(command)}")
|
||||
return False
|
||||
|
||||
|
||||
def handle_zip_artifact(
|
||||
zip_source: str,
|
||||
cache_manager: PlatformIOCache,
|
||||
env_section: str,
|
||||
) -> str:
|
||||
"""
|
||||
Enhanced artifact handler with validation and error recovery.
|
||||
Returns the resolved local path for the artifact.
|
||||
"""
|
||||
try:
|
||||
# Download and cache the artifact
|
||||
cached_zip_path = cache_manager.download_artifact(zip_source)
|
||||
|
||||
if not cached_zip_path or not Path(cached_zip_path).exists():
|
||||
raise FileNotFoundError(f"Download failed for {zip_source}")
|
||||
|
||||
# Extract and get the content path
|
||||
cache_key = cache_manager._get_cache_key(zip_source)
|
||||
cached_zip_path_obj = Path(cached_zip_path)
|
||||
|
||||
# Extract to a directory alongside the zip file
|
||||
artifact_dir = cached_zip_path_obj.parent
|
||||
extracted_dir = artifact_dir / "extracted"
|
||||
temp_unzip_dir = artifact_dir / "temp_extract"
|
||||
|
||||
# Check if already extracted
|
||||
if extracted_dir.exists():
|
||||
# Validate existing extraction and auto-detect type
|
||||
manifest_result = validate_and_detect_manifest(extracted_dir)
|
||||
if manifest_result.is_valid:
|
||||
print(f"Using existing extraction: {extracted_dir}")
|
||||
|
||||
# Check for completion status using URL-based cache key (consistent with status file creation)
|
||||
url_cache_key = cache_manager._get_cache_key(zip_source)
|
||||
status_file = _get_status_file(artifact_dir, url_cache_key)
|
||||
if _is_processing_complete(status_file):
|
||||
print(
|
||||
f"Skipping PlatformIO installation for {env_section}: found completion status"
|
||||
)
|
||||
return get_proper_file_url(extracted_dir)
|
||||
|
||||
# Create a session cache key for this artifact
|
||||
session_cache_key = f"{'framework' if manifest_result.is_framework else 'platform'}:{get_platformio_command_path(extracted_dir)}"
|
||||
|
||||
# If we've already handled this exact artifact in this session, skip PlatformIO entirely
|
||||
if session_cache_key in _session_installation_cache:
|
||||
print(
|
||||
f"Skipping PlatformIO installation for {env_section}: already processed in this session"
|
||||
)
|
||||
return get_proper_file_url(extracted_dir)
|
||||
|
||||
# Otherwise, install via PlatformIO
|
||||
install_success = install_with_platformio(
|
||||
extracted_dir, manifest_result.is_framework, env_section
|
||||
)
|
||||
# Status file will be created later using URL-based cache key
|
||||
|
||||
if install_success:
|
||||
print(f"Successfully installed {zip_source} for {env_section}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Installation completed with warnings for {zip_source}"
|
||||
)
|
||||
return get_proper_file_url(extracted_dir)
|
||||
|
||||
# Clean extraction directory for fresh extraction
|
||||
if temp_unzip_dir.exists():
|
||||
shutil.rmtree(temp_unzip_dir)
|
||||
temp_unzip_dir.mkdir(parents=True)
|
||||
|
||||
# Extract the zip
|
||||
with zipfile.ZipFile(cached_zip_path, "r") as zip_ref:
|
||||
zip_ref.extractall(temp_unzip_dir)
|
||||
|
||||
# Find content directory (handle nested structures)
|
||||
content_items = list(temp_unzip_dir.iterdir())
|
||||
if len(content_items) == 1 and content_items[0].is_dir():
|
||||
unzipped_content_path = content_items[0]
|
||||
else:
|
||||
unzipped_content_path = temp_unzip_dir
|
||||
|
||||
# Validate manifest files and auto-detect type
|
||||
manifest_result = validate_and_detect_manifest(unzipped_content_path)
|
||||
if not manifest_result.is_valid:
|
||||
raise ValueError(f"No valid manifest found in {unzipped_content_path}")
|
||||
|
||||
# Move to final location
|
||||
if extracted_dir.exists():
|
||||
shutil.rmtree(extracted_dir)
|
||||
shutil.move(unzipped_content_path, extracted_dir)
|
||||
|
||||
# Clean up temp directory
|
||||
if temp_unzip_dir.exists():
|
||||
shutil.rmtree(temp_unzip_dir)
|
||||
|
||||
# Install via PlatformIO
|
||||
install_success = install_with_platformio(
|
||||
extracted_dir, manifest_result.is_framework, env_section
|
||||
)
|
||||
|
||||
# Create status file with processing results
|
||||
cache_key = cache_manager._get_cache_key(zip_source)
|
||||
status_file = _get_status_file(artifact_dir, cache_key)
|
||||
|
||||
# Get zip file size
|
||||
zip_file = artifact_dir / "artifact.zip"
|
||||
zip_size = zip_file.stat().st_size if zip_file.exists() else 0
|
||||
|
||||
status = {
|
||||
"status": "complete" if install_success else "warning",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"url": zip_source,
|
||||
"env_section": env_section,
|
||||
"extracted_dir": str(extracted_dir.relative_to(artifact_dir)),
|
||||
"zip_size_bytes": zip_size,
|
||||
}
|
||||
_write_status(status_file, status)
|
||||
|
||||
if install_success:
|
||||
print(f"Successfully installed {zip_source} for {env_section}")
|
||||
else:
|
||||
logger.warning(f"Installation completed with warnings for {zip_source}")
|
||||
|
||||
# Return the file URL for the extracted directory
|
||||
return get_proper_file_url(extracted_dir)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(f"Failed to handle artifact {zip_source}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _process_artifact(
|
||||
artifact_url: str,
|
||||
env_section: str,
|
||||
cache_manager: "PlatformIOCache",
|
||||
) -> ArtifactProcessingResult:
|
||||
"""Process a single artifact (download, extract, install)."""
|
||||
try:
|
||||
resolved_path = handle_zip_artifact(artifact_url, cache_manager, env_section)
|
||||
return ArtifactProcessingResult(
|
||||
url=artifact_url,
|
||||
is_framework=False, # This will be determined during processing
|
||||
env_section=env_section,
|
||||
resolved_path=resolved_path,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process {artifact_url}: {e}", exc_info=e)
|
||||
return ArtifactProcessingResult(
|
||||
url=artifact_url,
|
||||
is_framework=False, # This will be determined during processing
|
||||
env_section=env_section,
|
||||
exception=e,
|
||||
)
|
||||
|
||||
|
||||
def _collect_all_zip_urls(pio_ini: PlatformIOIni) -> List[ZipUrlInfo]:
|
||||
"""
|
||||
Collect all zip URLs from platformio.ini in a single pass.
|
||||
"""
|
||||
all_urls: List[ZipUrlInfo] = []
|
||||
|
||||
# Collect platform URLs
|
||||
for section_name, option_name, url in pio_ini.get_platform_urls():
|
||||
if _is_zip_web_url(url):
|
||||
print(f"Found platform zip: {url} in {section_name}")
|
||||
all_urls.append(ZipUrlInfo(url, section_name, option_name))
|
||||
|
||||
# Collect framework URLs
|
||||
for section_name, option_name, url in pio_ini.get_framework_urls():
|
||||
if _is_zip_web_url(url):
|
||||
print(f"Found framework zip: {url} in {section_name}")
|
||||
all_urls.append(ZipUrlInfo(url, section_name, option_name))
|
||||
|
||||
return all_urls
|
||||
|
||||
|
||||
def _dedupe_urls(
|
||||
all_urls: List[ZipUrlInfo],
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Deduplicate URLs, keeping the first occurrence of each unique URL.
|
||||
Returns dict mapping url -> env_section.
|
||||
"""
|
||||
unique_urls: Dict[str, str] = {}
|
||||
for url_info in all_urls:
|
||||
if url_info.url not in unique_urls:
|
||||
unique_urls[url_info.url] = url_info.section_name
|
||||
return unique_urls
|
||||
|
||||
|
||||
def _download_and_process_urls(
|
||||
unique_urls: Dict[str, str], cache_manager: PlatformIOCache
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Download and process all URLs concurrently.
|
||||
Returns dict mapping original_url -> resolved_local_path.
|
||||
Raises exception immediately if any download fails.
|
||||
"""
|
||||
if not unique_urls:
|
||||
return {}
|
||||
|
||||
max_workers = min(4, len(unique_urls)) # Don't create more threads than needed
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=max_workers, thread_name_prefix="download"
|
||||
) as executor:
|
||||
# Submit all download tasks
|
||||
futures: List[Future[ArtifactProcessingResult]] = []
|
||||
for url, env_section in unique_urls.items():
|
||||
future = executor.submit(_process_artifact, url, env_section, cache_manager)
|
||||
futures.append(future)
|
||||
|
||||
replacements: Dict[str, str] = {}
|
||||
|
||||
try:
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
result = future.result()
|
||||
if result.success and result.resolved_path is not None:
|
||||
replacements[result.url] = result.resolved_path
|
||||
print(f"✅ Resolved {result.url} -> {result.resolved_path}")
|
||||
else:
|
||||
logger.error(
|
||||
f"❌ Failed to process {result.url}: {result.exception}"
|
||||
)
|
||||
# Re-raise all exceptions - no downloads should fail silently
|
||||
if result.exception:
|
||||
raise result.exception
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Future failed with unexpected error: {e}", exc_info=e
|
||||
)
|
||||
raise # Re-raise unexpected exceptions
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Processing interrupted, cancelling remaining downloads...")
|
||||
_global_cancel_event.set()
|
||||
# The context manager will handle cleanup of the executor
|
||||
raise
|
||||
|
||||
return replacements
|
||||
|
||||
|
||||
def _replace_all_urls(
|
||||
pio_ini: PlatformIOIni,
|
||||
all_urls: List[ZipUrlInfo],
|
||||
replacements: Dict[str, str],
|
||||
) -> None:
|
||||
"""
|
||||
Replace all URLs in platformio.ini with their resolved local paths.
|
||||
"""
|
||||
for url_info in all_urls:
|
||||
if url_info.url in replacements:
|
||||
pio_ini.replace_url(
|
||||
url_info.section_name,
|
||||
url_info.option_name,
|
||||
url_info.url,
|
||||
replacements[url_info.url],
|
||||
)
|
||||
|
||||
|
||||
def _apply_board_specific_config(
|
||||
platformio_ini_path: Path, custom_zip_cache_dir: Path
|
||||
) -> None:
|
||||
"""
|
||||
Enhanced config parser with improved zip detection and error handling.
|
||||
Modifies platformio.ini in-place with resolved local paths.
|
||||
Uses concurrent downloads for better performance.
|
||||
"""
|
||||
cache_manager = PlatformIOCache(custom_zip_cache_dir)
|
||||
|
||||
try:
|
||||
pio_ini = PlatformIOIni.parseFile(platformio_ini_path)
|
||||
print(f"Parsed platformio.ini: {platformio_ini_path}")
|
||||
except (configparser.Error, FileNotFoundError) as e:
|
||||
logger.error(f"Error reading platformio.ini: {e}")
|
||||
return
|
||||
|
||||
# Step 1: Collect all URLs from platformio.ini
|
||||
all_urls = _collect_all_zip_urls(pio_ini)
|
||||
if not all_urls:
|
||||
print("No zip artifacts found to process")
|
||||
return
|
||||
|
||||
# Step 2: Deduplicate URLs
|
||||
unique_urls = _dedupe_urls(all_urls)
|
||||
print(f"Found {len(all_urls)} total URLs, {len(unique_urls)} unique")
|
||||
|
||||
# Step 3: Download and process all unique URLs
|
||||
replacements = _download_and_process_urls(unique_urls, cache_manager)
|
||||
|
||||
# Step 4: Replace all URLs in platformio.ini with resolved local paths
|
||||
if replacements:
|
||||
_replace_all_urls(pio_ini, all_urls, replacements)
|
||||
|
||||
# Write back atomically
|
||||
try:
|
||||
pio_ini.dump(platformio_ini_path)
|
||||
print(f"Updated platformio.ini with {len(replacements)} resolved artifacts")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update platformio.ini: {e}")
|
||||
raise
|
||||
|
||||
|
||||
# Public API function
|
||||
def resolve_and_cache_platform_artifacts(
|
||||
platformio_ini_path: Path, cache_dir: Path
|
||||
) -> None:
|
||||
"""
|
||||
Main entry point for resolving and caching PlatformIO platform artifacts.
|
||||
|
||||
Args:
|
||||
platformio_ini_path: Path to the platformio.ini file to process
|
||||
cache_dir: Cache directory for storing artifacts
|
||||
"""
|
||||
print(f"Starting platform artifact resolution for {platformio_ini_path}")
|
||||
print(f"Using cache directory: {cache_dir}")
|
||||
|
||||
_apply_board_specific_config(platformio_ini_path, cache_dir)
|
||||
|
||||
print("Platform artifact resolution completed")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,983 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FastLED Test Build System - Python Compiler API Integration
|
||||
|
||||
This module provides a high-performance test compilation system built on the proven
|
||||
ci.util.ci.util.clang_compiler API that delivers 8x faster build times compared to CMake.
|
||||
|
||||
Key Features:
|
||||
- Parallel test compilation using ThreadPoolExecutor
|
||||
- 90%+ cache hit rates with proven compiler settings
|
||||
- Integration with build_unit.toml configuration
|
||||
- Support for STUB platform testing without hardware dependencies
|
||||
- GDB-compatible debug symbol generation
|
||||
- Consistent performance across all platforms
|
||||
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
import tomllib
|
||||
from concurrent.futures import Future, as_completed
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from types import TracebackType
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from ci.util.paths import PROJECT_ROOT
|
||||
|
||||
|
||||
def optimize_python_command(cmd: list[str]) -> list[str]:
|
||||
"""
|
||||
Optimize command list for subprocess execution in uv environment.
|
||||
|
||||
For python commands, we need to use 'uv run python' to ensure access to
|
||||
installed packages like ziglang. Direct sys.executable bypasses uv environment.
|
||||
|
||||
Args:
|
||||
cmd: Command list that may contain 'python' as first element
|
||||
|
||||
Returns:
|
||||
list[str]: Optimized command with 'python' prefixed by 'uv run'
|
||||
"""
|
||||
if cmd and (cmd[0] == "python" or cmd[0] == "python3"):
|
||||
# Use uv run python to ensure access to uv-managed packages
|
||||
optimized_cmd = ["uv", "run", "python"] + cmd[1:]
|
||||
return optimized_cmd
|
||||
return cmd
|
||||
|
||||
|
||||
from ci.compiler.clang_compiler import (
|
||||
BuildFlags,
|
||||
BuildTools,
|
||||
Compiler,
|
||||
CompilerOptions,
|
||||
LinkOptions,
|
||||
Result,
|
||||
create_compiler_options_from_toml,
|
||||
link_program_sync,
|
||||
load_build_flags_from_toml,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestExecutable:
|
||||
"""Represents a compiled test executable"""
|
||||
|
||||
name: str
|
||||
executable_path: Path
|
||||
test_source_path: Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompileResult:
|
||||
"""Result of test compilation operation"""
|
||||
|
||||
success: bool
|
||||
compiled_count: int
|
||||
duration: float
|
||||
errors: List["CompileError"] = field(default_factory=lambda: list())
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompileError:
|
||||
"""Compilation error for a specific test"""
|
||||
|
||||
test_name: str
|
||||
message: str
|
||||
|
||||
|
||||
class FastLEDTestCompiler:
|
||||
"""
|
||||
Test compiler built on proven Compiler API for 8x faster builds.
|
||||
|
||||
This class leverages the proven ci.util.ci.util.clang_compiler.Compiler API that has already
|
||||
demonstrated 4x+ performance improvements in example compilation. By using the same
|
||||
proven patterns and configurations, we inherit the optimized build flags, caching,
|
||||
and parallel compilation capabilities.
|
||||
"""
|
||||
|
||||
# Class variable to store existing instance for get_existing_instance()
|
||||
_existing_instance: Optional["FastLEDTestCompiler"] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
compiler: Compiler,
|
||||
build_dir: Path,
|
||||
project_root: Path,
|
||||
quick_build: bool = True,
|
||||
strict_mode: bool = False,
|
||||
no_unity: bool = False,
|
||||
):
|
||||
self.compiler = compiler
|
||||
self.build_dir = build_dir
|
||||
self.project_root = project_root
|
||||
self.quick_build = quick_build
|
||||
self.strict_mode = strict_mode
|
||||
self.no_unity = no_unity
|
||||
self.compiled_tests: List[TestExecutable] = []
|
||||
self.linking_failures: List[str] = []
|
||||
self.build_flags = self._load_build_flags()
|
||||
FastLEDTestCompiler._existing_instance = self
|
||||
|
||||
def _load_build_flags(self) -> BuildFlags:
|
||||
"""Load build flags from TOML configuration"""
|
||||
toml_path = self.project_root / "ci" / "build_unit.toml"
|
||||
return BuildFlags.parse(toml_path, self.quick_build, self.strict_mode)
|
||||
|
||||
@classmethod
|
||||
def create_for_unit_tests(
|
||||
cls,
|
||||
project_root: Path,
|
||||
clean_build: bool = False,
|
||||
enable_static_analysis: bool = False,
|
||||
specific_test: str | None = None,
|
||||
quick_build: bool = True,
|
||||
strict_mode: bool = False,
|
||||
no_unity: bool = False,
|
||||
) -> "FastLEDTestCompiler":
|
||||
"""
|
||||
Create compiler configured for FastLED unit tests using TOML build flags.
|
||||
|
||||
This configuration uses the new TOML-based build flag system:
|
||||
- ci/build_unit.toml for centralized flag configuration
|
||||
- Support for build modes (quick/debug) and strict mode
|
||||
- STUB platform for hardware-free testing
|
||||
- Precompiled headers for faster compilation
|
||||
- Parallel compilation with ThreadPoolExecutor
|
||||
"""
|
||||
|
||||
# Set up build directory in .build/fled/unit for consistency
|
||||
build_dir = project_root / ".build" / "fled" / "unit"
|
||||
if clean_build and build_dir.exists():
|
||||
print("###########################")
|
||||
print("# CLEANING UNIT TEST BUILD DIR #")
|
||||
print("###########################")
|
||||
import errno
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import time
|
||||
|
||||
def handle_remove_readonly(
|
||||
func: Callable[[str], None],
|
||||
path: str,
|
||||
exc: tuple[type[BaseException], BaseException, TracebackType | None],
|
||||
) -> None:
|
||||
"""Error handler for Windows readonly files"""
|
||||
if hasattr(exc[1], "errno") and exc[1].errno == errno.EACCES: # type: ignore
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
func(path)
|
||||
else:
|
||||
raise
|
||||
|
||||
# Windows-compatible directory removal with retry
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Use onerror parameter for compatibility across Python versions
|
||||
shutil.rmtree(build_dir, onerror=handle_remove_readonly)
|
||||
break
|
||||
except (OSError, PermissionError) as e:
|
||||
if attempt < max_retries - 1:
|
||||
print(
|
||||
f"Warning: Failed to remove build directory (attempt {attempt + 1}): {e}"
|
||||
)
|
||||
time.sleep(0.01) # Brief pause before retry
|
||||
continue
|
||||
else:
|
||||
print(
|
||||
f"Warning: Could not remove build directory after {max_retries} attempts: {e}"
|
||||
)
|
||||
print("Continuing with existing directory...")
|
||||
break
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Additional compiler args beyond TOML
|
||||
additional_compiler_args = [
|
||||
f"-I{project_root}/src/platforms/stub", # STUB platform headers
|
||||
]
|
||||
|
||||
# Load build flags using BuildFlags.parse() to get platform-specific configuration
|
||||
toml_path = project_root / "ci" / "build_unit.toml"
|
||||
build_flags = BuildFlags.parse(toml_path, quick_build, strict_mode)
|
||||
|
||||
# Load TOML config for test-specific flags that aren't in BuildFlags
|
||||
with open(toml_path, "rb") as f:
|
||||
config = tomllib.load(f)
|
||||
|
||||
# Get test-specific defines from TOML (not included in BuildFlags.parse)
|
||||
test_defines = config.get("test", {}).get("defines", [])
|
||||
|
||||
# Create compiler args from BuildFlags (includes platform-specific flags)
|
||||
compiler_args: List[str] = []
|
||||
compiler_args.extend(
|
||||
build_flags.compiler_flags
|
||||
) # Platform-aware compiler flags
|
||||
compiler_args.extend(build_flags.include_flags) # Platform-aware include flags
|
||||
|
||||
# Add strict mode flags if enabled (already handled by BuildFlags.parse)
|
||||
|
||||
# Add additional compiler args
|
||||
if additional_compiler_args:
|
||||
compiler_args.extend(additional_compiler_args)
|
||||
|
||||
# Extract defines without the "-D" prefix for CompilerOptions
|
||||
defines: List[str] = []
|
||||
for define in test_defines:
|
||||
if define.startswith("-D"):
|
||||
defines.append(define[2:]) # Remove "-D" prefix
|
||||
else:
|
||||
defines.append(define)
|
||||
|
||||
# Get tools configuration from BuildFlags (respects build_unit.toml)
|
||||
# Use modern command-based approach
|
||||
compiler_command = build_flags.tools.cpp_compiler
|
||||
|
||||
print(f"Using compiler from build_unit.toml: {compiler_command}")
|
||||
|
||||
# Use compiler command from TOML + flags as final compiler args
|
||||
final_compiler_args = compiler_command + compiler_args
|
||||
|
||||
# Create compiler options with TOML-loaded flags and tools
|
||||
settings = CompilerOptions(
|
||||
include_path=str(project_root / "src"),
|
||||
defines=defines,
|
||||
compiler_args=final_compiler_args, # Use cache-aware compiler args
|
||||
# Note: archiver and compiler fields are now handled via command-based approach
|
||||
)
|
||||
|
||||
compiler = Compiler(settings, build_flags)
|
||||
|
||||
# Create final instance with proper compiler and flags
|
||||
instance = cls(
|
||||
compiler, build_dir, project_root, quick_build, strict_mode, no_unity
|
||||
)
|
||||
return instance
|
||||
|
||||
def discover_test_files(self, specific_test: str | None = None) -> List[Path]:
|
||||
"""
|
||||
Discover test_*.cpp files using proven patterns.
|
||||
|
||||
Uses the same file discovery patterns as the existing CMake system
|
||||
to ensure 100% compatibility with current test discovery logic.
|
||||
"""
|
||||
tests_dir = Path(PROJECT_ROOT) / "tests"
|
||||
test_files: List[Path] = []
|
||||
|
||||
# Find all test files in tests directory and subdirectories
|
||||
for test_file in tests_dir.rglob("test_*.cpp"):
|
||||
# Skip the doctest_main.cpp file (not a test file)
|
||||
if test_file.name == "doctest_main.cpp":
|
||||
continue
|
||||
|
||||
# Handle both "test_name" and "name" formats for compatibility
|
||||
test_stem = test_file.stem
|
||||
test_name = test_stem.replace("test_", "")
|
||||
|
||||
# Check if we should do fuzzy matching (if there's a * in the name)
|
||||
if specific_test:
|
||||
if "*" in specific_test:
|
||||
# Convert glob pattern to regex pattern
|
||||
import re
|
||||
|
||||
pattern = specific_test.replace("*", ".*")
|
||||
if re.search(pattern, test_stem) or re.search(pattern, test_name):
|
||||
test_files.append(test_file)
|
||||
else:
|
||||
# Exact matching - either the full name or with test_ prefix (case-insensitive)
|
||||
if (
|
||||
test_stem.lower() == specific_test.lower()
|
||||
or test_stem.lower() == f"test_{specific_test}".lower()
|
||||
or test_name.lower() == specific_test.lower()
|
||||
):
|
||||
test_files.append(test_file)
|
||||
else:
|
||||
test_files.append(test_file)
|
||||
|
||||
return test_files
|
||||
|
||||
def compile_all_tests(self, specific_test: str | None = None) -> CompileResult:
|
||||
"""
|
||||
Compile all tests in parallel using proven API patterns.
|
||||
|
||||
This method uses the same parallel compilation patterns proven in
|
||||
example compilation that deliver 4x+ performance improvements.
|
||||
"""
|
||||
compile_start = time.time()
|
||||
|
||||
# If specific_test is not provided in the method call, use the one from discover_test_files
|
||||
test_files = self.discover_test_files(specific_test)
|
||||
|
||||
if not test_files:
|
||||
return CompileResult(success=True, compiled_count=0, duration=0.0)
|
||||
|
||||
print(f"Compiling {len(test_files)} test files using proven Python API...")
|
||||
# Convert absolute path to relative for display
|
||||
rel_build_dir = os.path.relpath(self.build_dir)
|
||||
print(f"Build directory: {rel_build_dir}")
|
||||
|
||||
# Always show build configuration in unit test mode
|
||||
print("\nFastLED Library Build Configuration:")
|
||||
if self.no_unity:
|
||||
print(" ❌ Unity build disabled - Compiling individual source files")
|
||||
else:
|
||||
print(" ✅ Unity build enabled - Using glob pattern:")
|
||||
print(" src/**/*.cpp")
|
||||
|
||||
print("\nPrecompiled header status:")
|
||||
if self.compiler.settings.use_pch:
|
||||
print(" ✅ PCH enabled - Using precompiled headers for faster compilation")
|
||||
print(" PCH content:")
|
||||
print(textwrap.indent(self.compiler.generate_pch_header(), " "))
|
||||
|
||||
# Create PCH for faster compilation
|
||||
print("\n🔧 Creating precompiled header...")
|
||||
pch_success = self.compiler.create_pch_file()
|
||||
if pch_success:
|
||||
print(" ✅ PCH created successfully")
|
||||
else:
|
||||
print(" ❌ PCH creation failed - continuing with direct compilation")
|
||||
else:
|
||||
print(" ❌ PCH disabled - Not using precompiled headers")
|
||||
|
||||
print("\nCompiler flags:")
|
||||
for flag in self.compiler.get_compiler_args():
|
||||
print(f" {flag}")
|
||||
print("\nLinker flags:")
|
||||
for flag in self._get_platform_linker_args(self.build_dir / "libfastled.lib"):
|
||||
print(f" {flag}")
|
||||
print("")
|
||||
|
||||
# Print list of test files being compiled
|
||||
print("Test files to compile:")
|
||||
for i, test_file in enumerate(test_files, 1):
|
||||
print(f" {i}. {test_file.name}")
|
||||
print("")
|
||||
|
||||
# Submit parallel compilation jobs (proven pattern from example compilation)
|
||||
future_to_test: Dict[Future[Result], Path] = {}
|
||||
|
||||
# Check if we're running in parallel or sequential mode
|
||||
if os.environ.get("NO_PARALLEL"):
|
||||
print("Starting sequential compilation of test files...")
|
||||
else:
|
||||
print("Starting parallel compilation of test files...")
|
||||
for test_file in test_files:
|
||||
# Compile to object file first (since compile_cpp_file uses -c flag)
|
||||
obj_path = self.build_dir / f"{test_file.stem}.o"
|
||||
# Convert absolute path to relative for display
|
||||
rel_obj_path = os.path.relpath(obj_path)
|
||||
print(f"Submitting compilation job for: {test_file.name} -> {rel_obj_path}")
|
||||
# Show compilation command if enabled
|
||||
if os.environ.get("FASTLED_TEST_SHOW_COMPILE", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
):
|
||||
# Convert absolute path to relative for display
|
||||
rel_obj_path = os.path.relpath(obj_path)
|
||||
print(f"[COMPILE] {test_file.name} -> {rel_obj_path}")
|
||||
|
||||
compile_future = self.compiler.compile_cpp_file(
|
||||
test_file,
|
||||
output_path=obj_path,
|
||||
additional_flags=[
|
||||
"-I",
|
||||
str(self.project_root / "src"),
|
||||
"-I",
|
||||
str(self.project_root / "tests"),
|
||||
# NOTE: Compiler flags now come from build_unit.toml
|
||||
],
|
||||
)
|
||||
future_to_test[compile_future] = test_file
|
||||
|
||||
# Collect compilation results with progress reporting
|
||||
compiled_objects: List[Path] = []
|
||||
errors: List[CompileError] = []
|
||||
completed = 0
|
||||
|
||||
print(f"Waiting for {len(future_to_test)} compilation jobs to complete...")
|
||||
|
||||
try:
|
||||
for future in as_completed(future_to_test.keys()):
|
||||
test_file = future_to_test[future]
|
||||
result: Result = future.result()
|
||||
completed += 1
|
||||
|
||||
if result.ok:
|
||||
obj_path = self.build_dir / f"{test_file.stem}.o"
|
||||
compiled_objects.append(obj_path)
|
||||
print(f"[{completed}/{len(test_files)}] Compiled {test_file.name}")
|
||||
else:
|
||||
errors.append(
|
||||
CompileError(
|
||||
test_name=test_file.stem,
|
||||
message=result.stderr
|
||||
or result.stdout
|
||||
or "Compilation failed",
|
||||
)
|
||||
)
|
||||
print(
|
||||
f"[{completed}/{len(test_files)}] FAILED {test_file.name}: {result.stderr}"
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\nKeyboard interrupt detected during compilation")
|
||||
# Clean up any in-progress compilations
|
||||
for future in future_to_test.keys():
|
||||
future.cancel()
|
||||
import _thread
|
||||
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
|
||||
if errors:
|
||||
duration = time.time() - compile_start
|
||||
print(f"Compilation failed with {len(errors)} errors in {duration:.2f}s")
|
||||
return CompileResult(
|
||||
success=False, compiled_count=0, duration=duration, errors=errors
|
||||
)
|
||||
|
||||
print(f"All {len(compiled_objects)} object files compiled successfully")
|
||||
|
||||
# Link each test to executable using proven linking API
|
||||
self.compiled_tests = self._link_tests(compiled_objects)
|
||||
|
||||
# Check for linking failures and add them to errors
|
||||
if hasattr(self, "linking_failures") and self.linking_failures:
|
||||
for failure in self.linking_failures:
|
||||
# Parse the failure string to extract test name and error
|
||||
if ":" in failure:
|
||||
test_name, error_msg = failure.split(":", 1)
|
||||
errors.append(
|
||||
CompileError(
|
||||
test_name=test_name.strip(),
|
||||
message=f"Linking failed: {error_msg.strip()}",
|
||||
)
|
||||
)
|
||||
|
||||
duration = time.time() - compile_start
|
||||
|
||||
# Success requires both compilation AND linking to succeed
|
||||
success = len(errors) == 0 and len(self.compiled_tests) > 0
|
||||
|
||||
if success:
|
||||
print(
|
||||
f"SUCCESS: Compiled and linked {len(self.compiled_tests)} tests in {duration:.2f}s"
|
||||
)
|
||||
# List all the successful test executables
|
||||
print("Test executables created:")
|
||||
for i, test in enumerate(self.compiled_tests, 1):
|
||||
# Convert absolute path to relative for display
|
||||
rel_exe_path = os.path.relpath(test.executable_path)
|
||||
print(f" {i}. {test.name} -> {rel_exe_path}")
|
||||
else:
|
||||
print(f"FAILED: {len(errors)} total failures (compilation + linking)")
|
||||
|
||||
return CompileResult(
|
||||
success=success,
|
||||
compiled_count=len(self.compiled_tests),
|
||||
duration=duration,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
def _link_tests(self, compiled_objects: List[Path]) -> List[TestExecutable]:
|
||||
"""Link each test to executable using proven linking API"""
|
||||
print(f"Linking {len(compiled_objects)} test executables...")
|
||||
|
||||
# First, build a complete FastLED library similar to CMake approach
|
||||
fastled_lib_path = self._build_fastled_library()
|
||||
|
||||
# Compile doctest_main.cpp once for all tests (provides main function and doctest implementation)
|
||||
doctest_main_path = self.project_root / "tests" / "doctest_main.cpp"
|
||||
doctest_obj_path = self.build_dir / "doctest_main.o"
|
||||
|
||||
if not doctest_obj_path.exists():
|
||||
print("Compiling doctest_main.cpp...")
|
||||
doctest_future = self.compiler.compile_cpp_file(
|
||||
doctest_main_path,
|
||||
output_path=doctest_obj_path,
|
||||
additional_flags=["-c"], # Compile only
|
||||
)
|
||||
doctest_result: Result = doctest_future.result()
|
||||
if not doctest_result.ok:
|
||||
print(
|
||||
f"ERROR: Failed to compile doctest_main.cpp: {doctest_result.stderr}"
|
||||
)
|
||||
return []
|
||||
|
||||
print(f"Compiled FastLED library and doctest_main")
|
||||
|
||||
compiled_tests: List[TestExecutable] = []
|
||||
linking_failures: List[str] = []
|
||||
cache_hits = 0
|
||||
cache_misses = 0
|
||||
|
||||
# Link each test with the FastLED library
|
||||
for obj_path in compiled_objects:
|
||||
# Derive test name from object file
|
||||
test_name = obj_path.stem.replace("test_", "")
|
||||
exe_path = self.build_dir / f"test_{test_name}.exe"
|
||||
|
||||
# Define clean execution path in tests/bin/ (this is where tests will actually run from)
|
||||
tests_bin_dir = self.project_root / "tests" / "bin"
|
||||
tests_bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
clean_exe_path = tests_bin_dir / f"test_{test_name}.exe"
|
||||
|
||||
# Check if this test has its own main() function
|
||||
test_source = self.project_root / "tests" / f"test_{test_name}.cpp"
|
||||
has_own_main = self._test_has_own_main(test_source)
|
||||
|
||||
# Get linker args (needed for cache key calculation)
|
||||
linker_args = self._get_platform_linker_args(fastled_lib_path)
|
||||
|
||||
# Calculate comprehensive cache key based on all linking inputs
|
||||
if has_own_main:
|
||||
# For standalone tests, only include test object file
|
||||
cache_key = self._calculate_link_cache_key(
|
||||
obj_path, fastled_lib_path, linker_args
|
||||
)
|
||||
else:
|
||||
# For doctest tests, include both test object and doctest_main object
|
||||
# Calculate hashes for both object files and combine them
|
||||
test_obj_hash = self._calculate_file_hash(obj_path)
|
||||
doctest_obj_hash = self._calculate_file_hash(doctest_obj_path)
|
||||
combined_obj_hash = hashlib.sha256(
|
||||
f"{test_obj_hash}+{doctest_obj_hash}".encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
# Create a temporary cache key with combined object hash
|
||||
fastled_hash = self._calculate_file_hash(fastled_lib_path)
|
||||
linker_hash = self._calculate_linker_args_hash(linker_args)
|
||||
combined = f"fastled:{fastled_hash}|test:{combined_obj_hash}|flags:{linker_hash}"
|
||||
cache_key = hashlib.sha256(combined.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
# Check for cached executable based on comprehensive cache key
|
||||
cached_exe = self._get_cached_executable(test_name, cache_key)
|
||||
if cached_exe:
|
||||
print(f" {test_name}: Using cached executable (link cache hit)")
|
||||
try:
|
||||
shutil.copy2(cached_exe, exe_path)
|
||||
# Also copy to clean execution location
|
||||
shutil.copy2(cached_exe, clean_exe_path)
|
||||
cache_hits += 1
|
||||
|
||||
test_exe = TestExecutable(
|
||||
name=test_name,
|
||||
executable_path=clean_exe_path, # Point to clean location for execution
|
||||
test_source_path=test_source,
|
||||
)
|
||||
compiled_tests.append(test_exe)
|
||||
continue # Skip linking, we have cached result
|
||||
except Exception as e:
|
||||
print(
|
||||
f" {test_name}: Warning - failed to copy cached executable, will relink: {e}"
|
||||
)
|
||||
# Fall through to linking logic
|
||||
|
||||
if has_own_main:
|
||||
# Link standalone test without doctest_main.o
|
||||
link_options = LinkOptions(
|
||||
object_files=[obj_path], # Only the test object file
|
||||
output_executable=exe_path,
|
||||
linker=f'"{sys.executable}" -m ziglang c++', # Use optimized Python executable for linking
|
||||
linker_args=linker_args, # Use pre-calculated args for consistency
|
||||
)
|
||||
else:
|
||||
# Link with the FastLED library instead of individual objects
|
||||
link_options = LinkOptions(
|
||||
object_files=[
|
||||
obj_path,
|
||||
doctest_obj_path,
|
||||
], # Test object + doctest main
|
||||
output_executable=exe_path,
|
||||
linker=f'"{sys.executable}" -m ziglang c++', # Use optimized Python executable for linking
|
||||
linker_args=linker_args, # Use pre-calculated args for consistency
|
||||
)
|
||||
|
||||
# Show linking command if enabled
|
||||
if os.environ.get("FASTLED_TEST_SHOW_LINK", "").lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
):
|
||||
# Convert absolute path to relative for display
|
||||
rel_exe_path = os.path.relpath(exe_path)
|
||||
print(f"[LINK] {test_name} -> {rel_exe_path}")
|
||||
|
||||
print(f"Linking test: {test_name}")
|
||||
# STRICT: Provide explicit BuildFlags - NO defaults allowed
|
||||
link_result: Result = link_program_sync(
|
||||
link_options, self.compiler.build_flags
|
||||
)
|
||||
|
||||
if not link_result.ok:
|
||||
# Create more detailed error message showing link command context
|
||||
error_msg = f" {test_name}: Linking failed - Linker: {link_options.linker}, Output: {link_options.output_executable}, Objects: {len(link_options.object_files)} files"
|
||||
print(error_msg)
|
||||
print(f" Error details: {link_result.stderr}")
|
||||
linking_failures.append(f"{test_name}: {link_result.stderr}")
|
||||
continue
|
||||
|
||||
print(f" {test_name}: Linking successful")
|
||||
cache_misses += 1
|
||||
|
||||
# Cache the newly linked executable for future use
|
||||
self._cache_executable(test_name, cache_key, exe_path)
|
||||
|
||||
# Copy to clean execution location
|
||||
shutil.copy2(exe_path, clean_exe_path)
|
||||
|
||||
test_exe = TestExecutable(
|
||||
name=test_name,
|
||||
executable_path=clean_exe_path,
|
||||
test_source_path=test_source, # Point to clean location for execution
|
||||
)
|
||||
compiled_tests.append(test_exe)
|
||||
|
||||
if linking_failures:
|
||||
print(f"ERROR: {len(linking_failures)} linking failures:")
|
||||
for failure in linking_failures:
|
||||
print(f" {failure}")
|
||||
# Store linking failures for later reporting
|
||||
self.linking_failures = linking_failures
|
||||
else:
|
||||
self.linking_failures = []
|
||||
|
||||
# Report link cache statistics
|
||||
total_tests = cache_hits + cache_misses
|
||||
if total_tests > 0:
|
||||
hit_rate = (cache_hits / total_tests) * 100
|
||||
print(
|
||||
f"Link cache: {cache_hits} hits, {cache_misses} misses ({hit_rate:.1f}% hit rate) - caching by lib+test+flags"
|
||||
)
|
||||
|
||||
print(f"Successfully linked {len(compiled_tests)} test executables")
|
||||
return compiled_tests
|
||||
|
||||
def _test_has_own_main(self, test_source_path: Path) -> bool:
|
||||
"""Check if a test file defines its own main() function"""
|
||||
try:
|
||||
if not test_source_path.exists():
|
||||
return False
|
||||
|
||||
content = test_source_path.read_text(encoding="utf-8")
|
||||
# Look for main function definition (simple pattern matching)
|
||||
import re
|
||||
|
||||
# Match "int main(" allowing for whitespace and various formats
|
||||
main_pattern = r"\bint\s+main\s*\("
|
||||
return bool(re.search(main_pattern, content))
|
||||
except Exception:
|
||||
# If we can't read the file, assume it doesn't have main
|
||||
return False
|
||||
|
||||
def _build_fastled_library(self) -> Path:
|
||||
"""Build a complete FastLED static library like CMake does"""
|
||||
print("Building FastLED static library...")
|
||||
|
||||
# Define library path
|
||||
fastled_lib_path = self.build_dir / "libfastled.lib"
|
||||
|
||||
# If library already exists, return it (for faster rebuilds)
|
||||
if fastled_lib_path.exists():
|
||||
# Convert absolute path to relative for display
|
||||
rel_lib_path = os.path.relpath(fastled_lib_path)
|
||||
print(f"Using existing FastLED library: {rel_lib_path}")
|
||||
return fastled_lib_path
|
||||
|
||||
# Compile essential FastLED source files for STUB platform
|
||||
# User directive: Just glob ALL .cpp files in src/** - no exclusions needed
|
||||
import glob
|
||||
|
||||
fastled_src_dir = self.project_root / "src"
|
||||
# Include ALL .cpp files recursively - no platform exclusions
|
||||
all_cpp_files: List[str] = glob.glob(
|
||||
str(fastled_src_dir / "**" / "*.cpp"), recursive=True
|
||||
)
|
||||
|
||||
fastled_sources: List[Path] = []
|
||||
for cpp_file in all_cpp_files:
|
||||
# Include ALL files - no filtering needed
|
||||
fastled_sources.append(Path(cpp_file))
|
||||
|
||||
if self.no_unity:
|
||||
print(
|
||||
f"Individual compilation: Found {len(fastled_sources)} FastLED .cpp files for compilation"
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Unity build mode: Found {len(fastled_sources)} FastLED .cpp files for compilation"
|
||||
)
|
||||
|
||||
# Compile all FastLED source files to object files
|
||||
fastled_objects: List[Path] = []
|
||||
for src_file in fastled_sources:
|
||||
src_path = self.project_root / src_file
|
||||
# Create unique object file name including directory path to avoid collisions
|
||||
# Convert path separators to underscores to create valid filename
|
||||
relative_path = src_path.relative_to(self.project_root / "src")
|
||||
safe_name = (
|
||||
str(relative_path.with_suffix("")).replace("/", "_").replace("\\", "_")
|
||||
)
|
||||
obj_path = self.build_dir / f"{safe_name}_fastled.o"
|
||||
|
||||
if not obj_path.exists():
|
||||
future = self.compiler.compile_cpp_file(
|
||||
src_path,
|
||||
output_path=obj_path,
|
||||
additional_flags=[
|
||||
"-c", # Compile only
|
||||
"-DFASTLED_STUB_IMPL", # Essential for STUB platform
|
||||
# NOTE: All defines and compiler flags now come from build_unit.toml
|
||||
# Remove hardcoded defines - they should be in [test] section
|
||||
],
|
||||
)
|
||||
result: Result = future.result()
|
||||
if not result.ok:
|
||||
print(f"ERROR: Failed to compile {src_file}: {result.stderr}")
|
||||
# Continue with other files rather than failing completely
|
||||
continue
|
||||
|
||||
fastled_objects.append(obj_path)
|
||||
|
||||
print(f"Creating static library from {len(fastled_objects)} object files...")
|
||||
|
||||
# Create static library using ar (archiver)
|
||||
# First try llvm-ar since it's more likely to be available
|
||||
ar_cmd: List[str] = [
|
||||
"llvm-ar", # LLVM archiver tool (works with Clang)
|
||||
"rcs", # Create archive with symbol table
|
||||
str(fastled_lib_path),
|
||||
] + [str(obj) for obj in fastled_objects]
|
||||
|
||||
# Create static library using ziglang ar (archiver)
|
||||
print("Creating static library using ziglang ar...")
|
||||
lib_cmd: List[str] = [
|
||||
"python",
|
||||
"-m",
|
||||
"ziglang",
|
||||
"ar",
|
||||
"rcs", # Create archive with symbol table
|
||||
str(fastled_lib_path),
|
||||
] + [str(obj) for obj in fastled_objects]
|
||||
|
||||
try:
|
||||
# Optimize command to use sys.executable instead of shell 'python' resolution
|
||||
python_exe = optimize_python_command(lib_cmd)
|
||||
|
||||
# Use streaming to prevent buffer overflow
|
||||
process = subprocess.Popen(
|
||||
python_exe,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=self.build_dir,
|
||||
)
|
||||
|
||||
stdout_lines: list[str] = []
|
||||
stderr_lines: list[str] = []
|
||||
|
||||
while True:
|
||||
stdout_line = process.stdout.readline() if process.stdout else ""
|
||||
stderr_line = process.stderr.readline() if process.stderr else ""
|
||||
|
||||
if stdout_line:
|
||||
stdout_lines.append(stdout_line.rstrip())
|
||||
if stderr_line:
|
||||
stderr_lines.append(stderr_line.rstrip())
|
||||
|
||||
if process.poll() is not None:
|
||||
remaining_stdout = process.stdout.read() if process.stdout else ""
|
||||
remaining_stderr = process.stderr.read() if process.stderr else ""
|
||||
|
||||
if remaining_stdout:
|
||||
for line in remaining_stdout.splitlines():
|
||||
stdout_lines.append(line.rstrip())
|
||||
if remaining_stderr:
|
||||
for line in remaining_stderr.splitlines():
|
||||
stderr_lines.append(line.rstrip())
|
||||
break
|
||||
|
||||
if process.returncode != 0:
|
||||
print(f"ERROR: Failed to create static library: {stderr_lines}")
|
||||
return fastled_lib_path # Return path even if creation failed
|
||||
|
||||
# Convert absolute path to relative for display
|
||||
rel_lib_path = os.path.relpath(fastled_lib_path)
|
||||
print(f"Successfully created FastLED library: {rel_lib_path}")
|
||||
return fastled_lib_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Exception during library creation: {e}")
|
||||
raise
|
||||
|
||||
def get_test_executables(
|
||||
self, specific_test: str | None = None
|
||||
) -> List[TestExecutable]:
|
||||
"""Get compiled test executables with name filtering"""
|
||||
if specific_test:
|
||||
# Check if we should do fuzzy matching (if there's a * in the name)
|
||||
if "*" in specific_test:
|
||||
# Convert glob pattern to regex pattern
|
||||
import re
|
||||
|
||||
pattern = specific_test.replace("*", ".*")
|
||||
return [
|
||||
t
|
||||
for t in self.compiled_tests
|
||||
if re.search(pattern, t.name)
|
||||
or re.search(pattern, f"test_{t.name}")
|
||||
]
|
||||
else:
|
||||
# Exact matching - either the full name or with test_ prefix (case-insensitive)
|
||||
return [
|
||||
t
|
||||
for t in self.compiled_tests
|
||||
if (
|
||||
t.name.lower() == specific_test.lower()
|
||||
or t.name.lower() == f"test_{specific_test}".lower()
|
||||
or t.name.lower() == specific_test.replace("test_", "").lower()
|
||||
)
|
||||
]
|
||||
return self.compiled_tests
|
||||
|
||||
@property
|
||||
def link_cache_dir(self) -> Path:
|
||||
"""Get the link cache directory"""
|
||||
cache_dir = self.build_dir / "link_cache"
|
||||
cache_dir.mkdir(exist_ok=True)
|
||||
return cache_dir
|
||||
|
||||
def _calculate_file_hash(self, file_path: Path) -> str:
|
||||
"""Calculate SHA256 hash of a file"""
|
||||
if not file_path.exists():
|
||||
return "no_file"
|
||||
|
||||
hash_sha256 = hashlib.sha256()
|
||||
with open(file_path, "rb") as f:
|
||||
# Read in chunks to handle large files efficiently
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
hash_sha256.update(chunk)
|
||||
return hash_sha256.hexdigest()
|
||||
|
||||
def _calculate_linker_args_hash(self, linker_args: List[str]) -> str:
|
||||
"""Calculate SHA256 hash of linker arguments"""
|
||||
# Convert linker args to a stable string representation
|
||||
args_str = "|".join(sorted(linker_args))
|
||||
hash_sha256 = hashlib.sha256()
|
||||
hash_sha256.update(args_str.encode("utf-8"))
|
||||
return hash_sha256.hexdigest()
|
||||
|
||||
def _calculate_link_cache_key(
|
||||
self, test_obj_path: Path, fastled_lib_path: Path, linker_args: List[str]
|
||||
) -> str:
|
||||
"""Calculate comprehensive cache key for linking"""
|
||||
# Hash all the factors that affect linking output
|
||||
fastled_hash = self._calculate_file_hash(fastled_lib_path)
|
||||
test_obj_hash = self._calculate_file_hash(test_obj_path)
|
||||
linker_hash = self._calculate_linker_args_hash(linker_args)
|
||||
|
||||
# Combine all hashes into a single cache key
|
||||
combined = f"fastled:{fastled_hash}|test:{test_obj_hash}|flags:{linker_hash}"
|
||||
final_hash = hashlib.sha256(combined.encode("utf-8")).hexdigest()
|
||||
|
||||
return final_hash[:16] # Use first 16 chars for readability
|
||||
|
||||
def _get_cached_executable(self, test_name: str, cache_key: str) -> Optional[Path]:
|
||||
"""Check if a cached executable exists for this test and cache key"""
|
||||
cached_exe = self.link_cache_dir / f"{test_name}_{cache_key}.exe"
|
||||
return cached_exe if cached_exe.exists() else None
|
||||
|
||||
def _cache_executable(self, test_name: str, cache_key: str, exe_path: Path) -> None:
|
||||
"""Cache an executable with the given cache key"""
|
||||
if not exe_path.exists():
|
||||
return
|
||||
|
||||
cached_exe = self.link_cache_dir / f"{test_name}_{cache_key}.exe"
|
||||
try:
|
||||
shutil.copy2(exe_path, cached_exe)
|
||||
print(f" {test_name}: Cached executable for future use")
|
||||
except Exception as e:
|
||||
print(f" {test_name}: Warning - failed to cache executable: {e}")
|
||||
|
||||
def _get_platform_linker_args(self, fastled_lib_path: Path) -> List[str]:
|
||||
"""Get platform-specific linker arguments using BuildFlags"""
|
||||
# Use the build_flags that includes platform-specific configuration
|
||||
args = self.build_flags.link_flags.copy()
|
||||
|
||||
# Load TOML config for test-specific and build mode flags
|
||||
toml_path = self.project_root / "ci" / "build_unit.toml"
|
||||
with open(toml_path, "rb") as f:
|
||||
config = tomllib.load(f)
|
||||
|
||||
# Add test-specific linking flags if available
|
||||
test_link_flags = config.get("linking", {}).get("test", {}).get("flags", [])
|
||||
args.extend(test_link_flags)
|
||||
|
||||
# Add build mode specific flags (use test-specific debug mode)
|
||||
mode = "quick" if self.quick_build else "test_debug"
|
||||
mode_flags = config.get("build_modes", {}).get(mode, {}).get("link_flags", [])
|
||||
args.extend(mode_flags)
|
||||
|
||||
# Add FastLED library
|
||||
args.append(str(fastled_lib_path)) # Link against the FastLED static library
|
||||
|
||||
return args
|
||||
|
||||
@classmethod
|
||||
def get_existing_instance(cls) -> Optional["FastLEDTestCompiler"]:
|
||||
"""Get existing compiler instance for reuse"""
|
||||
return cls._existing_instance
|
||||
|
||||
|
||||
def check_iwyu_available() -> bool:
|
||||
"""Check if include-what-you-use is available (preserving existing logic)"""
|
||||
import shutil
|
||||
|
||||
return (
|
||||
shutil.which("include-what-you-use") is not None
|
||||
or shutil.which("iwyu") is not None
|
||||
)
|
||||
|
||||
|
||||
# For testing this module directly
|
||||
if __name__ == "__main__":
|
||||
print("Testing FastLEDTestCompiler...")
|
||||
|
||||
# Create test compiler
|
||||
test_compiler = FastLEDTestCompiler.create_for_unit_tests(
|
||||
project_root=Path(PROJECT_ROOT), clean_build=True
|
||||
)
|
||||
|
||||
# Discover test files
|
||||
test_files = test_compiler.discover_test_files()
|
||||
print(f"Discovered {len(test_files)} test files")
|
||||
|
||||
# Test compilation of a few files
|
||||
if test_files:
|
||||
limited_files = test_files[:3] # Test first 3 files
|
||||
print(f"Testing compilation of {len(limited_files)} test files...")
|
||||
|
||||
compile_result = test_compiler.compile_all_tests()
|
||||
|
||||
if compile_result.success:
|
||||
print(
|
||||
f"SUCCESS: Compiled {compile_result.compiled_count} tests in {compile_result.duration:.2f}s"
|
||||
)
|
||||
executables = test_compiler.get_test_executables()
|
||||
for exe in executables:
|
||||
print(f" Executable: {exe.name} -> {exe.executable_path}")
|
||||
else:
|
||||
print(f"FAILED: {len(compile_result.errors)} errors")
|
||||
for error in compile_result.errors:
|
||||
print(f" {error.test_name}: {error.message}")
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import Future
|
||||
from pathlib import Path
|
||||
from typing import List, Sequence
|
||||
|
||||
from ci.compiler.clang_compiler import Compiler, CompilerOptions, Result
|
||||
from ci.compiler.test_example_compilation import (
|
||||
create_fastled_compiler,
|
||||
get_fastled_core_sources,
|
||||
)
|
||||
|
||||
|
||||
def _sorted_src_files(files: Sequence[Path]) -> List[Path]:
|
||||
project_root = Path.cwd()
|
||||
|
||||
def key_fn(p: Path) -> str:
|
||||
try:
|
||||
rel = p.relative_to(project_root)
|
||||
except Exception:
|
||||
rel = p
|
||||
return rel.as_posix()
|
||||
|
||||
return sorted(files, key=key_fn)
|
||||
|
||||
|
||||
def _partition(files: List[Path], chunks: int) -> List[List[Path]]:
|
||||
total = len(files)
|
||||
if chunks <= 0:
|
||||
raise ValueError("chunks must be >= 1")
|
||||
chunks = min(chunks, total) if total > 0 else 1
|
||||
base = total // chunks
|
||||
rem = total % chunks
|
||||
partitions: List[List[Path]] = []
|
||||
start = 0
|
||||
for i in range(chunks):
|
||||
size = base + (1 if i < rem else 0)
|
||||
end = start + size
|
||||
if size > 0:
|
||||
partitions.append(files[start:end])
|
||||
start = end
|
||||
return partitions
|
||||
|
||||
|
||||
def build_unity_chunks_and_archive(
|
||||
compiler: Compiler,
|
||||
chunks: int,
|
||||
output_archive: Path,
|
||||
unity_dir: Path,
|
||||
no_parallel: bool,
|
||||
) -> Path:
|
||||
unity_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Gather and sort source files consistently with existing library logic
|
||||
files = get_fastled_core_sources()
|
||||
# Exclude any main-like TU
|
||||
files = [p for p in files if p.name != "stub_main.cpp"]
|
||||
files = _sorted_src_files(files)
|
||||
if not files:
|
||||
raise RuntimeError("No source files found under src/** for unity build")
|
||||
|
||||
partitions = _partition(files, chunks)
|
||||
|
||||
# Prepare per-chunk unity paths
|
||||
unity_cpp_paths: List[Path] = [
|
||||
unity_dir / f"unity{i + 1}.cpp" for i in range(len(partitions))
|
||||
]
|
||||
unity_obj_paths: List[Path] = [p.with_suffix(".o") for p in unity_cpp_paths]
|
||||
|
||||
# Compile chunks
|
||||
futures: List[Future[Result]] = []
|
||||
compile_opts = CompilerOptions(
|
||||
include_path=compiler.settings.include_path,
|
||||
compiler=compiler.settings.compiler,
|
||||
defines=compiler.settings.defines,
|
||||
std_version=compiler.settings.std_version,
|
||||
compiler_args=compiler.settings.compiler_args,
|
||||
use_pch=False,
|
||||
additional_flags=["-c"],
|
||||
parallel=not no_parallel,
|
||||
)
|
||||
|
||||
def submit(idx: int) -> Future[Result]:
|
||||
chunk_files = partitions[idx]
|
||||
unity_cpp = unity_cpp_paths[idx]
|
||||
return compiler.compile_unity(compile_opts, chunk_files, unity_cpp)
|
||||
|
||||
if no_parallel:
|
||||
# Sequential execution
|
||||
for i in range(len(partitions)):
|
||||
result = submit(i).result()
|
||||
if not result.ok:
|
||||
raise RuntimeError(f"Unity chunk {i + 1} failed: {result.stderr}")
|
||||
else:
|
||||
futures = [submit(i) for i in range(len(partitions))]
|
||||
for i, fut in enumerate(futures):
|
||||
result = fut.result()
|
||||
if not result.ok:
|
||||
raise RuntimeError(f"Unity chunk {i + 1} failed: {result.stderr}")
|
||||
|
||||
# Create archive from chunk objects
|
||||
from ci.compiler.clang_compiler import LibarchiveOptions
|
||||
|
||||
output_archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
archive_result = compiler.create_archive(
|
||||
unity_obj_paths, output_archive, LibarchiveOptions()
|
||||
).result()
|
||||
if not archive_result.ok:
|
||||
raise RuntimeError(f"Archive creation failed: {archive_result.stderr}")
|
||||
|
||||
return output_archive
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Chunked UNITY build and archive for FastLED src/**"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--chunks", type=int, default=1, help="Number of unity chunks (1-4 typical)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-parallel", action="store_true", help="Compile unity chunks sequentially"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=str,
|
||||
default=str(Path(".build/fastled/libfastled.a")),
|
||||
help="Path to output archive libfastled.a",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--unity-dir",
|
||||
type=str,
|
||||
default=str(Path(".build/fastled/unity")),
|
||||
help="Directory to place generated unity{N}.cpp/.o files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--use-pch",
|
||||
action="store_true",
|
||||
help="Enable PCH (not recommended for unity builds)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Anchor to project root (two levels up from this file)
|
||||
here = Path(__file__).resolve()
|
||||
project_root = here.parent.parent.parent
|
||||
os.chdir(project_root)
|
||||
|
||||
try:
|
||||
compiler = create_fastled_compiler(
|
||||
use_pch=args.use_pch, parallel=not args.no_parallel
|
||||
)
|
||||
out = build_unity_chunks_and_archive(
|
||||
compiler=compiler,
|
||||
chunks=args.chunks,
|
||||
output_archive=Path(args.output),
|
||||
unity_dir=Path(args.unity_dir),
|
||||
no_parallel=args.no_parallel,
|
||||
)
|
||||
print(f"[UNITY] Created archive: {out}")
|
||||
return 0
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"\x1b[31m[UNITY] FAILED: {e}\x1b[0m")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false
|
||||
"""
|
||||
Demo script showing how to use the new generic symbol analysis functionality
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
def run_symbol_analysis(board_name: str):
|
||||
"""Run symbol analysis for a specific board"""
|
||||
print(f"Running symbol analysis for {board_name}...")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "ci/util/symbol_analysis.py", "--board", board_name],
|
||||
cwd=".",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f"✅ Symbol analysis completed successfully for {board_name}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Symbol analysis failed for {board_name}")
|
||||
print(f"Error: {result.stderr}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Error running symbol analysis for {board_name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def load_and_summarize_results(board_name: str):
|
||||
"""Load and display a summary of symbol analysis results"""
|
||||
json_file = Path(f".build/{board_name}_symbol_analysis.json")
|
||||
|
||||
if not json_file.exists():
|
||||
print(f"❌ Results file not found: {json_file}")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(json_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
summary = data["summary"]
|
||||
print(f"\n📊 SUMMARY FOR {board_name.upper()}:")
|
||||
print(f" Total symbols: {summary['total_symbols']}")
|
||||
print(
|
||||
f" Total size: {summary['total_size']} bytes ({summary['total_size'] / 1024:.1f} KB)"
|
||||
)
|
||||
|
||||
print("\n🔝 TOP 5 LARGEST SYMBOLS:")
|
||||
for i, symbol in enumerate(summary["largest_symbols"][:5], 1):
|
||||
name = symbol.get("demangled_name", symbol["name"])
|
||||
print(
|
||||
f" {i}. {symbol['size']:6d} bytes - {name[:80]}{'...' if len(name) > 80 else ''}"
|
||||
)
|
||||
|
||||
print("\n📁 SYMBOL TYPES:")
|
||||
for sym_type, stats in list(summary["type_breakdown"].items())[:5]:
|
||||
print(f" {sym_type}: {stats['count']} symbols, {stats['size']} bytes")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading results for {board_name}: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Demo the symbol analysis functionality"""
|
||||
print("=" * 80)
|
||||
print("FASTLED SYMBOL ANALYSIS DEMO")
|
||||
print("=" * 80)
|
||||
|
||||
# List of boards to analyze (you can add more)
|
||||
boards_to_analyze: List[str] = []
|
||||
|
||||
# Check which boards have build info available
|
||||
build_dir = Path(".build")
|
||||
if build_dir.exists():
|
||||
for item in build_dir.iterdir():
|
||||
if item.is_dir() and (item / "build_info.json").exists():
|
||||
boards_to_analyze.append(item.name)
|
||||
|
||||
if not boards_to_analyze:
|
||||
print("❌ No boards with build_info.json found in .build directory")
|
||||
print(
|
||||
" Please compile some examples first using: uv run -m ci.ci-compile <board> --examples Blink"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(
|
||||
f"📋 Found {len(boards_to_analyze)} boards with build info: {', '.join(boards_to_analyze)}"
|
||||
)
|
||||
|
||||
# Run symbol analysis for each board
|
||||
successful_boards: List[str] = []
|
||||
for board in boards_to_analyze:
|
||||
if run_symbol_analysis(board):
|
||||
successful_boards.append(board)
|
||||
|
||||
print(f"\n📈 RESULTS SUMMARY FOR {len(successful_boards)} BOARDS:")
|
||||
print("=" * 80)
|
||||
|
||||
# Load and summarize results
|
||||
for board in successful_boards:
|
||||
load_and_summarize_results(board)
|
||||
print()
|
||||
|
||||
print("🎉 Symbol analysis demo completed!")
|
||||
print("📁 Detailed JSON files saved in .build/ directory")
|
||||
print("💡 Use these files to identify optimization opportunities")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,339 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class DockerManager:
|
||||
def __init__(self):
|
||||
# No direct client object, will use subprocess for docker CLI commands
|
||||
pass
|
||||
|
||||
def _run_docker_command(
|
||||
self,
|
||||
command: List[str],
|
||||
check: bool = True,
|
||||
stream_output: bool = False,
|
||||
timeout: Optional[int] = None,
|
||||
interrupt_pattern: Optional[str] = None,
|
||||
output_file: Optional[str] = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
full_command = ["docker"] + command
|
||||
print(f"Executing Docker command: {' '.join(full_command)}")
|
||||
|
||||
# Set environment to prevent MSYS2/Git Bash path conversion on Windows
|
||||
env = os.environ.copy()
|
||||
# Set UTF-8 encoding environment variables for Windows
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
env["PYTHONUTF8"] = "1"
|
||||
# Only set MSYS_NO_PATHCONV if we're in a Git Bash/MSYS2 environment
|
||||
if (
|
||||
"MSYSTEM" in os.environ
|
||||
or os.environ.get("TERM") == "xterm"
|
||||
or "bash.exe" in os.environ.get("SHELL", "")
|
||||
):
|
||||
env["MSYS_NO_PATHCONV"] = "1"
|
||||
|
||||
if stream_output:
|
||||
# Use RunningProcess for streaming output
|
||||
import re
|
||||
import time
|
||||
|
||||
from ci.util.running_process import RunningProcess
|
||||
|
||||
proc = RunningProcess(full_command, env=env, auto_run=True)
|
||||
pattern_found = False
|
||||
timeout_occurred = False
|
||||
start_time = time.time()
|
||||
|
||||
# Open output file if specified
|
||||
output_handle = None
|
||||
if output_file:
|
||||
try:
|
||||
output_handle = open(output_file, "w", encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not open output file {output_file}: {e}")
|
||||
|
||||
try:
|
||||
with proc.line_iter(timeout=None) as it:
|
||||
for line in it:
|
||||
print(line)
|
||||
|
||||
# Write to output file if specified
|
||||
if output_handle:
|
||||
output_handle.write(line + "\n")
|
||||
output_handle.flush() # Ensure immediate write
|
||||
|
||||
# Check for interrupt pattern (for informational purposes only)
|
||||
if interrupt_pattern and re.search(interrupt_pattern, line):
|
||||
print(f"Pattern detected: {line}")
|
||||
pattern_found = True
|
||||
|
||||
# Check timeout
|
||||
if timeout and (time.time() - start_time) > timeout:
|
||||
print(
|
||||
f"Timeout reached ({timeout}s), terminating container..."
|
||||
)
|
||||
timeout_occurred = True
|
||||
break
|
||||
|
||||
# Wait for process to complete
|
||||
returncode = proc.wait()
|
||||
|
||||
# Handle timeout case: return success (0) for timeouts as per requirement
|
||||
if timeout_occurred:
|
||||
print("Process terminated due to timeout - treating as success")
|
||||
final_returncode = 0
|
||||
else:
|
||||
# Return the actual container exit code for all other cases
|
||||
final_returncode = returncode
|
||||
|
||||
return subprocess.CompletedProcess(
|
||||
args=full_command, returncode=final_returncode, stdout="", stderr=""
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during streaming: {e}")
|
||||
returncode = proc.wait() if hasattr(proc, "wait") else 1
|
||||
return subprocess.CompletedProcess(
|
||||
args=full_command, returncode=returncode, stdout="", stderr=str(e)
|
||||
)
|
||||
finally:
|
||||
# Close output file if it was opened
|
||||
if output_handle:
|
||||
output_handle.close()
|
||||
else:
|
||||
# Use regular subprocess for non-streaming commands
|
||||
result = subprocess.run(
|
||||
full_command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
env=env,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
print(
|
||||
f"Docker command failed with exit code {result.returncode}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"Stdout: {result.stdout}", file=sys.stderr)
|
||||
print(f"Stderr: {result.stderr}", file=sys.stderr)
|
||||
return result
|
||||
|
||||
def pull_image(self, image_name: str, tag: str = "latest"):
|
||||
"""Pulls the specified Docker image."""
|
||||
print(f"Pulling image: {image_name}:{tag}")
|
||||
self._run_docker_command(["pull", f"{image_name}:{tag}"])
|
||||
print(f"Image {image_name}:{tag} pulled successfully.")
|
||||
|
||||
def run_container(
|
||||
self,
|
||||
image_name: str,
|
||||
command: List[str],
|
||||
volumes: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
environment: Optional[Dict[str, str]] = None,
|
||||
detach: bool = False,
|
||||
name: Optional[str] = None,
|
||||
) -> str: # Return container ID as string
|
||||
"""
|
||||
Runs a Docker container with specified command, volume mounts, and environment variables.
|
||||
Returns the container ID.
|
||||
"""
|
||||
print(
|
||||
f"Running container from image: {image_name} with command: {' '.join(command)}"
|
||||
)
|
||||
docker_cmd = ["run", "--rm"] # --rm to automatically remove container on exit
|
||||
if detach:
|
||||
docker_cmd.append("-d")
|
||||
if name:
|
||||
docker_cmd.extend(["--name", name])
|
||||
if volumes:
|
||||
for host_path, container_path_info in volumes.items():
|
||||
mode = container_path_info.get("mode", "rw")
|
||||
docker_cmd.extend(
|
||||
["-v", f"{host_path}:{container_path_info['bind']}:{mode}"]
|
||||
)
|
||||
if environment:
|
||||
for key, value in environment.items():
|
||||
docker_cmd.extend(["-e", f"{key}={value}"])
|
||||
|
||||
docker_cmd.append(image_name)
|
||||
docker_cmd.extend(command)
|
||||
|
||||
result = self._run_docker_command(docker_cmd)
|
||||
container_id = result.stdout.strip()
|
||||
print(f"Container {container_id} started.")
|
||||
return container_id
|
||||
|
||||
def run_container_streaming(
|
||||
self,
|
||||
image_name: str,
|
||||
command: List[str],
|
||||
volumes: Optional[Dict[str, Dict[str, str]]] = None,
|
||||
environment: Optional[Dict[str, str]] = None,
|
||||
name: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
interrupt_pattern: Optional[str] = None,
|
||||
output_file: Optional[str] = None,
|
||||
) -> int:
|
||||
"""
|
||||
Runs a Docker container with streaming output.
|
||||
Returns actual container exit code (0 for success, timeout is treated as success).
|
||||
|
||||
Args:
|
||||
image_name: Docker image to run
|
||||
command: Command to execute in container
|
||||
volumes: Volume mounts
|
||||
environment: Environment variables
|
||||
name: Container name
|
||||
timeout: Timeout in seconds (if exceeded, returns 0)
|
||||
interrupt_pattern: Pattern to detect in output (informational only)
|
||||
output_file: Optional file path to write output to
|
||||
"""
|
||||
print(
|
||||
f"Running container from image: {image_name} with command: {' '.join(command)}"
|
||||
)
|
||||
docker_cmd = ["run", "--rm"] # --rm to automatically remove container on exit
|
||||
if name:
|
||||
docker_cmd.extend(["--name", name])
|
||||
if volumes:
|
||||
for host_path, container_path_info in volumes.items():
|
||||
mode = container_path_info.get("mode", "rw")
|
||||
docker_cmd.extend(
|
||||
["-v", f"{host_path}:{container_path_info['bind']}:{mode}"]
|
||||
)
|
||||
if environment:
|
||||
for key, value in environment.items():
|
||||
docker_cmd.extend(["-e", f"{key}={value}"])
|
||||
|
||||
docker_cmd.append(image_name)
|
||||
docker_cmd.extend(command)
|
||||
|
||||
result = self._run_docker_command(
|
||||
docker_cmd,
|
||||
check=False,
|
||||
stream_output=True,
|
||||
timeout=timeout,
|
||||
interrupt_pattern=interrupt_pattern,
|
||||
output_file=output_file,
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
def get_container_logs(self, container_id_or_name: str) -> str:
|
||||
"""Retrieves logs from a container."""
|
||||
print(f"Getting logs for container: {container_id_or_name}")
|
||||
try:
|
||||
result = self._run_docker_command(
|
||||
["logs", container_id_or_name], check=False
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logs = result.stdout
|
||||
print(f"Logs retrieved for {container_id_or_name}.")
|
||||
return logs
|
||||
else:
|
||||
# Container may have exited, try to get logs anyway
|
||||
print(f"Warning: docker logs returned exit code {result.returncode}")
|
||||
logs = result.stdout if result.stdout else result.stderr
|
||||
return logs
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to get container logs: {e}")
|
||||
return ""
|
||||
|
||||
def stop_container(self, container_id_or_name: str):
|
||||
"""Stops a running container."""
|
||||
print(f"Stopping container: {container_id_or_name}")
|
||||
self._run_docker_command(["stop", container_id_or_name])
|
||||
print(f"Container {container_id_or_name} stopped.")
|
||||
|
||||
def remove_container(self, container_id_or_name: str):
|
||||
"""Removes a container."""
|
||||
print(f"Removing container: {container_id_or_name}")
|
||||
self._run_docker_command(["rm", container_id_or_name])
|
||||
print(f"Container {container_id_or_name} removed.")
|
||||
|
||||
def execute_command_in_container(
|
||||
self, container_id_or_name: str, command: List[str]
|
||||
) -> tuple[int, str, str]:
|
||||
"""Executes a command inside a running container and returns exit code, stdout, and stderr."""
|
||||
print(
|
||||
f"Executing command '{' '.join(command)}' in container: {container_id_or_name}"
|
||||
)
|
||||
result = self._run_docker_command(
|
||||
["exec", container_id_or_name] + command, check=False
|
||||
)
|
||||
stdout = result.stdout
|
||||
stderr = result.stderr
|
||||
print(
|
||||
f"Command executed in {container_id_or_name}. Exit code: {result.returncode}"
|
||||
)
|
||||
return result.returncode, stdout, stderr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage:
|
||||
manager = DockerManager()
|
||||
image_to_use = "mluis/qemu-esp32"
|
||||
container_name = "test_esp32_qemu"
|
||||
|
||||
try:
|
||||
print(f"--- Starting DockerManager test with image: {image_to_use} ---")
|
||||
manager.pull_image(image_to_use)
|
||||
|
||||
# Example: Run a simple command in the container
|
||||
print("\n--- Running a simple command ---")
|
||||
container_id = manager.run_container(
|
||||
image_name=image_to_use,
|
||||
command=["echo", "Hello from Docker!"],
|
||||
detach=True,
|
||||
name=container_name,
|
||||
)
|
||||
|
||||
# Wait a bit for the command to execute and logs to be generated
|
||||
import time
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
logs = manager.get_container_logs(container_id)
|
||||
print("Container Logs:\n", logs)
|
||||
|
||||
# Check if the expected output is in the logs
|
||||
if "Hello from Docker!" in logs:
|
||||
print("SUCCESS: Expected output found in container logs.")
|
||||
else:
|
||||
print("FAILURE: Expected output NOT found in container logs.")
|
||||
|
||||
# Example: Execute a command inside a running container
|
||||
print("\n--- Executing command inside container ---")
|
||||
exit_code, stdout, stderr = manager.execute_command_in_container(
|
||||
container_id, ["sh", "-c", "echo 'Executed inside!'; exit 42"]
|
||||
)
|
||||
print(f"Exec command stdout: {stdout}")
|
||||
print(f"Exec command stderr: {stderr}")
|
||||
print(f"Exec command exit code: {exit_code}")
|
||||
if exit_code == 42 and "Executed inside!" in stdout:
|
||||
print("SUCCESS: Command executed inside container as expected.")
|
||||
else:
|
||||
print("FAILURE: Command execution inside container failed.")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error during Docker operation: {e}", file=sys.stderr)
|
||||
print(f"Stdout: {e.stdout}", file=sys.stderr)
|
||||
print(f"Stderr: {e.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
finally:
|
||||
# Ensure cleanup
|
||||
try:
|
||||
manager.stop_container(container_name)
|
||||
manager.remove_container(container_name)
|
||||
print(f"Container {container_name} stopped and removed.")
|
||||
except subprocess.CalledProcessError:
|
||||
print(
|
||||
f"Container {container_name} not found or already removed during cleanup."
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error during cleanup of {container_name}: {e}", file=sys.stderr)
|
||||
@@ -0,0 +1,106 @@
|
||||
# Dockerfile for ESP32 QEMU emulation environment
|
||||
# Based on ESP-IDF with QEMU support
|
||||
|
||||
FROM espressif/idf:v5.1.2
|
||||
|
||||
# Install additional dependencies for QEMU
|
||||
RUN apt-get update && apt-get install -y \
|
||||
qemu-system-misc \
|
||||
qemu-utils \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies for ESP tools
|
||||
RUN pip3 install --no-cache-dir \
|
||||
esptool \
|
||||
pyserial
|
||||
|
||||
# Create workspace directory
|
||||
WORKDIR /workspace
|
||||
|
||||
# Set up environment for QEMU ESP32
|
||||
ENV QEMU_ESP32_VERSION=esp-develop-8.2.0-20240122
|
||||
|
||||
# Download and install ESP32 QEMU if not using system QEMU
|
||||
RUN mkdir -p /opt/qemu-esp32 && \
|
||||
cd /opt/qemu-esp32 && \
|
||||
wget -q https://github.com/espressif/qemu/releases/download/${QEMU_ESP32_VERSION}/qemu-xtensa-softmmu-esp_develop_8.2.0_20240122-x86_64-linux-gnu.tar.xz && \
|
||||
tar -xf qemu-xtensa-softmmu-esp_develop_8.2.0_20240122-x86_64-linux-gnu.tar.xz && \
|
||||
rm qemu-xtensa-softmmu-esp_develop_8.2.0_20240122-x86_64-linux-gnu.tar.xz && \
|
||||
ln -sf /opt/qemu-esp32/qemu/bin/qemu-system-xtensa /usr/local/bin/qemu-system-xtensa
|
||||
|
||||
# Download ESP32 ROM files for QEMU
|
||||
RUN mkdir -p /opt/esp-rom && \
|
||||
cd /opt/esp-rom && \
|
||||
wget -q https://github.com/espressif/qemu/raw/esp-develop/pc-bios/esp32-v3-rom.bin && \
|
||||
wget -q https://github.com/espressif/qemu/raw/esp-develop/pc-bios/esp32-v3-rom-eco3.bin && \
|
||||
wget -q https://github.com/espressif/qemu/raw/esp-develop/pc-bios/esp32c3-rom.bin && \
|
||||
wget -q https://github.com/espressif/qemu/raw/esp-develop/pc-bios/esp32s2-rom.bin && \
|
||||
wget -q https://github.com/espressif/qemu/raw/esp-develop/pc-bios/esp32s3-rom.bin
|
||||
|
||||
# Set environment variables for ROM files
|
||||
ENV ESP_ROM_PATH=/opt/esp-rom
|
||||
ENV ESP32_ROM=${ESP_ROM_PATH}/esp32-v3-rom.bin
|
||||
ENV ESP32C3_ROM=${ESP_ROM_PATH}/esp32c3-rom.bin
|
||||
ENV ESP32S2_ROM=${ESP_ROM_PATH}/esp32s2-rom.bin
|
||||
ENV ESP32S3_ROM=${ESP_ROM_PATH}/esp32s3-rom.bin
|
||||
|
||||
# Create helper script for running QEMU with proper settings
|
||||
RUN cat > /usr/local/bin/run-qemu-esp32 << 'EOF'
|
||||
#!/bin/bash
|
||||
# Helper script to run QEMU with ESP32 firmware
|
||||
|
||||
FIRMWARE_PATH=${1:-/workspace/firmware.bin}
|
||||
MACHINE=${2:-esp32}
|
||||
FLASH_SIZE=${3:-4}
|
||||
|
||||
# Select ROM file based on machine type
|
||||
case "$MACHINE" in
|
||||
esp32)
|
||||
ROM_FILE=$ESP32_ROM
|
||||
;;
|
||||
esp32c3)
|
||||
ROM_FILE=$ESP32C3_ROM
|
||||
;;
|
||||
esp32s2)
|
||||
ROM_FILE=$ESP32S2_ROM
|
||||
;;
|
||||
esp32s3)
|
||||
ROM_FILE=$ESP32S3_ROM
|
||||
;;
|
||||
*)
|
||||
echo "Unknown machine type: $MACHINE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Create flash image if needed
|
||||
FLASH_IMAGE=/tmp/flash_${RANDOM}.bin
|
||||
if [ -f "$FIRMWARE_PATH" ]; then
|
||||
# Create empty flash image
|
||||
dd if=/dev/zero of=$FLASH_IMAGE bs=1M count=$FLASH_SIZE 2>/dev/null
|
||||
dd if=$FIRMWARE_PATH of=$FLASH_IMAGE bs=1 seek=$((0x10000)) conv=notrunc 2>/dev/null
|
||||
else
|
||||
echo "Firmware not found: $FIRMWARE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run QEMU
|
||||
exec qemu-system-xtensa \
|
||||
-nographic \
|
||||
-machine $MACHINE \
|
||||
-bios $ROM_FILE \
|
||||
-drive file=$FLASH_IMAGE,if=mtd,format=raw \
|
||||
-global driver=timer.esp32.timg,property=wdt_disable,value=true \
|
||||
"$@"
|
||||
EOF
|
||||
|
||||
RUN chmod +x /usr/local/bin/run-qemu-esp32
|
||||
|
||||
# Default command
|
||||
CMD ["/usr/local/bin/run-qemu-esp32"]
|
||||
|
||||
# Labels
|
||||
LABEL maintainer="FastLED Team"
|
||||
LABEL description="ESP32 QEMU emulation environment for FastLED testing"
|
||||
LABEL version="1.0.0"
|
||||
@@ -0,0 +1,92 @@
|
||||
# Lightweight Dockerfile for ESP32 QEMU emulation
|
||||
# Uses pre-built QEMU binaries without full ESP-IDF
|
||||
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# Install minimal dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
wget \
|
||||
xz-utils \
|
||||
python3 \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install esptool for flash image creation
|
||||
RUN pip3 install --no-cache-dir esptool
|
||||
|
||||
# Create workspace
|
||||
WORKDIR /workspace
|
||||
|
||||
# Download and install ESP32 QEMU binaries
|
||||
ENV QEMU_VERSION=esp-develop-8.2.0-20240122
|
||||
RUN mkdir -p /opt/qemu && \
|
||||
cd /opt/qemu && \
|
||||
wget -q https://github.com/espressif/qemu/releases/download/${QEMU_VERSION}/qemu-xtensa-softmmu-${QEMU_VERSION}-x86_64-linux-gnu.tar.xz && \
|
||||
tar -xf qemu-xtensa-softmmu-${QEMU_VERSION}-x86_64-linux-gnu.tar.xz && \
|
||||
rm qemu-xtensa-softmmu-${QEMU_VERSION}-x86_64-linux-gnu.tar.xz && \
|
||||
mv qemu /usr/local/
|
||||
|
||||
# Add QEMU to PATH
|
||||
ENV PATH=/usr/local/qemu/bin:$PATH
|
||||
|
||||
# Download ESP32 ROM files
|
||||
RUN mkdir -p /opt/esp-rom && \
|
||||
cd /opt/esp-rom && \
|
||||
wget -q https://github.com/espressif/qemu/raw/esp-develop/pc-bios/esp32-v3-rom.bin && \
|
||||
wget -q https://github.com/espressif/qemu/raw/esp-develop/pc-bios/esp32c3-rom.bin && \
|
||||
wget -q https://github.com/espressif/qemu/raw/esp-develop/pc-bios/esp32s2-rom.bin && \
|
||||
wget -q https://github.com/espressif/qemu/raw/esp-develop/pc-bios/esp32s3-rom.bin
|
||||
|
||||
# Set ROM path environment variable
|
||||
ENV ESP_ROM_PATH=/opt/esp-rom
|
||||
|
||||
# Create entrypoint script
|
||||
RUN cat > /entrypoint.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
FIRMWARE=${FIRMWARE:-/workspace/firmware.bin}
|
||||
MACHINE=${MACHINE:-esp32}
|
||||
FLASH_SIZE=${FLASH_SIZE:-4}
|
||||
TIMEOUT=${TIMEOUT:-30}
|
||||
|
||||
# Select ROM based on machine type
|
||||
case "$MACHINE" in
|
||||
esp32) ROM_FILE=/opt/esp-rom/esp32-v3-rom.bin ;;
|
||||
esp32c3) ROM_FILE=/opt/esp-rom/esp32c3-rom.bin ;;
|
||||
esp32s2) ROM_FILE=/opt/esp-rom/esp32s2-rom.bin ;;
|
||||
esp32s3) ROM_FILE=/opt/esp-rom/esp32s3-rom.bin ;;
|
||||
*) echo "Unknown machine: $MACHINE"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Check if firmware exists
|
||||
if [ ! -f "$FIRMWARE" ]; then
|
||||
echo "ERROR: Firmware not found: $FIRMWARE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create flash image
|
||||
FLASH_IMG=/tmp/flash.bin
|
||||
dd if=/dev/zero of=$FLASH_IMG bs=1M count=$FLASH_SIZE 2>/dev/null
|
||||
dd if=$FIRMWARE of=$FLASH_IMG bs=1 seek=$((0x10000)) conv=notrunc 2>/dev/null
|
||||
|
||||
# Run QEMU with timeout
|
||||
timeout $TIMEOUT qemu-system-xtensa \
|
||||
-nographic \
|
||||
-machine $MACHINE \
|
||||
-bios $ROM_FILE \
|
||||
-drive file=$FLASH_IMG,if=mtd,format=raw \
|
||||
-global driver=timer.esp32.timg,property=wdt_disable,value=true \
|
||||
"$@"
|
||||
EOF
|
||||
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Labels
|
||||
LABEL maintainer="FastLED Team"
|
||||
LABEL description="Lightweight ESP32 QEMU Docker image"
|
||||
LABEL version="1.0.0"
|
||||
@@ -0,0 +1,205 @@
|
||||
# Docker-based QEMU ESP32 Testing
|
||||
|
||||
This directory contains Docker-based QEMU ESP32 emulation tools for FastLED testing.
|
||||
|
||||
## Overview
|
||||
|
||||
The Docker QEMU solution provides:
|
||||
- Consistent testing environment across different platforms
|
||||
- No need for local QEMU installation
|
||||
- Isolated execution environment
|
||||
- Support for multiple ESP32 variants (ESP32, ESP32-C3, ESP32-S2, ESP32-S3)
|
||||
|
||||
## Components
|
||||
|
||||
### Core Files
|
||||
|
||||
- `DockerManager.py` - Docker container management utilities
|
||||
- `qemu_esp32_docker.py` - Main Docker QEMU runner script
|
||||
- `qemu_test_integration.py` - Integration with existing test framework
|
||||
- `Dockerfile.qemu-esp32` - Full ESP-IDF + QEMU environment
|
||||
- `Dockerfile.qemu-esp32-lite` - Lightweight QEMU-only environment
|
||||
- `docker-compose.yml` - Docker Compose configuration
|
||||
|
||||
## Usage
|
||||
|
||||
### Quick Start
|
||||
|
||||
1. **Run a firmware test with Docker:**
|
||||
```bash
|
||||
uv run ci/docker/qemu_esp32_docker.py path/to/firmware.bin
|
||||
```
|
||||
|
||||
2. **Run with specific timeout and interrupt pattern:**
|
||||
```bash
|
||||
uv run ci/docker/qemu_esp32_docker.py firmware.bin \
|
||||
--timeout 60 \
|
||||
--interrupt-regex "Test passed"
|
||||
```
|
||||
|
||||
3. **Use docker-compose:**
|
||||
```bash
|
||||
cd ci/docker
|
||||
FIRMWARE_DIR=../../.pio/build/esp32dev docker-compose up qemu-esp32
|
||||
```
|
||||
|
||||
### Integration with Test Framework
|
||||
|
||||
The Docker QEMU runner integrates with the existing test framework:
|
||||
|
||||
```bash
|
||||
# Check available runners
|
||||
uv run ci/docker/qemu_test_integration.py check
|
||||
|
||||
# Run test with automatic runner selection
|
||||
uv run ci/docker/qemu_test_integration.py test --firmware path/to/firmware.bin
|
||||
|
||||
# Force Docker runner
|
||||
uv run ci/docker/qemu_test_integration.py test --firmware firmware.bin --docker
|
||||
```
|
||||
|
||||
### Building Docker Images
|
||||
|
||||
```bash
|
||||
# Build lightweight image
|
||||
docker build -f Dockerfile.qemu-esp32-lite -t fastled/qemu-esp32:lite .
|
||||
|
||||
# Build full ESP-IDF image
|
||||
docker build -f Dockerfile.qemu-esp32 -t fastled/qemu-esp32:full .
|
||||
|
||||
# Or use docker-compose
|
||||
docker-compose build
|
||||
```
|
||||
|
||||
## Docker Images
|
||||
|
||||
### Pre-built Images
|
||||
|
||||
The runner can use pre-built images:
|
||||
- `espressif/qemu:esp-develop-8.2.0-20240122` (Official Espressif image)
|
||||
- `mluis/qemu-esp32:latest` (Community image)
|
||||
|
||||
### Custom Images
|
||||
|
||||
Two Dockerfiles are provided:
|
||||
|
||||
1. **Dockerfile.qemu-esp32-lite** (Recommended)
|
||||
- Minimal Ubuntu base
|
||||
- QEMU binaries only
|
||||
- ~200MB image size
|
||||
- Fast startup
|
||||
|
||||
2. **Dockerfile.qemu-esp32**
|
||||
- Full ESP-IDF environment
|
||||
- Development tools included
|
||||
- ~2GB image size
|
||||
- Complete toolchain
|
||||
|
||||
## Features
|
||||
|
||||
### Automatic Fallback
|
||||
|
||||
The integration module automatically selects the best available runner:
|
||||
1. Native QEMU (if installed)
|
||||
2. Docker QEMU (if Docker available)
|
||||
3. Error if neither available
|
||||
|
||||
### Platform Support
|
||||
|
||||
Works on:
|
||||
- Linux (native Docker)
|
||||
- macOS (Docker Desktop)
|
||||
- Windows (Docker Desktop, WSL2)
|
||||
|
||||
### Firmware Formats
|
||||
|
||||
Supports:
|
||||
- Direct `firmware.bin` files
|
||||
- PlatformIO build directories
|
||||
- ESP-IDF build directories
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `FIRMWARE_DIR` - Directory containing firmware files
|
||||
- `FIRMWARE_FILE` - Specific firmware file to run
|
||||
- `MACHINE` - ESP32 variant (esp32, esp32c3, esp32s2, esp32s3)
|
||||
- `FLASH_SIZE` - Flash size in MB (default: 4)
|
||||
- `TIMEOUT` - Execution timeout in seconds (default: 30)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker Not Found
|
||||
|
||||
Install Docker:
|
||||
- **Windows/Mac**: Install Docker Desktop
|
||||
- **Linux**: `sudo apt-get install docker.io` or equivalent
|
||||
|
||||
### Permission Denied
|
||||
|
||||
Add user to docker group:
|
||||
```bash
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in
|
||||
```
|
||||
|
||||
### Image Pull Failed
|
||||
|
||||
Check internet connection and Docker Hub access. The runner will automatically try alternative images if the primary fails.
|
||||
|
||||
### QEMU Timeout
|
||||
|
||||
Increase timeout value:
|
||||
```bash
|
||||
uv run ci/docker/qemu_esp32_docker.py firmware.bin --timeout 120
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
- name: Run QEMU Test in Docker
|
||||
run: |
|
||||
uv run ci/docker/qemu_esp32_docker.py \
|
||||
.pio/build/esp32dev/firmware.bin \
|
||||
--timeout 60 \
|
||||
--interrupt-regex "Setup complete"
|
||||
```
|
||||
|
||||
### Local CI
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv pip install -r requirements.txt
|
||||
|
||||
# Run tests
|
||||
uv run ci/docker/qemu_test_integration.py test \
|
||||
--firmware .pio/build/esp32dev/firmware.bin
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New ESP32 Variants
|
||||
|
||||
1. Update Dockerfiles with new ROM files
|
||||
2. Add machine type to runner scripts
|
||||
3. Test with sample firmware
|
||||
|
||||
### Debugging
|
||||
|
||||
Run interactively:
|
||||
```bash
|
||||
uv run ci/docker/qemu_esp32_docker.py firmware.bin --interactive
|
||||
```
|
||||
|
||||
Enable verbose output:
|
||||
```bash
|
||||
docker-compose up qemu-esp32-interactive
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
- Docker adds ~2-5 seconds overhead vs native QEMU
|
||||
- First run pulls image (one-time ~100MB download)
|
||||
- Subsequent runs use cached image
|
||||
- Consider using lightweight image for CI/CD
|
||||
Whitespace-only changes.
@@ -0,0 +1,61 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
qemu-esp32:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.qemu-esp32-lite
|
||||
image: fastled/qemu-esp32:latest
|
||||
container_name: fastled-qemu-esp32
|
||||
volumes:
|
||||
# Mount firmware directory
|
||||
- ${FIRMWARE_DIR:-./firmware}:/workspace:ro
|
||||
# Mount output directory for logs
|
||||
- ${OUTPUT_DIR:-./output}:/output
|
||||
environment:
|
||||
- FIRMWARE=${FIRMWARE_FILE:-/workspace/firmware.bin}
|
||||
- MACHINE=${MACHINE:-esp32}
|
||||
- FLASH_SIZE=${FLASH_SIZE:-4}
|
||||
- TIMEOUT=${TIMEOUT:-30}
|
||||
command: []
|
||||
stdin_open: false
|
||||
tty: false
|
||||
networks:
|
||||
- qemu-network
|
||||
|
||||
qemu-esp32-interactive:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.qemu-esp32-lite
|
||||
image: fastled/qemu-esp32:latest
|
||||
container_name: fastled-qemu-esp32-interactive
|
||||
volumes:
|
||||
- ${FIRMWARE_DIR:-./firmware}:/workspace:ro
|
||||
environment:
|
||||
- FIRMWARE=${FIRMWARE_FILE:-/workspace/firmware.bin}
|
||||
- MACHINE=${MACHINE:-esp32}
|
||||
- FLASH_SIZE=${FLASH_SIZE:-4}
|
||||
- TIMEOUT=${TIMEOUT:-300}
|
||||
stdin_open: true
|
||||
tty: true
|
||||
networks:
|
||||
- qemu-network
|
||||
|
||||
# Service for building the Docker image
|
||||
builder:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.qemu-esp32-lite
|
||||
image: fastled/qemu-esp32:latest
|
||||
command: echo "Image built successfully"
|
||||
|
||||
networks:
|
||||
qemu-network:
|
||||
driver: bridge
|
||||
|
||||
# Volume definitions (optional, for persistent storage)
|
||||
volumes:
|
||||
firmware:
|
||||
driver: local
|
||||
output:
|
||||
driver: local
|
||||
@@ -0,0 +1,502 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Docker-based QEMU ESP32 Runner
|
||||
|
||||
Runs ESP32 firmware in QEMU using Docker containers for better isolation
|
||||
and consistency across different environments.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from ci.dockerfiles.DockerManager import DockerManager
|
||||
from ci.util.running_process import RunningProcess
|
||||
|
||||
|
||||
def get_docker_env() -> Dict[str, str]:
|
||||
"""Get environment for Docker commands, handling Git Bash/MSYS2 path conversion."""
|
||||
env = os.environ.copy()
|
||||
# Set UTF-8 encoding environment variables for Windows
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
env["PYTHONUTF8"] = "1"
|
||||
# Only set MSYS_NO_PATHCONV if we're in a Git Bash/MSYS2 environment
|
||||
if (
|
||||
"MSYSTEM" in os.environ
|
||||
or os.environ.get("TERM") == "xterm"
|
||||
or "bash.exe" in os.environ.get("SHELL", "")
|
||||
):
|
||||
env["MSYS_NO_PATHCONV"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def run_subprocess_safe(
|
||||
cmd: List[str], **kwargs: Any
|
||||
) -> subprocess.CompletedProcess[Any]:
|
||||
"""Run subprocess with safe UTF-8 handling and error replacement."""
|
||||
kwargs.setdefault("encoding", "utf-8")
|
||||
kwargs.setdefault("errors", "replace")
|
||||
kwargs.setdefault("env", get_docker_env())
|
||||
return subprocess.run(cmd, **kwargs) # type: ignore[misc]
|
||||
|
||||
|
||||
def run_docker_command_streaming(cmd: List[str]) -> int:
|
||||
"""Run docker command with streaming output using RunningProcess."""
|
||||
print(f"Executing: {' '.join(cmd)}")
|
||||
proc = RunningProcess(cmd, env=get_docker_env(), auto_run=True)
|
||||
|
||||
# Stream all output to stdout
|
||||
with proc.line_iter(timeout=None) as it:
|
||||
for line in it:
|
||||
print(line)
|
||||
|
||||
returncode = proc.wait()
|
||||
if returncode != 0:
|
||||
raise subprocess.CalledProcessError(returncode, cmd)
|
||||
return returncode
|
||||
|
||||
|
||||
def run_docker_command_no_fail(cmd: List[str]) -> int:
|
||||
"""Run docker command with streaming output, but don't raise exceptions on failure."""
|
||||
print(f"Executing: {' '.join(cmd)}")
|
||||
proc = RunningProcess(cmd, env=get_docker_env(), auto_run=True)
|
||||
|
||||
# Stream all output to stdout
|
||||
with proc.line_iter(timeout=None) as it:
|
||||
for line in it:
|
||||
print(line)
|
||||
|
||||
return proc.wait()
|
||||
|
||||
|
||||
class DockerQEMURunner:
|
||||
"""Runner for ESP32 QEMU emulation using Docker containers."""
|
||||
|
||||
DEFAULT_IMAGE = "mluis/qemu-esp32:latest"
|
||||
ALTERNATIVE_IMAGE = "espressif/idf:latest"
|
||||
FALLBACK_IMAGE = "espressif/idf:release-v5.2"
|
||||
|
||||
def __init__(self, docker_image: Optional[str] = None):
|
||||
"""Initialize Docker QEMU runner.
|
||||
|
||||
Args:
|
||||
docker_image: Docker image to use, defaults to espressif/qemu
|
||||
"""
|
||||
self.docker_manager = DockerManager()
|
||||
self.docker_image = docker_image or self.DEFAULT_IMAGE
|
||||
self.container_name = None
|
||||
# Use Linux-style paths for all containers since we're using Ubuntu/Alpine
|
||||
self.firmware_mount_path = "/workspace/firmware"
|
||||
|
||||
def check_docker_available(self) -> bool:
|
||||
"""Check if Docker is available and running."""
|
||||
try:
|
||||
proc = RunningProcess(
|
||||
["docker", "version"], env=get_docker_env(), auto_run=True
|
||||
)
|
||||
returncode = proc.wait(timeout=5)
|
||||
return returncode == 0
|
||||
except (Exception, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def pull_image(self):
|
||||
"""Pull the Docker image if not already available."""
|
||||
print(f"Ensuring Docker image {self.docker_image} is available...")
|
||||
try:
|
||||
# Check if image exists locally
|
||||
proc = RunningProcess(
|
||||
["docker", "images", "-q", self.docker_image],
|
||||
env=get_docker_env(),
|
||||
auto_run=True,
|
||||
)
|
||||
stdout_lines: List[str] = []
|
||||
with proc.line_iter(timeout=None) as it:
|
||||
for line in it:
|
||||
stdout_lines.append(line)
|
||||
result_stdout = "\n".join(stdout_lines)
|
||||
if not result_stdout.strip():
|
||||
# Image doesn't exist, pull it directly using docker command
|
||||
print(f"Pulling Docker image: {self.docker_image}")
|
||||
run_docker_command_streaming(["docker", "pull", self.docker_image])
|
||||
print(f"Successfully pulled {self.docker_image}")
|
||||
else:
|
||||
print(f"Image {self.docker_image} already available locally")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Warning: Failed to pull image {self.docker_image}: {e}")
|
||||
if self.docker_image == self.DEFAULT_IMAGE:
|
||||
print(f"Trying alternative image: {self.ALTERNATIVE_IMAGE}")
|
||||
self.docker_image = self.ALTERNATIVE_IMAGE
|
||||
try:
|
||||
print(f"Pulling alternative Docker image: {self.docker_image}")
|
||||
run_docker_command_streaming(["docker", "pull", self.docker_image])
|
||||
print(f"Successfully pulled {self.docker_image}")
|
||||
except subprocess.CalledProcessError as e2:
|
||||
print(f"Failed to pull alternative image: {e2}")
|
||||
print(f"Trying fallback image: {self.FALLBACK_IMAGE}")
|
||||
self.docker_image = self.FALLBACK_IMAGE
|
||||
try:
|
||||
print(f"Pulling fallback Docker image: {self.docker_image}")
|
||||
run_docker_command_streaming(
|
||||
["docker", "pull", self.docker_image]
|
||||
)
|
||||
print(f"Successfully pulled {self.docker_image}")
|
||||
except subprocess.CalledProcessError as e3:
|
||||
print(f"Failed to pull fallback image: {e3}")
|
||||
raise e3
|
||||
else:
|
||||
raise e
|
||||
|
||||
def prepare_firmware(self, firmware_path: Path) -> Path:
|
||||
"""Prepare firmware files for mounting into Docker container.
|
||||
|
||||
Args:
|
||||
firmware_path: Path to firmware.bin or build directory
|
||||
|
||||
Returns:
|
||||
Path to the prepared firmware directory
|
||||
"""
|
||||
# Create temporary directory for firmware files
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="qemu_firmware_"))
|
||||
|
||||
try:
|
||||
if firmware_path.is_file() and firmware_path.suffix == ".bin":
|
||||
# Copy single firmware file
|
||||
shutil.copy2(firmware_path, temp_dir / "firmware.bin")
|
||||
# Create proper 4MB flash image for QEMU
|
||||
self._create_flash_image(firmware_path, temp_dir / "flash.bin")
|
||||
else:
|
||||
# Copy entire build directory
|
||||
if firmware_path.is_dir():
|
||||
# Copy relevant files from build directory
|
||||
patterns = ["*.bin", "*.elf", "partitions.csv", "flash_args"]
|
||||
for pattern in patterns:
|
||||
for file in firmware_path.glob(pattern):
|
||||
shutil.copy2(file, temp_dir)
|
||||
|
||||
# Also check for PlatformIO build structure
|
||||
pio_build = firmware_path / ".pio" / "build" / "esp32dev"
|
||||
if pio_build.exists():
|
||||
for pattern in patterns:
|
||||
for file in pio_build.glob(pattern):
|
||||
shutil.copy2(file, temp_dir)
|
||||
|
||||
# Create proper flash.bin file from firmware.bin
|
||||
firmware_bin = temp_dir / "firmware.bin"
|
||||
if firmware_bin.exists():
|
||||
# Create proper 4MB flash image for QEMU
|
||||
self._create_flash_image(firmware_bin, temp_dir / "flash.bin")
|
||||
else:
|
||||
# Create a minimal 4MB flash.bin for testing
|
||||
flash_bin = temp_dir / "flash.bin"
|
||||
flash_bin.write_bytes(
|
||||
b"\xff" * (4 * 1024 * 1024)
|
||||
) # 4MB of 0xFF
|
||||
else:
|
||||
raise ValueError(f"Invalid firmware path: {firmware_path}")
|
||||
|
||||
return temp_dir
|
||||
except Exception as e:
|
||||
# Clean up on error
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
raise e
|
||||
|
||||
def _create_flash_image(
|
||||
self, firmware_path: Path, output_path: Path, flash_size_mb: int = 4
|
||||
):
|
||||
"""Create a proper flash image for QEMU ESP32.
|
||||
|
||||
Args:
|
||||
firmware_path: Path to the firmware.bin file
|
||||
output_path: Path where to write the flash.bin
|
||||
flash_size_mb: Flash size in MB (must be 2, 4, 8, or 16)
|
||||
"""
|
||||
if flash_size_mb not in [2, 4, 8, 16]:
|
||||
raise ValueError(
|
||||
f"Flash size must be 2, 4, 8, or 16 MB, got {flash_size_mb}"
|
||||
)
|
||||
|
||||
flash_size = flash_size_mb * 1024 * 1024
|
||||
|
||||
# Read firmware content
|
||||
firmware_data = firmware_path.read_bytes()
|
||||
|
||||
# Create flash image: firmware at beginning, rest filled with 0xFF
|
||||
flash_data = firmware_data + b"\xff" * (flash_size - len(firmware_data))
|
||||
|
||||
# Ensure we have exactly the right size
|
||||
if len(flash_data) > flash_size:
|
||||
raise ValueError(
|
||||
f"Firmware size ({len(firmware_data)} bytes) exceeds flash size ({flash_size} bytes)"
|
||||
)
|
||||
|
||||
flash_data = flash_data[:flash_size] # Truncate to exact size
|
||||
|
||||
output_path.write_bytes(flash_data)
|
||||
|
||||
def build_qemu_command(
|
||||
self,
|
||||
firmware_name: str = "firmware.bin",
|
||||
flash_size: int = 4,
|
||||
machine: str = "esp32",
|
||||
) -> List[str]:
|
||||
"""Build QEMU command to run inside Docker container.
|
||||
|
||||
Args:
|
||||
firmware_name: Name of firmware file in mounted directory
|
||||
flash_size: Flash size in MB
|
||||
machine: QEMU machine type (esp32, esp32c3, esp32s3, etc.)
|
||||
|
||||
Returns:
|
||||
List of command arguments for QEMU
|
||||
"""
|
||||
# For Linux containers, use a workaround to run QEMU through Docker Desktop's Windows integration
|
||||
firmware_path = f"{self.firmware_mount_path}/flash.bin"
|
||||
|
||||
# Determine QEMU system and machine based on target
|
||||
if machine == "esp32c3":
|
||||
qemu_system = "/usr/local/bin/qemu-system-riscv32"
|
||||
qemu_machine = "esp32c3"
|
||||
echo_target = "ESP32C3"
|
||||
elif machine == "esp32s3":
|
||||
qemu_system = "/usr/local/bin/qemu-system-xtensa"
|
||||
qemu_machine = "esp32s3"
|
||||
echo_target = "ESP32S3"
|
||||
else:
|
||||
# Default to ESP32 (Xtensa)
|
||||
qemu_system = "/usr/local/bin/qemu-system-xtensa"
|
||||
qemu_machine = "esp32"
|
||||
echo_target = "ESP32"
|
||||
|
||||
# Use container's QEMU with proper configuration
|
||||
wrapper_script = f'''#!/bin/bash
|
||||
set -e
|
||||
echo "Starting {echo_target} QEMU emulation..."
|
||||
echo "Firmware: {firmware_path}"
|
||||
echo "Machine: {qemu_machine}"
|
||||
echo "QEMU system: {qemu_system}"
|
||||
echo "Container: $(cat /etc/os-release | head -1)"
|
||||
|
||||
# Check if firmware file exists
|
||||
if [ ! -f "{firmware_path}" ]; then
|
||||
echo "ERROR: Firmware file not found: {firmware_path}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check firmware size
|
||||
FIRMWARE_SIZE=$(stat -c%s "{firmware_path}")
|
||||
echo "Firmware size: $FIRMWARE_SIZE bytes"
|
||||
|
||||
# Copy firmware to writable location since QEMU needs write access
|
||||
cp "{firmware_path}" /tmp/flash.bin
|
||||
echo "Copied firmware to writable location: /tmp/flash.bin"
|
||||
|
||||
# Try different QEMU configurations depending on machine type
|
||||
if [ "{qemu_machine}" = "esp32c3" ]; then
|
||||
# ESP32C3 uses RISC-V architecture
|
||||
echo "Running {qemu_system} for {qemu_machine}"
|
||||
{qemu_system} \\
|
||||
-nographic \\
|
||||
-machine {qemu_machine} \\
|
||||
-drive file="/tmp/flash.bin",if=mtd,format=raw \\
|
||||
-monitor none \\
|
||||
-serial mon:stdio
|
||||
else
|
||||
# ESP32 uses Xtensa architecture
|
||||
echo "Running {qemu_system} for {qemu_machine}"
|
||||
{qemu_system} \\
|
||||
-nographic \\
|
||||
-machine {qemu_machine} \\
|
||||
-drive file="/tmp/flash.bin",if=mtd,format=raw \\
|
||||
-global driver=timer.esp32.timg,property=wdt_disable,value=true \\
|
||||
-monitor none \\
|
||||
-serial mon:stdio
|
||||
fi
|
||||
|
||||
echo "QEMU execution completed"
|
||||
exit 0
|
||||
'''
|
||||
|
||||
cmd = ["bash", "-c", wrapper_script]
|
||||
|
||||
return cmd
|
||||
|
||||
def run(
|
||||
self,
|
||||
firmware_path: Path,
|
||||
timeout: int = 30,
|
||||
flash_size: int = 4,
|
||||
interrupt_regex: Optional[str] = None,
|
||||
interactive: bool = False,
|
||||
output_file: Optional[str] = None,
|
||||
machine: str = "esp32",
|
||||
) -> int:
|
||||
"""Run ESP32/ESP32C3/ESP32S3 firmware in QEMU using Docker.
|
||||
|
||||
Args:
|
||||
firmware_path: Path to firmware.bin or build directory
|
||||
timeout: Timeout in seconds (timeout is treated as success)
|
||||
flash_size: Flash size in MB
|
||||
interrupt_regex: Regex pattern to detect in output (informational only)
|
||||
interactive: Run in interactive mode (attach to container)
|
||||
output_file: Optional file path to write QEMU output to
|
||||
machine: QEMU machine type (esp32, esp32c3, esp32s3, etc.)
|
||||
|
||||
Returns:
|
||||
Exit code: actual QEMU/container exit code, except timeout returns 0
|
||||
"""
|
||||
if not self.check_docker_available():
|
||||
print("ERROR: Docker is not available or not running", file=sys.stderr)
|
||||
print("Please install Docker and ensure it's running", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Pull image if needed
|
||||
self.pull_image()
|
||||
|
||||
# Prepare firmware files
|
||||
print(f"Preparing firmware from: {firmware_path}")
|
||||
temp_firmware_dir = None
|
||||
|
||||
try:
|
||||
temp_firmware_dir = self.prepare_firmware(firmware_path)
|
||||
|
||||
# Generate unique container name
|
||||
self.container_name = f"qemu_esp32_{int(time.time())}"
|
||||
|
||||
# Convert Windows paths to Docker-compatible paths
|
||||
def windows_to_docker_path(path_str: Union[str, Path]) -> str:
|
||||
"""Convert Windows path to Docker volume mount format."""
|
||||
import os
|
||||
|
||||
# Check if we're in Git Bash/MSYS2 environment
|
||||
is_git_bash = (
|
||||
"MSYSTEM" in os.environ
|
||||
or os.environ.get("TERM") == "xterm"
|
||||
or "bash.exe" in os.environ.get("SHELL", "")
|
||||
)
|
||||
|
||||
if os.name == "nt" and is_git_bash:
|
||||
# Convert C:\path\to\dir to /c/path/to/dir for Git Bash
|
||||
path_str = str(path_str).replace("\\", "/")
|
||||
if len(path_str) > 2 and path_str[1:3] == ":/": # Drive letter
|
||||
path_str = "/" + path_str[0].lower() + path_str[2:]
|
||||
else:
|
||||
# For cmd.exe on Windows, keep native Windows paths
|
||||
path_str = str(path_str)
|
||||
|
||||
return path_str
|
||||
|
||||
docker_firmware_path = windows_to_docker_path(temp_firmware_dir)
|
||||
|
||||
# Only mount the firmware directory - QEMU is built into the container
|
||||
volumes = {
|
||||
docker_firmware_path: {
|
||||
"bind": self.firmware_mount_path,
|
||||
"mode": "ro", # Read-only mount
|
||||
},
|
||||
}
|
||||
|
||||
# Build QEMU command
|
||||
qemu_cmd = self.build_qemu_command(
|
||||
firmware_name="firmware.bin", flash_size=flash_size, machine=machine
|
||||
)
|
||||
|
||||
print(f"Running QEMU in Docker container: {self.container_name}")
|
||||
print(f"QEMU command: {' '.join(qemu_cmd)}")
|
||||
|
||||
if interactive:
|
||||
# Run interactively with streaming
|
||||
return self.docker_manager.run_container_streaming(
|
||||
image_name=self.docker_image,
|
||||
command=qemu_cmd,
|
||||
volumes=volumes,
|
||||
name=self.container_name,
|
||||
timeout=timeout,
|
||||
interrupt_pattern=interrupt_regex,
|
||||
output_file=output_file,
|
||||
)
|
||||
else:
|
||||
# Run with streaming output and return actual exit code
|
||||
return self.docker_manager.run_container_streaming(
|
||||
image_name=self.docker_image,
|
||||
command=qemu_cmd,
|
||||
volumes=volumes,
|
||||
name=self.container_name,
|
||||
timeout=timeout,
|
||||
interrupt_pattern=interrupt_regex,
|
||||
output_file=output_file,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
# Cleanup
|
||||
if temp_firmware_dir and temp_firmware_dir.exists():
|
||||
shutil.rmtree(temp_firmware_dir, ignore_errors=True)
|
||||
|
||||
# Ensure container is stopped and removed
|
||||
if self.container_name:
|
||||
try:
|
||||
# Stop and remove container
|
||||
run_docker_command_no_fail(["docker", "stop", self.container_name])
|
||||
run_docker_command_no_fail(["docker", "rm", self.container_name])
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run ESP32 firmware in QEMU using Docker"
|
||||
)
|
||||
parser.add_argument(
|
||||
"firmware_path", type=Path, help="Path to firmware.bin file or build directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--docker-image",
|
||||
type=str,
|
||||
help=f"Docker image to use (default: {DockerQEMURunner.DEFAULT_IMAGE})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--flash-size", type=int, default=4, help="Flash size in MB (default: 4)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", type=int, default=30, help="Timeout in seconds (default: 30)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interrupt-regex", type=str, help="Regex pattern to interrupt execution"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interactive", action="store_true", help="Run in interactive mode"
|
||||
)
|
||||
parser.add_argument("--output-file", type=str, help="File to write QEMU output to")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.firmware_path.exists():
|
||||
print(
|
||||
f"ERROR: Firmware path does not exist: {args.firmware_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
runner = DockerQEMURunner(docker_image=args.docker_image)
|
||||
return runner.run(
|
||||
firmware_path=args.firmware_path,
|
||||
timeout=args.timeout,
|
||||
flash_size=args.flash_size,
|
||||
interrupt_regex=args.interrupt_regex,
|
||||
interactive=args.interactive,
|
||||
output_file=args.output_file,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration module for Docker-based QEMU ESP32 testing.
|
||||
|
||||
This module provides a bridge between the existing test infrastructure
|
||||
and the Docker-based QEMU runner.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from ci.dockerfiles.qemu_esp32_docker import DockerQEMURunner
|
||||
|
||||
|
||||
class QEMUTestIntegration:
|
||||
"""Integration class for QEMU testing with Docker fallback."""
|
||||
|
||||
def __init__(self, prefer_docker: bool = False):
|
||||
"""Initialize QEMU test integration.
|
||||
|
||||
Args:
|
||||
prefer_docker: If True, prefer Docker even if native QEMU is available
|
||||
"""
|
||||
self.prefer_docker = prefer_docker
|
||||
self.docker_available = self._check_docker()
|
||||
self.native_qemu_available = self._check_native_qemu()
|
||||
|
||||
def _check_docker(self) -> bool:
|
||||
"""Check if Docker is available."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "version"], capture_output=True, timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def _check_native_qemu(self) -> bool:
|
||||
"""Check if native QEMU ESP32 is available."""
|
||||
try:
|
||||
# Import the native QEMU module to check
|
||||
from ci.qemu_esp32 import find_qemu_binary # type: ignore[import-untyped]
|
||||
|
||||
return find_qemu_binary() is not None
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def select_runner(self) -> str:
|
||||
"""Select the best available runner.
|
||||
|
||||
Returns:
|
||||
'docker' or 'native' based on availability and preference
|
||||
"""
|
||||
if self.prefer_docker and self.docker_available:
|
||||
return "docker"
|
||||
elif self.native_qemu_available:
|
||||
return "native"
|
||||
elif self.docker_available:
|
||||
return "docker"
|
||||
else:
|
||||
return "none"
|
||||
|
||||
def run_qemu_test(
|
||||
self,
|
||||
firmware_path: Union[str, Path],
|
||||
timeout: int = 30,
|
||||
interrupt_regex: Optional[str] = None,
|
||||
flash_size: int = 4,
|
||||
force_runner: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Run QEMU test with automatic runner selection.
|
||||
|
||||
Args:
|
||||
firmware_path: Path to firmware or build directory
|
||||
timeout: Test timeout in seconds
|
||||
interrupt_regex: Pattern to interrupt on success
|
||||
flash_size: Flash size in MB
|
||||
force_runner: Force specific runner ('docker' or 'native')
|
||||
|
||||
Returns:
|
||||
Exit code (0 for success)
|
||||
"""
|
||||
firmware_path = Path(firmware_path)
|
||||
|
||||
# Determine which runner to use
|
||||
if force_runner:
|
||||
runner_type = force_runner
|
||||
else:
|
||||
runner_type = self.select_runner()
|
||||
|
||||
print(f"Selected QEMU runner: {runner_type}")
|
||||
|
||||
if runner_type == "docker":
|
||||
return self._run_docker_qemu(
|
||||
firmware_path, timeout, interrupt_regex, flash_size
|
||||
)
|
||||
elif runner_type == "native":
|
||||
return self._run_native_qemu(
|
||||
firmware_path, timeout, interrupt_regex, flash_size
|
||||
)
|
||||
else:
|
||||
print("ERROR: No QEMU runner available!", file=sys.stderr)
|
||||
print("Install Docker or native QEMU ESP32", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
def _run_docker_qemu(
|
||||
self,
|
||||
firmware_path: Path,
|
||||
timeout: int,
|
||||
interrupt_regex: Optional[str],
|
||||
flash_size: int,
|
||||
) -> int:
|
||||
"""Run QEMU test using Docker."""
|
||||
print("Running QEMU test in Docker container...")
|
||||
|
||||
runner = DockerQEMURunner()
|
||||
return runner.run(
|
||||
firmware_path=firmware_path,
|
||||
timeout=timeout,
|
||||
interrupt_regex=interrupt_regex,
|
||||
flash_size=flash_size,
|
||||
)
|
||||
|
||||
def _run_native_qemu(
|
||||
self,
|
||||
firmware_path: Path,
|
||||
timeout: int,
|
||||
interrupt_regex: Optional[str],
|
||||
flash_size: int,
|
||||
) -> int:
|
||||
"""Run QEMU test using native installation."""
|
||||
print("Running QEMU test with native installation...")
|
||||
|
||||
# Import and use the native runner
|
||||
from ci.qemu_esp32 import QEMURunner # type: ignore[import-untyped]
|
||||
|
||||
runner = QEMURunner() # type: ignore[no-untyped-call]
|
||||
return runner.run( # type: ignore[no-untyped-call]
|
||||
firmware_path=firmware_path,
|
||||
timeout=timeout,
|
||||
interrupt_regex=interrupt_regex,
|
||||
flash_size=flash_size,
|
||||
)
|
||||
|
||||
def install_qemu(self, use_docker: bool = False) -> bool:
|
||||
"""Install QEMU (native or pull Docker image).
|
||||
|
||||
Args:
|
||||
use_docker: If True, pull Docker image instead of native install
|
||||
|
||||
Returns:
|
||||
True if installation successful
|
||||
"""
|
||||
if use_docker:
|
||||
print("Pulling Docker QEMU image...")
|
||||
try:
|
||||
runner = DockerQEMURunner()
|
||||
runner.pull_image()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to pull Docker image: {e}", file=sys.stderr)
|
||||
return False
|
||||
else:
|
||||
print("Installing native QEMU...")
|
||||
try:
|
||||
# Run the native install script
|
||||
result = subprocess.run(
|
||||
[sys.executable, "ci/install-qemu.py"],
|
||||
cwd=Path(__file__).parent.parent.parent,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
print(f"Failed to install native QEMU: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def integrate_with_test_framework():
|
||||
"""Integrate Docker QEMU with the existing test framework.
|
||||
|
||||
This function modifies the test.py to add Docker support.
|
||||
"""
|
||||
test_file = Path(__file__).parent.parent.parent / "test.py"
|
||||
|
||||
if not test_file.exists():
|
||||
print(f"ERROR: test.py not found at {test_file}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
print(f"Integrating Docker QEMU support into {test_file}")
|
||||
|
||||
# Read the test.py file
|
||||
content = test_file.read_text()
|
||||
|
||||
# Check if already integrated
|
||||
if "docker.qemu_test_integration" in content:
|
||||
print("Docker QEMU support already integrated")
|
||||
return True
|
||||
|
||||
# Find the QEMU test section and add Docker support
|
||||
integration_code = """
|
||||
# Docker QEMU integration
|
||||
try:
|
||||
from ci.dockerfiles.qemu_test_integration import QEMUTestIntegration
|
||||
DOCKER_QEMU_AVAILABLE = True
|
||||
except ImportError:
|
||||
DOCKER_QEMU_AVAILABLE = False
|
||||
"""
|
||||
|
||||
# Add the import at the top of the file after other imports
|
||||
import_marker = "from ci.qemu_esp32 import"
|
||||
if import_marker in content:
|
||||
# Add after the existing QEMU import
|
||||
content = content.replace(
|
||||
import_marker, integration_code + "\n" + import_marker
|
||||
)
|
||||
|
||||
# Save the modified file
|
||||
test_file.write_text(content)
|
||||
print("Successfully integrated Docker QEMU support")
|
||||
return True
|
||||
else:
|
||||
print("WARNING: Could not find appropriate location to add integration")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function for testing the integration."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="QEMU Test Integration with Docker support"
|
||||
)
|
||||
parser.add_argument(
|
||||
"command",
|
||||
choices=["test", "install", "integrate", "check"],
|
||||
help="Command to execute",
|
||||
)
|
||||
parser.add_argument("--firmware", type=Path, help="Firmware path for test command")
|
||||
parser.add_argument(
|
||||
"--docker", action="store_true", help="Prefer Docker over native QEMU"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", type=int, default=30, help="Test timeout in seconds"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
integration = QEMUTestIntegration(prefer_docker=args.docker)
|
||||
|
||||
if args.command == "check":
|
||||
print(f"Docker available: {integration.docker_available}")
|
||||
print(f"Native QEMU available: {integration.native_qemu_available}")
|
||||
print(f"Selected runner: {integration.select_runner()}")
|
||||
return 0
|
||||
|
||||
elif args.command == "install":
|
||||
success = integration.install_qemu(use_docker=args.docker)
|
||||
return 0 if success else 1
|
||||
|
||||
elif args.command == "integrate":
|
||||
success = integrate_with_test_framework()
|
||||
return 0 if success else 1
|
||||
|
||||
elif args.command == "test":
|
||||
if not args.firmware:
|
||||
print("ERROR: --firmware required for test command", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return integration.run_qemu_test(
|
||||
firmware_path=args.firmware,
|
||||
timeout=args.timeout,
|
||||
force_runner="docker" if args.docker else None,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Work in progress to generate doxygen via a script instead of a GitHub action.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from download import download # type: ignore
|
||||
|
||||
|
||||
# Configs
|
||||
DOXYGEN_VERSION = (
|
||||
"1.13.2" # DOXYGEN_AWESOME styler is has certain restrictions with doxygen version
|
||||
)
|
||||
DOXYGEN_AWESOME_VERSION = "2.3.4" # deprecating
|
||||
DOXYFILE_PATH = Path("docs/Doxyfile")
|
||||
HTML_OUTPUT_DIR = Path("docs/html")
|
||||
DOXYGEN_CSS_REPO = "https://github.com/jothepro/doxygen-awesome-css" # deprecating
|
||||
|
||||
|
||||
HERE = Path(__file__).parent.resolve()
|
||||
PROJECT_ROOT = HERE.parent
|
||||
|
||||
DOCS_ROOT = PROJECT_ROOT / "docs"
|
||||
DOCS_TOOL_PATH = PROJECT_ROOT / ".tools_cache"
|
||||
DOCS_OUTPUT_PATH = DOCS_ROOT / "html"
|
||||
|
||||
|
||||
def run(
|
||||
cmd: str,
|
||||
cwd: Optional[str] = None,
|
||||
shell: bool = True,
|
||||
check: bool = True,
|
||||
capture: bool = True,
|
||||
) -> str:
|
||||
print(f"Running: {cmd}")
|
||||
result = subprocess.run(
|
||||
cmd, shell=shell, cwd=cwd, check=False, capture_output=capture, text=False
|
||||
)
|
||||
if capture:
|
||||
stdout = result.stdout.decode("utf-8") if result.stdout else ""
|
||||
stderr = result.stderr.decode("utf-8") if result.stderr else ""
|
||||
else:
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
if result.returncode != 0:
|
||||
msg = f"Command failed with exit code {result.returncode}:\nstdout:\n{stdout}\n\nstderr:\n{stderr}"
|
||||
warnings.warn(msg)
|
||||
if check:
|
||||
raise subprocess.CalledProcessError(
|
||||
result.returncode, cmd, output=result.stdout
|
||||
)
|
||||
return stdout.strip()
|
||||
|
||||
|
||||
def get_git_info() -> Tuple[str, str]:
|
||||
release_tag = os.environ.get("RELEASE_TAG", "")
|
||||
|
||||
try:
|
||||
latest_tag = run("git tag | grep -E '^[0-9]' | sort -V | tail -1")
|
||||
latest_tag = latest_tag if latest_tag else ""
|
||||
except subprocess.CalledProcessError:
|
||||
latest_tag = ""
|
||||
|
||||
git_sha_short = run("git rev-parse --short HEAD")
|
||||
full_sha = run("git rev-parse HEAD")
|
||||
project_number = release_tag or latest_tag or git_sha_short
|
||||
commit_message = (
|
||||
f"{project_number} ({full_sha})"
|
||||
if project_number != git_sha_short
|
||||
else project_number
|
||||
)
|
||||
|
||||
print(f"Project number: {project_number}")
|
||||
print(f"Commit message: {commit_message}")
|
||||
return project_number, commit_message
|
||||
|
||||
|
||||
def install_doxygen_windows() -> Path:
|
||||
print("Installing Doxygen...")
|
||||
doxygen_url = (
|
||||
f"https://www.doxygen.nl/files/doxygen-{DOXYGEN_VERSION}.windows.x64.bin.zip"
|
||||
)
|
||||
zip_path = DOCS_TOOL_PATH / f"doxygen-{DOXYGEN_VERSION}.zip"
|
||||
extract_dir = DOCS_TOOL_PATH / f"doxygen-{DOXYGEN_VERSION}"
|
||||
|
||||
# Create tool path if it doesn't exist
|
||||
DOCS_TOOL_PATH.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
download(doxygen_url, zip_path)
|
||||
shutil.unpack_archive(str(zip_path), extract_dir)
|
||||
bin_path = next(extract_dir.glob("**/doxygen.exe"), None)
|
||||
if not bin_path:
|
||||
raise FileNotFoundError("Doxygen executable not found after extraction.")
|
||||
print(f"Doxygen installed at: {bin_path}")
|
||||
return bin_path
|
||||
|
||||
|
||||
def install_doxygen_unix() -> Path:
|
||||
print("Installing Doxygen...")
|
||||
archive = f"doxygen-{DOXYGEN_VERSION}.linux.bin.tar.gz"
|
||||
url = f"https://www.doxygen.nl/files/{archive}"
|
||||
|
||||
# Create tool path if it doesn't exist
|
||||
DOCS_TOOL_PATH.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# Change to tool directory for download and extraction
|
||||
original_dir = os.getcwd()
|
||||
os.chdir(str(DOCS_TOOL_PATH))
|
||||
|
||||
try:
|
||||
run(f"wget -q {url}")
|
||||
run(f"tar -xf {archive}")
|
||||
bin_dir = DOCS_TOOL_PATH / f"doxygen-{DOXYGEN_VERSION}"
|
||||
return bin_dir / "bin" / "doxygen"
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
|
||||
|
||||
def install_theme() -> Path:
|
||||
print("Installing Doxygen Awesome Theme...")
|
||||
theme_path = DOCS_ROOT / "doxygen-awesome-css"
|
||||
if theme_path.exists():
|
||||
return theme_path
|
||||
run(
|
||||
f"git clone --depth 1 -b v{DOXYGEN_AWESOME_VERSION} {DOXYGEN_CSS_REPO}",
|
||||
cwd=str(DOCS_ROOT),
|
||||
)
|
||||
return theme_path
|
||||
|
||||
|
||||
def generate_docs(doxygen_bin: Path) -> None:
|
||||
print("Generating documentation...")
|
||||
cmd_str = f'"{doxygen_bin}" {DOXYFILE_PATH.name}'
|
||||
run(cmd_str, cwd=str(DOCS_ROOT), capture=False)
|
||||
|
||||
|
||||
# def install_graphviz() -> None:
|
||||
# url: str = get_latest_release_for_platform()
|
||||
# print(url)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
is_windows = platform.system() == "Windows"
|
||||
# is_macos = platform.system() == "Darwin"
|
||||
_, commit_msg = get_git_info()
|
||||
|
||||
if is_windows:
|
||||
doxygen_bin = install_doxygen_windows()
|
||||
# add to path C:\Program Files\Graphviz\bin\
|
||||
os.environ["PATH"] += os.pathsep + r"C:\Program Files\Graphviz\bin"
|
||||
else:
|
||||
doxygen_bin = install_doxygen_unix()
|
||||
|
||||
# install_theme()
|
||||
|
||||
# install_graphviz() # Work in progress
|
||||
|
||||
# Verify Graphviz installation
|
||||
try:
|
||||
dot_version = run("dot -V", check=False)
|
||||
print(f"Graphviz detected: {dot_version}")
|
||||
except Exception:
|
||||
warnings.warn(
|
||||
"Graphviz (dot) not found in PATH. Diagrams may not be generated."
|
||||
)
|
||||
|
||||
# Check it graphviz is installed
|
||||
# if linux
|
||||
if not is_windows:
|
||||
run("dot -Tsvg -Kneato -Grankdir=LR", check=True)
|
||||
|
||||
generate_docs(doxygen_bin=doxygen_bin)
|
||||
|
||||
print(f"\n✅ Docs generated in: {HTML_OUTPUT_DIR}")
|
||||
print(f"📄 Commit message: {commit_msg}")
|
||||
print("✨ You can now manually deploy to GitHub Pages or automate this step.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ESP32 Symbol Analysis Runner for GitHub Actions
|
||||
Wrapper script to run ESP32 symbol analysis with proper error handling
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ci.util.esp32_symbol_analysis import main as esp32_symbol_analysis
|
||||
|
||||
|
||||
def is_esp32_board(board_name: str) -> bool:
|
||||
"""Check if the board is ESP32-based"""
|
||||
esp32_boards = [
|
||||
"esp32dev",
|
||||
"esp32",
|
||||
"esp32s2",
|
||||
"esp32s3",
|
||||
"esp32c3",
|
||||
"esp32c6",
|
||||
"esp32h2",
|
||||
"esp32p4",
|
||||
"esp32c2",
|
||||
]
|
||||
return any(esp32_board in board_name.lower() for esp32_board in esp32_boards)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run ESP32 symbol analysis for GitHub Actions"
|
||||
)
|
||||
parser.add_argument("--board", required=True, help="Board name being analyzed")
|
||||
parser.add_argument("--example", default="Blink", help="Example that was compiled")
|
||||
parser.add_argument(
|
||||
"--skip-on-failure",
|
||||
action="store_true",
|
||||
help="Don't fail the build if symbol analysis fails",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(
|
||||
f"ESP32 Symbol Analysis Runner - Board: {args.board}, Example: {args.example}"
|
||||
)
|
||||
|
||||
# Only run for ESP32 boards
|
||||
if not is_esp32_board(args.board):
|
||||
print(f"Skipping symbol analysis - {args.board} is not an ESP32-based board")
|
||||
return 0
|
||||
|
||||
# Check if build_info.json exists
|
||||
build_info_path = Path(".build") / args.board / "build_info.json"
|
||||
if not build_info_path.exists():
|
||||
message = (
|
||||
f"Build info not found at {build_info_path}. Skipping symbol analysis."
|
||||
)
|
||||
if args.skip_on_failure:
|
||||
print(f"Warning: {message}")
|
||||
return 0
|
||||
else:
|
||||
print(f"Error: {message}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
# TODO - Remove this.
|
||||
# Change to the ci/util directory since the script expects to be run from there
|
||||
original_cwd = os.getcwd()
|
||||
ci_ci_dir = Path(__file__).parent / "ci"
|
||||
os.chdir(ci_ci_dir)
|
||||
|
||||
# Import and run the ESP32 symbol analysis
|
||||
try:
|
||||
print("Running ESP32 symbol analysis...")
|
||||
esp32_symbol_analysis()
|
||||
print("ESP32 symbol analysis completed successfully!")
|
||||
return 0
|
||||
except ImportError as e:
|
||||
print(f"Failed to import esp32_symbol_analysis module: {e}")
|
||||
if args.skip_on_failure:
|
||||
return 0
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"ESP32 symbol analysis failed: {e}")
|
||||
if args.skip_on_failure:
|
||||
return 0
|
||||
return 1
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Unexpected error in ESP32 symbol analysis runner: {e}")
|
||||
if args.skip_on_failure:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demonstration of the Fingerprint Cache Feature
|
||||
|
||||
This script shows how to use the fingerprint cache for efficient file change detection
|
||||
in build systems and development workflows.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from ci.ci.fingerprint_cache import FingerprintCache, has_changed
|
||||
|
||||
|
||||
def demo_basic_usage() -> None:
|
||||
"""Demonstrate basic fingerprint cache usage."""
|
||||
print("=== Basic Fingerprint Cache Demo ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
cache_file = temp_path / "demo_cache.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Create a source file
|
||||
source_file = temp_path / "example.cpp"
|
||||
with open(source_file, "w") as f:
|
||||
f.write("#include <iostream>\nint main() { return 0; }")
|
||||
|
||||
baseline_time = time.time() - 3600 # 1 hour ago
|
||||
current_modtime = os.path.getmtime(source_file)
|
||||
|
||||
print(f"Source file: {source_file}")
|
||||
print(f"Baseline time: {baseline_time}")
|
||||
print(f"Current modtime: {current_modtime}")
|
||||
|
||||
# First check - file is newer than baseline
|
||||
changed = cache.has_changed(source_file, baseline_time)
|
||||
print(f"First check (vs baseline): {changed} (expected: True)")
|
||||
|
||||
# Second check - same modtime
|
||||
changed = cache.has_changed(source_file, current_modtime)
|
||||
print(f"Second check (same modtime): {changed} (expected: False)")
|
||||
|
||||
# Touch file (change modtime but not content)
|
||||
time.sleep(0.01)
|
||||
source_file.touch()
|
||||
|
||||
# Third check - modtime changed but content same
|
||||
changed = cache.has_changed(source_file, current_modtime)
|
||||
print(
|
||||
f"Third check (touched file): {changed} (expected: False - content unchanged)"
|
||||
)
|
||||
|
||||
# Actually modify content
|
||||
time.sleep(0.01)
|
||||
with open(source_file, "w") as f:
|
||||
f.write(
|
||||
'#include <iostream>\nint main() { std::cout << "Hello"; return 0; }'
|
||||
)
|
||||
|
||||
# Fourth check - content actually changed
|
||||
changed = cache.has_changed(source_file, current_modtime)
|
||||
print(f"Fourth check (content changed): {changed} (expected: True)")
|
||||
|
||||
|
||||
def demo_build_system_workflow() -> None:
|
||||
"""Demonstrate build system integration workflow."""
|
||||
print("\n=== Build System Workflow Demo ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
cache_file = temp_path / "build_cache.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Simulate multiple source files
|
||||
source_files = [
|
||||
temp_path / "main.cpp",
|
||||
temp_path / "utils.cpp",
|
||||
temp_path / "config.h",
|
||||
]
|
||||
|
||||
# Create source files
|
||||
contents = [
|
||||
'#include "config.h"\n#include "utils.h"\nint main() { return 0; }',
|
||||
'#include "utils.h"\nvoid utility_function() {}',
|
||||
'#pragma once\n#define VERSION "1.0"',
|
||||
]
|
||||
|
||||
for src_file, content in zip(source_files, contents):
|
||||
with open(src_file, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"Created {len(source_files)} source files")
|
||||
|
||||
# Simulate first build
|
||||
print("\n--- First Build (all files new) ---")
|
||||
last_build_time = time.time() - 3600 # 1 hour ago
|
||||
changed_files: List[str] = []
|
||||
|
||||
for src_file in source_files:
|
||||
if cache.has_changed(src_file, last_build_time):
|
||||
changed_files.append(src_file.name)
|
||||
|
||||
print(
|
||||
f"Files to rebuild: {changed_files} ({len(changed_files)}/{len(source_files)})"
|
||||
)
|
||||
|
||||
# Update last build time
|
||||
current_modtimes = [os.path.getmtime(f) for f in source_files]
|
||||
|
||||
# Simulate second build (no changes)
|
||||
print("\n--- Second Build (no changes) ---")
|
||||
changed_files: List[str] = []
|
||||
|
||||
for src_file, modtime in zip(source_files, current_modtimes):
|
||||
if cache.has_changed(src_file, modtime):
|
||||
changed_files.append(src_file.name)
|
||||
|
||||
print(
|
||||
f"Files to rebuild: {changed_files} ({len(changed_files)}/{len(source_files)})"
|
||||
)
|
||||
|
||||
# Modify one file
|
||||
print("\n--- Third Build (one file modified) ---")
|
||||
time.sleep(0.01)
|
||||
with open(source_files[0], "a") as f:
|
||||
f.write("\n// Added comment")
|
||||
|
||||
changed_files: List[str] = []
|
||||
for src_file, modtime in zip(source_files, current_modtimes):
|
||||
if cache.has_changed(src_file, modtime):
|
||||
changed_files.append(src_file.name)
|
||||
|
||||
print(
|
||||
f"Files to rebuild: {changed_files} ({len(changed_files)}/{len(source_files)})"
|
||||
)
|
||||
|
||||
|
||||
def demo_performance() -> None:
|
||||
"""Demonstrate cache performance characteristics."""
|
||||
print("\n=== Performance Demo ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
cache_file = temp_path / "perf_cache.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Create multiple test files
|
||||
num_files = 20
|
||||
test_files: List[Path] = []
|
||||
|
||||
for i in range(num_files):
|
||||
test_file = temp_path / f"file_{i:03d}.cpp"
|
||||
with open(test_file, "w") as f:
|
||||
f.write(
|
||||
f"// File {i}\n#include <iostream>\nint func_{i}() {{ return {i}; }}"
|
||||
)
|
||||
test_files.append(test_file)
|
||||
|
||||
print(f"Created {num_files} test files")
|
||||
|
||||
# Measure cache miss performance (first check)
|
||||
start_time = time.time()
|
||||
baseline_time = time.time() - 3600
|
||||
|
||||
for test_file in test_files:
|
||||
cache.has_changed(test_file, baseline_time)
|
||||
|
||||
miss_time = time.time() - start_time
|
||||
print(
|
||||
f"Cache miss time: {miss_time * 1000:.1f}ms ({miss_time * 1000 / num_files:.2f}ms per file)"
|
||||
)
|
||||
|
||||
# Measure cache hit performance (second check with same modtime)
|
||||
modtimes = [os.path.getmtime(f) for f in test_files]
|
||||
|
||||
start_time = time.time()
|
||||
for test_file, modtime in zip(test_files, modtimes):
|
||||
cache.has_changed(test_file, modtime)
|
||||
|
||||
hit_time = time.time() - start_time
|
||||
print(
|
||||
f"Cache hit time: {hit_time * 1000:.1f}ms ({hit_time * 1000 / num_files:.2f}ms per file)"
|
||||
)
|
||||
print(f"Speedup: {miss_time / hit_time:.1f}x faster")
|
||||
|
||||
# Show cache statistics
|
||||
stats = cache.get_cache_stats()
|
||||
print(f"Cache entries: {stats['total_entries']}")
|
||||
print(f"Cache file size: {stats['cache_file_size_bytes']} bytes")
|
||||
|
||||
|
||||
def demo_convenience_function() -> None:
|
||||
"""Demonstrate the convenience has_changed function."""
|
||||
print("\n=== Convenience Function Demo ===")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
cache_file = temp_path / "convenience_cache.json"
|
||||
|
||||
# Create test file
|
||||
test_file = temp_path / "test.h"
|
||||
with open(test_file, "w") as f:
|
||||
f.write("#pragma once\n#define API_VERSION 1")
|
||||
|
||||
baseline_time = time.time() - 1800 # 30 minutes ago
|
||||
|
||||
# Use convenience function
|
||||
changed = has_changed(test_file, baseline_time, cache_file)
|
||||
print(f"Convenience function result: {changed}")
|
||||
print(f"Cache file created: {cache_file.exists()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Fingerprint Cache Feature Demonstration")
|
||||
print("=" * 50)
|
||||
|
||||
demo_basic_usage()
|
||||
demo_build_system_workflow()
|
||||
demo_performance()
|
||||
demo_convenience_function()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("Demo completed successfully!")
|
||||
print("\nKey Benefits:")
|
||||
print("- Fast modification time checks avoid expensive MD5 computation")
|
||||
print("- Content-based verification prevents false positives")
|
||||
print("- Persistent cache survives across tool invocations")
|
||||
print("- Significant speedup for incremental builds and testing")
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Dict
|
||||
|
||||
from ci.util.bin_2_elf import bin_to_elf
|
||||
from ci.util.elf import dump_symbol_sizes
|
||||
from ci.util.map_dump import map_dump
|
||||
|
||||
|
||||
def cpp_filt(cpp_filt_path: Path, input_text: str) -> str:
|
||||
"""
|
||||
Demangle C++ symbols using c++filt.
|
||||
|
||||
Args:
|
||||
cpp_filt_path (Path): Path to c++filt executable.
|
||||
input_text (str): Text to demangle.
|
||||
|
||||
Returns:
|
||||
str: Demangled text.
|
||||
"""
|
||||
if not cpp_filt_path.exists():
|
||||
raise FileNotFoundError(f"cppfilt not found at '{cpp_filt_path}'")
|
||||
command = [str(cpp_filt_path), "-t", "-n"]
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
stdout, stderr = process.communicate(input=input_text)
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(f"Error running c++filt: {stderr}")
|
||||
return stdout
|
||||
|
||||
|
||||
def demangle_gnu_linkonce_symbols(cpp_filt_path: Path, map_text: str) -> str:
|
||||
"""
|
||||
Demangle .gnu.linkonce.t symbols in the map file.
|
||||
|
||||
Args:
|
||||
cpp_filt_path (Path): Path to c++filt executable.
|
||||
map_text (str): Content of the map file.
|
||||
|
||||
Returns:
|
||||
str: Map file content with demangled symbols.
|
||||
"""
|
||||
# Extract all .gnu.linkonce.t symbols
|
||||
pattern = r"\.gnu\\.linkonce\\.t\\.(.+?)\\s"
|
||||
matches = re.findall(pattern, map_text)
|
||||
|
||||
if not matches:
|
||||
return map_text
|
||||
|
||||
# Create a block of text with the extracted symbols
|
||||
symbols_block = "\n".join(matches)
|
||||
|
||||
# Demangle the symbols
|
||||
demangled_block = cpp_filt(cpp_filt_path, symbols_block)
|
||||
|
||||
# Create a dictionary of mangled to demangled symbols
|
||||
demangled_dict = dict(zip(matches, demangled_block.strip().split("\n")))
|
||||
|
||||
# Replace the mangled symbols with demangled ones in the original text
|
||||
for mangled, demangled in demangled_dict.items():
|
||||
map_text = map_text.replace(
|
||||
f".gnu.linkonce.t.{mangled}", f".gnu.linkonce.t.{demangled}"
|
||||
)
|
||||
|
||||
return map_text
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert a binary file to ELF using map file."
|
||||
)
|
||||
parser.add_argument("--first", action="store_true", help="Inspect the first board")
|
||||
parser.add_argument("--cwd", type=Path, help="Custom working directory")
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_build_info(build_info_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Load build information from a JSON file.
|
||||
|
||||
Args:
|
||||
build_info_path (Path): Path to the build_info.json file.
|
||||
|
||||
Returns:
|
||||
dict: Parsed JSON data.
|
||||
"""
|
||||
if not build_info_path.exists():
|
||||
raise FileNotFoundError(f"Build info JSON not found at '{build_info_path}'")
|
||||
return json.loads(build_info_path.read_text())
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.cwd:
|
||||
root_build_dir = args.cwd / ".build"
|
||||
else:
|
||||
root_build_dir = Path(".build")
|
||||
|
||||
# Support nested PlatformIO structure: .build/pio/<board>
|
||||
nested_pio_dir = root_build_dir / "pio"
|
||||
if nested_pio_dir.is_dir():
|
||||
root_build_dir = nested_pio_dir
|
||||
|
||||
board_dirs = [d for d in root_build_dir.iterdir() if d.is_dir()]
|
||||
if not board_dirs:
|
||||
print(f"No board directories found in {root_build_dir.absolute()}")
|
||||
return 1
|
||||
|
||||
print("Available boards:")
|
||||
for i, board_dir in enumerate(board_dirs):
|
||||
print(f"[{i}]: {board_dir.name}")
|
||||
|
||||
which = (
|
||||
0
|
||||
if args.first
|
||||
else int(input("Enter the number of the board you want to inspect: "))
|
||||
)
|
||||
board_dir = board_dirs[which]
|
||||
build_info_json = board_dir / "build_info.json"
|
||||
|
||||
build_info = load_build_info(build_info_json)
|
||||
board = board_dir.name
|
||||
board_info = build_info.get(board) or build_info[next(iter(build_info))]
|
||||
|
||||
# Validate paths from build_info.json
|
||||
elf_path = Path(board_info.get("prog_path", ""))
|
||||
if not elf_path.exists():
|
||||
print(
|
||||
f"Error: ELF path '{elf_path}' does not exist. Check the 'prog_path' in build_info.json."
|
||||
)
|
||||
return 1
|
||||
|
||||
bin_file = elf_path.with_suffix(".bin")
|
||||
if not bin_file.exists():
|
||||
# use .hex or .uf2 if .bin doesn't exist
|
||||
bin_file = elf_path.with_suffix(".hex")
|
||||
if not bin_file.exists():
|
||||
bin_file = elf_path.with_suffix(".uf2")
|
||||
if not bin_file.exists():
|
||||
print(f"Error: Binary file not found for '{elf_path}'")
|
||||
return 1
|
||||
cpp_filt_path = Path(board_info["aliases"]["c++filt"])
|
||||
ld_path = Path(board_info["aliases"]["ld"])
|
||||
as_path = Path(board_info["aliases"]["as"])
|
||||
nm_path = Path(board_info["aliases"]["nm"])
|
||||
objcopy_path = Path(board_info["aliases"]["objcopy"])
|
||||
nm_path = Path(board_info["aliases"]["nm"])
|
||||
map_file = board_dir / "firmware.map"
|
||||
if not map_file.exists():
|
||||
# Search for the map file
|
||||
map_file = bin_file.with_suffix(".map")
|
||||
if not map_file.exists():
|
||||
possible_map_files = list(board_dir.glob("**/firmware.map"))
|
||||
if possible_map_files:
|
||||
map_file = possible_map_files[0]
|
||||
else:
|
||||
print("Error: firmware.map file not found")
|
||||
return 1
|
||||
|
||||
try:
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
temp_dir_path = Path(temp_dir)
|
||||
output_elf = bin_to_elf(
|
||||
bin_file,
|
||||
map_file,
|
||||
as_path,
|
||||
ld_path,
|
||||
objcopy_path,
|
||||
temp_dir_path / "output.elf",
|
||||
)
|
||||
out = dump_symbol_sizes(nm_path, cpp_filt_path, output_elf)
|
||||
print(out)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error while converting binary to ELF, binary analysis will not work on this build: {e}"
|
||||
)
|
||||
|
||||
map_dump(map_file)
|
||||
|
||||
# Demangle .gnu.linkonce.t symbols and print map file
|
||||
print("\n##################################################")
|
||||
print("# Map file dump:")
|
||||
print("##################################################\n")
|
||||
map_text = map_file.read_text()
|
||||
demangled_map_text = demangle_gnu_linkonce_symbols(cpp_filt_path, map_text)
|
||||
print(demangled_map_text)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,78 @@
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from ci.util.elf import dump_symbol_sizes
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = HERE.parent
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Inspect a compiled binary")
|
||||
parser.add_argument("--first", action="store_true", help="Inspect the first board")
|
||||
parser.add_argument("--cwd", type=str, help="Custom working directory")
|
||||
parser.add_argument("--elf", type=str, help="Path to the ELF file to inspect")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.elf:
|
||||
firmware_path = Path(args.elf)
|
||||
if not firmware_path.exists():
|
||||
print(f"ELF file not found: {firmware_path}")
|
||||
return 1
|
||||
|
||||
if args.cwd:
|
||||
# os.chdir(args.cwd)
|
||||
root_build_dir = Path(args.cwd) / ".build"
|
||||
else:
|
||||
root_build_dir = Path(".build")
|
||||
|
||||
# Support nested PlatformIO structure: .build/pio/<board>
|
||||
nested_pio_dir = root_build_dir / "pio"
|
||||
if nested_pio_dir.is_dir():
|
||||
root_build_dir = nested_pio_dir
|
||||
|
||||
# Find the first board directory
|
||||
board_dirs = [d for d in root_build_dir.iterdir() if d.is_dir()]
|
||||
if not board_dirs:
|
||||
# print("No board directories found in .build")
|
||||
print(f"No board directories found in {root_build_dir.absolute()}")
|
||||
return 1
|
||||
|
||||
# display all the boards to the user and ask them to select which one they want by number
|
||||
print("Available boards:")
|
||||
for i, board_dir in enumerate(board_dirs):
|
||||
print(f"[{i}]: {board_dir.name}")
|
||||
|
||||
if args.first:
|
||||
which = 0
|
||||
else:
|
||||
which = int(input("Enter the number of the board you want to inspect: "))
|
||||
|
||||
board_dir = board_dirs[which]
|
||||
board = board_dir.name
|
||||
|
||||
build_info_json = board_dir / "build_info.json"
|
||||
build_info = json.loads(build_info_json.read_text())
|
||||
board_info = build_info.get(board) or build_info[next(iter(build_info))]
|
||||
|
||||
firmware_path = Path(board_info["prog_path"])
|
||||
cpp_filt_path = Path(board_info["aliases"]["c++filt"])
|
||||
|
||||
print(f"Dumping symbol sizes for {board} firmware: {firmware_path}")
|
||||
try:
|
||||
nm_path = Path(board_info["aliases"]["nm"])
|
||||
symbol_sizes = dump_symbol_sizes(nm_path, cpp_filt_path, firmware_path)
|
||||
print(symbol_sizes)
|
||||
except Exception as e:
|
||||
print(f"Error while dumping symbol sizes: {e}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from ci.util.paths import BUILD
|
||||
from ci.util.tools import load_tools
|
||||
|
||||
|
||||
def _list_builds() -> List[Path]:
|
||||
str_paths = os.listdir(BUILD)
|
||||
paths = [BUILD / p for p in str_paths]
|
||||
dirs = [p for p in paths if p.is_dir()]
|
||||
return dirs
|
||||
|
||||
|
||||
def _check_build(build: Path) -> bool:
|
||||
# 1. should contain a build_info.json file
|
||||
# 2. should contain a .pio/build directory
|
||||
has_build_info = (build / "build_info.json").exists()
|
||||
has_pio_build = (build / ".pio" / "build").exists()
|
||||
return has_build_info and has_pio_build
|
||||
|
||||
|
||||
def _prompt_build() -> Path:
|
||||
builds = _list_builds()
|
||||
if not builds:
|
||||
print("Error: No builds found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("Select a build:")
|
||||
for i, build in enumerate(builds):
|
||||
print(f" [{i}]: {build}")
|
||||
while True:
|
||||
try:
|
||||
which = int(input("Enter the number of the build to use: "))
|
||||
if 0 <= which < len(builds):
|
||||
valid = _check_build(BUILD / builds[which])
|
||||
if valid:
|
||||
return BUILD / builds[which]
|
||||
print("Error: Invalid build", file=sys.stderr)
|
||||
else:
|
||||
print("Error: Invalid selection", file=sys.stderr)
|
||||
continue
|
||||
except ValueError:
|
||||
print("Error: Invalid input", file=sys.stderr)
|
||||
continue
|
||||
|
||||
|
||||
def _prompt_object_file(build: Path) -> Path:
|
||||
# Look for object files in .pio/build directory
|
||||
build_dir = build / ".pio" / "build"
|
||||
object_files: List[Path] = list(build_dir.rglob("*.o"))
|
||||
|
||||
if not object_files:
|
||||
print("Error: No object files found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print("\nSelect an object file:")
|
||||
for i, obj_file in enumerate(object_files):
|
||||
print(f" [{i}]: {obj_file.relative_to(build_dir)}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
which = int(input("Enter the number of the object file to use: "))
|
||||
if 0 <= which < len(object_files):
|
||||
return object_files[which]
|
||||
print("Error: Invalid selection", file=sys.stderr)
|
||||
except ValueError:
|
||||
print("Error: Invalid input", file=sys.stderr)
|
||||
continue
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Dump object file information using build tools"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"build_path",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
help="Path to build directory containing build info JSON file",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
build_path = args.build_path
|
||||
|
||||
# Check if object file was provided and exists
|
||||
if build_path is None:
|
||||
build_path = _prompt_build()
|
||||
else:
|
||||
if not _check_build(build_path):
|
||||
print("Error: Invalid build directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
assert build_path is not None
|
||||
assert build_path
|
||||
|
||||
build_info_path = build_path / "build_info.json"
|
||||
assert build_info_path.exists(), f"File not found: {build_info_path}"
|
||||
|
||||
tools = load_tools(build_info_path)
|
||||
|
||||
object_file = _prompt_object_file(build_path)
|
||||
|
||||
cmd = [str(tools.objdump_path), "--syms", str(object_file)]
|
||||
if sys.platform == "win32":
|
||||
cmd = ["cmd", "/c"] + cmd
|
||||
cmd_str = subprocess.list2cmdline(cmd)
|
||||
subprocess.run(cmd, check=True)
|
||||
print("\nDone. Command used:", cmd_str)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
cli()
|
||||
except KeyboardInterrupt:
|
||||
print("Exiting...")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone QEMU Installation Script
|
||||
|
||||
This is a convenience wrapper around install_qemu_esp32.py
|
||||
that can be called directly to install QEMU for ESP32 emulation.
|
||||
|
||||
Usage:
|
||||
uv run ci/install-qemu.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from install_qemu_esp32 import main as install_qemu_main
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the QEMU installation script."""
|
||||
print("=== FastLED QEMU Installation ===")
|
||||
print("Starting QEMU installation process...")
|
||||
print(f"Python executable: {sys.executable}")
|
||||
print(f"Working directory: {os.getcwd()}")
|
||||
print()
|
||||
|
||||
try:
|
||||
# Call the main function directly
|
||||
print("Calling install_qemu_esp32.main()...")
|
||||
install_qemu_main()
|
||||
print("install_qemu_esp32.main() completed successfully")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nInstallation cancelled by user")
|
||||
sys.exit(130)
|
||||
except SystemExit as e:
|
||||
print(f"SystemExit caught with code: {e.code}")
|
||||
# Re-raise SystemExit to preserve exit codes
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"ERROR: Installation failed: {e}")
|
||||
import traceback
|
||||
|
||||
print("Full traceback:")
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,732 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ESP32 QEMU Installation Script
|
||||
|
||||
Installs ESP32-compatible QEMU emulator using ESP-IDF tools.
|
||||
Cross-platform support for Linux, macOS, and Windows.
|
||||
|
||||
WARNING: Never use sys.stdout.flush() in this file!
|
||||
It causes blocking issues on Windows that hang QEMU processes.
|
||||
Python's default buffering behavior works correctly across platforms.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from download import download # type: ignore
|
||||
|
||||
from ci.util.running_process import RunningProcess
|
||||
|
||||
|
||||
def get_platform_info():
|
||||
"""Get platform-specific information for QEMU installation."""
|
||||
print("IMMEDIATE DEBUG: get_platform_info() called")
|
||||
print("Detecting platform information...")
|
||||
system = platform.system().lower()
|
||||
print(f"IMMEDIATE DEBUG: platform.system() = {system}")
|
||||
machine = platform.machine().lower()
|
||||
print(f"IMMEDIATE DEBUG: platform.machine() = {machine}")
|
||||
print(f"System: {system}, Machine: {machine}")
|
||||
|
||||
# Normalize machine architecture
|
||||
if machine in ["x86_64", "amd64"]:
|
||||
arch = "x86_64"
|
||||
elif machine in ["aarch64", "arm64"]:
|
||||
arch = "arm64"
|
||||
elif machine.startswith("arm"):
|
||||
arch = "arm"
|
||||
else:
|
||||
arch = machine
|
||||
|
||||
return system, arch
|
||||
|
||||
|
||||
def install_system_dependencies():
|
||||
"""Install system dependencies required for QEMU."""
|
||||
system, _ = get_platform_info()
|
||||
|
||||
print("Installing system dependencies...")
|
||||
|
||||
if system == "linux":
|
||||
# Ubuntu/Debian dependencies
|
||||
deps = [
|
||||
"libgcrypt20",
|
||||
"libglib2.0-0",
|
||||
"libpixman-1-0",
|
||||
"libsdl2-2.0-0",
|
||||
"libslirp0",
|
||||
]
|
||||
|
||||
try:
|
||||
# Check if we can install packages (requires sudo)
|
||||
print("Updating package list...")
|
||||
process = RunningProcess(["sudo", "apt-get", "update"])
|
||||
result = process.wait(echo=True)
|
||||
if result == 0:
|
||||
print("Installing system dependencies:", " ".join(deps))
|
||||
process = RunningProcess(["sudo", "apt-get", "install", "-y"] + deps)
|
||||
result = process.wait(echo=True)
|
||||
if result != 0:
|
||||
raise subprocess.CalledProcessError(
|
||||
result if result is not None else 1,
|
||||
"apt-get install",
|
||||
)
|
||||
print("System dependencies installed")
|
||||
else:
|
||||
print("WARNING: Could not install system dependencies (sudo required)")
|
||||
print("Please install manually:", " ".join(deps))
|
||||
except subprocess.CalledProcessError:
|
||||
print("WARNING: Failed to install system dependencies")
|
||||
|
||||
elif system == "darwin":
|
||||
# macOS - dependencies usually available via system
|
||||
print("macOS system dependencies should be available")
|
||||
|
||||
elif system == "windows":
|
||||
# Windows - QEMU binaries are self-contained
|
||||
print("Windows - no additional dependencies required")
|
||||
|
||||
|
||||
def download_esp_idf_tools():
|
||||
"""Download and setup ESP-IDF tools for QEMU installation."""
|
||||
tools_dir = Path.home() / ".espressif"
|
||||
tools_dir.mkdir(exist_ok=True)
|
||||
|
||||
# ESP-IDF tools script URL
|
||||
tools_url = (
|
||||
"https://raw.githubusercontent.com/espressif/esp-idf/master/tools/idf_tools.py"
|
||||
)
|
||||
tools_script = tools_dir / "idf_tools.py"
|
||||
|
||||
if not tools_script.exists():
|
||||
print("Downloading ESP-IDF tools...")
|
||||
print(f"Source: {tools_url}")
|
||||
print(f"Destination: {tools_script}")
|
||||
download(tools_url, tools_script)
|
||||
print(f"Downloaded ESP-IDF tools to {tools_script}")
|
||||
else:
|
||||
print(f"ESP-IDF tools already exist at {tools_script}")
|
||||
|
||||
return tools_script
|
||||
|
||||
|
||||
def check_command_available(command: str) -> bool:
|
||||
"""Check if a command is available in PATH."""
|
||||
print(f"Checking if {command} is available...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[command, "--version"], capture_output=True, text=True, timeout=10
|
||||
)
|
||||
available = result.returncode == 0
|
||||
print(f"{command} {'found' if available else 'not found'}")
|
||||
return available
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
print(f"{command} not found or timed out")
|
||||
return False
|
||||
|
||||
|
||||
def verify_file_hash(file_path: Path, expected_hash: str) -> bool:
|
||||
"""Verify SHA256 hash of a downloaded file."""
|
||||
print(f"Verifying SHA256 hash of {file_path}...")
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
file_hash = hashlib.sha256(f.read()).hexdigest()
|
||||
|
||||
if file_hash == expected_hash:
|
||||
print(f"SHA256 verification passed: {file_hash}")
|
||||
return True
|
||||
else:
|
||||
print(f"SHA256 verification failed!")
|
||||
print(f"Expected: {expected_hash}")
|
||||
print(f"Got: {file_hash}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error verifying hash: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def try_espressif_binary_install():
|
||||
"""Download and install QEMU using Espressif official precompiled binaries."""
|
||||
try:
|
||||
print("\n--- Espressif Official Binary Installation ---")
|
||||
print("Downloading QEMU from Espressif official releases...")
|
||||
|
||||
# Create local QEMU directory (project-local)
|
||||
qemu_dir = Path(".cache") / "qemu"
|
||||
qemu_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check if already installed
|
||||
qemu_xtensa_binary = qemu_dir / "qemu-system-xtensa.exe"
|
||||
qemu_riscv32_binary = qemu_dir / "qemu-system-riscv32.exe"
|
||||
|
||||
print(f"Checking for existing QEMU at {qemu_xtensa_binary}...")
|
||||
if qemu_xtensa_binary.exists() and qemu_riscv32_binary.exists():
|
||||
print(
|
||||
f"QEMU already installed at {qemu_xtensa_binary} and {qemu_riscv32_binary}"
|
||||
)
|
||||
return True
|
||||
else:
|
||||
print("QEMU not found, proceeding with download...")
|
||||
|
||||
# Espressif official QEMU binaries for Windows x64
|
||||
binaries = [
|
||||
{
|
||||
"name": "QEMU-Xtensa (ESP32/ESP32-S2/ESP32-S3)",
|
||||
"url": "https://github.com/espressif/qemu/releases/download/esp-develop-9.2.2-20250817/qemu-xtensa-softmmu-esp_develop_9.2.2_20250817-x86_64-w64-mingw32.tar.xz",
|
||||
"sha256": "ef550b912726997f3c1ff4a4fb13c1569e2b692efdc5c9f9c3c926a8f7c540fa",
|
||||
"binary_name": "qemu-system-xtensa.exe",
|
||||
},
|
||||
{
|
||||
"name": "QEMU-RISCV32 (ESP32-C3/ESP32-H2/ESP32-C2)",
|
||||
"url": "https://github.com/espressif/qemu/releases/download/esp-develop-9.2.2-20250817/qemu-riscv32-softmmu-esp_develop_9.2.2_20250817-x86_64-w64-mingw32.tar.xz",
|
||||
"sha256": "9474015f24d27acb7516955ec932e5307226bd9d6652cdc870793ed36010ab73",
|
||||
"binary_name": "qemu-system-riscv32.exe",
|
||||
},
|
||||
]
|
||||
|
||||
for binary_info in binaries:
|
||||
print(f"\nDownloading {binary_info['name']}...")
|
||||
print("This may take a few minutes depending on your internet connection.")
|
||||
|
||||
archive_path = qemu_dir / f"{binary_info['binary_name']}.tar.xz"
|
||||
|
||||
print(f"Downloading from: {binary_info['url']}")
|
||||
print(f"Saving to: {archive_path}")
|
||||
|
||||
try:
|
||||
# Download the archive
|
||||
download(binary_info["url"], archive_path)
|
||||
print(f"Download completed: {archive_path}")
|
||||
|
||||
# Verify SHA256 hash
|
||||
if not verify_file_hash(archive_path, binary_info["sha256"]):
|
||||
print(f"Hash verification failed for {binary_info['name']}")
|
||||
archive_path.unlink() # Remove corrupted file
|
||||
return False
|
||||
|
||||
# Extract the archive
|
||||
print(f"Extracting {binary_info['name']}...")
|
||||
with tarfile.open(archive_path, "r:xz") as tar:
|
||||
# List contents to understand structure
|
||||
members = tar.getnames()
|
||||
print(f"Archive contains {len(members)} files")
|
||||
|
||||
# Extract to temporary directory first
|
||||
temp_extract_dir = qemu_dir / "temp_extract"
|
||||
temp_extract_dir.mkdir(exist_ok=True)
|
||||
|
||||
tar.extractall(temp_extract_dir)
|
||||
print(f"Extracted to temporary directory: {temp_extract_dir}")
|
||||
|
||||
# Find the binary in the extracted contents
|
||||
found_binary = None
|
||||
for root, dirs, files in os.walk(temp_extract_dir):
|
||||
for file in files:
|
||||
if file == binary_info["binary_name"]:
|
||||
found_binary = Path(root) / file
|
||||
break
|
||||
if found_binary:
|
||||
break
|
||||
|
||||
if found_binary:
|
||||
target_binary = qemu_dir / binary_info["binary_name"]
|
||||
print(
|
||||
f"Copying {binary_info['binary_name']} to {target_binary}"
|
||||
)
|
||||
shutil.copy2(found_binary, target_binary)
|
||||
|
||||
# Make sure it's executable (though Windows doesn't use execute bit)
|
||||
target_binary.chmod(0o755)
|
||||
|
||||
print(f"Successfully installed {binary_info['binary_name']}")
|
||||
else:
|
||||
print(
|
||||
f"ERROR: Could not find {binary_info['binary_name']} in extracted archive"
|
||||
)
|
||||
return False
|
||||
|
||||
# Clean up temporary extraction directory
|
||||
shutil.rmtree(temp_extract_dir)
|
||||
|
||||
# Clean up downloaded archive
|
||||
archive_path.unlink()
|
||||
print(f"Cleaned up {archive_path}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error downloading/extracting {binary_info['name']}: {e}")
|
||||
# Clean up on error
|
||||
if archive_path.exists():
|
||||
archive_path.unlink()
|
||||
return False
|
||||
|
||||
print("Espressif binary installation completed successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Espressif binary installation error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def try_direct_download_install():
|
||||
"""Download and install QEMU directly from the official source."""
|
||||
print("\n--- Legacy Installer Method (Requires Admin) ---")
|
||||
print("WARNING: This method requires administrator privileges and may fail")
|
||||
print("Consider using the Espressif binary method instead")
|
||||
|
||||
try:
|
||||
print("Downloading QEMU directly from official source...")
|
||||
|
||||
# Create local QEMU directory (project-local)
|
||||
qemu_dir = Path(".cache") / "qemu"
|
||||
qemu_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check if already installed
|
||||
qemu_binary = qemu_dir / "qemu-system-xtensa.exe"
|
||||
print(f"Checking for existing QEMU at {qemu_binary}...")
|
||||
if qemu_binary.exists():
|
||||
print(f"QEMU already installed at {qemu_binary}")
|
||||
return True
|
||||
else:
|
||||
print("QEMU not found, proceeding with download...")
|
||||
|
||||
# Download QEMU from official source
|
||||
print("Downloading QEMU for Windows...")
|
||||
print("This may take several minutes depending on your internet connection.")
|
||||
|
||||
# QEMU download URL from the official Windows builds
|
||||
qemu_url = "https://qemu.weilnetz.de/w64/2024/qemu-w64-setup-20241220.exe"
|
||||
installer_path = qemu_dir / "qemu-installer.exe"
|
||||
|
||||
print(f"Downloading from: {qemu_url}")
|
||||
print(f"Saving to: {installer_path}")
|
||||
|
||||
try:
|
||||
# Download the installer
|
||||
download(qemu_url, installer_path)
|
||||
print(f"Download completed: {installer_path}")
|
||||
|
||||
# Run the installer in silent mode
|
||||
print("Running QEMU installer in silent mode...")
|
||||
print("Note: This may require administrator privileges")
|
||||
|
||||
# Try different installation approaches
|
||||
install_attempts = [
|
||||
# Attempt 1: Install to custom directory (may require admin)
|
||||
[
|
||||
str(installer_path),
|
||||
"/VERYSILENT", # Very silent install
|
||||
"/SUPPRESSMSGBOXES", # Suppress message boxes
|
||||
f"/DIR={qemu_dir.absolute()}", # Install directory
|
||||
],
|
||||
# Attempt 2: Install to default location (may require admin)
|
||||
[
|
||||
str(installer_path),
|
||||
"/VERYSILENT", # Very silent install
|
||||
"/SUPPRESSMSGBOXES", # Suppress message boxes
|
||||
],
|
||||
# Attempt 3: Use basic silent install
|
||||
[
|
||||
str(installer_path),
|
||||
"/S", # Silent install
|
||||
],
|
||||
]
|
||||
|
||||
result = None
|
||||
process = None
|
||||
for i, install_cmd in enumerate(install_attempts, 1):
|
||||
print(
|
||||
f"Installation attempt {i}/{len(install_attempts)}: {' '.join(install_cmd)}"
|
||||
)
|
||||
try:
|
||||
process = RunningProcess(
|
||||
install_cmd,
|
||||
timeout=300, # 5 minute timeout
|
||||
)
|
||||
result = process.wait(echo=True)
|
||||
|
||||
if result == 0:
|
||||
print(f"Installation attempt {i} succeeded")
|
||||
break
|
||||
else:
|
||||
print(
|
||||
f"Installation attempt {i} failed with exit code: {result}"
|
||||
)
|
||||
if i < len(install_attempts):
|
||||
print("Trying next installation method...")
|
||||
except Exception as e:
|
||||
print(f"Installation attempt {i} failed with error: {e}")
|
||||
if i < len(install_attempts):
|
||||
print("Trying next installation method...")
|
||||
|
||||
if result == 0:
|
||||
print("QEMU installer completed successfully")
|
||||
|
||||
# Look for the installed qemu-system-xtensa.exe
|
||||
for qemu_path in qemu_dir.rglob("qemu-system-xtensa.exe"):
|
||||
if qemu_path.is_file():
|
||||
print(f"Found QEMU binary at: {qemu_path}")
|
||||
|
||||
# If it's not in the expected location, copy it there
|
||||
if qemu_path != qemu_binary:
|
||||
print(
|
||||
f"Copying QEMU binary to standard location: {qemu_binary}"
|
||||
)
|
||||
shutil.copy2(qemu_path, qemu_binary)
|
||||
|
||||
# Clean up installer
|
||||
if installer_path.exists():
|
||||
installer_path.unlink()
|
||||
|
||||
print("Direct download installation completed successfully")
|
||||
return True
|
||||
|
||||
print("QEMU installer completed but qemu-system-xtensa.exe not found")
|
||||
else:
|
||||
print(f"QEMU installer failed with exit code: {result}")
|
||||
if process:
|
||||
stderr_output = process.stdout
|
||||
if stderr_output:
|
||||
print(f"Error output: {stderr_output[:200]}")
|
||||
|
||||
except Exception as download_error:
|
||||
print(f"Download/install error: {download_error}")
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Direct download installation error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def try_windows_installation():
|
||||
"""Try installing QEMU using Espressif binaries for Windows."""
|
||||
print("Attempting automatic QEMU installation on Windows...")
|
||||
|
||||
# Try Espressif official binaries first (recommended method)
|
||||
print("Using Espressif official binaries...")
|
||||
if try_espressif_binary_install():
|
||||
print("Espressif binary installation completed, verifying...")
|
||||
# Verify installation worked
|
||||
if find_qemu_binary():
|
||||
print("SUCCESS: QEMU installed via Espressif official binaries")
|
||||
return True
|
||||
else:
|
||||
print(
|
||||
"WARNING: Espressif binary installation reported success but QEMU binary not found"
|
||||
)
|
||||
else:
|
||||
print("Espressif binary installation failed")
|
||||
|
||||
# Fall back to legacy installer method (requires admin privileges)
|
||||
print("Falling back to legacy installer method...")
|
||||
if try_direct_download_install():
|
||||
print("Legacy installer completed, verifying...")
|
||||
# Verify installation worked
|
||||
if find_qemu_binary():
|
||||
print("SUCCESS: QEMU installed via legacy installer")
|
||||
return True
|
||||
else:
|
||||
print(
|
||||
"WARNING: Legacy installer reported success but QEMU binary not found"
|
||||
)
|
||||
else:
|
||||
print("Legacy installer failed")
|
||||
|
||||
print("Windows QEMU installation failed")
|
||||
return False
|
||||
|
||||
|
||||
def install_qemu_esp32():
|
||||
"""Install ESP32 QEMU using ESP-IDF tools."""
|
||||
print("\n=== Starting ESP32 QEMU Installation ===")
|
||||
system, arch = get_platform_info()
|
||||
|
||||
print(f"\nChecking for ESP32 QEMU for {system}-{arch}...")
|
||||
|
||||
# Check if already installed
|
||||
print("Searching for existing QEMU installation...")
|
||||
qemu_binary = find_qemu_binary()
|
||||
print(f"IMMEDIATE DEBUG: find_qemu_binary returned: {qemu_binary}")
|
||||
if qemu_binary:
|
||||
print("ESP32 QEMU already installed")
|
||||
return True
|
||||
else:
|
||||
print("No existing QEMU installation found")
|
||||
|
||||
# Try automated installation
|
||||
print("Attempting automatic installation...")
|
||||
|
||||
if system == "linux":
|
||||
# Check if we're in CI first for sudo operations
|
||||
if os.getenv("CI") == "true" or os.getenv("GITHUB_ACTIONS") == "true":
|
||||
try:
|
||||
# Try to install QEMU from package manager in CI
|
||||
print("Installing QEMU via apt-get...")
|
||||
print("Updating package list...")
|
||||
process = RunningProcess(["sudo", "apt-get", "update"])
|
||||
result = process.wait(echo=True)
|
||||
|
||||
if result == 0:
|
||||
print("Package list updated successfully")
|
||||
print("Installing QEMU packages...")
|
||||
print("Installing: qemu-system-misc qemu-system-xtensa")
|
||||
process = RunningProcess(
|
||||
[
|
||||
"sudo",
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"qemu-system-misc",
|
||||
"qemu-system-xtensa",
|
||||
]
|
||||
)
|
||||
result = process.wait(echo=True)
|
||||
|
||||
if result == 0:
|
||||
print("QEMU packages installed successfully via apt-get")
|
||||
return True
|
||||
else:
|
||||
print("Failed to install QEMU packages via apt-get")
|
||||
print("Return code:", result)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Automatic installation failed: {e}")
|
||||
else:
|
||||
print(
|
||||
"Local Linux environment - automatic installation requires sudo privileges"
|
||||
)
|
||||
|
||||
elif system == "windows":
|
||||
print("Windows platform detected, using direct download...")
|
||||
# Try direct download installation (works in both CI and local environments)
|
||||
if try_windows_installation():
|
||||
print("Windows QEMU installation successful")
|
||||
return True
|
||||
else:
|
||||
print("Windows installation failed")
|
||||
|
||||
elif system == "darwin":
|
||||
# Try macOS installation methods
|
||||
print("macOS platform detected")
|
||||
print("Attempting macOS installation...")
|
||||
print("Note: Homebrew installation could be added here in the future")
|
||||
# Could add homebrew installation here in the future
|
||||
|
||||
# If not found, provide installation instructions
|
||||
print("ESP32 QEMU not found. Please install using one of these methods:")
|
||||
print()
|
||||
|
||||
if system == "windows":
|
||||
print("Windows Installation Options:")
|
||||
print()
|
||||
print("Option 1: Espressif Official Binaries (Recommended)")
|
||||
print(
|
||||
" This is what the automatic installer uses - no administrator privileges required"
|
||||
)
|
||||
print(
|
||||
" Espressif QEMU Xtensa: https://github.com/espressif/qemu/releases/download/esp-develop-9.2.2-20250817/qemu-xtensa-softmmu-esp_develop_9.2.2_20250817-x86_64-w64-mingw32.tar.xz"
|
||||
)
|
||||
print(
|
||||
" Espressif QEMU RISC-V32: https://github.com/espressif/qemu/releases/download/esp-develop-9.2.2-20250817/qemu-riscv32-softmmu-esp_develop_9.2.2_20250817-x86_64-w64-mingw32.tar.xz"
|
||||
)
|
||||
print(" Extract to .cache/qemu/ directory")
|
||||
print()
|
||||
print("Option 2: Official QEMU Installer (Requires Administrator)")
|
||||
print(
|
||||
" Download QEMU from: https://qemu.weilnetz.de/w64/2024/qemu-w64-setup-20241220.exe"
|
||||
)
|
||||
print(" Run the installer and ensure qemu-system-xtensa.exe is in your PATH")
|
||||
print()
|
||||
print("Option 3: ESP-IDF (includes QEMU)")
|
||||
print(
|
||||
" Download ESP-IDF from: https://docs.espressif.com/projects/esp-idf/en/latest/esp32/get-started/windows-setup.html"
|
||||
)
|
||||
print()
|
||||
print("Option 4: Manual Installation")
|
||||
print(" Download QEMU from: https://www.qemu.org/download/")
|
||||
print(" Make sure qemu-system-xtensa.exe is in your PATH")
|
||||
|
||||
elif system == "linux":
|
||||
print("Option 1: Install via ESP-IDF")
|
||||
print(" git clone --recursive https://github.com/espressif/esp-idf.git")
|
||||
print(" cd esp-idf && ./install.sh esp32s3")
|
||||
print(" source ./export.sh")
|
||||
print()
|
||||
print("Option 2: Install QEMU from package manager")
|
||||
print(" sudo apt-get install qemu-system-misc qemu-system-xtensa")
|
||||
|
||||
elif system == "darwin":
|
||||
print("Option 1: Install via ESP-IDF")
|
||||
print(" git clone --recursive https://github.com/espressif/esp-idf.git")
|
||||
print(" cd esp-idf && ./install.sh esp32s3")
|
||||
print(" source ./export.sh")
|
||||
print()
|
||||
print("Option 2: Install via Homebrew")
|
||||
print(" brew install qemu")
|
||||
|
||||
print()
|
||||
print("For CI/GitHub Actions, QEMU will be cached automatically once installed.")
|
||||
return False
|
||||
|
||||
|
||||
def find_qemu_binary() -> Optional[Path]:
|
||||
"""Find the installed QEMU binary."""
|
||||
print("IMMEDIATE DEBUG: find_qemu_binary() called")
|
||||
system, _ = get_platform_info()
|
||||
print(f"IMMEDIATE DEBUG: got system: {system}")
|
||||
|
||||
# Look for both Xtensa and RISC-V32 binaries
|
||||
binary_names = []
|
||||
if system == "windows":
|
||||
binary_names = ["qemu-system-xtensa.exe", "qemu-system-riscv32.exe"]
|
||||
else:
|
||||
binary_names = ["qemu-system-xtensa", "qemu-system-riscv32"]
|
||||
|
||||
print(f"IMMEDIATE DEBUG: looking for binaries: {binary_names}")
|
||||
|
||||
# ESP-IDF installation paths and portable installation
|
||||
esp_paths = [
|
||||
Path(".cache") / "qemu", # Project-local portable installation directory
|
||||
Path.home()
|
||||
/ ".cache"
|
||||
/ "qemu", # Legacy user cache location (for backward compatibility)
|
||||
Path.home() / ".espressif" / "tools" / "qemu-xtensa",
|
||||
Path.home() / ".espressif" / "python_env",
|
||||
Path("/opt/espressif/tools/qemu-xtensa"),
|
||||
]
|
||||
|
||||
# Search in ESP-IDF paths first
|
||||
for base_path in esp_paths:
|
||||
print(f"Searching in: {base_path}")
|
||||
try:
|
||||
if base_path.exists():
|
||||
print(f" Directory exists, searching for QEMU binaries...")
|
||||
for binary_name in binary_names:
|
||||
print(
|
||||
f" Starting recursive search for {binary_name} in {base_path}..."
|
||||
)
|
||||
for qemu_path in base_path.rglob(binary_name):
|
||||
print(f" Checking: {qemu_path}")
|
||||
if qemu_path.is_file():
|
||||
print(f"Found QEMU binary at: {qemu_path}")
|
||||
return qemu_path
|
||||
print(f" No QEMU binaries found in {base_path}")
|
||||
else:
|
||||
print(f" Directory does not exist")
|
||||
except Exception as e:
|
||||
print(f" Error searching {base_path}: {e}")
|
||||
|
||||
# Check system PATH for each binary
|
||||
print("Checking system PATH...")
|
||||
for binary_name in binary_names:
|
||||
try:
|
||||
cmd = ["where" if system == "windows" else "which", binary_name]
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
print("About to call subprocess.run...")
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
print("subprocess.run completed")
|
||||
if result.returncode == 0:
|
||||
# Take first line in case of multiple results (Windows)
|
||||
first_line = result.stdout.strip().split("\n")[0]
|
||||
qemu_path = Path(first_line)
|
||||
print(f"Found QEMU binary in PATH: {qemu_path}")
|
||||
return qemu_path
|
||||
else:
|
||||
print(f"Command returned code {result.returncode}")
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"PATH check for {binary_name} timed out after 10 seconds")
|
||||
except (subprocess.SubprocessError, FileNotFoundError) as e:
|
||||
print(f"Error checking PATH for {binary_name}: {e}")
|
||||
|
||||
print("ERROR: Could not find any QEMU binary")
|
||||
return None
|
||||
|
||||
|
||||
def create_qemu_wrapper():
|
||||
"""Create a wrapper script for easy QEMU access."""
|
||||
# DISABLED: This function was overwriting the actual qemu-esp32.py runner script
|
||||
# The qemu-esp32.py script already exists and handles all the necessary
|
||||
# functionality for running ESP32 firmware in QEMU with proper argument parsing
|
||||
print("Skipping wrapper creation - using existing qemu-esp32.py runner script")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""Main installation routine."""
|
||||
print("IMMEDIATE DEBUG: main() function called")
|
||||
print("=== ESP32 QEMU Installation ===")
|
||||
print("Step 1/5: Starting installation process...")
|
||||
|
||||
# Show platform info
|
||||
print("Step 2/5: Detecting platform...")
|
||||
system, arch = get_platform_info()
|
||||
print(f"IMMEDIATE DEBUG: get_platform_info completed, got: {system}, {arch}")
|
||||
print(f"Platform: {system}-{arch}")
|
||||
|
||||
# Install system dependencies
|
||||
print("Step 3/5: Installing system dependencies...")
|
||||
install_system_dependencies()
|
||||
print("IMMEDIATE DEBUG: install_system_dependencies completed")
|
||||
print("System dependencies step completed")
|
||||
|
||||
# Install ESP32 QEMU
|
||||
print("Step 4/5: Installing ESP32 QEMU...")
|
||||
qemu_installed = install_qemu_esp32()
|
||||
print(
|
||||
f"IMMEDIATE DEBUG: install_qemu_esp32 completed with result: {qemu_installed}"
|
||||
)
|
||||
print(f"ESP32 QEMU installation step completed (success: {qemu_installed})")
|
||||
|
||||
if qemu_installed:
|
||||
# Create wrapper script
|
||||
print("Step 5/5: Creating wrapper scripts...")
|
||||
if not create_qemu_wrapper():
|
||||
print("WARNING: Could not create wrapper script, but QEMU is available")
|
||||
else:
|
||||
print("QEMU installation required for ESP32 emulation")
|
||||
sys.exit(1)
|
||||
|
||||
print("=== Installation Complete ===")
|
||||
print("ESP32 QEMU is ready to use!")
|
||||
|
||||
# Test installation
|
||||
print("\n=== Verifying Installation ===")
|
||||
print("Searching for QEMU binary...")
|
||||
qemu_binary = find_qemu_binary()
|
||||
if qemu_binary:
|
||||
print(f"Testing QEMU binary: {qemu_binary}")
|
||||
try:
|
||||
print("Running version check...")
|
||||
result = subprocess.run(
|
||||
[str(qemu_binary), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(f"QEMU version: {result.stdout.strip()}")
|
||||
print("Installation verification successful!")
|
||||
sys.exit(0) # Explicit success exit
|
||||
else:
|
||||
print("WARNING: QEMU installed but version check failed")
|
||||
print(f"Version check return code: {result.returncode}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"WARNING: Could not verify QEMU installation: {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("ERROR: QEMU installation verification failed")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
# FastLED Include What You Use Mapping File
|
||||
# This file tells IWYU about FastLED's specific include patterns and structure
|
||||
|
||||
[
|
||||
# FastLED main headers - these are the public API
|
||||
{ include: ['"FastLED.h"', 'public', '"FastLED.h"', 'public'] },
|
||||
{ include: ['"fastled.h"', 'public', '"FastLED.h"', 'public'] },
|
||||
|
||||
# Core FastLED headers that should be included directly
|
||||
{ include: ['"lib8tion.h"', 'public', '"lib8tion.h"', 'public'] },
|
||||
{ include: ['"pixeltypes.h"', 'public', '"pixeltypes.h"', 'public'] },
|
||||
{ include: ['"hsv2rgb.h"', 'public', '"hsv2rgb.h"', 'public'] },
|
||||
{ include: ['"colorutils.h"', 'public', '"colorutils.h"', 'public'] },
|
||||
{ include: ['"colorpalettes.h"', 'public', '"colorpalettes.h"', 'public'] },
|
||||
|
||||
# Platform-specific headers should not be suggested directly
|
||||
{ include: ['"platforms/*/fastled_*.h"', 'private', '"FastLED.h"', 'public'] },
|
||||
{ include: ['"platforms/*/clockless_*.h"', 'private', '"FastLED.h"', 'public'] },
|
||||
{ include: ['"platforms/*/*"', 'private', '"FastLED.h"', 'public'] },
|
||||
|
||||
# Internal headers should redirect to public API
|
||||
{ include: ['"controller.h"', 'private', '"FastLED.h"', 'public'] },
|
||||
{ include: ['"led_sysdefs.h"', 'private', '"FastLED.h"', 'public'] },
|
||||
{ include: ['"fastled_config.h"', 'private', '"FastLED.h"', 'public'] },
|
||||
{ include: ['"fastled_delay.h"', 'private', '"FastLED.h"', 'public'] },
|
||||
{ include: ['"fastled_progmem.h"', 'private', '"FastLED.h"', 'public'] },
|
||||
|
||||
# fl/ namespace headers - the modern FastLED API
|
||||
{ include: ['"fl/namespace.h"', 'public', '"fl/namespace.h"', 'public'] },
|
||||
{ include: ['"fl/ptr.h"', 'public', '"fl/ptr.h"', 'public'] },
|
||||
{ include: ['"fl/vector.h"', 'public', '"fl/vector.h"', 'public'] },
|
||||
{ include: ['"fl/allocator.h"', 'public', '"fl/allocator.h"', 'public'] },
|
||||
{ include: ['"fl/math.h"', 'public', '"fl/math.h"', 'public'] },
|
||||
{ include: ['"fl/force_inline.h"', 'public', '"fl/force_inline.h"', 'public'] },
|
||||
|
||||
# FX headers - effects system
|
||||
{ include: ['"fx/fx.h"', 'public', '"fx/fx.h"', 'public'] },
|
||||
{ include: ['"fx/fx_engine.h"', 'public', '"fx/fx_engine.h"', 'public'] },
|
||||
{ include: ['"fx/frame.h"', 'public', '"fx/frame.h"', 'public'] },
|
||||
{ include: ['"fx/detail/*"', 'private', '"fx/fx.h"', 'public'] },
|
||||
|
||||
# Third-party headers should not be suggested for user code
|
||||
{ include: ['"third_party/*/*"', 'private', '"FastLED.h"', 'public'] },
|
||||
|
||||
# Arduino-specific headers
|
||||
{ include: ['<Arduino.h>', 'public', '<Arduino.h>', 'public'] },
|
||||
{ include: ['<avr/pgmspace.h>', 'public', '<avr/pgmspace.h>', 'public'] },
|
||||
|
||||
# Chipset-specific headers redirect to main header
|
||||
{ include: ['"chipsets.h"', 'private', '"FastLED.h"', 'public'] },
|
||||
{ include: ['"ws2812_controller.h"', 'private', '"FastLED.h"', 'public'] },
|
||||
{ include: ['"apa102_controller.h"', 'private', '"FastLED.h"', 'public'] },
|
||||
|
||||
# Testing headers - keep as-is for test files
|
||||
{ include: ['"test_*.h"', 'public', '"test_*.h"', 'public'] },
|
||||
{ include: ['"doctest.h"', 'public', '"doctest.h"', 'public'] },
|
||||
]
|
||||
@@ -0,0 +1,67 @@
|
||||
# Standard Library Include What You Use Mapping File
|
||||
# This file provides standard library header mappings for better IWYU suggestions
|
||||
|
||||
[
|
||||
# C++ standard library headers
|
||||
{ include: ['<cstdint>', 'public', '<cstdint>', 'public'] },
|
||||
{ include: ['<cstddef>', 'public', '<cstddef>', 'public'] },
|
||||
{ include: ['<cstring>', 'public', '<cstring>', 'public'] },
|
||||
{ include: ['<cstdlib>', 'public', '<cstdlib>', 'public'] },
|
||||
{ include: ['<cmath>', 'public', '<cmath>', 'public'] },
|
||||
{ include: ['<cassert>', 'public', '<cassert>', 'public'] },
|
||||
{ include: ['<climits>', 'public', '<climits>', 'public'] },
|
||||
{ include: ['<cfloat>', 'public', '<cfloat>', 'public'] },
|
||||
|
||||
# C++ containers and algorithms
|
||||
{ include: ['<vector>', 'public', '<vector>', 'public'] },
|
||||
{ include: ['<array>', 'public', '<array>', 'public'] },
|
||||
{ include: ['<string>', 'public', '<string>', 'public'] },
|
||||
{ include: ['<algorithm>', 'public', '<algorithm>', 'public'] },
|
||||
{ include: ['<memory>', 'public', '<memory>', 'public'] },
|
||||
{ include: ['<functional>', 'public', '<functional>', 'public'] },
|
||||
{ include: ['<utility>', 'public', '<utility>', 'public'] },
|
||||
{ include: ['<type_traits>', 'public', '<type_traits>', 'public'] },
|
||||
|
||||
# I/O headers
|
||||
{ include: ['<iostream>', 'public', '<iostream>', 'public'] },
|
||||
{ include: ['<iomanip>', 'public', '<iomanip>', 'public'] },
|
||||
{ include: ['<sstream>', 'public', '<sstream>', 'public'] },
|
||||
{ include: ['<fstream>', 'public', '<fstream>', 'public'] },
|
||||
|
||||
# Common type mappings
|
||||
{ symbol: ['std::uint8_t', 'public', '<cstdint>', 'public'] },
|
||||
{ symbol: ['std::uint16_t', 'public', '<cstdint>', 'public'] },
|
||||
{ symbol: ['std::uint32_t', 'public', '<cstdint>', 'public'] },
|
||||
{ symbol: ['std::uint64_t', 'public', '<cstdint>', 'public'] },
|
||||
{ symbol: ['std::int8_t', 'public', '<cstdint>', 'public'] },
|
||||
{ symbol: ['std::int16_t', 'public', '<cstdint>', 'public'] },
|
||||
{ symbol: ['std::int32_t', 'public', '<cstdint>', 'public'] },
|
||||
{ symbol: ['std::int64_t', 'public', '<cstdint>', 'public'] },
|
||||
{ symbol: ['std::size_t', 'public', '<cstddef>', 'public'] },
|
||||
{ symbol: ['std::ptrdiff_t', 'public', '<cstddef>', 'public'] },
|
||||
|
||||
# Common functions
|
||||
{ symbol: ['std::memcpy', 'public', '<cstring>', 'public'] },
|
||||
{ symbol: ['std::memset', 'public', '<cstring>', 'public'] },
|
||||
{ symbol: ['std::strlen', 'public', '<cstring>', 'public'] },
|
||||
{ symbol: ['std::malloc', 'public', '<cstdlib>', 'public'] },
|
||||
{ symbol: ['std::free', 'public', '<cstdlib>', 'public'] },
|
||||
{ symbol: ['std::abs', 'public', '<cmath>', 'public'] },
|
||||
{ symbol: ['std::sin', 'public', '<cmath>', 'public'] },
|
||||
{ symbol: ['std::cos', 'public', '<cmath>', 'public'] },
|
||||
{ symbol: ['std::sqrt', 'public', '<cmath>', 'public'] },
|
||||
|
||||
# C standard library compatibility (prefer C++ versions)
|
||||
{ include: ['<stdint.h>', 'public', '<cstdint>', 'public'] },
|
||||
{ include: ['<stddef.h>', 'public', '<cstddef>', 'public'] },
|
||||
{ include: ['<string.h>', 'public', '<cstring>', 'public'] },
|
||||
{ include: ['<stdlib.h>', 'public', '<cstdlib>', 'public'] },
|
||||
{ include: ['<math.h>', 'public', '<cmath>', 'public'] },
|
||||
{ include: ['<assert.h>', 'public', '<cassert>', 'public'] },
|
||||
{ include: ['<limits.h>', 'public', '<climits>', 'public'] },
|
||||
{ include: ['<float.h>', 'public', '<cfloat>', 'public'] },
|
||||
|
||||
# Platform-specific but common headers
|
||||
{ include: ['<sys/types.h>', 'public', '<sys/types.h>', 'public'] },
|
||||
{ include: ['<unistd.h>', 'public', '<unistd.h>', 'public'] },
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
# FastLED JavaScript Linting Tools
|
||||
|
||||
This directory contains JavaScript linting tools for the FastLED project, using Node.js + ESLint for **fast** JavaScript validation.
|
||||
|
||||
|
||||
### Via Main Lint Script (Recommended)
|
||||
|
||||
```bash
|
||||
# JavaScript linting is included by default (fast only!)
|
||||
bash lint
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
The fast JavaScript linter is automatically installed during project setup:
|
||||
|
||||
```bash
|
||||
# Installs everything including fast JS linting
|
||||
./install
|
||||
```
|
||||
|
||||
Or manually install just the JavaScript linter:
|
||||
|
||||
```bash
|
||||
uv run ci/setup-js-linting-fast.py
|
||||
```
|
||||
|
||||
This will:
|
||||
- Download Node.js binary to `.js-tools/node/`
|
||||
- Install ESLint to `.js-tools/node_modules/`
|
||||
- Create ESLint configuration in `.js-tools/.eslintrc.js`
|
||||
- Create fast linting script `.cache/js-tools/lint-js-fast`
|
||||
|
||||
## Philosophy: Fast Only
|
||||
|
||||
**JavaScript linting follows a "fast only" policy:**
|
||||
- ✅ **Fast linting available**: Uses Node.js + ESLint (~0.9 seconds)
|
||||
- ⚠️ **Fast linting unavailable**: Skips JavaScript linting entirely
|
||||
- ❌ **No slow fallback**: Fast linting or no linting at all
|
||||
|
||||
## Configuration
|
||||
|
||||
The fast linter only checks for **critical runtime issues**:
|
||||
- `no-debugger` - Prevents debugger statements in production
|
||||
- `no-eval` - Prevents eval() security vulnerabilities
|
||||
|
||||
**No style enforcement** - focuses on critical issues only for maximum speed.
|
||||
|
||||
## Integration
|
||||
|
||||
JavaScript linting is included by default in `bash lint` when fast linting is available.
|
||||
|
||||
## Performance
|
||||
|
||||
- **Fast linting**: ~0.9 seconds (53x faster than previous solution)
|
||||
- **Total lint time**: ~1.6 seconds (Python + C++ + JavaScript)
|
||||
@@ -0,0 +1,16 @@
|
||||
#include "FastLED.h"
|
||||
|
||||
// Let's include a bunch of stuff and see if it breaks the build.
|
||||
#include <WiFi.h>
|
||||
#include <ESPmDNS.h>
|
||||
#include <NetworkUdp.h>
|
||||
#include <ArduinoOTA.h>
|
||||
#include <ArduinoJson.h>
|
||||
|
||||
void setup() {
|
||||
|
||||
}
|
||||
|
||||
void loop() {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[platformio]
|
||||
src_dir = symlink://../../../src
|
||||
|
||||
[env:dev]
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.04/platform-espressif32.zip
|
||||
board = esp32-s3-devkitc-1
|
||||
framework = arduino
|
||||
lib_deps =
|
||||
https://github.com/dvarrel/AsyncTCP
|
||||
https://github.com/mathieucarbou/ESPAsyncWebServer
|
||||
ArduinoJson
|
||||
FS
|
||||
ArduinoOTA
|
||||
ESPmDNS
|
||||
|
||||
lib_ldf_mode=deep
|
||||
build_flags=-DFASTLED_STUB_MAIN_INCLUDE_INO="../ci/kitchensink/kitchensink.ino.cpp"
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/bin/bash
|
||||
# FastLED JavaScript Linting Script (Node.js + ESLint + JSDoc Type Checking - FAST!)
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}FAST FastLED JavaScript Linting (Node.js + ESLint + JSDoc Type Checking - FAST!)${NC}"
|
||||
|
||||
# Check if ESLint is installed
|
||||
if [ ! -f ".cache/js-tools/node_modules/.bin/eslint.cmd" ]; then
|
||||
echo -e "${RED}ERROR: ESLint not found. Run: uv run ci/setup-js-linting-fast.py${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find JavaScript files in WASM platform
|
||||
JS_FILES=$(find src/platforms/wasm -name "*.js" -type f 2>/dev/null)
|
||||
|
||||
if [ -z "$JS_FILES" ]; then
|
||||
echo -e "${YELLOW}WARNING: No JavaScript files found in src/platforms/wasm/${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Found JavaScript files:${NC}"
|
||||
echo "$JS_FILES" | sed 's/^/ /'
|
||||
|
||||
# Run ESLint using config from ci/ directory
|
||||
echo -e "${BLUE}Running ESLint...${NC}"
|
||||
cd .cache/js-tools
|
||||
if "./node_modules/.bin/eslint.cmd" --no-eslintrc --no-inline-config -c ../../ci/.eslintrc.js ../../src/platforms/wasm/compiler/*.js ../../src/platforms/wasm/compiler/modules/*.js; then
|
||||
echo -e "${GREEN}✅ ESLint completed successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}ERROR: ESLint failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run JSDoc Type Checking using TypeScript
|
||||
echo -e "${BLUE}Running JSDoc Type Checking...${NC}"
|
||||
cd ../../src/platforms/wasm/compiler
|
||||
|
||||
# Check if TypeScript is available (local installation first, then global)
|
||||
if [ -f "../../../../.cache/js-tools/node_modules/.bin/tsc.cmd" ]; then
|
||||
echo -e "${BLUE}Using local TypeScript for JSDoc type checking...${NC}"
|
||||
# Run TypeScript and filter out Node.js events library errors
|
||||
TSC_OUTPUT=$(../../../../.cache/js-tools/node_modules/.bin/tsc.cmd --noEmit --project jsconfig.json 2>&1)
|
||||
TSC_EXIT_CODE=$?
|
||||
# Filter out the specific Node.js events error
|
||||
FILTERED_OUTPUT=$(echo "$TSC_OUTPUT" | grep -v "node_modules/events/events.js.*Property 'context' does not exist on type 'Error'" || true)
|
||||
|
||||
if [ -n "$FILTERED_OUTPUT" ]; then
|
||||
echo "$FILTERED_OUTPUT"
|
||||
if [ $TSC_EXIT_CODE -ne 0 ]; then
|
||||
echo -e "${RED}❌ JSDoc type checking failed with errors${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo -e "${GREEN}✅ JSDoc type checking completed successfully${NC}"
|
||||
elif [ -f "../../../../.cache/js-tools/node_modules/.bin/tsc" ]; then
|
||||
echo -e "${BLUE}Using local TypeScript for JSDoc type checking...${NC}"
|
||||
# Run TypeScript and filter out Node.js events library errors
|
||||
TSC_OUTPUT=$(../../../../.cache/js-tools/node_modules/.bin/tsc --noEmit --project jsconfig.json 2>&1)
|
||||
TSC_EXIT_CODE=$?
|
||||
# Filter out the specific Node.js events error
|
||||
FILTERED_OUTPUT=$(echo "$TSC_OUTPUT" | grep -v "node_modules/events/events.js.*Property 'context' does not exist on type 'Error'" || true)
|
||||
|
||||
if [ -n "$FILTERED_OUTPUT" ]; then
|
||||
echo "$FILTERED_OUTPUT"
|
||||
if [ $TSC_EXIT_CODE -ne 0 ]; then
|
||||
echo -e "${RED}❌ JSDoc type checking failed with errors${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo -e "${GREEN}✅ JSDoc type checking completed successfully${NC}"
|
||||
elif command -v npx >/dev/null 2>&1 && npx tsc --version >/dev/null 2>&1; then
|
||||
echo -e "${BLUE}Using global TypeScript for JSDoc type checking...${NC}"
|
||||
# Run TypeScript and filter out Node.js events library errors
|
||||
TSC_OUTPUT=$(npx tsc --noEmit --project jsconfig.json 2>&1)
|
||||
TSC_EXIT_CODE=$?
|
||||
# Filter out the specific Node.js events error
|
||||
FILTERED_OUTPUT=$(echo "$TSC_OUTPUT" | grep -v "node_modules/events/events.js.*Property 'context' does not exist on type 'Error'" || true)
|
||||
|
||||
if [ -n "$FILTERED_OUTPUT" ]; then
|
||||
echo "$FILTERED_OUTPUT"
|
||||
if [ $TSC_EXIT_CODE -ne 0 ]; then
|
||||
echo -e "${RED}❌ JSDoc type checking failed with errors${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo -e "${GREEN}✅ JSDoc type checking completed successfully${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ TypeScript not found, skipping JSDoc type checking${NC}"
|
||||
echo -e "${BLUE}💡 To enable JSDoc checking: Run the ./install script or npm install -g typescript${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}SUCCESS: JavaScript linting and type checking completed successfully${NC}"
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# FastLED JavaScript Linting Script (Node.js + ESLint - FAST!)
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}FAST FastLED JavaScript Linting (Node.js + ESLint - FAST!)${NC}"
|
||||
|
||||
# Check if ESLint is installed
|
||||
if [ ! -f ".cache/js-tools/node_modules/.bin/eslint.cmd" ]; then
|
||||
echo -e "${RED}ERROR: ESLint not found. Run: uv run ci/setup-js-linting-fast.py${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find JavaScript files in WASM platform
|
||||
JS_FILES=$(find src/platforms/wasm -name "*.js" -type f 2>/dev/null)
|
||||
|
||||
if [ -z "$JS_FILES" ]; then
|
||||
echo -e "${YELLOW}WARNING: No JavaScript files found in src/platforms/wasm/${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${BLUE}Found JavaScript files:${NC}"
|
||||
echo "$JS_FILES" | sed 's/^/ /'
|
||||
|
||||
# Run ESLint using config from ci/ directory
|
||||
echo -e "${BLUE}Running ESLint...${NC}"
|
||||
cd .cache/js-tools
|
||||
if "./node_modules/.bin/eslint.cmd" --no-eslintrc --no-inline-config -c ../../ci/.eslintrc.js ../../src/platforms/wasm/compiler/*.js ../../src/platforms/wasm/compiler/modules/*.js; then
|
||||
echo -e "${GREEN}SUCCESS: JavaScript linting completed successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}ERROR: JavaScript linting failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FastLED Build System Migration Example
|
||||
|
||||
This shows how to migrate from the old scattered build logic
|
||||
to the new unified BuildAPI.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from ci.build_api import create_example_builder, create_unit_test_builder
|
||||
|
||||
|
||||
def old_way_unit_test_building():
|
||||
"""
|
||||
OLD WAY: Scattered logic with manual PCH and library building
|
||||
|
||||
This is how unit test building worked before the refactor:
|
||||
- Manual PCH building in multiple places
|
||||
- Manual library building with duplicated logic
|
||||
- Mixed build flags between unit tests and examples
|
||||
- Manual directory management
|
||||
"""
|
||||
|
||||
# OLD CODE (before refactor) - DO NOT USE
|
||||
"""
|
||||
# Manual PCH building (scattered in multiple files)
|
||||
from ci.compiler.test_example_compilation import create_fastled_compiler
|
||||
from ci.compiler.cpp_test_compile import create_unit_test_fastled_library
|
||||
|
||||
compiler = create_fastled_compiler(use_pch=True, use_sccache=False, parallel=True)
|
||||
|
||||
# Manual library building (duplicated logic)
|
||||
lib_file = create_unit_test_fastled_library(clean=False, use_pch=True)
|
||||
|
||||
# Manual target compilation loop
|
||||
for test_file in test_files:
|
||||
success = compiler.compile_cpp_file(test_file, output_obj)
|
||||
if success:
|
||||
link_executable(output_obj, lib_file, output_exe)
|
||||
"""
|
||||
|
||||
|
||||
def new_way_unit_test_building():
|
||||
"""
|
||||
NEW WAY: Unified BuildAPI with automatic PCH and library building
|
||||
|
||||
This is how unit test building works after the refactor:
|
||||
- Automatic PCH building (once, reused by all targets)
|
||||
- Automatic library building (once, reused by all targets)
|
||||
- Clean separation of unit test build flags
|
||||
- Automatic directory organization
|
||||
"""
|
||||
|
||||
# NEW CODE (after refactor) - USE THIS
|
||||
unit_builder = create_unit_test_builder(
|
||||
build_dir=".build/unit", use_pch=True, parallel=True
|
||||
)
|
||||
|
||||
test_files = [
|
||||
Path("tests/test_json.cpp"),
|
||||
Path("tests/test_color.cpp"),
|
||||
Path("tests/test_math.cpp"),
|
||||
]
|
||||
|
||||
# Single call handles everything:
|
||||
# - PCH building (automatically, once)
|
||||
# - Library building (automatically, once)
|
||||
# - Individual target compilation and linking
|
||||
results = unit_builder.build_targets(test_files)
|
||||
|
||||
# Clean result handling
|
||||
for result in results:
|
||||
if result.success:
|
||||
print(f"✓ {result.target.name} built successfully")
|
||||
else:
|
||||
print(f"✗ {result.target.name} failed: {result.message}")
|
||||
|
||||
|
||||
def old_way_example_building():
|
||||
"""
|
||||
OLD WAY: Mixed build configuration and manual management
|
||||
|
||||
Examples used the same build_flags.toml as unit tests, causing:
|
||||
- Wrong defines (FASTLED_FORCE_NAMESPACE=1 for examples)
|
||||
- Mixed configuration between unit tests and examples
|
||||
- Manual PCH and library building
|
||||
"""
|
||||
|
||||
# OLD CODE (before refactor) - DO NOT USE
|
||||
"""
|
||||
# Used same build_flags.toml as unit tests (wrong!)
|
||||
compiler = create_fastled_compiler(use_pch=True, use_sccache=False, parallel=True)
|
||||
|
||||
# Manual example compilation with wrong defines
|
||||
for example_file in example_files:
|
||||
success = compiler.compile_ino_file(example_file, output_obj)
|
||||
# ... manual linking logic ...
|
||||
"""
|
||||
|
||||
|
||||
def new_way_example_building():
|
||||
"""
|
||||
NEW WAY: Clean separation with dedicated build configuration
|
||||
|
||||
Examples now have their own build_example.toml with:
|
||||
- Correct defines (no FASTLED_FORCE_NAMESPACE=1)
|
||||
- Arduino compatibility defines
|
||||
- Automatic PCH and library building
|
||||
"""
|
||||
|
||||
# NEW CODE (after refactor) - USE THIS
|
||||
example_builder = create_example_builder(
|
||||
build_dir=".build/examples", use_pch=True, parallel=True
|
||||
)
|
||||
|
||||
example_files = [
|
||||
Path("examples/Blink/Blink.ino"),
|
||||
Path("examples/DemoReel100/DemoReel100.ino"),
|
||||
]
|
||||
|
||||
# Single call handles everything with correct configuration
|
||||
results = example_builder.build_targets(example_files)
|
||||
|
||||
for result in results:
|
||||
if result.success:
|
||||
print(f"✓ {result.target.name} built successfully")
|
||||
else:
|
||||
print(f"✗ {result.target.name} failed: {result.message}")
|
||||
|
||||
|
||||
def migration_benefits():
|
||||
"""
|
||||
Benefits of the new unified BuildAPI:
|
||||
"""
|
||||
|
||||
print("MIGRATION BENEFITS:")
|
||||
print("=" * 50)
|
||||
|
||||
print("1. UNIFIED API:")
|
||||
print(
|
||||
" Before: create_fastled_compiler() + create_unit_test_fastled_library() + manual loops"
|
||||
)
|
||||
print(" After: create_unit_test_builder().build_targets(files)")
|
||||
|
||||
print("\n2. CLEAN SEPARATION:")
|
||||
print(" Before: build_flags.toml used by both unit tests and examples")
|
||||
print(" After: build_unit.toml vs build_example.toml")
|
||||
|
||||
print("\n3. AUTOMATIC ARTIFACT BUILDING:")
|
||||
print(" Before: Manual PCH and library building in callers")
|
||||
print(" After: Automatic PCH and library building in BuildAPI")
|
||||
|
||||
print("\n4. ORGANIZED DIRECTORIES:")
|
||||
print(" Before: Mixed artifacts in .build/")
|
||||
print(" After: Organized .build/unit/ and .build/examples/ with subdirectories")
|
||||
|
||||
print("\n5. MULTIPLE TARGET EFFICIENCY:")
|
||||
print(" Before: PCH and library built for each target")
|
||||
print(" After: PCH and library built once, reused by all targets")
|
||||
|
||||
print("\n6. SIMPLE ERROR HANDLING:")
|
||||
print(" Before: Manual error handling in compilation loops")
|
||||
print(" After: Structured SketchResult objects with clear success/failure")
|
||||
|
||||
|
||||
def migration_checklist():
|
||||
"""
|
||||
Checklist for migrating existing code to the new BuildAPI:
|
||||
"""
|
||||
|
||||
print("\nMIGRATION CHECKLIST:")
|
||||
print("=" * 50)
|
||||
|
||||
print("✓ COMPLETED:")
|
||||
print(" - Split ci/build_flags.toml into build_unit.toml and build_example.toml")
|
||||
print(" - Created unified BuildAPI class")
|
||||
print(" - Implemented automatic PCH and library building")
|
||||
print(" - Implemented organized build directory structure")
|
||||
print(
|
||||
" - Created convenience functions create_unit_test_builder() and create_example_builder()"
|
||||
)
|
||||
|
||||
print("\n⚠ TODO:")
|
||||
print(
|
||||
" - Update existing unit test compilation code to use create_unit_test_builder()"
|
||||
)
|
||||
print(
|
||||
" - Update existing example compilation code to use create_example_builder()"
|
||||
)
|
||||
print(" - Remove old scattered PCH and library building logic")
|
||||
print(" - Update test scripts to use new API")
|
||||
print(" - Deprecate old build_flags.toml (marked as legacy)")
|
||||
|
||||
|
||||
def show_api_simplification():
|
||||
"""
|
||||
Show how the API is dramatically simplified:
|
||||
"""
|
||||
|
||||
print("\nAPI SIMPLIFICATION:")
|
||||
print("=" * 50)
|
||||
|
||||
print("BEFORE (scattered across multiple files):")
|
||||
print("""
|
||||
# In ci/compiler/test_example_compilation.py
|
||||
compiler = create_fastled_compiler(use_pch=True, use_sccache=False, parallel=True)
|
||||
|
||||
# In ci/compiler/cpp_test_compile.py
|
||||
lib_file = create_unit_test_fastled_library(clean=False, use_pch=True)
|
||||
|
||||
# In test runner
|
||||
for test_file in test_files:
|
||||
obj_file = build_dir / f"{test_file.stem}.o"
|
||||
success = compiler.compile_cpp_file(test_file, obj_file)
|
||||
if success:
|
||||
exe_file = build_dir / f"{test_file.stem}.exe"
|
||||
link_success = link_executable(obj_file, lib_file, exe_file)
|
||||
""")
|
||||
|
||||
print("AFTER (unified API):")
|
||||
print("""
|
||||
# Single unified API
|
||||
builder = create_unit_test_builder()
|
||||
results = builder.build_targets(test_files)
|
||||
|
||||
# That's it! Everything else is automatic:
|
||||
# - PCH building
|
||||
# - Library building
|
||||
# - Individual compilation
|
||||
# - Linking
|
||||
# - Directory organization
|
||||
# - Error handling
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("FastLED Build System Migration Example")
|
||||
print("=" * 60)
|
||||
|
||||
migration_benefits()
|
||||
migration_checklist()
|
||||
show_api_simplification()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("NEXT STEPS:")
|
||||
print("1. Update existing unit test compilation to use create_unit_test_builder()")
|
||||
print("2. Update existing example compilation to use create_example_builder()")
|
||||
print("3. Remove old scattered build logic")
|
||||
print("4. Test the new unified system")
|
||||
print("=" * 60)
|
||||
@@ -0,0 +1,17 @@
|
||||
[platformio]
|
||||
src_dir = symlink://../../../src
|
||||
|
||||
[env:dev]
|
||||
platform = platformio/native
|
||||
|
||||
lib_deps =
|
||||
FastLED
|
||||
|
||||
build_flags =
|
||||
-DFASTLED_STUB_IMPL
|
||||
-DFASTLED_USE_STUB_ARDUINO
|
||||
-DFASTLED_STUB_MAIN_INCLUDE_INO="../examples/Blink/Blink.ino"
|
||||
-std=c++17
|
||||
-I../../src/platforms
|
||||
-isystem ../../src/platforms/stub ; for Arduino.h
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert a binary file to ELF using map file."
|
||||
)
|
||||
parser.add_argument("--first", action="store_true", help="Inspect the first board")
|
||||
parser.add_argument("--cwd", type=Path, help="Custom working directory")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.cwd:
|
||||
root_build_dir = args.cwd / ".build"
|
||||
else:
|
||||
root_build_dir = Path(".build")
|
||||
|
||||
# Support nested PlatformIO structure: .build/pio/<board>
|
||||
nested_pio_dir = root_build_dir / "pio"
|
||||
if nested_pio_dir.is_dir():
|
||||
root_build_dir = nested_pio_dir
|
||||
|
||||
board_dirs = [d for d in root_build_dir.iterdir() if d.is_dir()]
|
||||
if not board_dirs:
|
||||
print(f"No board directories found in {root_build_dir.absolute()}")
|
||||
return 1
|
||||
print("Available boards:")
|
||||
for i, board_dir in enumerate(board_dirs):
|
||||
print(f"[{i}]: {board_dir.name}")
|
||||
which = (
|
||||
0
|
||||
if args.first
|
||||
else int(input("Enter the number of the board you want to inspect: "))
|
||||
)
|
||||
board_dir = board_dirs[which]
|
||||
# build_info_json = board_dir / "build_info.json"
|
||||
optimization_report = board_dir / "optimization_report.txt"
|
||||
text = optimization_report.read_text(encoding="utf-8", errors="replace")
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false
|
||||
"""
|
||||
Caching wrapper for pyright that only runs if files have changed.
|
||||
This significantly speeds up repeated pyright runs during development.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
def get_file_hash(file_path: Path) -> str:
|
||||
"""Get SHA256 hash of a file."""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
return hashlib.sha256(f.read()).hexdigest()
|
||||
except (OSError, IOError):
|
||||
return ""
|
||||
|
||||
|
||||
def get_directory_hash(directory: Path, extensions: Optional[List[str]] = None) -> str:
|
||||
"""Get combined hash of all files in a directory."""
|
||||
if extensions is None:
|
||||
extensions = [".py"]
|
||||
|
||||
hashes: List[str] = []
|
||||
if directory.exists():
|
||||
for ext in extensions:
|
||||
for file_path in directory.rglob(f"*{ext}"):
|
||||
if file_path.is_file():
|
||||
hashes.append(
|
||||
f"{file_path.relative_to(directory)}:{get_file_hash(file_path)}"
|
||||
)
|
||||
|
||||
hashes.sort() # Ensure consistent ordering
|
||||
return hashlib.sha256("\n".join(hashes).encode()).hexdigest()
|
||||
|
||||
|
||||
def get_cache_file() -> Path:
|
||||
"""Get the cache file path."""
|
||||
cache_dir = Path(".cache/pyright")
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir / "file_hashes.json"
|
||||
|
||||
|
||||
def load_cache() -> Dict[str, str]:
|
||||
"""Load the cache file."""
|
||||
cache_file = get_cache_file()
|
||||
if cache_file.exists():
|
||||
try:
|
||||
with open(cache_file, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def save_cache(cache: Dict[str, str]) -> None:
|
||||
"""Save the cache file."""
|
||||
cache_file = get_cache_file()
|
||||
try:
|
||||
with open(cache_file, "w") as f:
|
||||
json.dump(cache, f, indent=2)
|
||||
except IOError:
|
||||
pass # Ignore cache save errors
|
||||
|
||||
|
||||
def has_files_changed() -> bool:
|
||||
"""Check if any relevant files have changed since last run."""
|
||||
cache = load_cache()
|
||||
changed = False
|
||||
|
||||
# Files and directories to check
|
||||
files_to_check = [
|
||||
Path("pyrightconfig.json"),
|
||||
Path(".pyrightconfig.json"),
|
||||
Path("pyproject.toml"),
|
||||
]
|
||||
|
||||
directories_to_check = [
|
||||
Path("ci"),
|
||||
]
|
||||
|
||||
# Check individual files
|
||||
for file_path in files_to_check:
|
||||
if file_path.exists():
|
||||
current_hash = get_file_hash(file_path)
|
||||
if cache.get(str(file_path)) != current_hash:
|
||||
cache[str(file_path)] = current_hash
|
||||
changed = True
|
||||
|
||||
# Check directories
|
||||
for dir_path in directories_to_check:
|
||||
if dir_path.exists():
|
||||
current_hash = get_directory_hash(dir_path)
|
||||
cache_key = f"dir:{dir_path}"
|
||||
if cache.get(cache_key) != current_hash:
|
||||
cache[cache_key] = current_hash
|
||||
changed = True
|
||||
|
||||
# Save cache if any changes were detected
|
||||
if changed:
|
||||
save_cache(cache)
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def run_pyright(args: List[str]) -> int:
|
||||
"""Run pyright with the given arguments."""
|
||||
env = os.environ.copy()
|
||||
# env["PYRIGHT_PYTHON_FORCE_VERSION"] = "latest"
|
||||
env["PYRIGHT_PYTHON_CACHE_DIR"] = ".cache/pyright"
|
||||
|
||||
cmd = ["uv", "run", "pyright"] + args
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, env=env)
|
||||
return result.returncode
|
||||
except KeyboardInterrupt:
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error running pyright: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
args = sys.argv[1:] # Remove script name
|
||||
|
||||
# If --force is passed, skip cache check
|
||||
if "--force" in args:
|
||||
args.remove("--force")
|
||||
print("Forcing pyright run (--force flag detected)")
|
||||
return run_pyright(args)
|
||||
|
||||
# Check if files have changed
|
||||
if not has_files_changed():
|
||||
print("No changes detected, skipping pyright (use --force to override)")
|
||||
return 0
|
||||
|
||||
print("Changes detected, running pyright...")
|
||||
return run_pyright(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,548 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ESP32 QEMU Runner
|
||||
|
||||
Runs ESP32 firmware in QEMU with logging and validation.
|
||||
Designed to replace the tobozo/esp32-qemu-sim GitHub Action.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, TextIO
|
||||
|
||||
from ci.util.running_process import RunningProcess
|
||||
|
||||
|
||||
def get_binary_name() -> str:
|
||||
"""Get platform-specific QEMU binary name."""
|
||||
return (
|
||||
"qemu-system-xtensa.exe"
|
||||
if platform.system().lower() == "windows"
|
||||
else "qemu-system-xtensa"
|
||||
)
|
||||
|
||||
|
||||
def find_executable(binary_name: str) -> Optional[Path]:
|
||||
"""Find executable in PATH using platform-appropriate command."""
|
||||
try:
|
||||
cmd = [
|
||||
"where" if platform.system().lower() == "windows" else "which",
|
||||
binary_name,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
# Take first line in case of multiple results (Windows 'where')
|
||||
return Path(result.stdout.strip().split("\n")[0])
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def find_qemu_binary() -> Optional[Path]:
|
||||
"""Find QEMU ESP32 binary in common locations."""
|
||||
binary_name = get_binary_name()
|
||||
|
||||
# ESP-IDF installation paths and portable installation
|
||||
esp_paths = [
|
||||
Path(".cache") / "qemu", # Project-local portable installation directory
|
||||
Path.home() / ".espressif" / "tools" / "qemu-xtensa",
|
||||
Path.home() / ".espressif" / "python_env",
|
||||
]
|
||||
|
||||
for base_path in esp_paths:
|
||||
if base_path.exists():
|
||||
for qemu_path in base_path.rglob(binary_name):
|
||||
if qemu_path.is_file():
|
||||
return qemu_path
|
||||
|
||||
# Check system PATH
|
||||
return find_executable(binary_name)
|
||||
|
||||
|
||||
def show_installation_help():
|
||||
"""Display platform-specific installation instructions."""
|
||||
binary_name = get_binary_name()
|
||||
print(f"ERROR: QEMU ESP32 emulator ({binary_name}) not found!", file=sys.stderr)
|
||||
print()
|
||||
|
||||
if platform.system().lower() == "windows":
|
||||
print("To install QEMU on Windows:")
|
||||
print("1. Chocolatey: choco install qemu -y")
|
||||
print("2. winget: winget install SoftwareFreedomConservancy.QEMU --scope user")
|
||||
print("3. Manual: Download from https://qemu.weilnetz.de/w64/")
|
||||
print("4. ESP-IDF: uv run ci/install-qemu.py")
|
||||
else:
|
||||
print("Run: uv run ci/install-qemu.py")
|
||||
|
||||
|
||||
class QEMURunner:
|
||||
def __init__(self, qemu_binary: Optional[Path] = None):
|
||||
self.qemu_binary = qemu_binary or self._get_qemu_binary()
|
||||
self.output_lines: List[str] = []
|
||||
self.interrupt_met = False
|
||||
|
||||
def _get_qemu_binary(self) -> Path:
|
||||
"""Get QEMU binary path or raise error."""
|
||||
qemu_path = find_qemu_binary()
|
||||
if not qemu_path:
|
||||
raise FileNotFoundError(
|
||||
f"Could not find {get_binary_name()}. Please run install-qemu.py first."
|
||||
)
|
||||
return qemu_path
|
||||
|
||||
def build_qemu_command(self, firmware_path: Path, flash_size: int = 4) -> List[str]:
|
||||
"""Build QEMU command line arguments.
|
||||
|
||||
Args:
|
||||
firmware_path: Path to firmware.bin or directory containing build artifacts
|
||||
flash_size: Flash size in MB
|
||||
"""
|
||||
# Validate that flash_size is used correctly internally only
|
||||
if not isinstance(flash_size, int) or flash_size <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid flash_size: {flash_size}. Must be a positive integer."
|
||||
)
|
||||
|
||||
# Determine if we have a direct firmware.bin or a build directory
|
||||
if firmware_path.is_file() and firmware_path.suffix == ".bin":
|
||||
# Direct firmware.bin path
|
||||
flash_image_path = firmware_path.parent / "flash.bin"
|
||||
print(f"Creating flash image for firmware: {firmware_path}")
|
||||
self._create_flash_image_from_firmware(
|
||||
firmware_path, flash_image_path, flash_size
|
||||
)
|
||||
else:
|
||||
# Build directory path
|
||||
flash_image_path = firmware_path / "flash.bin"
|
||||
print(
|
||||
f"Creating combined flash image from build directory: {firmware_path}"
|
||||
)
|
||||
self._create_flash_image_from_build_dir(
|
||||
firmware_path, flash_image_path, flash_size
|
||||
)
|
||||
|
||||
# Check for ROM file in multiple locations
|
||||
rom_paths = [
|
||||
Path(".cache") / "qemu" / "esp32-v3-rom.bin",
|
||||
Path(".cache") / "qemu" / "qemu" / "share" / "qemu" / "esp32-v3-rom.bin",
|
||||
Path(".cache") / "qemu" / "extracted" / "share" / "esp32-v3-rom.bin",
|
||||
]
|
||||
|
||||
rom_path = None
|
||||
for path in rom_paths:
|
||||
if path.exists():
|
||||
rom_path = path
|
||||
break
|
||||
|
||||
# Ensure flash image path is absolute and exists
|
||||
flash_image_path = flash_image_path.resolve()
|
||||
if not flash_image_path.exists():
|
||||
raise FileNotFoundError(f"Flash image not found: {flash_image_path}")
|
||||
|
||||
cmd = [
|
||||
str(self.qemu_binary),
|
||||
"-nographic",
|
||||
"-machine",
|
||||
"esp32",
|
||||
"-drive",
|
||||
f"file={str(flash_image_path)},if=mtd,format=raw",
|
||||
"-global",
|
||||
"driver=timer.esp32.timg,property=wdt_disable,value=true",
|
||||
]
|
||||
|
||||
# Set QEMU data directory if ROM files are available
|
||||
if rom_path and rom_path.exists():
|
||||
qemu_data_dir = rom_path.parent
|
||||
cmd.extend(["-L", str(qemu_data_dir)])
|
||||
print(f"Setting QEMU data directory: {qemu_data_dir}")
|
||||
|
||||
# Note: ESP32 ROM is built into QEMU machine, no need to load external ROM
|
||||
# ESP32 QEMU machine has the ROM integrated, so we don't need to specify it
|
||||
if rom_path and rom_path.exists():
|
||||
print(
|
||||
f"ROM file available at: {rom_path} (but not needed for ESP32 machine)"
|
||||
)
|
||||
else:
|
||||
print("Note: ROM file not found, but ESP32 machine has integrated ROM")
|
||||
|
||||
# Validate that no custom script arguments are accidentally included
|
||||
custom_args = ["--flash-size", "--timeout", "--interrupt-regex"]
|
||||
for arg in cmd:
|
||||
if any(custom_arg in str(arg) for custom_arg in custom_args):
|
||||
raise ValueError(
|
||||
f"Custom script argument {arg} should not be passed to QEMU"
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
||||
def _create_flash_image_from_firmware(
|
||||
self, firmware_bin: Path, flash_bin: Path, flash_size: int
|
||||
):
|
||||
"""Create flash image from a direct firmware.bin file."""
|
||||
if flash_bin.exists():
|
||||
print(f"Using existing flash image: {flash_bin}")
|
||||
return
|
||||
|
||||
print(f"Creating flash image from firmware: {firmware_bin}")
|
||||
|
||||
# Create empty flash image
|
||||
flash_size_bytes = flash_size * 1024 * 1024
|
||||
flash_bin.write_bytes(b"\xff" * flash_size_bytes)
|
||||
|
||||
try:
|
||||
esptool_cmd = self._find_esptool()
|
||||
if esptool_cmd:
|
||||
# Use esptool to create proper flash image
|
||||
self._merge_with_esptool_firmware(esptool_cmd, firmware_bin, flash_bin)
|
||||
else:
|
||||
# Simple manual merge - just write firmware at 0x10000
|
||||
self._merge_manually_firmware(firmware_bin, flash_bin)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not create proper flash image: {e}")
|
||||
|
||||
def _create_flash_image_from_build_dir(
|
||||
self, build_folder: Path, flash_bin: Path, flash_size: int
|
||||
):
|
||||
"""Create combined flash image from ESP32 build artifacts in a directory."""
|
||||
if flash_bin.exists():
|
||||
print(f"Using existing flash image: {flash_bin}")
|
||||
return
|
||||
|
||||
print("Creating combined flash image from build directory...")
|
||||
|
||||
# Create empty flash image
|
||||
flash_size_bytes = flash_size * 1024 * 1024
|
||||
flash_bin.write_bytes(b"\xff" * flash_size_bytes)
|
||||
|
||||
try:
|
||||
esptool_cmd = self._find_esptool()
|
||||
if esptool_cmd:
|
||||
self._merge_with_esptool(esptool_cmd, build_folder, flash_bin)
|
||||
else:
|
||||
self._merge_manually(build_folder, flash_bin)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not create proper flash image: {e}")
|
||||
|
||||
def _find_esptool(self) -> Optional[str]:
|
||||
"""Find esptool.py executable."""
|
||||
# Try direct esptool.py
|
||||
if find_executable("esptool.py"):
|
||||
return "esptool.py"
|
||||
|
||||
# Try python -m esptool
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "esptool", "--help"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return f"{sys.executable} -m esptool"
|
||||
except (subprocess.SubprocessError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def _merge_with_esptool(
|
||||
self, esptool_cmd: str, build_folder: Path, flash_bin: Path
|
||||
):
|
||||
"""Merge using esptool.py merge_bin command."""
|
||||
cmd = esptool_cmd.split() + [
|
||||
"--chip",
|
||||
"esp32", # Use esp32 chip type for esp32dev machine
|
||||
"merge-bin", # Updated command name
|
||||
"-o",
|
||||
str(flash_bin),
|
||||
"--flash-mode", # Updated option name
|
||||
"dio",
|
||||
"--flash-freq", # Updated option name
|
||||
"40m", # Standard frequency for esp32
|
||||
"--flash-size", # Updated option name
|
||||
"4MB",
|
||||
]
|
||||
|
||||
# Add binaries at their offsets - check if we're in the actual build directory
|
||||
if (build_folder / "firmware.bin").exists():
|
||||
firmware_path = build_folder / "firmware.bin"
|
||||
else:
|
||||
firmware_path = (
|
||||
build_folder / ".pio" / "build" / "esp32dev" / "firmware.bin"
|
||||
)
|
||||
|
||||
if not firmware_path.exists():
|
||||
print(
|
||||
f"FATAL: ESP32dev firmware not found at {firmware_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("Please build for esp32dev target first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
binaries = [
|
||||
("0x1000", build_folder / "bootloader.bin"), # ESP32 bootloader offset
|
||||
("0x8000", build_folder / "partitions.bin"),
|
||||
("0x10000", firmware_path),
|
||||
]
|
||||
|
||||
for offset, bin_path in binaries:
|
||||
if bin_path.exists():
|
||||
cmd.extend([offset, str(bin_path)])
|
||||
|
||||
# Add padding to 4MB at the end
|
||||
cmd.extend(["--pad-to-size", "4MB"])
|
||||
|
||||
print(f"Running esptool command: {' '.join(cmd)}")
|
||||
esptool_proc = RunningProcess(cmd, timeout=60, auto_run=True)
|
||||
|
||||
# Stream esptool output and check for errors
|
||||
has_errors = False
|
||||
with esptool_proc.line_iter(timeout=None) as it:
|
||||
for line in it:
|
||||
print(f"[esptool] {line}")
|
||||
# Check for common esptool error patterns
|
||||
if any(
|
||||
error_pattern in line.lower()
|
||||
for error_pattern in ["error", "failed", "exception"]
|
||||
):
|
||||
has_errors = True
|
||||
|
||||
returncode = esptool_proc.wait()
|
||||
if returncode != 0:
|
||||
raise subprocess.CalledProcessError(returncode, cmd)
|
||||
if has_errors:
|
||||
print("Warning: Errors detected in esptool output")
|
||||
print("Flash image created with esptool")
|
||||
|
||||
def _merge_with_esptool_firmware(
|
||||
self, esptool_cmd: str, firmware_bin: Path, flash_bin: Path
|
||||
):
|
||||
"""Merge firmware.bin using esptool with minimal bootloader."""
|
||||
cmd = esptool_cmd.split() + [
|
||||
"--chip",
|
||||
"esp32",
|
||||
"merge-bin", # Updated command name
|
||||
"-o",
|
||||
str(flash_bin),
|
||||
"--flash-mode", # Updated option name
|
||||
"dio",
|
||||
"--flash-freq", # Updated option name
|
||||
"40m",
|
||||
"--flash-size", # Updated option name
|
||||
"4MB",
|
||||
"0x10000", # Application offset
|
||||
str(firmware_bin),
|
||||
]
|
||||
|
||||
# Note: Without bootloader and partition table, QEMU may not boot properly
|
||||
# but we'll try with just the app for now
|
||||
print(f"Running esptool firmware command: {' '.join(cmd)}")
|
||||
esptool_proc = RunningProcess(cmd, timeout=60, auto_run=True)
|
||||
|
||||
# Stream esptool output and check for errors
|
||||
has_errors = False
|
||||
with esptool_proc.line_iter(timeout=None) as it:
|
||||
for line in it:
|
||||
print(f"[esptool] {line}")
|
||||
# Check for common esptool error patterns
|
||||
if any(
|
||||
error_pattern in line.lower()
|
||||
for error_pattern in ["error", "failed", "exception"]
|
||||
):
|
||||
has_errors = True
|
||||
|
||||
returncode = esptool_proc.wait()
|
||||
if returncode != 0:
|
||||
raise subprocess.CalledProcessError(returncode, cmd)
|
||||
if has_errors:
|
||||
print("Warning: Errors detected in esptool output")
|
||||
print("Flash image created with esptool (firmware only)")
|
||||
|
||||
def _merge_manually_firmware(self, firmware_bin: Path, flash_bin: Path):
|
||||
"""Manually write firmware.bin to flash image."""
|
||||
print("WARNING: Manually merging firmware (esptool not found)")
|
||||
print("Note: Without bootloader, QEMU may not boot properly")
|
||||
|
||||
with open(flash_bin, "r+b") as flash:
|
||||
# Write firmware at application offset
|
||||
flash.seek(0x10000)
|
||||
flash.write(firmware_bin.read_bytes())
|
||||
print(f"Firmware written to flash image at offset 0x10000")
|
||||
|
||||
def _merge_manually(self, build_folder: Path, flash_bin: Path):
|
||||
"""Manually merge binary files at correct offsets."""
|
||||
print("WARNING: Manually merging binaries (esptool not found)")
|
||||
|
||||
# Find firmware binary - check if we're in the actual build directory
|
||||
if (build_folder / "firmware.bin").exists():
|
||||
firmware_path = build_folder / "firmware.bin"
|
||||
else:
|
||||
firmware_path = (
|
||||
build_folder / ".pio" / "build" / "esp32dev" / "firmware.bin"
|
||||
)
|
||||
|
||||
if not firmware_path.exists():
|
||||
print(
|
||||
f"FATAL: ESP32dev firmware not found at {firmware_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("Please build for esp32dev target first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
binaries = [
|
||||
("bootloader.bin", 0x1000), # ESP32 bootloader offset
|
||||
("partitions.bin", 0x8000),
|
||||
(firmware_path, 0x10000),
|
||||
]
|
||||
|
||||
with open(flash_bin, "r+b") as flash:
|
||||
for filename, offset in binaries:
|
||||
bin_path = build_folder / filename
|
||||
if bin_path.exists():
|
||||
flash.seek(offset)
|
||||
flash.write(bin_path.read_bytes())
|
||||
|
||||
def run(
|
||||
self,
|
||||
firmware_path: Path,
|
||||
timeout: int = 30,
|
||||
interrupt_regex: Optional[str] = None,
|
||||
flash_size: int = 4,
|
||||
) -> int:
|
||||
"""Run ESP32 firmware in QEMU.
|
||||
|
||||
Args:
|
||||
firmware_path: Path to firmware.bin file or build directory
|
||||
timeout: Timeout in seconds
|
||||
interrupt_regex: Regex pattern to interrupt execution
|
||||
flash_size: Flash size in MB
|
||||
"""
|
||||
print("=== Running ESP32 firmware in QEMU ===")
|
||||
print(f"Firmware path: {firmware_path}")
|
||||
print(f"Timeout: {timeout}s")
|
||||
print(f"Flash size: {flash_size}MB")
|
||||
if interrupt_regex:
|
||||
print(f"Interrupt pattern: {interrupt_regex}")
|
||||
|
||||
if not firmware_path.exists():
|
||||
print(f"ERROR: Path does not exist: {firmware_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
cmd = self.build_qemu_command(firmware_path, flash_size)
|
||||
print(f"QEMU command: {' '.join(cmd)}")
|
||||
except Exception as e:
|
||||
print(f"ERROR: Failed to build QEMU command: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
qemu_proc = RunningProcess(
|
||||
cmd,
|
||||
timeout=timeout,
|
||||
auto_run=True,
|
||||
)
|
||||
|
||||
self.interrupt_met = False
|
||||
interrupt_pattern = re.compile(interrupt_regex) if interrupt_regex else None
|
||||
self.output_lines = []
|
||||
|
||||
try:
|
||||
with qemu_proc.line_iter(timeout=timeout) as it:
|
||||
for line in it:
|
||||
print(line, flush=True)
|
||||
self.output_lines.append(line)
|
||||
if interrupt_pattern and interrupt_pattern.search(line):
|
||||
print(f"SUCCESS: Interrupt condition met: {line}")
|
||||
self.interrupt_met = True
|
||||
time.sleep(2)
|
||||
except TimeoutError:
|
||||
print(
|
||||
f"ERROR: QEMU timed out after {timeout} seconds of no output.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
qemu_proc.kill()
|
||||
return 1
|
||||
|
||||
return_code = qemu_proc.wait()
|
||||
|
||||
log_file = Path("qemu_output.log")
|
||||
log_file.write_text("\n".join(self.output_lines))
|
||||
print(f"QEMU output saved to: {log_file}")
|
||||
print(f"Total output lines: {len(self.output_lines)}")
|
||||
|
||||
if self.interrupt_met:
|
||||
return 0
|
||||
|
||||
return return_code
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Error running QEMU: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run ESP32 firmware in QEMU")
|
||||
parser.add_argument(
|
||||
"firmware_path",
|
||||
type=Path,
|
||||
help="Path to firmware.bin file or build directory containing firmware files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--flash-size", type=int, default=4, help="Flash size in MB (default: 4)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", type=int, default=30, help="Timeout in seconds (default: 30)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--interrupt-regex", type=str, help="Regex pattern to interrupt execution"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate arguments to prevent issues
|
||||
if args.flash_size <= 0:
|
||||
print(
|
||||
f"ERROR: Invalid flash size: {args.flash_size}. Must be positive.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.timeout <= 0:
|
||||
print(
|
||||
f"ERROR: Invalid timeout: {args.timeout}. Must be positive.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Ensure we're not accidentally being called as a wrapper for qemu-system-xtensa
|
||||
if len(sys.argv) > 1 and any("qemu-system-xtensa" in arg for arg in sys.argv):
|
||||
print(
|
||||
"ERROR: This script should not be used as a wrapper for qemu-system-xtensa",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"Use this script directly: python qemu-esp32.py <build_folder> [options]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Check if QEMU is available
|
||||
if not find_qemu_binary():
|
||||
show_installation_help()
|
||||
return 1
|
||||
|
||||
runner = QEMURunner()
|
||||
return runner.run(
|
||||
firmware_path=args.firmware_path,
|
||||
timeout=args.timeout,
|
||||
interrupt_regex=args.interrupt_regex,
|
||||
flash_size=args.flash_size,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,435 @@
|
||||
#!/usr/bin/env python
|
||||
# pyright: reportUnknownMemberType=false
|
||||
"""A wrapper script around clang-format, suitable for linting multiple files
|
||||
and to use for continuous integration.
|
||||
|
||||
This is an alternative API for the clang-format command line.
|
||||
It runs over multiple files and directories in parallel.
|
||||
A diff output is produced and a sensible exit code is returned.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import print_function, unicode_literals
|
||||
|
||||
import argparse
|
||||
import codecs
|
||||
import difflib
|
||||
import errno
|
||||
import fnmatch
|
||||
import io
|
||||
import multiprocessing
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
from functools import partial
|
||||
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
||||
|
||||
|
||||
try:
|
||||
from subprocess import DEVNULL # py3k
|
||||
except ImportError:
|
||||
DEVNULL = open(os.devnull, "wb") # type: ignore
|
||||
|
||||
|
||||
DEFAULT_EXTENSIONS = "c,h,C,H,cpp,hpp,cc,hh,c++,h++,cxx,hxx"
|
||||
DEFAULT_CLANG_FORMAT_IGNORE = ".clang-format-ignore"
|
||||
|
||||
|
||||
class ExitStatus:
|
||||
SUCCESS = 0
|
||||
DIFF = 1
|
||||
TROUBLE = 2
|
||||
|
||||
|
||||
def excludes_from_file(ignore_file: str) -> List[str]:
|
||||
excludes: List[str] = []
|
||||
try:
|
||||
with io.open(ignore_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.startswith("#"):
|
||||
# ignore comments
|
||||
continue
|
||||
pattern = line.rstrip()
|
||||
if not pattern:
|
||||
# allow empty lines
|
||||
continue
|
||||
excludes.append(pattern)
|
||||
except EnvironmentError as e:
|
||||
if e.errno != errno.ENOENT:
|
||||
raise
|
||||
return excludes
|
||||
|
||||
|
||||
def list_files(
|
||||
files: List[str],
|
||||
recursive: bool = False,
|
||||
extensions: Optional[List[str]] = None,
|
||||
exclude: Optional[List[str]] = None,
|
||||
) -> List[str]:
|
||||
if extensions is None:
|
||||
extensions = []
|
||||
if exclude is None:
|
||||
exclude = []
|
||||
|
||||
out: List[str] = []
|
||||
for file in files:
|
||||
if recursive and os.path.isdir(file):
|
||||
for dirpath, dnames, fnames in os.walk(file):
|
||||
fpaths = [os.path.join(dirpath, fname) for fname in fnames]
|
||||
for pattern in exclude:
|
||||
# os.walk() supports trimming down the dnames list
|
||||
# by modifying it in-place,
|
||||
# to avoid unnecessary directory listings.
|
||||
dnames[:] = [
|
||||
x
|
||||
for x in dnames
|
||||
if not fnmatch.fnmatch(os.path.join(dirpath, x), pattern)
|
||||
]
|
||||
fpaths = [x for x in fpaths if not fnmatch.fnmatch(x, pattern)]
|
||||
for f in fpaths:
|
||||
ext = os.path.splitext(f)[1][1:]
|
||||
if ext in extensions:
|
||||
out.append(f)
|
||||
else:
|
||||
out.append(file)
|
||||
return out
|
||||
|
||||
|
||||
def make_diff(file: str, original: List[str], reformatted: List[str]) -> List[str]:
|
||||
return list(
|
||||
difflib.unified_diff(
|
||||
original,
|
||||
reformatted,
|
||||
fromfile="{}\t(original)".format(file),
|
||||
tofile="{}\t(reformatted)".format(file),
|
||||
n=3,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class DiffError(Exception):
|
||||
def __init__(self, message: str, errs: Optional[List[str]] = None):
|
||||
super(DiffError, self).__init__(message)
|
||||
self.errs: List[str] = errs or []
|
||||
|
||||
|
||||
class UnexpectedError(Exception):
|
||||
def __init__(self, message: str, exc: Optional[Exception] = None):
|
||||
super(UnexpectedError, self).__init__(message)
|
||||
self.formatted_traceback = traceback.format_exc()
|
||||
self.exc = exc
|
||||
|
||||
|
||||
def run_clang_format_diff_wrapper(args: Any, file: str) -> Tuple[List[str], List[str]]:
|
||||
try:
|
||||
ret = run_clang_format_diff(args, file)
|
||||
return ret
|
||||
except DiffError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise UnexpectedError("{}: {}: {}".format(file, e.__class__.__name__, e), e)
|
||||
|
||||
|
||||
def run_clang_format_diff(args: Any, file: str) -> Tuple[List[str], List[str]]:
|
||||
try:
|
||||
with io.open(file, "r", encoding="utf-8") as f:
|
||||
original = f.readlines()
|
||||
except IOError as exc:
|
||||
raise DiffError(str(exc))
|
||||
|
||||
if args.in_place:
|
||||
invocation = [args.clang_format_executable, "-i", file]
|
||||
else:
|
||||
invocation = [args.clang_format_executable, file]
|
||||
|
||||
if args.style:
|
||||
invocation.extend(["--style", args.style])
|
||||
|
||||
if args.dry_run:
|
||||
print(" ".join(invocation))
|
||||
return [], []
|
||||
|
||||
# Use of utf-8 to decode the process output.
|
||||
#
|
||||
# Hopefully, this is the correct thing to do.
|
||||
#
|
||||
# It's done due to the following assumptions (which may be incorrect):
|
||||
# - clang-format will returns the bytes read from the files as-is,
|
||||
# without conversion, and it is already assumed that the files use utf-8.
|
||||
# - if the diagnostics were internationalized, they would use utf-8:
|
||||
# > Adding Translations to Clang
|
||||
# >
|
||||
# > Not possible yet!
|
||||
# > Diagnostic strings should be written in UTF-8,
|
||||
# > the client can translate to the relevant code page if needed.
|
||||
# > Each translation completely replaces the format string
|
||||
# > for the diagnostic.
|
||||
# > -- http://clang.llvm.org/docs/InternalsManual.html#internals-diag-translation
|
||||
#
|
||||
# It's not pretty, due to Python 2 & 3 compatibility.
|
||||
encoding_py3: Dict[str, str] = {}
|
||||
if sys.version_info[0] >= 3:
|
||||
encoding_py3["encoding"] = "utf-8"
|
||||
|
||||
try:
|
||||
if sys.version_info[0] >= 3:
|
||||
proc: subprocess.Popen[str] = subprocess.Popen(
|
||||
invocation,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
else:
|
||||
proc: subprocess.Popen[str] = subprocess.Popen(
|
||||
invocation,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise DiffError(
|
||||
"Command '{}' failed to start: {}".format(
|
||||
subprocess.list2cmdline(invocation), exc
|
||||
)
|
||||
)
|
||||
proc_stdout = proc.stdout
|
||||
proc_stderr = proc.stderr
|
||||
assert proc_stdout is not None
|
||||
assert proc_stderr is not None
|
||||
if sys.version_info[0] < 3:
|
||||
# make the pipes compatible with Python 3,
|
||||
# reading lines should output unicode
|
||||
encoding = "utf-8"
|
||||
proc_stdout = codecs.getreader(encoding)(proc_stdout)
|
||||
proc_stderr = codecs.getreader(encoding)(proc_stderr)
|
||||
# hopefully the stderr pipe won't get full and block the process
|
||||
outs = list(proc_stdout.readlines())
|
||||
errs = list(proc_stderr.readlines())
|
||||
proc.wait()
|
||||
if proc.returncode:
|
||||
raise DiffError(
|
||||
"Command '{}' returned non-zero exit status {}".format(
|
||||
subprocess.list2cmdline(invocation), proc.returncode
|
||||
),
|
||||
errs,
|
||||
)
|
||||
if args.in_place:
|
||||
return [], errs
|
||||
return make_diff(file, original, outs), errs
|
||||
|
||||
|
||||
def bold_red(s: str) -> str:
|
||||
return "\x1b[1m\x1b[31m" + s + "\x1b[0m"
|
||||
|
||||
|
||||
def colorize(diff_lines: List[str]) -> Generator[str, None, None]:
|
||||
def bold(s: str) -> str:
|
||||
return "\x1b[1m" + s + "\x1b[0m"
|
||||
|
||||
def cyan(s: str) -> str:
|
||||
return "\x1b[36m" + s + "\x1b[0m"
|
||||
|
||||
def green(s: str) -> str:
|
||||
return "\x1b[32m" + s + "\x1b[0m"
|
||||
|
||||
def red(s: str) -> str:
|
||||
return "\x1b[31m" + s + "\x1b[0m"
|
||||
|
||||
for line in diff_lines:
|
||||
if line[:4] in ["--- ", "+++ "]:
|
||||
yield bold(line)
|
||||
elif line.startswith("@@ "):
|
||||
yield cyan(line)
|
||||
elif line.startswith("+"):
|
||||
yield green(line)
|
||||
elif line.startswith("-"):
|
||||
yield red(line)
|
||||
else:
|
||||
yield line
|
||||
|
||||
|
||||
def print_diff(diff_lines: List[str], use_color: bool) -> None:
|
||||
if use_color:
|
||||
diff_lines = list(colorize(diff_lines))
|
||||
if sys.version_info[0] < 3:
|
||||
sys.stdout.writelines((line.encode("utf-8") for line in diff_lines))
|
||||
else:
|
||||
sys.stdout.writelines(diff_lines)
|
||||
|
||||
|
||||
def print_trouble(prog: str, message: str, use_colors: bool) -> None:
|
||||
error_text = "error:"
|
||||
if use_colors:
|
||||
error_text = bold_red(error_text)
|
||||
print("{}: {} {}".format(prog, error_text, message), file=sys.stderr)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--clang-format-executable",
|
||||
metavar="EXECUTABLE",
|
||||
help="path to the clang-format executable",
|
||||
default="clang-format",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--extensions",
|
||||
help="comma separated list of file extensions (default: {})".format(
|
||||
DEFAULT_EXTENSIONS
|
||||
),
|
||||
default=DEFAULT_EXTENSIONS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--recursive",
|
||||
action="store_true",
|
||||
help="run recursively over directories",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d", "--dry-run", action="store_true", help="just print the list of files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--in-place",
|
||||
action="store_true",
|
||||
help="format file instead of printing differences",
|
||||
)
|
||||
parser.add_argument("files", metavar="file", nargs="+")
|
||||
parser.add_argument(
|
||||
"-q",
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="disable output, useful for the exit code",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-j",
|
||||
metavar="N",
|
||||
type=int,
|
||||
default=0,
|
||||
help="run N clang-format jobs in parallel (default number of cpus + 1)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--color",
|
||||
default="auto",
|
||||
choices=["auto", "always", "never"],
|
||||
help="show colored diff (default: auto)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-e",
|
||||
"--exclude",
|
||||
metavar="PATTERN",
|
||||
action="append",
|
||||
default=[],
|
||||
help="exclude paths matching the given glob-like pattern(s)"
|
||||
" from recursive search",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--style",
|
||||
help="formatting style to apply (LLVM, Google, Chromium, Mozilla, WebKit)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# use default signal handling, like diff return SIGINT value on ^C
|
||||
# https://bugs.python.org/issue14229#msg156446
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
|
||||
try:
|
||||
signal.SIGPIPE # type: ignore
|
||||
except AttributeError:
|
||||
# compatibility, SIGPIPE does not exist on Windows
|
||||
pass
|
||||
else:
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL) # type: ignore
|
||||
|
||||
colored_stdout = False
|
||||
colored_stderr = False
|
||||
if args.color == "always":
|
||||
colored_stdout = True
|
||||
colored_stderr = True
|
||||
elif args.color == "auto":
|
||||
colored_stdout = sys.stdout.isatty()
|
||||
colored_stderr = sys.stderr.isatty()
|
||||
|
||||
version_invocation = [args.clang_format_executable, str("--version")]
|
||||
try:
|
||||
subprocess.check_call(version_invocation, stdout=DEVNULL)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_trouble(parser.prog, str(e), use_colors=colored_stderr)
|
||||
return ExitStatus.TROUBLE
|
||||
except OSError as e:
|
||||
print_trouble(
|
||||
parser.prog,
|
||||
"Command '{}' failed to start: {}".format(
|
||||
subprocess.list2cmdline(version_invocation), e
|
||||
),
|
||||
use_colors=colored_stderr,
|
||||
)
|
||||
return ExitStatus.TROUBLE
|
||||
|
||||
retcode = ExitStatus.SUCCESS
|
||||
|
||||
excludes = excludes_from_file(DEFAULT_CLANG_FORMAT_IGNORE)
|
||||
excludes.extend(args.exclude)
|
||||
|
||||
files = list_files(
|
||||
args.files,
|
||||
recursive=args.recursive,
|
||||
exclude=excludes,
|
||||
extensions=args.extensions.split(","),
|
||||
)
|
||||
|
||||
if not files:
|
||||
return retcode
|
||||
|
||||
njobs = args.j
|
||||
if njobs == 0:
|
||||
njobs = multiprocessing.cpu_count() + 1
|
||||
njobs = min(len(files), njobs)
|
||||
|
||||
if njobs == 1:
|
||||
# execute directly instead of in a pool,
|
||||
# less overhead, simpler stacktraces
|
||||
it = (run_clang_format_diff_wrapper(args, file) for file in files)
|
||||
pool = None
|
||||
else:
|
||||
pool = multiprocessing.Pool(njobs)
|
||||
it = pool.imap_unordered(partial(run_clang_format_diff_wrapper, args), files)
|
||||
pool.close()
|
||||
while True:
|
||||
try:
|
||||
outs, errs = next(it)
|
||||
except StopIteration:
|
||||
break
|
||||
except DiffError as e:
|
||||
print_trouble(parser.prog, str(e), use_colors=colored_stderr)
|
||||
retcode = ExitStatus.TROUBLE
|
||||
sys.stderr.writelines(e.errs)
|
||||
except UnexpectedError as e:
|
||||
print_trouble(parser.prog, str(e), use_colors=colored_stderr)
|
||||
sys.stderr.write(e.formatted_traceback)
|
||||
retcode = ExitStatus.TROUBLE
|
||||
# stop at the first unexpected error,
|
||||
# something could be very wrong,
|
||||
# don't process all files unnecessarily
|
||||
if pool:
|
||||
pool.terminate()
|
||||
break
|
||||
else:
|
||||
sys.stderr.writelines(errs)
|
||||
if outs == []:
|
||||
continue
|
||||
if not args.quiet:
|
||||
print_diff(outs, use_color=colored_stdout)
|
||||
if retcode == ExitStatus.SUCCESS:
|
||||
retcode = ExitStatus.DIFF
|
||||
if pool:
|
||||
pool.join()
|
||||
return retcode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false
|
||||
"""
|
||||
Unit test file.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class ApiTester(unittest.TestCase):
|
||||
"""Main tester class."""
|
||||
|
||||
def test_build_all_examples(self) -> None:
|
||||
"""Test command line interface (CLI)."""
|
||||
from fastled import Api, Test # type: ignore
|
||||
|
||||
with Api.server(auto_updates=True) as server:
|
||||
exception_map = Test.test_examples(host=server)
|
||||
if len(exception_map) > 0:
|
||||
exception: Exception
|
||||
msg: str = ""
|
||||
for example, exception in exception_map.items():
|
||||
msg += f"Failed to compile example: {example}, error: {exception}\n"
|
||||
self.fail(msg)
|
||||
|
||||
# self.assertEqual(0, len(out), f"Failed tests: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test runner for FastLED unit tests.
|
||||
Discovers and runs test executables in parallel with clean output handling.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from ci.util.running_process import RunningProcess
|
||||
from ci.util.test_exceptions import (
|
||||
TestExecutionFailedException,
|
||||
TestFailureInfo,
|
||||
)
|
||||
|
||||
|
||||
_HERE = Path(__file__).parent
|
||||
_PROJECT_ROOT = _HERE.parent
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
"""Result of a test execution"""
|
||||
|
||||
name: str
|
||||
success: bool
|
||||
duration: float
|
||||
output: str
|
||||
|
||||
|
||||
def _is_test_executable(f: Path) -> bool:
|
||||
"""Check if a file is a valid test executable"""
|
||||
return (
|
||||
f.is_file() and f.suffix not in [".o", ".obj", ".pdb"] and os.access(f, os.X_OK)
|
||||
)
|
||||
|
||||
|
||||
def _get_test_patterns() -> List[str]:
|
||||
"""Get test patterns based on platform"""
|
||||
# On Windows, check both .exe and no extension (Clang generates without .exe)
|
||||
return ["test_*.exe"] if sys.platform == "win32" else ["test_*"]
|
||||
|
||||
|
||||
def discover_tests(build_dir: Path, specific_test: Optional[str] = None) -> List[Path]:
|
||||
"""Find test executables in the build directory"""
|
||||
# Check test directory
|
||||
possible_test_dirs = [
|
||||
_PROJECT_ROOT / "tests" / "bin", # Python build system
|
||||
]
|
||||
|
||||
test_dir = None
|
||||
for possible_dir in possible_test_dirs:
|
||||
if possible_dir.exists():
|
||||
# Check if this directory has actual executable test files
|
||||
executable_tests = [
|
||||
f
|
||||
for pattern in _get_test_patterns()
|
||||
for f in possible_dir.glob(pattern)
|
||||
if _is_test_executable(f)
|
||||
]
|
||||
if executable_tests:
|
||||
test_dir = possible_dir
|
||||
break
|
||||
|
||||
if not test_dir:
|
||||
print(f"Error: No test directory found. Checked: {possible_test_dirs}")
|
||||
sys.exit(1)
|
||||
|
||||
test_files: List[Path] = []
|
||||
for pattern in _get_test_patterns():
|
||||
for test_file in test_dir.glob(pattern):
|
||||
if not _is_test_executable(test_file):
|
||||
continue
|
||||
if specific_test:
|
||||
# Support both "test_name" and "name" formats (case-insensitive)
|
||||
test_stem = test_file.stem
|
||||
test_name = test_stem.replace("test_", "")
|
||||
if (
|
||||
test_stem.lower() == specific_test.lower()
|
||||
or test_name.lower() == specific_test.lower()
|
||||
):
|
||||
test_files.append(test_file)
|
||||
else:
|
||||
test_files.append(test_file)
|
||||
|
||||
return test_files
|
||||
|
||||
|
||||
def run_test(test_file: Path, verbose: bool = False) -> TestResult:
|
||||
"""Run a single test and capture its output"""
|
||||
start_time = time.time()
|
||||
|
||||
# Build command with doctest flags
|
||||
# Convert to absolute path for Windows compatibility
|
||||
test_executable = test_file.resolve()
|
||||
cmd = [str(test_executable)]
|
||||
if not verbose:
|
||||
cmd.append("--minimal") # Only show output for failures
|
||||
|
||||
try:
|
||||
process = RunningProcess(
|
||||
command=cmd,
|
||||
cwd=Path(".").absolute(),
|
||||
check=False,
|
||||
shell=False,
|
||||
auto_run=True,
|
||||
)
|
||||
|
||||
with process.line_iter(timeout=30) as it:
|
||||
for line in it:
|
||||
print(line)
|
||||
|
||||
# Wait for process to complete
|
||||
process.wait()
|
||||
success = process.poll() == 0
|
||||
output = process.stdout
|
||||
|
||||
except Exception as e:
|
||||
success = False
|
||||
output = f"Error running test: {e}"
|
||||
|
||||
duration = time.time() - start_time
|
||||
return TestResult(
|
||||
name=test_file.stem, success=success, duration=duration, output=output
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
parser = argparse.ArgumentParser(description="Run FastLED unit tests")
|
||||
parser.add_argument("--test", help="Run specific test (without test_ prefix)")
|
||||
parser.add_argument(
|
||||
"--verbose", "-v", action="store_true", help="Show all test output"
|
||||
)
|
||||
parser.add_argument("--jobs", "-j", type=int, help="Number of parallel jobs")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Find build directory
|
||||
build_dir = Path.cwd() / ".build"
|
||||
if not build_dir.exists():
|
||||
print("Error: Build directory not found. Run compilation first.")
|
||||
sys.exit(1)
|
||||
|
||||
# Discover tests
|
||||
test_files = discover_tests(build_dir, args.test)
|
||||
if not test_files:
|
||||
if args.test:
|
||||
print(f"Error: No test found matching '{args.test}'")
|
||||
else:
|
||||
print("Error: No tests found")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Running {len(test_files)} tests...")
|
||||
if args.test:
|
||||
print(f"Filter: {args.test}")
|
||||
|
||||
# Determine number of parallel jobs
|
||||
if os.environ.get("NO_PARALLEL"):
|
||||
max_workers = 1
|
||||
print(
|
||||
"NO_PARALLEL environment variable set - forcing sequential test execution"
|
||||
)
|
||||
elif args.jobs:
|
||||
max_workers = args.jobs
|
||||
else:
|
||||
import multiprocessing
|
||||
|
||||
max_workers = max(1, multiprocessing.cpu_count() - 1)
|
||||
|
||||
# Run tests in parallel
|
||||
start_time = time.time()
|
||||
results: List[TestResult] = []
|
||||
failed_tests: List[TestResult] = []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
future_to_test = {
|
||||
executor.submit(run_test, test_file, args.verbose): test_file
|
||||
for test_file in test_files
|
||||
}
|
||||
|
||||
completed = 0
|
||||
for future in as_completed(future_to_test):
|
||||
test_file = future_to_test[future]
|
||||
completed += 1
|
||||
try:
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
|
||||
# Show progress
|
||||
status = "PASS" if result.success else "FAIL"
|
||||
print(
|
||||
f"[{completed}/{len(test_files)}] {status} {result.name} ({result.duration:.2f}s)"
|
||||
)
|
||||
|
||||
# Show output for failures or in verbose mode
|
||||
if not result.success or args.verbose:
|
||||
if result.output:
|
||||
print(result.output)
|
||||
|
||||
if not result.success:
|
||||
failed_tests.append(result)
|
||||
|
||||
# Early abort after 3 failures
|
||||
if len(failed_tests) >= 3:
|
||||
print(
|
||||
"Reached failure threshold (3). Aborting remaining unit tests."
|
||||
)
|
||||
# Cancel remaining futures and stop accepting new work
|
||||
try:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
# Python < 3.9: cancel_futures not available
|
||||
pass
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error running {test_file.name}: {e}")
|
||||
failed_tests.append(
|
||||
TestResult(
|
||||
name=test_file.stem,
|
||||
success=False,
|
||||
duration=0.0,
|
||||
output=str(e),
|
||||
)
|
||||
)
|
||||
if len(failed_tests) >= 3:
|
||||
print(
|
||||
"Reached failure threshold (3) due to errors. Aborting remaining unit tests."
|
||||
)
|
||||
try:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
pass
|
||||
break
|
||||
|
||||
# Show summary
|
||||
total_duration = time.time() - start_time
|
||||
success_count = len(results) - len(failed_tests)
|
||||
|
||||
print("\nTest Summary:")
|
||||
print(f"Total tests: {len(results)}")
|
||||
print(f"Passed: {success_count}")
|
||||
print(f"Failed: {len(failed_tests)}")
|
||||
print(f"Total time: {total_duration:.2f}s")
|
||||
|
||||
if failed_tests:
|
||||
print("\nFailed tests:")
|
||||
failures: list[TestFailureInfo] = []
|
||||
for test in failed_tests:
|
||||
print(f" {test.name}")
|
||||
failures.append(
|
||||
TestFailureInfo(
|
||||
test_name=test.name,
|
||||
command=f"test_{test.name}",
|
||||
return_code=1, # Failed tests get return code 1
|
||||
output=test.output,
|
||||
error_type="test_execution_failure",
|
||||
)
|
||||
)
|
||||
|
||||
raise TestExecutionFailedException(
|
||||
f"{len(failed_tests)} test(s) failed", failures
|
||||
)
|
||||
|
||||
except TestExecutionFailedException as e:
|
||||
# Print detailed failure information
|
||||
print("\n" + "=" * 60)
|
||||
print("FASTLED TEST EXECUTION FAILURE DETAILS")
|
||||
print("=" * 60)
|
||||
print(e.get_detailed_failure_info())
|
||||
print("=" * 60)
|
||||
|
||||
# Exit with appropriate code
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
|
||||
|
||||
"""
|
||||
Fast JavaScript linting setup for FastLED using Node.js + ESLint
|
||||
Downloads Node.js binary and sets up fast linting - much faster than Deno
|
||||
Uses uv run --script for dynamic package management
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
# Import httpx for HTTP requests (dynamically managed by uv)
|
||||
import httpx
|
||||
|
||||
|
||||
# Force UTF-8 output for Windows consoles
|
||||
if sys.platform.startswith("win"):
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
|
||||
# Configuration
|
||||
NODE_VERSION = "20.11.0" # LTS version
|
||||
TOOLS_DIR = Path(".cache/js-tools")
|
||||
NODE_DIR = TOOLS_DIR / "node"
|
||||
|
||||
|
||||
def get_node_download_info():
|
||||
"""Get Node.js download URL and filename based on platform"""
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
# Map architecture names
|
||||
if machine in ["x86_64", "amd64"]:
|
||||
arch = "x64"
|
||||
elif machine in ["aarch64", "arm64"]:
|
||||
arch = "arm64"
|
||||
else:
|
||||
raise ValueError(f"Unsupported architecture: {machine}")
|
||||
|
||||
if system == "windows":
|
||||
filename = f"node-v{NODE_VERSION}-win-{arch}.zip"
|
||||
is_zip = True
|
||||
elif system == "darwin":
|
||||
filename = f"node-v{NODE_VERSION}-darwin-{arch}.tar.gz"
|
||||
is_zip = False
|
||||
elif system == "linux":
|
||||
filename = f"node-v{NODE_VERSION}-linux-{arch}.tar.xz"
|
||||
is_zip = False
|
||||
else:
|
||||
raise ValueError(f"Unsupported platform: {system}")
|
||||
|
||||
url = f"https://nodejs.org/dist/v{NODE_VERSION}/{filename}"
|
||||
return url, filename, is_zip
|
||||
|
||||
|
||||
def download_and_extract_node():
|
||||
"""Download and extract Node.js binary"""
|
||||
url, filename, is_zip = get_node_download_info()
|
||||
download_path = TOOLS_DIR / filename
|
||||
|
||||
# Get architecture for extraction path
|
||||
machine = platform.machine().lower()
|
||||
if machine in ["x86_64", "amd64"]:
|
||||
arch = "x64"
|
||||
elif machine in ["aarch64", "arm64"]:
|
||||
arch = "arm64"
|
||||
else:
|
||||
raise ValueError(f"Unsupported architecture: {machine}")
|
||||
|
||||
print(f"Downloading Node.js v{NODE_VERSION}...")
|
||||
TOOLS_DIR.mkdir(exist_ok=True)
|
||||
NODE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
if not download_path.exists():
|
||||
print(f"Downloading from: {url}")
|
||||
with httpx.stream("GET", url, follow_redirects=True) as response:
|
||||
response.raise_for_status()
|
||||
with open(download_path, "wb") as f:
|
||||
for chunk in response.iter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
print(f"SUCCESS: Downloaded {filename}")
|
||||
|
||||
# Extract Node.js
|
||||
if platform.system() == "Windows":
|
||||
node_exe = NODE_DIR / "node.exe"
|
||||
else:
|
||||
node_exe = NODE_DIR / "bin" / "node"
|
||||
|
||||
if not node_exe.exists():
|
||||
print("Extracting Node.js...")
|
||||
|
||||
if is_zip:
|
||||
with zipfile.ZipFile(download_path, "r") as zip_ref:
|
||||
zip_ref.extractall(NODE_DIR)
|
||||
# Move contents from nested folder to NODE_DIR
|
||||
nested_dir = NODE_DIR / f"node-v{NODE_VERSION}-win-{arch}"
|
||||
if nested_dir.exists():
|
||||
for item in nested_dir.iterdir():
|
||||
if item.is_dir():
|
||||
# For directories, move contents recursively
|
||||
target_dir = NODE_DIR / item.name
|
||||
if target_dir.exists():
|
||||
shutil.rmtree(target_dir)
|
||||
shutil.move(str(item), str(target_dir))
|
||||
else:
|
||||
# For files, move directly
|
||||
target_file = NODE_DIR / item.name
|
||||
if target_file.exists():
|
||||
target_file.unlink()
|
||||
item.rename(target_file)
|
||||
nested_dir.rmdir()
|
||||
else:
|
||||
with tarfile.open(download_path, "r:*") as tar_ref:
|
||||
tar_ref.extractall(NODE_DIR)
|
||||
# Move contents from nested folder to NODE_DIR
|
||||
nested_dir = (
|
||||
NODE_DIR / f"node-v{NODE_VERSION}-{platform.system().lower()}-x64"
|
||||
)
|
||||
if nested_dir.exists():
|
||||
for item in nested_dir.iterdir():
|
||||
item.rename(NODE_DIR / item.name)
|
||||
nested_dir.rmdir()
|
||||
|
||||
print("SUCCESS: Node.js extracted")
|
||||
|
||||
# Clean up download file
|
||||
if download_path.exists():
|
||||
download_path.unlink()
|
||||
|
||||
return node_exe
|
||||
|
||||
|
||||
def setup_eslint():
|
||||
"""Install ESLint and create configuration"""
|
||||
if platform.system() == "Windows":
|
||||
node_exe = NODE_DIR / "node.exe"
|
||||
npm_exe = NODE_DIR / "npm.cmd"
|
||||
else:
|
||||
node_exe = NODE_DIR / "bin" / "node"
|
||||
npm_exe = NODE_DIR / "bin" / "npm"
|
||||
|
||||
print("Installing ESLint...")
|
||||
|
||||
# Create package.json
|
||||
package_json = {
|
||||
"name": "fastled-js-linting",
|
||||
"version": "1.0.0",
|
||||
"private": True,
|
||||
"dependencies": {"eslint": "^8.56.0"},
|
||||
}
|
||||
|
||||
with open(TOOLS_DIR / "package.json", "w") as f:
|
||||
json.dump(package_json, f, indent=2)
|
||||
|
||||
# Install ESLint
|
||||
try:
|
||||
# Use absolute path for npm executable
|
||||
npm_exe_abs = (
|
||||
(TOOLS_DIR / "node" / "npm.cmd").resolve()
|
||||
if platform.system() == "Windows"
|
||||
else (TOOLS_DIR / "node" / "bin" / "npm").resolve()
|
||||
)
|
||||
|
||||
# On Windows, use shell=True to properly execute .cmd files
|
||||
if platform.system() == "Windows":
|
||||
result = subprocess.run(
|
||||
[str(npm_exe_abs), "install"],
|
||||
cwd=TOOLS_DIR,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
shell=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[str(npm_exe_abs), "install"],
|
||||
cwd=TOOLS_DIR,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# npm install succeeded
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Show stderr output for debugging
|
||||
print(f"npm install stderr: {e.stderr}")
|
||||
print(f"npm install stdout: {e.stdout}")
|
||||
raise
|
||||
|
||||
# ESLint config is now in ci/.eslintrc.js (tracked in git)
|
||||
# No need to create it here since it's already in version control
|
||||
|
||||
print("SUCCESS: ESLint configured")
|
||||
|
||||
|
||||
def create_fast_lint_script():
|
||||
"""Create fast linting script"""
|
||||
if platform.system() == "Windows":
|
||||
node_exe = NODE_DIR / "node.exe"
|
||||
eslint_exe = TOOLS_DIR / "node_modules" / ".bin" / "eslint.cmd"
|
||||
else:
|
||||
node_exe = NODE_DIR / "bin" / "node"
|
||||
eslint_exe = TOOLS_DIR / "node_modules" / ".bin" / "eslint"
|
||||
|
||||
script_content = f"""#!/bin/bash
|
||||
# FastLED JavaScript Linting Script (Node.js + ESLint - FAST!)
|
||||
|
||||
# Colors for output
|
||||
RED='\\033[0;31m'
|
||||
GREEN='\\033[0;32m'
|
||||
YELLOW='\\033[1;33m'
|
||||
BLUE='\\033[0;34m'
|
||||
NC='\\033[0m' # No Color
|
||||
|
||||
echo -e "${{BLUE}}FAST FastLED JavaScript Linting (Node.js + ESLint - FAST!)${{NC}}"
|
||||
|
||||
# Check if ESLint is installed
|
||||
if [ ! -f ".cache/js-tools/node_modules/.bin/eslint{".cmd" if platform.system() == "Windows" else ""}" ]; then
|
||||
echo -e "${{RED}}ERROR: ESLint not found. Run: uv run ci/setup-js-linting-fast.py${{NC}}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find JavaScript files in WASM platform
|
||||
JS_FILES=$(find src/platforms/wasm -name "*.js" -type f 2>/dev/null)
|
||||
|
||||
if [ -z "$JS_FILES" ]; then
|
||||
echo -e "${{YELLOW}}WARNING: No JavaScript files found in src/platforms/wasm/${{NC}}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${{BLUE}}Found JavaScript files:${{NC}}"
|
||||
echo "$JS_FILES" | sed 's/^/ /'
|
||||
|
||||
# Run ESLint
|
||||
echo -e "${{BLUE}}Running ESLint...${{NC}}"
|
||||
cd .cache/js-tools
|
||||
if "./node_modules/.bin/eslint{".cmd" if platform.system() == "Windows" else ""}" --no-eslintrc --no-inline-config -c .eslintrc.js ../../src/platforms/wasm/compiler/*.js ../../src/platforms/wasm/compiler/modules/*.js; then
|
||||
echo -e "${{GREEN}}SUCCESS: JavaScript linting completed successfully${{NC}}"
|
||||
else
|
||||
echo -e "${{RED}}ERROR: JavaScript linting failed${{NC}}"
|
||||
exit 1
|
||||
fi
|
||||
"""
|
||||
|
||||
# Lint script is now in ci/lint-js-fast (tracked in git)
|
||||
# No need to create it here since it's already in version control
|
||||
script_path = Path("ci/lint-js-fast")
|
||||
# Script already exists in ci/ and is tracked in git
|
||||
# Just ensure it's executable
|
||||
if platform.system() != "Windows" and script_path.exists():
|
||||
os.chmod(script_path, 0o755)
|
||||
|
||||
print("SUCCESS: Fast lint script created")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main setup function"""
|
||||
print("Setting up fast JavaScript linting (Node.js + ESLint)...")
|
||||
|
||||
try:
|
||||
node_exe = download_and_extract_node()
|
||||
setup_eslint()
|
||||
create_fast_lint_script()
|
||||
|
||||
print("\\nFast JavaScript linting setup complete!")
|
||||
print("\\nUsage:")
|
||||
print(" bash ci/lint-js-fast # Fast linting with ESLint")
|
||||
print(" For more info: ci/js/README.md")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Setup failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,969 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import _thread
|
||||
import atexit
|
||||
import json
|
||||
import lzma
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Protocol, Tuple, cast
|
||||
|
||||
from ci.util.resumable_downloader import ResumableDownloader
|
||||
|
||||
|
||||
class _SysConf(Protocol):
|
||||
def short_os_name_and_version(self) -> str: ...
|
||||
|
||||
architecture: str
|
||||
|
||||
|
||||
import sys_detection
|
||||
from llvm_installer import LlvmInstaller
|
||||
|
||||
|
||||
_HERE = Path(__file__).parent
|
||||
_PROJECT_ROOT = _HERE.parent
|
||||
|
||||
# Global download directory - lazily initialized
|
||||
_download_dir: Optional[Path] = None
|
||||
|
||||
|
||||
def _get_download_dir() -> Path:
|
||||
"""Get or create the download directory lazily."""
|
||||
global _download_dir
|
||||
if _download_dir is None:
|
||||
_download_dir = _PROJECT_ROOT / ".cache" / "downloads"
|
||||
_download_dir.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Created download directory: {_download_dir}")
|
||||
# Register cleanup on exit as fallback
|
||||
atexit.register(_cleanup_download_dir)
|
||||
return _download_dir
|
||||
|
||||
|
||||
def _cleanup_download_dir() -> None:
|
||||
"""Clean up the download directory."""
|
||||
global _download_dir
|
||||
if _download_dir and _download_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(_download_dir)
|
||||
print(f"Cleaned up download directory: {_download_dir}")
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Warning: Failed to clean up download directory {_download_dir}: {e}"
|
||||
)
|
||||
_download_dir = None
|
||||
|
||||
|
||||
def _download(url: str, filename: str) -> Path:
|
||||
"""Download to the managed download directory.
|
||||
|
||||
Args:
|
||||
url: URL to download
|
||||
filename: Filename to save as (not full path)
|
||||
|
||||
Returns:
|
||||
Path to the downloaded file
|
||||
"""
|
||||
download_dir = _get_download_dir()
|
||||
file_path = download_dir / filename
|
||||
|
||||
print(f"download {url} to {file_path}")
|
||||
|
||||
# Use resumable downloader for large files
|
||||
downloader = ResumableDownloader(chunk_size=1024 * 1024) # 1MB chunks
|
||||
|
||||
try:
|
||||
downloader.download(url, file_path)
|
||||
except Exception as e:
|
||||
# Print download failure banner
|
||||
url_truncated = url[:100]
|
||||
banner_width = max(35, len(url_truncated) + 6)
|
||||
banner_line = "#" * banner_width
|
||||
print(f"\n{banner_line}")
|
||||
print(
|
||||
f"# DOWNLOAD FAILED FOR {url_truncated}{'...' if len(url) > 100 else ''} #"
|
||||
)
|
||||
print(f"{banner_line}")
|
||||
print(f"Error: {e}")
|
||||
print()
|
||||
raise
|
||||
|
||||
return file_path
|
||||
|
||||
|
||||
def is_linux_or_unix() -> Tuple[bool, str]:
|
||||
"""Check if running on Linux or Unix-like system."""
|
||||
system = platform.system().lower()
|
||||
if system == "linux":
|
||||
return True, "linux"
|
||||
elif system in ["darwin", "freebsd", "openbsd", "netbsd"]:
|
||||
return True, system
|
||||
else:
|
||||
return False, system
|
||||
|
||||
|
||||
def get_tool_version(tool_name: str) -> Optional[str]:
|
||||
"""Get version of a tool if it exists on system PATH."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[tool_name, "--version"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def get_clang_version_number(version_output: str) -> Optional[int]:
|
||||
"""Extract major version number from clang version output."""
|
||||
try:
|
||||
# Look for "clang version X.Y.Z" pattern
|
||||
import re
|
||||
|
||||
match = re.search(r"clang version (\d+)\.", version_output)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def find_tool_path(tool_name: str) -> Optional[Path]:
|
||||
"""Find the full path to a tool if it exists."""
|
||||
tool_path = shutil.which(tool_name)
|
||||
return Path(tool_path) if tool_path else None
|
||||
|
||||
|
||||
def check_required_tools() -> Tuple[
|
||||
Dict[str, Optional[Path]], Dict[str, Optional[Path]]
|
||||
]:
|
||||
"""Check for essential and extra LLVM/Clang tools on system PATH.
|
||||
|
||||
On Windows, prefer lld-link as the linker front-end.
|
||||
"""
|
||||
system_name = platform.system().lower()
|
||||
|
||||
essential_tools: List[str] = [
|
||||
"clang",
|
||||
"clang++",
|
||||
"llvm-ar",
|
||||
"llvm-nm",
|
||||
"llvm-objdump",
|
||||
"llvm-addr2line",
|
||||
"llvm-strip",
|
||||
"llvm-objcopy",
|
||||
]
|
||||
|
||||
# Linker tools differ across platforms
|
||||
if system_name == "windows":
|
||||
essential_tools.append("lld-link")
|
||||
else:
|
||||
essential_tools.append("lld")
|
||||
|
||||
# Useful developer tools (not strictly required for building)
|
||||
extra_tools: List[str] = [
|
||||
"clangd",
|
||||
"clang-format",
|
||||
"clang-tidy",
|
||||
"lldb",
|
||||
]
|
||||
|
||||
found_essential: Dict[str, Optional[Path]] = {}
|
||||
for tool in essential_tools:
|
||||
found_essential[tool] = find_tool_path(tool)
|
||||
|
||||
found_extras: Dict[str, Optional[Path]] = {}
|
||||
for tool in extra_tools:
|
||||
found_extras[tool] = find_tool_path(tool)
|
||||
|
||||
return found_essential, found_extras
|
||||
|
||||
|
||||
def create_hard_links(source_tools: Dict[str, Path], target_dir: Path) -> None:
|
||||
"""Create hard links from system tools to target directory."""
|
||||
bin_dir = target_dir / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
linked_count: int = 0
|
||||
copied_count: int = 0
|
||||
failed_tools: list[str] = []
|
||||
|
||||
for tool_name, source_path in source_tools.items():
|
||||
if source_path and source_path.exists():
|
||||
# Preserve original filename to keep extensions on Windows (e.g., .exe)
|
||||
target_filename = source_path.name
|
||||
target_path = bin_dir / target_filename
|
||||
try:
|
||||
# Remove existing file if it exists
|
||||
if target_path.exists():
|
||||
target_path.unlink()
|
||||
|
||||
# Create hard link
|
||||
target_path.hardlink_to(source_path)
|
||||
print(f"[OK] Linked {tool_name}: {source_path} -> {target_path}")
|
||||
linked_count += 1
|
||||
except (OSError, PermissionError) as e:
|
||||
print(f"[WARN] Failed to link {tool_name}: {e}")
|
||||
# Fallback to copy if hard link fails
|
||||
try:
|
||||
shutil.copy2(source_path, target_path)
|
||||
target_path.chmod(0o755)
|
||||
print(f"[OK] Copied {tool_name}: {source_path} -> {target_path}")
|
||||
copied_count += 1
|
||||
except (OSError, PermissionError):
|
||||
print(f"[ERROR] Failed to copy {tool_name}")
|
||||
failed_tools.append(tool_name)
|
||||
|
||||
total_prepared: int = linked_count + copied_count
|
||||
print(
|
||||
f"Summary: prepared {total_prepared} tools (linked: {linked_count}, copied: {copied_count})"
|
||||
)
|
||||
if failed_tools:
|
||||
print(f"ERROR: Failed to mirror tools: {', '.join(failed_tools)}")
|
||||
|
||||
|
||||
def find_tools_in_directories(
|
||||
tool_names: List[str], directories: List[Path]
|
||||
) -> Dict[str, Optional[Path]]:
|
||||
"""Search for tools across a list of directories, returning first matches.
|
||||
|
||||
On Windows, will match executables with .exe suffix.
|
||||
"""
|
||||
found: Dict[str, Optional[Path]] = {}
|
||||
for tool in tool_names:
|
||||
found[tool] = None
|
||||
|
||||
for directory in directories:
|
||||
if not directory.exists():
|
||||
continue
|
||||
for tool in tool_names:
|
||||
if found[tool] is not None:
|
||||
continue
|
||||
candidate: Path = directory / tool
|
||||
if candidate.exists():
|
||||
found[tool] = candidate
|
||||
continue
|
||||
# Try platform-specific suffix
|
||||
if platform.system().lower() == "windows":
|
||||
exe_candidate: Path = directory / f"{tool}.exe"
|
||||
if exe_candidate.exists():
|
||||
found[tool] = exe_candidate
|
||||
return found
|
||||
|
||||
|
||||
def get_breadcrumb_file(target_dir: Path) -> Path:
|
||||
"""Get the path to the installation completion breadcrumb file."""
|
||||
cache_dir = target_dir / ".cache" / "cc"
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
return cache_dir / "done.txt"
|
||||
|
||||
|
||||
def create_breadcrumb(target_dir: Path) -> None:
|
||||
"""Create breadcrumb file to mark successful installation."""
|
||||
breadcrumb = get_breadcrumb_file(target_dir)
|
||||
breadcrumb.write_text(f"Installation completed at {os.path.basename(target_dir)}\n")
|
||||
print(f"Created installation breadcrumb: {breadcrumb}")
|
||||
|
||||
|
||||
def check_existing_installation(target_dir: Path) -> Tuple[bool, bool]:
|
||||
"""Check if there's an existing installation and if it's complete.
|
||||
|
||||
Returns:
|
||||
(has_artifacts, has_breadcrumb) - bool tuple indicating installation state
|
||||
"""
|
||||
breadcrumb = get_breadcrumb_file(target_dir)
|
||||
has_breadcrumb = breadcrumb.exists()
|
||||
|
||||
# Check for any installation artifacts
|
||||
bin_dir = target_dir / "bin"
|
||||
has_artifacts = bin_dir.exists() and any(bin_dir.iterdir())
|
||||
|
||||
return has_artifacts, has_breadcrumb
|
||||
|
||||
|
||||
def verify_clang_installation(target_dir: Path) -> bool:
|
||||
"""Test if clang is properly installed in target directory."""
|
||||
bin_dir = target_dir / "bin"
|
||||
clang_exe = (
|
||||
bin_dir / "clang.exe"
|
||||
if platform.system().lower() == "windows"
|
||||
else bin_dir / "clang"
|
||||
)
|
||||
|
||||
if not clang_exe.exists():
|
||||
return False
|
||||
|
||||
# Test that clang can run and report version
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(clang_exe), "--version"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def get_windows_default_llvm_bins() -> List[Path]:
|
||||
"""Return common Windows LLVM bin directories to probe."""
|
||||
roots: List[Path] = []
|
||||
program_files: Optional[str] = os.environ.get("ProgramFiles")
|
||||
program_files_x86: Optional[str] = os.environ.get("ProgramFiles(x86)")
|
||||
chocolatey_lib: Optional[str] = os.environ.get("ChocolateyInstall")
|
||||
scoop: Optional[str] = os.environ.get("SCOOP")
|
||||
|
||||
if program_files:
|
||||
roots.append(Path(program_files) / "LLVM" / "bin")
|
||||
if program_files_x86:
|
||||
roots.append(Path(program_files_x86) / "LLVM" / "bin")
|
||||
if chocolatey_lib:
|
||||
roots.append(Path(chocolatey_lib) / "lib" / "llvm" / "tools" / "llvm" / "bin")
|
||||
if scoop:
|
||||
roots.append(Path(scoop) / "apps" / "llvm" / "current" / "bin")
|
||||
|
||||
# Also consider PATH entries explicitly
|
||||
path_entries: List[str] = os.environ.get("PATH", "").split(os.pathsep)
|
||||
for entry in path_entries:
|
||||
# Only consider entries that look like LLVM bin directories
|
||||
if "llvm" in entry.lower() and "bin" in entry.lower():
|
||||
roots.append(Path(entry))
|
||||
return roots
|
||||
|
||||
|
||||
def _run_subprocess(args: List[str], timeout_seconds: int) -> tuple[int, str, str]:
|
||||
"""Run a subprocess command and return (returncode, stdout, stderr)."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return 124, "", f"Timeout after {timeout_seconds}s: {e}"
|
||||
except subprocess.SubprocessError as e:
|
||||
return 1, "", f"Subprocess error: {e}"
|
||||
|
||||
|
||||
def install_llvm_windows_direct(target_dir: Path) -> Path:
|
||||
"""Download and extract official LLVM Windows tar.xz archive into target_dir.
|
||||
|
||||
Returns the extracted LLVM bin directory path on success.
|
||||
Fails fast with a descriptive error on failure.
|
||||
"""
|
||||
# Prefer dynamically discovered releases; as fallback, try a small curated list
|
||||
preferred_versions: List[str] = [
|
||||
"20.1.8",
|
||||
"20.1.7",
|
||||
"20.1.6",
|
||||
"19.1.8",
|
||||
"19.1.7",
|
||||
"19.1.6",
|
||||
]
|
||||
|
||||
download_errors: List[str] = []
|
||||
|
||||
# First, try to discover the latest available Windows tar.xz for majors 20 and 19
|
||||
for probe_major in [20, 19]:
|
||||
print(f"Attempting discovery for LLVM major version {probe_major}...")
|
||||
discovered_url = discover_latest_github_llvm_win_zip(probe_major)
|
||||
if discovered_url is not None:
|
||||
print(f"Discovered URL for LLVM {probe_major}: {discovered_url}")
|
||||
# Put discovered URL at the front of attempts
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
extract_root: Path = target_dir
|
||||
|
||||
tmp_archive_path: Optional[Path] = None
|
||||
try:
|
||||
print(f"Attempting to download and extract LLVM {probe_major}...")
|
||||
# Download to managed directory
|
||||
tmp_archive_path = _download(
|
||||
discovered_url, f"llvm-discovered-{probe_major}.tar.xz"
|
||||
)
|
||||
extract_root = target_dir
|
||||
with lzma.open(tmp_archive_path, "rb") as xz_file:
|
||||
with tarfile.open(fileobj=xz_file, mode="r") as tar:
|
||||
tar.extractall(extract_root, filter="data")
|
||||
except KeyboardInterrupt:
|
||||
print("Operation interrupted by user")
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Failed to download/extract LLVM {probe_major}: {e}")
|
||||
download_errors.append(f"discovered-{probe_major}: {e}")
|
||||
finally:
|
||||
# Clean up the downloaded file immediately
|
||||
if tmp_archive_path and tmp_archive_path.exists():
|
||||
try:
|
||||
tmp_archive_path.unlink()
|
||||
print(f"Cleaned up: {tmp_archive_path}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to clean up {tmp_archive_path}: {e}")
|
||||
|
||||
extracted_bin: Optional[Path] = None
|
||||
for entry in extract_root.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
candidate = entry / "bin" / "clang.exe"
|
||||
if candidate.exists():
|
||||
extracted_bin = entry / "bin"
|
||||
break
|
||||
|
||||
if extracted_bin is not None:
|
||||
print(
|
||||
f"SUCCESS: LLVM {probe_major} installed successfully via discovery!"
|
||||
)
|
||||
print("OK: Successfully installed LLVM (Windows tar.xz, discovered)")
|
||||
print(f" Installation directory: {extracted_bin.parent}")
|
||||
print(f" Bin directory: {extracted_bin}")
|
||||
create_breadcrumb(target_dir)
|
||||
return extracted_bin
|
||||
else:
|
||||
print(
|
||||
f"WARNING: LLVM {probe_major} downloaded but clang.exe not found in expected location"
|
||||
)
|
||||
else:
|
||||
print(f"No URL discovered for LLVM major version {probe_major}")
|
||||
|
||||
# If discovery did not succeed, try curated versions list
|
||||
print("\n" + "=" * 60)
|
||||
print("Discovery failed for all major versions. Falling back to curated list...")
|
||||
print("=" * 60 + "\n")
|
||||
|
||||
for version in preferred_versions:
|
||||
base_url = (
|
||||
"https://github.com/llvm/llvm-project/releases/download/"
|
||||
f"llvmorg-{version}/clang+llvm-{version}-x86_64-pc-windows-msvc.tar.xz"
|
||||
)
|
||||
print(f"Attempting direct LLVM download for Windows: {base_url}")
|
||||
try:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
tmp_archive_path: Optional[Path] = None
|
||||
try:
|
||||
# Download to managed directory
|
||||
tmp_archive_path = _download(base_url, f"llvm-{version}.tar.xz")
|
||||
except KeyboardInterrupt:
|
||||
print("Operation interrupted by user")
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
download_errors.append(f"{version}: {e}")
|
||||
# Clean up on download error
|
||||
if tmp_archive_path and tmp_archive_path.exists():
|
||||
try:
|
||||
tmp_archive_path.unlink()
|
||||
print(f"Cleaned up after error: {tmp_archive_path}")
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
extract_root = target_dir
|
||||
try:
|
||||
with lzma.open(tmp_archive_path, "rb") as xz_file:
|
||||
with tarfile.open(fileobj=xz_file, mode="r") as tar:
|
||||
tar.extractall(extract_root, filter="data")
|
||||
finally:
|
||||
# Clean up the downloaded file immediately
|
||||
if tmp_archive_path and tmp_archive_path.exists():
|
||||
try:
|
||||
tmp_archive_path.unlink()
|
||||
print(f"Cleaned up: {tmp_archive_path}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to clean up {tmp_archive_path}: {e}")
|
||||
|
||||
# Find extracted directory that contains bin/clang.exe
|
||||
extracted_bin: Optional[Path] = None
|
||||
for entry in extract_root.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
candidate = entry / "bin" / "clang.exe"
|
||||
if candidate.exists():
|
||||
extracted_bin = entry / "bin"
|
||||
break
|
||||
|
||||
if extracted_bin is None:
|
||||
print(
|
||||
"ERROR: LLVM downloaded but clang.exe not found in expected location"
|
||||
)
|
||||
# Try next version
|
||||
continue
|
||||
|
||||
print("OK: Successfully installed LLVM (Windows tar.xz)")
|
||||
print(f" Installation directory: {extracted_bin.parent}")
|
||||
print(f" Bin directory: {extracted_bin}")
|
||||
create_breadcrumb(target_dir)
|
||||
return extracted_bin
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("Operation interrupted by user")
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
download_errors.append(f"{version}: {e}")
|
||||
continue
|
||||
|
||||
# If we reach here, all attempts failed
|
||||
print("CRITICAL: Failed to download LLVM for Windows from official releases.")
|
||||
if download_errors:
|
||||
for err in download_errors:
|
||||
print(f" - {err}")
|
||||
print("Please ensure internet access is available and retry.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def discover_latest_github_llvm_win_zip(major: int) -> Optional[str]:
|
||||
"""Find the latest LLVM Windows tar.xz asset for a given major version via GitHub API.
|
||||
|
||||
Returns a browser_download_url string if found, otherwise None.
|
||||
"""
|
||||
try:
|
||||
per_page: int = 50
|
||||
page: int = 1
|
||||
max_pages: int = 3
|
||||
while page <= max_pages:
|
||||
api_url: str = (
|
||||
"https://api.github.com/repos/llvm/llvm-project/releases"
|
||||
f"?per_page={per_page}&page={page}"
|
||||
)
|
||||
req = urllib.request.Request(
|
||||
api_url, headers={"User-Agent": "fastled-setup-llvm"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
raw: str = resp.read().decode("utf-8")
|
||||
releases_any: Any = json.loads(raw)
|
||||
releases: list[dict[str, Any]] = []
|
||||
if isinstance(releases_any, list):
|
||||
tmp_list: list[Any] = cast(list[Any], releases_any)
|
||||
filtered: list[dict[str, Any]] = []
|
||||
k: int = 0
|
||||
while k < len(tmp_list):
|
||||
item: Any = tmp_list[k]
|
||||
k += 1
|
||||
if isinstance(item, dict):
|
||||
filtered.append(cast(dict[str, Any], item))
|
||||
releases = filtered
|
||||
|
||||
# Iterate releases
|
||||
idx: int = 0
|
||||
while idx < len(releases):
|
||||
rel: dict[str, Any] = releases[idx]
|
||||
idx += 1
|
||||
tag_val: Any = rel.get("tag_name")
|
||||
tag_name: Optional[str] = tag_val if isinstance(tag_val, str) else None
|
||||
if tag_name is None:
|
||||
continue
|
||||
# Expect tags like llvmorg-19.1.6
|
||||
prefix: str = f"llvmorg-{major}."
|
||||
if not tag_name.startswith(prefix):
|
||||
continue
|
||||
assets_val: Any = rel.get("assets")
|
||||
assets: list[dict[str, Any]] = (
|
||||
cast(list[dict[str, Any]], assets_val)
|
||||
if isinstance(assets_val, list)
|
||||
else []
|
||||
)
|
||||
filtered_assets: list[dict[str, Any]] = [
|
||||
a for a in assets if isinstance(a, dict)
|
||||
]
|
||||
j: int = 0
|
||||
while j < len(filtered_assets):
|
||||
asset: dict[str, Any] = filtered_assets[j]
|
||||
j += 1
|
||||
name_val: Any = asset.get("name")
|
||||
url_val: Any = asset.get("browser_download_url")
|
||||
name: Optional[str] = (
|
||||
name_val if isinstance(name_val, str) else None
|
||||
)
|
||||
url: Optional[str] = url_val if isinstance(url_val, str) else None
|
||||
if name is not None and url is not None:
|
||||
# Match e.g., clang+llvm-19.1.6-x86_64-pc-windows-msvc.tar.xz
|
||||
if (
|
||||
name.startswith(f"clang+llvm-{major}.")
|
||||
and "x86_64-pc-windows-msvc.tar.xz" in name
|
||||
):
|
||||
return url
|
||||
page += 1
|
||||
return None
|
||||
except urllib.error.URLError:
|
||||
return None
|
||||
|
||||
|
||||
def download_and_install_llvm(target_dir: Path) -> None:
|
||||
"""Download and install LLVM using llvm-installer package."""
|
||||
print("Detecting system configuration...")
|
||||
try:
|
||||
sys_conf_obj2: Any = sys_detection.local_sys_conf()
|
||||
local_conf2: _SysConf = cast(_SysConf, sys_conf_obj2)
|
||||
detected_os: str = local_conf2.short_os_name_and_version()
|
||||
architecture: str = local_conf2.architecture
|
||||
print(f"Detected OS: {detected_os}")
|
||||
print(f"Architecture: {architecture}")
|
||||
|
||||
# Use Ubuntu 24.04 as fallback since it's known to work
|
||||
# This package seems to be from YugabyteDB and has limited OS support
|
||||
working_os: str = "ubuntu24.04"
|
||||
if detected_os.startswith("ubuntu24") or detected_os.startswith("ubuntu22"):
|
||||
working_os = detected_os
|
||||
|
||||
print(f"Using OS variant: {working_os}")
|
||||
|
||||
llvm_installer: Any = LlvmInstaller(
|
||||
short_os_name_and_version=working_os, architecture=architecture
|
||||
)
|
||||
|
||||
# Prefer most recent supported major release for linux tarball path
|
||||
llvm_url: str = ""
|
||||
selected_version: Optional[int] = None
|
||||
for version in [20, 19]:
|
||||
try:
|
||||
print(f"Installing LLVM version {version}...")
|
||||
llvm_url = llvm_installer.get_llvm_url(major_llvm_version=version)
|
||||
print(f"Found LLVM {version} at: {llvm_url}")
|
||||
selected_version = version
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
print("Operation interrupted by user")
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"WARN: Could not resolve LLVM {version}: {e}")
|
||||
|
||||
if not llvm_url:
|
||||
raise RuntimeError(
|
||||
"Could not resolve a suitable LLVM download URL for Linux"
|
||||
)
|
||||
|
||||
# Download and extract
|
||||
print("Downloading LLVM package...")
|
||||
|
||||
# Download to managed directory
|
||||
tmp_archive_path: Path = _download(
|
||||
llvm_url, f"llvm-linux-{selected_version or 'unknown'}.tar.gz"
|
||||
)
|
||||
|
||||
try:
|
||||
print("Extracting LLVM package...")
|
||||
with tarfile.open(tmp_archive_path, "r:gz") as tar:
|
||||
tar.extractall(target_dir, filter="data")
|
||||
finally:
|
||||
# Clean up immediately after extraction
|
||||
try:
|
||||
tmp_archive_path.unlink()
|
||||
print(f"Cleaned up: {tmp_archive_path}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to clean up {tmp_archive_path}: {e}")
|
||||
|
||||
# Find the extracted directory and verify clang exists
|
||||
for extracted_dir in target_dir.iterdir():
|
||||
if extracted_dir.is_dir():
|
||||
clang_path = extracted_dir / "bin" / "clang"
|
||||
|
||||
if clang_path.exists():
|
||||
print(
|
||||
f"OK: Successfully installed LLVM {selected_version if selected_version is not None else 'unknown'}"
|
||||
)
|
||||
print(f" Installation directory: {extracted_dir}")
|
||||
print(f" Clang binary: {clang_path}")
|
||||
|
||||
# List available tools (first few) and provide PATH guidance
|
||||
bin_dir = extracted_dir / "bin"
|
||||
if bin_dir.exists():
|
||||
tools = [
|
||||
f.name
|
||||
for f in bin_dir.iterdir()
|
||||
if f.is_file() and not f.name.endswith(".dll")
|
||||
]
|
||||
print(
|
||||
f" Available tools: {', '.join(sorted(tools[:10]))}{'...' if len(tools) > 10 else ''}"
|
||||
)
|
||||
print("")
|
||||
print("Next steps:")
|
||||
print(f" - Add to PATH: {bin_dir}")
|
||||
print(" - Example:")
|
||||
print(f' export PATH="{bin_dir}:$PATH"')
|
||||
print(" - Verify:")
|
||||
print(" clang --version")
|
||||
|
||||
return
|
||||
|
||||
print("ERROR: LLVM downloaded but clang not found in expected location")
|
||||
sys.exit(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("Operation interrupted by user")
|
||||
_thread.interrupt_main()
|
||||
raise
|
||||
except Exception as e:
|
||||
print(f"Error during installation: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Setup LLVM toolchain - check system first, fallback to package installation."""
|
||||
# Get target directory from command line (optional)
|
||||
if len(sys.argv) > 2:
|
||||
print("Usage: uv run python ci/setup-llvm.py [target_directory]")
|
||||
print(" target_directory: Optional. Defaults to .cache/cc in project root")
|
||||
sys.exit(1)
|
||||
|
||||
if len(sys.argv) == 2:
|
||||
target_dir = Path(sys.argv[1])
|
||||
else:
|
||||
# Default to project root .cache/cc directory
|
||||
target_dir = _PROJECT_ROOT / ".cache" / "cc"
|
||||
print(f"Using default target directory: {target_dir}")
|
||||
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check platform compatibility
|
||||
is_unix_like, platform_name = is_linux_or_unix()
|
||||
|
||||
if platform_name == "windows":
|
||||
print("Checking for existing LLVM/Clang tools on system (Windows)...")
|
||||
|
||||
# Check for existing installation in target directory
|
||||
has_artifacts, has_breadcrumb = check_existing_installation(target_dir)
|
||||
|
||||
if has_artifacts and not has_breadcrumb:
|
||||
print("WARN: Found installation artifacts without completion marker")
|
||||
print(" This suggests an incomplete or corrupted installation")
|
||||
print(" Re-downloading LLVM to ensure proper installation...")
|
||||
elif has_artifacts and has_breadcrumb:
|
||||
if verify_clang_installation(target_dir):
|
||||
print("OK: Valid LLVM installation found in target directory")
|
||||
print(" Skipping download - clang installation verified")
|
||||
return
|
||||
else:
|
||||
print(
|
||||
"WARN: Installation breadcrumb exists but clang verification failed"
|
||||
)
|
||||
print(" Re-downloading LLVM to fix installation...")
|
||||
|
||||
clang_version_output = get_tool_version("clang")
|
||||
if clang_version_output:
|
||||
clang_version = get_clang_version_number(clang_version_output)
|
||||
print(
|
||||
f"Found system clang: version {clang_version if clang_version else 'unknown'}"
|
||||
)
|
||||
else:
|
||||
clang_version = None
|
||||
|
||||
found_essential, found_extras = check_required_tools()
|
||||
available_essential = {
|
||||
name: path for name, path in found_essential.items() if path
|
||||
}
|
||||
missing_essential = [name for name, path in found_essential.items() if not path]
|
||||
|
||||
if len(available_essential) != len(found_essential):
|
||||
# Probe common install directories (e.g., C:\Program Files\LLVM\bin)
|
||||
probe_dirs = get_windows_default_llvm_bins()
|
||||
print(
|
||||
f"Probing default Windows LLVM locations: {', '.join(str(p) for p in probe_dirs)}"
|
||||
)
|
||||
probed = find_tools_in_directories(list(found_essential.keys()), probe_dirs)
|
||||
for name, p in probed.items():
|
||||
if p is not None and found_essential.get(name) is None:
|
||||
found_essential[name] = p
|
||||
|
||||
available_essential = {
|
||||
name: path for name, path in found_essential.items() if path
|
||||
}
|
||||
missing_essential = [
|
||||
name for name, path in found_essential.items() if not path
|
||||
]
|
||||
|
||||
if len(available_essential) == len(found_essential) and (
|
||||
clang_version is None or clang_version < 19
|
||||
):
|
||||
print(
|
||||
"Note: Detected toolchain but clang < 19. Continuing; ensure compatibility with your build settings."
|
||||
)
|
||||
|
||||
# On Windows, require core tools including llvm-addr2line for stack traces
|
||||
core_required = ["clang", "clang++", "lld-link", "llvm-addr2line"]
|
||||
missing_core = [name for name in core_required if not found_essential.get(name)]
|
||||
|
||||
if not missing_core:
|
||||
print("OK: Core tools present - proceeding with available tools")
|
||||
all_available_tools: Dict[str, Path] = {}
|
||||
for name, path in found_essential.items():
|
||||
if path is not None:
|
||||
all_available_tools[name] = cast(Path, path)
|
||||
for name, path in found_extras.items():
|
||||
if path is not None:
|
||||
all_available_tools[name] = cast(Path, path)
|
||||
|
||||
print(f"Creating hard links in {target_dir}...")
|
||||
create_hard_links(all_available_tools, target_dir)
|
||||
|
||||
version_file = target_dir / "VERSION"
|
||||
version_file.write_text(
|
||||
f"system-clang-{clang_version if clang_version else 'unknown'}\n"
|
||||
)
|
||||
bin_dir = target_dir / "bin"
|
||||
existing = [p for p in bin_dir.iterdir() if p.is_file()]
|
||||
print(f"OK: Tools available in {bin_dir}: {len(existing)} files")
|
||||
create_breadcrumb(target_dir)
|
||||
|
||||
# Warn about missing non-core tools
|
||||
missing_noncore = [
|
||||
name
|
||||
for name in list(found_essential.keys())
|
||||
if name not in core_required and not found_essential.get(name)
|
||||
]
|
||||
if missing_noncore:
|
||||
print(f"WARN: Missing non-core tools: {', '.join(missing_noncore)}")
|
||||
missing_extras = [name for name, path in found_extras.items() if not path]
|
||||
if missing_extras:
|
||||
print(f"WARN: Missing extra tools: {', '.join(missing_extras)}")
|
||||
return
|
||||
|
||||
# Try to auto-install on Windows via direct GitHub download
|
||||
print(f"ERROR: Missing core tools: {', '.join(missing_core)}")
|
||||
print("Downloading LLVM for Windows from GitHub releases...")
|
||||
extracted_bin_dir = install_llvm_windows_direct(target_dir)
|
||||
|
||||
# Collect tools from the extracted bin directory and link/copy them
|
||||
tool_names: List[str] = [
|
||||
"clang",
|
||||
"clang++",
|
||||
"lld-link",
|
||||
"llvm-addr2line",
|
||||
"llvm-ar",
|
||||
"llvm-nm",
|
||||
"llvm-objdump",
|
||||
"llvm-strip",
|
||||
"llvm-objcopy",
|
||||
"clangd",
|
||||
"clang-format",
|
||||
"clang-tidy",
|
||||
"lldb",
|
||||
]
|
||||
discovered: Dict[str, Optional[Path]] = find_tools_in_directories(
|
||||
tool_names, [extracted_bin_dir]
|
||||
)
|
||||
available_tools: Dict[str, Path] = {}
|
||||
for name, p in discovered.items():
|
||||
if p is not None:
|
||||
available_tools[name] = cast(Path, p)
|
||||
|
||||
if not available_tools:
|
||||
print(
|
||||
"CRITICAL: Extracted LLVM bin directory does not contain expected tools."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Creating hard links in {target_dir}...")
|
||||
create_hard_links(available_tools, target_dir)
|
||||
|
||||
version_file = target_dir / "VERSION"
|
||||
version_file.write_text("downloaded-llvm-windows\n")
|
||||
bin_dir = target_dir / "bin"
|
||||
existing = [p for p in bin_dir.iterdir() if p.is_file()]
|
||||
print(f"OK: Tools available in {bin_dir}: {len(existing)} files")
|
||||
create_breadcrumb(target_dir)
|
||||
return
|
||||
|
||||
print("Checking for existing LLVM/Clang tools on system...")
|
||||
|
||||
# Check if clang is available and get its version
|
||||
clang_version_output = get_tool_version("clang")
|
||||
if clang_version_output:
|
||||
clang_version = get_clang_version_number(clang_version_output)
|
||||
print(
|
||||
f"Found system clang: version {clang_version if clang_version else 'unknown'}"
|
||||
)
|
||||
|
||||
if clang_version and clang_version >= 19:
|
||||
print("OK: System clang 19+ detected - checking tools")
|
||||
|
||||
# Check for essential and extra tools
|
||||
found_essential, found_extras = check_required_tools()
|
||||
available_essential = {
|
||||
name: path for name, path in found_essential.items() if path
|
||||
}
|
||||
missing_essential = [
|
||||
name for name, path in found_essential.items() if not path
|
||||
]
|
||||
available_extras = {
|
||||
name: path for name, path in found_extras.items() if path
|
||||
}
|
||||
missing_extras = [name for name, path in found_extras.items() if not path]
|
||||
|
||||
# Check if all essential tools are present
|
||||
if len(available_essential) == len(found_essential):
|
||||
print("OK: All essential tools found - using system tools")
|
||||
|
||||
# Combine available tools for linking
|
||||
all_available_tools = {**available_essential, **available_extras}
|
||||
|
||||
print(f"Creating hard links in {target_dir}...")
|
||||
create_hard_links(all_available_tools, target_dir)
|
||||
|
||||
# Create a simple version file
|
||||
version_file = target_dir / "VERSION"
|
||||
version_file.write_text(f"system-clang-{clang_version}\n")
|
||||
|
||||
print(
|
||||
f"OK: Successfully linked {len(all_available_tools)} system tools"
|
||||
)
|
||||
|
||||
# Warn about missing extras in one line
|
||||
if missing_extras:
|
||||
print(f"WARN: Missing extra tools: {', '.join(missing_extras)}")
|
||||
|
||||
return
|
||||
else:
|
||||
# Missing essential tools - need to install LLVM
|
||||
print(f"ERROR: Missing essential tools: {', '.join(missing_essential)}")
|
||||
if platform_name == "linux":
|
||||
print("Installing LLVM package to provide missing tools...")
|
||||
download_and_install_llvm(target_dir)
|
||||
return
|
||||
else:
|
||||
print(
|
||||
f"WARN: Cannot auto-install on {platform_name} - please install manually"
|
||||
)
|
||||
return
|
||||
else:
|
||||
print(f"System clang version {clang_version} < 19")
|
||||
if platform_name == "linux":
|
||||
print("Installing LLVM 18 package...")
|
||||
download_and_install_llvm(target_dir)
|
||||
return
|
||||
else:
|
||||
print(
|
||||
f"WARN: Cannot auto-install on {platform_name} - please install manually"
|
||||
)
|
||||
return
|
||||
else:
|
||||
print("No system clang found")
|
||||
if platform_name == "linux":
|
||||
print("Installing LLVM package...")
|
||||
download_and_install_llvm(target_dir)
|
||||
else:
|
||||
print(
|
||||
f"WARN: Cannot auto-install on {platform_name} - please install manually"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
finally:
|
||||
# Clean up download directory at the end
|
||||
_cleanup_download_dir()
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generic Symbol Analysis Runner for GitHub Actions
|
||||
Wrapper script to run symbol analysis with proper error handling for any platform
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from ci.util.symbol_analysis import main as symbol_analysis
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run symbol analysis for any platform with GitHub Actions support"
|
||||
)
|
||||
parser.add_argument("--board", required=True, help="Board name being analyzed")
|
||||
parser.add_argument("--example", default="Blink", help="Example that was compiled")
|
||||
parser.add_argument(
|
||||
"--skip-on-failure",
|
||||
action="store_true",
|
||||
help="Don't fail the build if symbol analysis fails",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"Symbol Analysis Runner - Board: {args.board}, Example: {args.example}")
|
||||
|
||||
# Check if build_info.json exists
|
||||
build_info_path = Path(".build") / args.board / "build_info.json"
|
||||
if not build_info_path.exists():
|
||||
# Fall back to nested pio structure
|
||||
build_info_path = Path(".build") / "pio" / args.board / "build_info.json"
|
||||
|
||||
if not build_info_path.exists():
|
||||
message = (
|
||||
f"Build info not found at {build_info_path}. Skipping symbol analysis."
|
||||
)
|
||||
if args.skip_on_failure:
|
||||
print(f"Warning: {message}")
|
||||
return 0
|
||||
else:
|
||||
print(f"Error: {message}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
# Import and run the generic symbol analysis
|
||||
print("Running symbol analysis...")
|
||||
|
||||
# Override sys.argv to pass the board argument to the symbol analysis script
|
||||
original_argv = sys.argv
|
||||
sys.argv = ["symbol_analysis.py", "--board", args.board]
|
||||
|
||||
try:
|
||||
symbol_analysis()
|
||||
print("Symbol analysis completed successfully!")
|
||||
return 0
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Failed to import symbol_analysis module: {e}")
|
||||
if args.skip_on_failure:
|
||||
return 0
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Symbol analysis failed: {e}")
|
||||
if args.skip_on_failure:
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,464 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for xcache.py using real clang compiler.
|
||||
|
||||
This test suite verifies that xcache works correctly with actual compilation
|
||||
scenarios, including response files and direct execution modes.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResults:
|
||||
"""Test results container."""
|
||||
|
||||
passed: int = 0
|
||||
failed: int = 0
|
||||
errors: Optional[list[str]] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.errors is None:
|
||||
self.errors = []
|
||||
|
||||
|
||||
def find_clang() -> Optional[str]:
|
||||
"""Find clang compiler in PATH."""
|
||||
clang_path = shutil.which("clang")
|
||||
if clang_path:
|
||||
return clang_path
|
||||
|
||||
# Check common locations
|
||||
common_paths = [
|
||||
"/usr/bin/clang",
|
||||
"/usr/local/bin/clang",
|
||||
"/opt/local/bin/clang",
|
||||
"C:/Program Files/LLVM/bin/clang.exe",
|
||||
"C:/msys64/mingw64/bin/clang.exe",
|
||||
]
|
||||
|
||||
for path in common_paths:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_sccache() -> Optional[str]:
|
||||
"""Find sccache in PATH."""
|
||||
return shutil.which("sccache")
|
||||
|
||||
|
||||
def create_test_source_file(temp_dir: Path) -> Path:
|
||||
"""Create a simple C source file for testing."""
|
||||
source_file = temp_dir / "test.c"
|
||||
source_content = """
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
printf("Hello from xcache test!\\n");
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
source_file.write_text(source_content)
|
||||
return source_file
|
||||
|
||||
|
||||
def create_response_file(temp_dir: Path, args: list[str]) -> Path:
|
||||
"""Create a response file with the given arguments."""
|
||||
response_file = temp_dir / "compile_args.rsp"
|
||||
|
||||
# Write arguments to response file (one per line or space-separated)
|
||||
content = " ".join(args)
|
||||
response_file.write_text(content)
|
||||
|
||||
return response_file
|
||||
|
||||
|
||||
def test_xcache_direct_execution(results: TestResults) -> None:
|
||||
"""Test xcache with direct execution (no response files)."""
|
||||
print("🧪 Testing xcache direct execution...")
|
||||
|
||||
clang_path = find_clang()
|
||||
if not clang_path:
|
||||
results.errors.append(
|
||||
"clang compiler not found - skipping direct execution test"
|
||||
)
|
||||
return
|
||||
|
||||
sccache_path = find_sccache()
|
||||
if not sccache_path:
|
||||
results.errors.append("sccache not found - skipping direct execution test")
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create test source file
|
||||
source_file = create_test_source_file(temp_path)
|
||||
output_file = temp_path / "test_direct.o"
|
||||
|
||||
# Test xcache with direct arguments (no response files)
|
||||
env = os.environ.copy()
|
||||
env["XCACHE_DEBUG"] = "1"
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"ci/util/xcache.py",
|
||||
clang_path,
|
||||
"-c",
|
||||
str(source_file),
|
||||
"-o",
|
||||
str(output_file),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, env=env, timeout=30
|
||||
)
|
||||
|
||||
if result.returncode == 0 and output_file.exists():
|
||||
print(" ✅ Direct execution test passed")
|
||||
print(f" Output file created: {output_file.stat().st_size} bytes")
|
||||
results.passed += 1
|
||||
else:
|
||||
print(
|
||||
f" ❌ Direct execution test failed (return code: {result.returncode})"
|
||||
)
|
||||
print(f" Stderr: {result.stderr}")
|
||||
results.failed += 1
|
||||
results.errors.append(f"Direct execution failed: {result.stderr}")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" ❌ Direct execution test timed out")
|
||||
results.failed += 1
|
||||
results.errors.append("Direct execution test timed out")
|
||||
except Exception as e:
|
||||
print(f" ❌ Direct execution test error: {e}")
|
||||
results.failed += 1
|
||||
results.errors.append(f"Direct execution error: {e}")
|
||||
|
||||
|
||||
def test_xcache_response_file_execution(results: TestResults) -> None:
|
||||
"""Test xcache with response file execution."""
|
||||
print("\n🧪 Testing xcache response file execution...")
|
||||
|
||||
clang_path = find_clang()
|
||||
if not clang_path:
|
||||
results.errors.append("clang compiler not found - skipping response file test")
|
||||
return
|
||||
|
||||
sccache_path = find_sccache()
|
||||
if not sccache_path:
|
||||
results.errors.append("sccache not found - skipping response file test")
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create test source file
|
||||
source_file = create_test_source_file(temp_path)
|
||||
output_file = temp_path / "test_response.o"
|
||||
|
||||
# Create response file with compilation arguments
|
||||
compile_args = ["-c", str(source_file), "-o", str(output_file), "-O2", "-Wall"]
|
||||
response_file = create_response_file(temp_path, compile_args)
|
||||
|
||||
# Test xcache with response file
|
||||
env = os.environ.copy()
|
||||
env["XCACHE_DEBUG"] = "1"
|
||||
|
||||
cmd = [sys.executable, "ci/util/xcache.py", clang_path, f"@{response_file}"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, env=env, timeout=30
|
||||
)
|
||||
|
||||
if result.returncode == 0 and output_file.exists():
|
||||
print(" ✅ Response file execution test passed")
|
||||
print(f" Output file created: {output_file.stat().st_size} bytes")
|
||||
print(f" Response file handled: @{response_file}")
|
||||
|
||||
# Verify that response file was passed through (not expanded)
|
||||
if f"@{response_file}" in result.stderr:
|
||||
print(" ✅ Response file passed through correctly")
|
||||
else:
|
||||
print(
|
||||
" ⚠️ Response file passthrough not clearly visible in debug output"
|
||||
)
|
||||
|
||||
results.passed += 1
|
||||
else:
|
||||
print(
|
||||
f" ❌ Response file execution test failed (return code: {result.returncode})"
|
||||
)
|
||||
print(f" Stderr: {result.stderr}")
|
||||
results.failed += 1
|
||||
results.errors.append(
|
||||
f"Response file execution failed: {result.stderr}"
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" ❌ Response file execution test timed out")
|
||||
results.failed += 1
|
||||
results.errors.append("Response file execution test timed out")
|
||||
except Exception as e:
|
||||
print(f" ❌ Response file execution test error: {e}")
|
||||
results.failed += 1
|
||||
results.errors.append(f"Response file execution error: {e}")
|
||||
|
||||
|
||||
def test_xcache_mixed_arguments(results: TestResults) -> None:
|
||||
"""Test xcache with mixed regular arguments and response files."""
|
||||
print("\n🧪 Testing xcache mixed arguments...")
|
||||
|
||||
clang_path = find_clang()
|
||||
if not clang_path:
|
||||
results.errors.append(
|
||||
"clang compiler not found - skipping mixed arguments test"
|
||||
)
|
||||
return
|
||||
|
||||
sccache_path = find_sccache()
|
||||
if not sccache_path:
|
||||
results.errors.append("sccache not found - skipping mixed arguments test")
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create test source file
|
||||
source_file = create_test_source_file(temp_path)
|
||||
output_file = temp_path / "test_mixed.o"
|
||||
|
||||
# Create response file with some arguments
|
||||
response_args = ["-O2", "-Wall", "-Wextra"]
|
||||
response_file = create_response_file(temp_path, response_args)
|
||||
|
||||
# Test xcache with mixed arguments
|
||||
env = os.environ.copy()
|
||||
env["XCACHE_DEBUG"] = "1"
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"ci/util/xcache.py",
|
||||
clang_path,
|
||||
"-c",
|
||||
str(source_file),
|
||||
f"@{response_file}",
|
||||
"-o",
|
||||
str(output_file),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, env=env, timeout=30
|
||||
)
|
||||
|
||||
if result.returncode == 0 and output_file.exists():
|
||||
print(" ✅ Mixed arguments test passed")
|
||||
print(f" Output file created: {output_file.stat().st_size} bytes")
|
||||
print(f" Mixed args: regular + @{response_file}")
|
||||
results.passed += 1
|
||||
else:
|
||||
print(
|
||||
f" ❌ Mixed arguments test failed (return code: {result.returncode})"
|
||||
)
|
||||
print(f" Stderr: {result.stderr}")
|
||||
results.failed += 1
|
||||
results.errors.append(f"Mixed arguments failed: {result.stderr}")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" ❌ Mixed arguments test timed out")
|
||||
results.failed += 1
|
||||
results.errors.append("Mixed arguments test timed out")
|
||||
except Exception as e:
|
||||
print(f" ❌ Mixed arguments test error: {e}")
|
||||
results.failed += 1
|
||||
results.errors.append(f"Mixed arguments error: {e}")
|
||||
|
||||
|
||||
def test_xcache_cache_effectiveness(results: TestResults) -> None:
|
||||
"""Test that xcache actually caches compilation results."""
|
||||
print("\n🧪 Testing xcache cache effectiveness...")
|
||||
|
||||
clang_path = find_clang()
|
||||
if not clang_path:
|
||||
results.errors.append(
|
||||
"clang compiler not found - skipping cache effectiveness test"
|
||||
)
|
||||
return
|
||||
|
||||
sccache_path = find_sccache()
|
||||
if not sccache_path:
|
||||
results.errors.append("sccache not found - skipping cache effectiveness test")
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create test source file
|
||||
source_file = create_test_source_file(temp_path)
|
||||
|
||||
# Set up environment for caching
|
||||
env = os.environ.copy()
|
||||
env["XCACHE_DEBUG"] = "1"
|
||||
cache_dir = temp_path / "sccache"
|
||||
cache_dir.mkdir()
|
||||
env["SCCACHE_DIR"] = str(cache_dir)
|
||||
|
||||
# First compilation (should be cache miss)
|
||||
output_file1 = temp_path / "test_cache1.o"
|
||||
cmd1 = [
|
||||
sys.executable,
|
||||
"ci/util/xcache.py",
|
||||
clang_path,
|
||||
"-c",
|
||||
str(source_file),
|
||||
"-o",
|
||||
str(output_file1),
|
||||
]
|
||||
|
||||
# Second compilation (should be cache hit)
|
||||
output_file2 = temp_path / "test_cache2.o"
|
||||
cmd2 = [
|
||||
sys.executable,
|
||||
"ci/util/xcache.py",
|
||||
clang_path,
|
||||
"-c",
|
||||
str(source_file),
|
||||
"-o",
|
||||
str(output_file2),
|
||||
]
|
||||
|
||||
try:
|
||||
# First compilation
|
||||
result1 = subprocess.run(
|
||||
cmd1, capture_output=True, text=True, env=env, timeout=30
|
||||
)
|
||||
|
||||
# Second compilation
|
||||
result2 = subprocess.run(
|
||||
cmd2, capture_output=True, text=True, env=env, timeout=30
|
||||
)
|
||||
|
||||
if (
|
||||
result1.returncode == 0
|
||||
and output_file1.exists()
|
||||
and result2.returncode == 0
|
||||
and output_file2.exists()
|
||||
):
|
||||
print(" ✅ Cache effectiveness test passed")
|
||||
print(f" First compilation: {output_file1.stat().st_size} bytes")
|
||||
print(f" Second compilation: {output_file2.stat().st_size} bytes")
|
||||
|
||||
# Check if sccache cache directory has content
|
||||
cache_files = list(cache_dir.rglob("*"))
|
||||
if cache_files:
|
||||
print(f" Cache directory has {len(cache_files)} entries")
|
||||
else:
|
||||
print(" ⚠️ Cache directory appears empty")
|
||||
|
||||
results.passed += 1
|
||||
else:
|
||||
print(f" ❌ Cache effectiveness test failed")
|
||||
print(
|
||||
f" First result: {result1.returncode}, {output_file1.exists()}"
|
||||
)
|
||||
print(
|
||||
f" Second result: {result2.returncode}, {output_file2.exists()}"
|
||||
)
|
||||
results.failed += 1
|
||||
results.errors.append("Cache effectiveness test failed")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" ❌ Cache effectiveness test timed out")
|
||||
results.failed += 1
|
||||
results.errors.append("Cache effectiveness test timed out")
|
||||
except Exception as e:
|
||||
print(f" ❌ Cache effectiveness test error: {e}")
|
||||
results.failed += 1
|
||||
results.errors.append(f"Cache effectiveness error: {e}")
|
||||
|
||||
|
||||
def run_all_tests() -> TestResults:
|
||||
"""Run all xcache tests."""
|
||||
print("🚀 XCACHE INTEGRATION TESTS WITH CLANG")
|
||||
print("=" * 60)
|
||||
|
||||
results = TestResults()
|
||||
|
||||
# Check prerequisites
|
||||
clang_path = find_clang()
|
||||
sccache_path = find_sccache()
|
||||
|
||||
print(f"📋 Test Environment:")
|
||||
print(f" Clang: {clang_path or 'NOT FOUND'}")
|
||||
print(f" Sccache: {sccache_path or 'NOT FOUND'}")
|
||||
print(f" xcache: ci/util/xcache.py")
|
||||
print()
|
||||
|
||||
if not clang_path:
|
||||
print("❌ Clang compiler not found - install clang to run tests")
|
||||
results.errors.append("Clang compiler not available")
|
||||
return results
|
||||
|
||||
if not sccache_path:
|
||||
print("❌ Sccache not found - install sccache to run tests")
|
||||
results.errors.append("Sccache not available")
|
||||
return results
|
||||
|
||||
# Run tests
|
||||
test_xcache_direct_execution(results)
|
||||
test_xcache_response_file_execution(results)
|
||||
test_xcache_mixed_arguments(results)
|
||||
test_xcache_cache_effectiveness(results)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main test function."""
|
||||
results = run_all_tests()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("📊 TEST RESULTS SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
total_tests = results.passed + results.failed
|
||||
if total_tests > 0:
|
||||
success_rate = (results.passed / total_tests) * 100
|
||||
print(f" Tests passed: {results.passed}")
|
||||
print(f" Tests failed: {results.failed}")
|
||||
print(f" Success rate: {success_rate:.1f}%")
|
||||
else:
|
||||
print(" No tests were executed")
|
||||
|
||||
if results.errors:
|
||||
print(f"\n🚨 Errors encountered:")
|
||||
for i, error in enumerate(results.errors, 1):
|
||||
print(f" {i}. {error}")
|
||||
|
||||
if results.failed == 0 and results.passed > 0:
|
||||
print("\n✅ All xcache tests passed! Ready for ESP32S3 integration.")
|
||||
return 0
|
||||
elif results.passed > 0:
|
||||
print(
|
||||
f"\n⚠️ Some tests passed but {results.failed} failed. Check errors above."
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
print("\n❌ No tests passed. Check prerequisites and errors above.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone tests for xcache.py using clang compiler without requiring sccache server.
|
||||
|
||||
This test suite verifies that xcache works correctly by testing the wrapper
|
||||
functionality and ensuring proper command construction and execution.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResults:
|
||||
"""Test results container."""
|
||||
|
||||
passed: int = 0
|
||||
failed: int = 0
|
||||
errors: Optional[list[str]] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.errors is None:
|
||||
self.errors = []
|
||||
|
||||
|
||||
def find_clang() -> Optional[str]:
|
||||
"""Find clang compiler in PATH."""
|
||||
clang_path = shutil.which("clang")
|
||||
if clang_path:
|
||||
return clang_path
|
||||
|
||||
# Check common locations
|
||||
common_paths = [
|
||||
"/usr/bin/clang",
|
||||
"/usr/local/bin/clang",
|
||||
"/opt/local/bin/clang",
|
||||
"C:/Program Files/LLVM/bin/clang.exe",
|
||||
"C:/msys64/mingw64/bin/clang.exe",
|
||||
]
|
||||
|
||||
for path in common_paths:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_test_source_file(temp_dir: Path) -> Path:
|
||||
"""Create a simple C source file for testing."""
|
||||
source_file = temp_dir / "test.c"
|
||||
source_content = """
|
||||
#include <stdio.h>
|
||||
|
||||
int main() {
|
||||
printf("Hello from xcache test!\\n");
|
||||
return 0;
|
||||
}
|
||||
"""
|
||||
source_file.write_text(source_content)
|
||||
return source_file
|
||||
|
||||
|
||||
def create_response_file(temp_dir: Path, args: list[str]) -> Path:
|
||||
"""Create a response file with the given arguments."""
|
||||
response_file = temp_dir / "compile_args.rsp"
|
||||
|
||||
# Write arguments to response file (space-separated)
|
||||
# Properly quote arguments that contain spaces (Windows paths)
|
||||
# Use forward slashes for Windows paths to avoid backslash escape issues
|
||||
quoted_args: list[str] = []
|
||||
for arg in args:
|
||||
# Convert Windows backslashes to forward slashes for response files
|
||||
normalized_arg = arg.replace("\\", "/")
|
||||
if " " in normalized_arg and not (
|
||||
normalized_arg.startswith('"') and normalized_arg.endswith('"')
|
||||
):
|
||||
quoted_args.append(f'"{normalized_arg}"')
|
||||
else:
|
||||
quoted_args.append(normalized_arg)
|
||||
|
||||
content = " ".join(quoted_args)
|
||||
response_file.write_text(content)
|
||||
|
||||
return response_file
|
||||
|
||||
|
||||
def test_clang_direct_compilation(results: TestResults) -> None:
|
||||
"""Test that clang compilation works directly (baseline test)."""
|
||||
print("🧪 Testing direct clang compilation (baseline)...")
|
||||
|
||||
clang_path = find_clang()
|
||||
if not clang_path:
|
||||
results.errors.append("clang compiler not found - skipping baseline test")
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create test source file
|
||||
source_file = create_test_source_file(temp_path)
|
||||
output_file = temp_path / "test_baseline.o"
|
||||
|
||||
# Test direct clang compilation
|
||||
cmd = [clang_path, "-c", str(source_file), "-o", str(output_file)]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode == 0 and output_file.exists():
|
||||
print(" ✅ Direct clang compilation works")
|
||||
print(f" Output file created: {output_file.stat().st_size} bytes")
|
||||
results.passed += 1
|
||||
else:
|
||||
print(
|
||||
f" ❌ Direct clang compilation failed (return code: {result.returncode})"
|
||||
)
|
||||
print(f" Stderr: {result.stderr}")
|
||||
results.failed += 1
|
||||
results.errors.append(
|
||||
f"Direct clang compilation failed: {result.stderr}"
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" ❌ Direct clang compilation timed out")
|
||||
results.failed += 1
|
||||
results.errors.append("Direct clang compilation timed out")
|
||||
except Exception as e:
|
||||
print(f" ❌ Direct clang compilation error: {e}")
|
||||
results.failed += 1
|
||||
results.errors.append(f"Direct clang compilation error: {e}")
|
||||
|
||||
|
||||
def test_clang_response_file_compilation(results: TestResults) -> None:
|
||||
"""Test that clang can handle response files directly."""
|
||||
print("\n🧪 Testing clang response file compilation...")
|
||||
|
||||
clang_path = find_clang()
|
||||
if not clang_path:
|
||||
results.errors.append("clang compiler not found - skipping response file test")
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create test source file
|
||||
source_file = create_test_source_file(temp_path)
|
||||
output_file = temp_path / "test_response.o"
|
||||
|
||||
# Create response file with compilation arguments
|
||||
compile_args = ["-c", str(source_file), "-o", str(output_file)]
|
||||
response_file = create_response_file(temp_path, compile_args)
|
||||
|
||||
# Test clang with response file
|
||||
cmd = [clang_path, f"@{response_file}"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode == 0 and output_file.exists():
|
||||
print(" ✅ Clang response file compilation works")
|
||||
print(f" Output file created: {output_file.stat().st_size} bytes")
|
||||
print(f" Response file: @{response_file}")
|
||||
results.passed += 1
|
||||
else:
|
||||
print(
|
||||
f" ❌ Clang response file compilation failed (return code: {result.returncode})"
|
||||
)
|
||||
print(f" Stderr: {result.stderr}")
|
||||
results.failed += 1
|
||||
results.errors.append(
|
||||
f"Clang response file compilation failed: {result.stderr}"
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" ❌ Clang response file compilation timed out")
|
||||
results.failed += 1
|
||||
results.errors.append("Clang response file compilation timed out")
|
||||
except Exception as e:
|
||||
print(f" ❌ Clang response file compilation error: {e}")
|
||||
results.failed += 1
|
||||
results.errors.append(f"Clang response file compilation error: {e}")
|
||||
|
||||
|
||||
def test_xcache_wrapper_script_creation(results: TestResults) -> None:
|
||||
"""Test that xcache creates wrapper scripts correctly."""
|
||||
print("\n🧪 Testing xcache wrapper script creation...")
|
||||
|
||||
clang_path = find_clang()
|
||||
if not clang_path:
|
||||
results.errors.append("clang compiler not found - skipping wrapper script test")
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create test source file and response file
|
||||
source_file = create_test_source_file(temp_path)
|
||||
compile_args = ["-c", str(source_file), "-o", "test.o"]
|
||||
response_file = create_response_file(temp_path, compile_args)
|
||||
|
||||
# Create a mock sccache script that just echoes its arguments
|
||||
mock_sccache = (
|
||||
temp_path / "sccache.bat" if os.name == "nt" else temp_path / "sccache"
|
||||
)
|
||||
|
||||
if os.name == "nt":
|
||||
mock_content = """@echo off
|
||||
echo MOCK_SCCACHE_CALLED: %*
|
||||
"""
|
||||
else:
|
||||
mock_content = """#!/bin/bash
|
||||
echo "MOCK_SCCACHE_CALLED: $@"
|
||||
"""
|
||||
|
||||
mock_sccache.write_text(mock_content)
|
||||
if os.name != "nt":
|
||||
mock_sccache.chmod(0o755)
|
||||
|
||||
# Set up environment to use mock sccache
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = f"{temp_path}{os.pathsep}{env.get('PATH', '')}"
|
||||
env["XCACHE_DEBUG"] = "1"
|
||||
|
||||
# Test xcache with response file (should create wrapper script)
|
||||
cmd = [sys.executable, "ci/util/xcache.py", clang_path, f"@{response_file}"]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, env=env, timeout=30
|
||||
)
|
||||
|
||||
# Check if wrapper script was created and executed
|
||||
if "Created compiler wrapper:" in result.stderr:
|
||||
print(" ✅ Wrapper script creation detected")
|
||||
|
||||
# Check if wrapper was executed
|
||||
if "Executing with wrapper:" in result.stderr:
|
||||
print(" ✅ Wrapper script execution detected")
|
||||
|
||||
# Check if mock sccache was called
|
||||
if "MOCK_SCCACHE_CALLED:" in result.stdout:
|
||||
print(" ✅ Mock sccache was called through wrapper")
|
||||
print(f" Sccache called with: {result.stdout.strip()}")
|
||||
results.passed += 1
|
||||
else:
|
||||
print(" ❌ Mock sccache was not called")
|
||||
results.failed += 1
|
||||
results.errors.append(
|
||||
"Mock sccache was not called through wrapper"
|
||||
)
|
||||
else:
|
||||
print(" ❌ Wrapper script was not executed")
|
||||
results.failed += 1
|
||||
results.errors.append("Wrapper script was not executed")
|
||||
else:
|
||||
print(" ❌ Wrapper script was not created")
|
||||
print(f" Stderr: {result.stderr}")
|
||||
results.failed += 1
|
||||
results.errors.append(
|
||||
f"Wrapper script creation failed: {result.stderr}"
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" ❌ Wrapper script test timed out")
|
||||
results.failed += 1
|
||||
results.errors.append("Wrapper script test timed out")
|
||||
except Exception as e:
|
||||
print(f" ❌ Wrapper script test error: {e}")
|
||||
results.failed += 1
|
||||
results.errors.append(f"Wrapper script test error: {e}")
|
||||
|
||||
|
||||
def test_xcache_direct_mode(results: TestResults) -> None:
|
||||
"""Test xcache direct mode (no response files)."""
|
||||
print("\n🧪 Testing xcache direct mode...")
|
||||
|
||||
clang_path = find_clang()
|
||||
if not clang_path:
|
||||
results.errors.append("clang compiler not found - skipping direct mode test")
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create test source file
|
||||
source_file = create_test_source_file(temp_path)
|
||||
|
||||
# Create a mock sccache script that just passes through to real compiler
|
||||
mock_sccache = (
|
||||
temp_path / "sccache.bat" if os.name == "nt" else temp_path / "sccache"
|
||||
)
|
||||
|
||||
if os.name == "nt":
|
||||
mock_content = f'''@echo off
|
||||
echo MOCK_SCCACHE_DIRECT: %*
|
||||
"{clang_path}" %*
|
||||
'''
|
||||
else:
|
||||
mock_content = f'''#!/bin/bash
|
||||
echo "MOCK_SCCACHE_DIRECT: $@"
|
||||
"{clang_path}" "$@"
|
||||
'''
|
||||
|
||||
mock_sccache.write_text(mock_content)
|
||||
if os.name != "nt":
|
||||
mock_sccache.chmod(0o755)
|
||||
|
||||
# Set up environment to use mock sccache
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = f"{temp_path}{os.pathsep}{env.get('PATH', '')}"
|
||||
env["XCACHE_DEBUG"] = "1"
|
||||
|
||||
output_file = temp_path / "test_direct.o"
|
||||
|
||||
# Test xcache without response files (direct mode)
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"ci/util/xcache.py",
|
||||
clang_path,
|
||||
"-c",
|
||||
str(source_file),
|
||||
"-o",
|
||||
str(output_file),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, env=env, timeout=30
|
||||
)
|
||||
|
||||
if result.returncode == 0 and output_file.exists():
|
||||
print(" ✅ Direct mode compilation succeeded")
|
||||
print(f" Output file created: {output_file.stat().st_size} bytes")
|
||||
|
||||
# Check if direct execution was used (no wrapper)
|
||||
if "Direct execution:" in result.stderr:
|
||||
print(" ✅ Direct execution mode confirmed")
|
||||
results.passed += 1
|
||||
else:
|
||||
print(" ⚠️ Direct execution mode not clearly indicated")
|
||||
results.passed += 1 # Still pass since compilation worked
|
||||
|
||||
else:
|
||||
print(
|
||||
f" ❌ Direct mode compilation failed (return code: {result.returncode})"
|
||||
)
|
||||
print(f" Stderr: {result.stderr}")
|
||||
results.failed += 1
|
||||
results.errors.append(
|
||||
f"Direct mode compilation failed: {result.stderr}"
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" ❌ Direct mode test timed out")
|
||||
results.failed += 1
|
||||
results.errors.append("Direct mode test timed out")
|
||||
except Exception as e:
|
||||
print(f" ❌ Direct mode test error: {e}")
|
||||
results.failed += 1
|
||||
results.errors.append(f"Direct mode test error: {e}")
|
||||
|
||||
|
||||
def run_all_tests() -> TestResults:
|
||||
"""Run all xcache standalone tests."""
|
||||
print("🚀 XCACHE STANDALONE TESTS WITH CLANG")
|
||||
print("=" * 60)
|
||||
|
||||
results = TestResults()
|
||||
|
||||
# Check prerequisites
|
||||
clang_path = find_clang()
|
||||
|
||||
print(f"📋 Test Environment:")
|
||||
print(f" Clang: {clang_path or 'NOT FOUND'}")
|
||||
print(f" xcache: ci/util/xcache.py")
|
||||
print(f" Note: Using mock sccache for testing wrapper functionality")
|
||||
print()
|
||||
|
||||
if not clang_path:
|
||||
print("❌ Clang compiler not found - install clang to run tests")
|
||||
results.errors.append("Clang compiler not available")
|
||||
return results
|
||||
|
||||
# Run tests
|
||||
test_clang_direct_compilation(results)
|
||||
test_clang_response_file_compilation(results)
|
||||
test_xcache_wrapper_script_creation(results)
|
||||
test_xcache_direct_mode(results)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main test function."""
|
||||
results = run_all_tests()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("📊 TEST RESULTS SUMMARY")
|
||||
print("=" * 60)
|
||||
|
||||
total_tests = results.passed + results.failed
|
||||
if total_tests > 0:
|
||||
success_rate = (results.passed / total_tests) * 100
|
||||
print(f" Tests passed: {results.passed}")
|
||||
print(f" Tests failed: {results.failed}")
|
||||
print(f" Success rate: {success_rate:.1f}%")
|
||||
else:
|
||||
print(" No tests were executed")
|
||||
|
||||
if results.errors:
|
||||
print(f"\n🚨 Errors encountered:")
|
||||
for i, error in enumerate(results.errors, 1):
|
||||
print(f" {i}. {error}")
|
||||
|
||||
if results.failed == 0 and results.passed > 0:
|
||||
print(
|
||||
"\n✅ All xcache standalone tests passed! Wrapper functionality verified."
|
||||
)
|
||||
return 0
|
||||
elif results.passed > 0:
|
||||
print(
|
||||
f"\n⚠️ Some tests passed but {results.failed} failed. Check errors above."
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
print("\n❌ No tests passed. Check prerequisites and errors above.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to validate the new unified build system.
|
||||
|
||||
This script tests the core functionality of the BuildAPI to ensure
|
||||
the refactored build system works correctly.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from ci.build_api import BuildType, create_example_builder, create_unit_test_builder
|
||||
|
||||
|
||||
def test_build_api_creation():
|
||||
"""Test that BuildAPI instances can be created successfully"""
|
||||
print("Testing BuildAPI creation...")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Test unit test builder creation
|
||||
unit_builder = create_unit_test_builder(
|
||||
build_dir=temp_path / "unit", use_pch=True, parallel=True, clean=False
|
||||
)
|
||||
|
||||
assert unit_builder.build_type == BuildType.UNIT_TEST
|
||||
assert unit_builder.build_dir == temp_path / "unit"
|
||||
assert unit_builder.use_pch == True
|
||||
|
||||
print("✓ Unit test builder created successfully")
|
||||
|
||||
# Test example builder creation
|
||||
example_builder = create_example_builder(
|
||||
build_dir=temp_path / "examples", use_pch=True, parallel=True, clean=False
|
||||
)
|
||||
|
||||
assert example_builder.build_type == BuildType.EXAMPLE
|
||||
assert example_builder.build_dir == temp_path / "examples"
|
||||
assert example_builder.use_pch == True
|
||||
|
||||
print("✓ Example builder created successfully")
|
||||
|
||||
|
||||
def test_build_configuration_loading():
|
||||
"""Test that build configurations are loaded correctly"""
|
||||
print("\nTesting build configuration loading...")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Test unit test configuration
|
||||
unit_builder = create_unit_test_builder(build_dir=temp_path / "unit")
|
||||
|
||||
# Check that unit test specific defines are present
|
||||
unit_defines = unit_builder.compiler_settings.defines or []
|
||||
assert "FASTLED_FORCE_NAMESPACE=1" in unit_defines
|
||||
assert "FASTLED_TESTING=1" in unit_defines
|
||||
assert "STUB_PLATFORM" in unit_defines
|
||||
|
||||
print("✓ Unit test configuration loaded correctly")
|
||||
|
||||
# Test example configuration
|
||||
example_builder = create_example_builder(build_dir=temp_path / "examples")
|
||||
|
||||
# Check that example specific defines are present
|
||||
example_defines = example_builder.compiler_settings.defines or []
|
||||
assert "STUB_PLATFORM" in example_defines
|
||||
assert "ARDUINO=10808" in example_defines
|
||||
# Unit test specific defines should NOT be present
|
||||
assert "FASTLED_FORCE_NAMESPACE=1" not in example_defines
|
||||
assert "FASTLED_TESTING=1" not in example_defines
|
||||
|
||||
print("✓ Example configuration loaded correctly")
|
||||
|
||||
|
||||
def test_build_directory_structure():
|
||||
"""Test that build directory structure is created correctly"""
|
||||
print("\nTesting build directory structure...")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
unit_builder = create_unit_test_builder(build_dir=temp_path / "unit")
|
||||
|
||||
# Check that all required directories are created
|
||||
assert unit_builder.build_dir.exists()
|
||||
assert unit_builder.artifacts_dir.exists()
|
||||
assert unit_builder.targets_dir.exists()
|
||||
assert unit_builder.cache_dir.exists()
|
||||
|
||||
# Check directory organization
|
||||
expected_structure = [
|
||||
unit_builder.build_dir / "artifacts",
|
||||
unit_builder.build_dir / "targets",
|
||||
unit_builder.build_dir / "cache",
|
||||
]
|
||||
|
||||
for directory in expected_structure:
|
||||
assert directory.exists(), f"Directory {directory} should exist"
|
||||
|
||||
print("✓ Build directory structure created correctly")
|
||||
|
||||
|
||||
def test_build_info():
|
||||
"""Test that build info is returned correctly"""
|
||||
print("\nTesting build info...")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
unit_builder = create_unit_test_builder(build_dir=temp_path / "unit")
|
||||
info = unit_builder.get_build_info()
|
||||
|
||||
# Check required fields
|
||||
assert "config_file" in info
|
||||
assert "build_dir" in info
|
||||
assert "build_type" in info
|
||||
assert "use_pch" in info
|
||||
assert "parallel" in info
|
||||
|
||||
# Check values
|
||||
assert info["build_type"] == "unit_test"
|
||||
assert info["use_pch"] == True
|
||||
assert str(temp_path / "unit") in info["build_dir"]
|
||||
assert "build_unit.toml" in info["build_flags_toml"]
|
||||
|
||||
print("✓ Build info returned correctly")
|
||||
|
||||
|
||||
def test_clean_functionality():
|
||||
"""Test that clean functionality works"""
|
||||
print("\nTesting clean functionality...")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
unit_builder = create_unit_test_builder(build_dir=temp_path / "unit")
|
||||
|
||||
# Create some fake files to test cleaning
|
||||
test_file = unit_builder.build_dir / "test_file.txt"
|
||||
test_file.write_text("test content")
|
||||
assert test_file.exists()
|
||||
|
||||
# Test clean functionality
|
||||
unit_builder.clean_build_artifacts()
|
||||
|
||||
# Build directory should still exist but be empty (except for subdirs)
|
||||
assert unit_builder.build_dir.exists()
|
||||
assert not test_file.exists()
|
||||
|
||||
# Subdirectories should be recreated
|
||||
assert unit_builder.artifacts_dir.exists()
|
||||
assert unit_builder.targets_dir.exists()
|
||||
assert unit_builder.cache_dir.exists()
|
||||
|
||||
print("✓ Clean functionality works correctly")
|
||||
|
||||
|
||||
def test_configuration_separation():
|
||||
"""Test that unit test and example configurations are properly separated"""
|
||||
print("\nTesting configuration separation...")
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
# Create both builders
|
||||
unit_builder = create_unit_test_builder(build_dir=temp_path / "unit")
|
||||
example_builder = create_example_builder(build_dir=temp_path / "examples")
|
||||
|
||||
# Get build info for comparison
|
||||
unit_info = unit_builder.get_build_info()
|
||||
example_info = example_builder.get_build_info()
|
||||
|
||||
# Configurations should use different files
|
||||
assert "build_unit.toml" in unit_info["build_flags_toml"]
|
||||
assert "build_example.toml" in example_info["build_flags_toml"]
|
||||
|
||||
# Build types should be different
|
||||
assert unit_info["build_type"] == "unit_test"
|
||||
assert example_info["build_type"] == "example"
|
||||
|
||||
# Build directories should be different
|
||||
assert unit_info["build_dir"] != example_info["build_dir"]
|
||||
|
||||
print("✓ Configuration separation works correctly")
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all validation tests"""
|
||||
print("=" * 60)
|
||||
print("FASTLED UNIFIED BUILD SYSTEM VALIDATION")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
test_build_api_creation()
|
||||
test_build_configuration_loading()
|
||||
test_build_directory_structure()
|
||||
test_build_info()
|
||||
test_clean_functionality()
|
||||
test_configuration_separation()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("ALL TESTS PASSED ✓")
|
||||
print("=" * 60)
|
||||
print("The unified build system is working correctly!")
|
||||
print("\nKey features validated:")
|
||||
print("✓ BuildAPI creation for both unit tests and examples")
|
||||
print("✓ Proper configuration loading from separate TOML files")
|
||||
print("✓ Organized build directory structure")
|
||||
print("✓ Clean separation between unit test and example builds")
|
||||
print("✓ Build info reporting")
|
||||
print("✓ Clean functionality")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ TEST FAILED: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_all_tests()
|
||||
exit(0 if success else 1)
|
||||
@@ -0,0 +1,114 @@
|
||||
# pyright: reportUnknownMemberType=false, reportMissingParameterType=false
|
||||
"""Test for checking namespace includes in C++ headers."""
|
||||
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to check for includes after namespace declarations in C++ files.
|
||||
This is used in CI/CD to prevent bad code patterns.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def find_includes_after_namespace(file_path: Path) -> List[int]:
|
||||
"""
|
||||
Check if a C++ file has #include directives after namespace declarations.
|
||||
|
||||
Args:
|
||||
file_path (Path): Path to the C++ file to check
|
||||
|
||||
Returns:
|
||||
List[int]: List of line numbers where includes appear after namespaces
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.readlines()
|
||||
|
||||
violations: List[int] = []
|
||||
namespace_started = False
|
||||
|
||||
# Basic patterns
|
||||
namespace_pattern = re.compile(r"^\s*namespace\s+\w+\s*\{")
|
||||
include_pattern = re.compile(r"^\s*#\s*include")
|
||||
|
||||
for i, line in enumerate(content, 1):
|
||||
line = line.strip()
|
||||
|
||||
# Skip empty lines and comments
|
||||
if not line or line.startswith("//") or line.startswith("/*"):
|
||||
continue
|
||||
|
||||
# Check for namespace declaration
|
||||
if namespace_pattern.match(line):
|
||||
namespace_started = True
|
||||
|
||||
# Check for #include after namespace started
|
||||
if namespace_started and include_pattern.match(line):
|
||||
violations.append(i)
|
||||
|
||||
return violations
|
||||
except (UnicodeDecodeError, IOError):
|
||||
# Skip files that can't be read
|
||||
return []
|
||||
|
||||
|
||||
def scan_cpp_files(directory: str = ".") -> Dict[str, Any]:
|
||||
"""
|
||||
Scan all C++ files in a directory for includes after namespace declarations.
|
||||
|
||||
Args:
|
||||
directory (str): Directory to scan for C++ files
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Dictionary mapping file paths to lists of violation line numbers
|
||||
"""
|
||||
cpp_extensions = [
|
||||
".cpp",
|
||||
".cc",
|
||||
".cxx",
|
||||
".c++",
|
||||
".hpp",
|
||||
".h",
|
||||
".hh",
|
||||
".hxx",
|
||||
".h++",
|
||||
]
|
||||
violations: Dict[str, Any] = {}
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
|
||||
# Check if it's a C++ file
|
||||
if any(file.endswith(ext) for ext in cpp_extensions):
|
||||
line_numbers: List[int] = find_includes_after_namespace(Path(file_path))
|
||||
if line_numbers:
|
||||
violations[file_path] = line_numbers
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> None:
|
||||
violations: Dict[str, Any] = scan_cpp_files()
|
||||
|
||||
if violations:
|
||||
print("Found #include directives after namespace declarations:")
|
||||
for file_path, line_numbers in violations.items():
|
||||
print(f"\n{file_path}:")
|
||||
for line_num in line_numbers:
|
||||
print(f" Line {line_num}")
|
||||
|
||||
failing: List[str] = list(violations.keys())
|
||||
print(f"\nFailing files: {failing}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("No violations found.")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
# pyright: reportUnknownMemberType=false, reportMissingParameterType=false
|
||||
import os
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import List
|
||||
|
||||
from ci.util.paths import PROJECT_ROOT
|
||||
|
||||
|
||||
SRC_ROOT = PROJECT_ROOT / "src"
|
||||
PLATFORMS_DIR = os.path.join(SRC_ROOT, "platforms")
|
||||
|
||||
NUM_WORKERS = 1 if os.environ.get("NO_PARALLEL") else (os.cpu_count() or 1) * 4
|
||||
|
||||
|
||||
class NoUsingNamespaceFlInHeaderTester(unittest.TestCase):
|
||||
def check_file(self, file_path: str) -> List[str]:
|
||||
if "FastLED.h" in file_path:
|
||||
return []
|
||||
failings: List[str] = []
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
for line_number, line in enumerate(f, 1):
|
||||
if line.startswith("//"):
|
||||
continue
|
||||
if "using namespace fl;" in line:
|
||||
failings.append(f"{file_path}:{line_number}: {line.strip()}")
|
||||
return failings
|
||||
|
||||
def test_no_using_namespace(self) -> None:
|
||||
"""Searches through the program files to check for banned headers, excluding src/platforms."""
|
||||
files_to_check: List[str] = []
|
||||
for root, _, files in os.walk(SRC_ROOT):
|
||||
for file in files:
|
||||
if file.endswith(
|
||||
(".h", ".hpp")
|
||||
): # Add or remove file extensions as needed
|
||||
file_path = os.path.join(root, file)
|
||||
files_to_check.append(file_path)
|
||||
|
||||
all_failings: List[str] = []
|
||||
with ThreadPoolExecutor(max_workers=NUM_WORKERS) as executor:
|
||||
futures = [
|
||||
executor.submit(self.check_file, file_path)
|
||||
for file_path in files_to_check
|
||||
]
|
||||
for future in futures:
|
||||
all_failings.extend(future.result())
|
||||
|
||||
if all_failings:
|
||||
msg = (
|
||||
f'Found {len(all_failings)} header file(s) "using namespace fl": \n'
|
||||
+ "\n".join(all_failings)
|
||||
)
|
||||
for failing in all_failings:
|
||||
print(failing)
|
||||
self.fail(msg)
|
||||
else:
|
||||
print("No using namespace fl; found in headers.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,74 @@
|
||||
import subprocess
|
||||
import unittest
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
from ci.util.bin_2_elf import bin_to_elf
|
||||
from ci.util.elf import dump_symbol_sizes
|
||||
from ci.util.paths import PROJECT_ROOT
|
||||
from ci.util.tools import Tools, load_tools
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent.absolute()
|
||||
UNO = HERE / "uno"
|
||||
OUTPUT = HERE / "output"
|
||||
|
||||
|
||||
BUILD_INFO_PATH = PROJECT_ROOT / ".build" / "examples" / "uno" / "build_info.json"
|
||||
BUILD_INFO_PATH2 = (
|
||||
PROJECT_ROOT / ".build" / "fled" / "examples" / "uno" / "build_info.json"
|
||||
)
|
||||
|
||||
DISABLED = True
|
||||
|
||||
|
||||
class TestBinToElf(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if DISABLED:
|
||||
return
|
||||
uno_build = PROJECT_ROOT / ".build" / "uno"
|
||||
print(f"Checking for Uno build in: {uno_build}")
|
||||
if not uno_build.exists():
|
||||
print("Uno build not found. Running compilation...")
|
||||
try:
|
||||
subprocess.run(
|
||||
"uv run python -m ci.ci-compile uno --examples Blink",
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
print("Compilation completed successfully.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error during compilation: {e}")
|
||||
raise
|
||||
|
||||
@unittest.skip("Skip bin to elf conversion test")
|
||||
def test_bin_to_elf_conversion(self) -> None:
|
||||
if DISABLED:
|
||||
return
|
||||
tools: Tools
|
||||
try:
|
||||
tools = load_tools(BUILD_INFO_PATH)
|
||||
except FileNotFoundError as e:
|
||||
warnings.warn(f"Error while loading tools: {e}")
|
||||
tools = load_tools(BUILD_INFO_PATH2)
|
||||
bin_file = UNO / "firmware.hex"
|
||||
map_file = UNO / "firmware.map"
|
||||
output_elf = OUTPUT / "output.elf"
|
||||
try:
|
||||
bin_to_elf(
|
||||
bin_file,
|
||||
map_file,
|
||||
tools.as_path,
|
||||
tools.ld_path,
|
||||
tools.objcopy_path,
|
||||
output_elf,
|
||||
)
|
||||
stdout = dump_symbol_sizes(tools.nm_path, tools.cpp_filt_path, output_elf)
|
||||
print(stdout)
|
||||
except Exception as e:
|
||||
warnings.warn(f"Error while converting binary to ELF: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,53 @@
|
||||
import unittest
|
||||
|
||||
from ci.boards import Board
|
||||
|
||||
|
||||
class TestBoardToPlatformioIni(unittest.TestCase):
|
||||
"""Tests for Board.to_platformio_ini()."""
|
||||
|
||||
def _ini_to_set(self, ini: str) -> set[str]:
|
||||
"""Return a set with each non-empty, stripped line of the ini snippet."""
|
||||
return {line.strip() for line in ini.splitlines() if line.strip()}
|
||||
|
||||
def test_basic_fields(self):
|
||||
board = Board(board_name="uno", platform="atmelavr", framework="arduino")
|
||||
ini = board.to_platformio_ini()
|
||||
lines = self._ini_to_set(ini)
|
||||
expected = {
|
||||
"[env:uno]",
|
||||
"board = uno",
|
||||
"platform = atmelavr",
|
||||
"framework = arduino",
|
||||
}
|
||||
self.assertTrue(expected.issubset(lines))
|
||||
# Should not reference internal attributes
|
||||
self.assertNotIn("platform_needs_install", ini)
|
||||
|
||||
def test_real_board_name(self):
|
||||
board = Board(
|
||||
board_name="esp32c3",
|
||||
real_board_name="esp32-c3-devkitm-1",
|
||||
platform="espressif32",
|
||||
)
|
||||
ini = board.to_platformio_ini()
|
||||
lines = self._ini_to_set(ini)
|
||||
self.assertIn("[env:esp32c3]", lines)
|
||||
self.assertIn("board = esp32-c3-devkitm-1", lines)
|
||||
|
||||
def test_flags_and_unflags(self):
|
||||
board = Board(
|
||||
board_name="custom",
|
||||
defines=["FASTLED_TEST=1"],
|
||||
build_flags=["-O2"],
|
||||
)
|
||||
ini = board.to_platformio_ini()
|
||||
lines = self._ini_to_set(ini)
|
||||
# The build_flags are in multi-line format - check that both flags are present as separate lines
|
||||
self.assertIn("build_flags =", lines)
|
||||
self.assertIn("-DFASTLED_TEST=1", lines)
|
||||
self.assertIn("-O2", lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for BuildFlags TOML parsing and serialization functionality.
|
||||
|
||||
Tests the BuildFlags and BuildTools classes for parsing TOML build configuration
|
||||
files and serializing back to TOML format.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from ci.compiler.clang_compiler import ArchiveOptions, BuildFlags, BuildTools
|
||||
|
||||
|
||||
class TestBuildFlagsToml(unittest.TestCase):
|
||||
"""Test BuildFlags TOML parsing and serialization"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up test fixtures"""
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Clean up test fixtures"""
|
||||
# Clean up any test files
|
||||
for file in self.temp_dir.glob("*.toml"):
|
||||
file.unlink()
|
||||
self.temp_dir.rmdir()
|
||||
|
||||
def create_test_toml(self, content: str) -> Path:
|
||||
"""Create a test TOML file with the given content"""
|
||||
test_file = self.temp_dir / "test_build_flags.toml"
|
||||
with open(test_file, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return test_file
|
||||
|
||||
def test_build_tools_defaults(self) -> None:
|
||||
"""Test BuildTools default values"""
|
||||
tools = BuildTools(
|
||||
cpp_compiler=[],
|
||||
archiver=[],
|
||||
linker=[],
|
||||
c_compiler=[],
|
||||
objcopy=[],
|
||||
nm=[],
|
||||
strip=[],
|
||||
ranlib=[],
|
||||
)
|
||||
|
||||
# Test modern command-based fields (should be empty by default)
|
||||
self.assertEqual(tools.cpp_compiler, [])
|
||||
self.assertEqual(tools.archiver, [])
|
||||
self.assertEqual(tools.linker, [])
|
||||
|
||||
# Test other important fields
|
||||
self.assertEqual(tools.c_compiler, [])
|
||||
self.assertEqual(tools.objcopy, [])
|
||||
self.assertEqual(tools.nm, [])
|
||||
self.assertEqual(tools.strip, [])
|
||||
self.assertEqual(tools.ranlib, [])
|
||||
|
||||
def test_parse_minimal_toml(self) -> None:
|
||||
"""Test parsing minimal TOML file with required tools section"""
|
||||
toml_content = """
|
||||
[all]
|
||||
defines = ["-DTEST=1"]
|
||||
compiler_flags = ["-Wall"]
|
||||
include_flags = ["-I."]
|
||||
|
||||
[tools]
|
||||
cpp_compiler = ["uv", "run", "python", "-m", "ziglang", "c++"]
|
||||
linker = ["uv", "run", "python", "-m", "ziglang", "c++"]
|
||||
c_compiler = ["clang"]
|
||||
objcopy = ["uv", "run", "python", "-m", "ziglang", "objcopy"]
|
||||
nm = ["uv", "run", "python", "-m", "ziglang", "nm"]
|
||||
strip = ["uv", "run", "python", "-m", "ziglang", "strip"]
|
||||
ranlib = ["uv", "run", "python", "-m", "ziglang", "ranlib"]
|
||||
archiver = ["uv", "run", "python", "-m", "ziglang", "ar"]
|
||||
|
||||
[archive]
|
||||
flags = "rcsD"
|
||||
|
||||
[linking.base]
|
||||
flags = ["-pthread"]
|
||||
|
||||
[strict_mode]
|
||||
flags = ["-Werror"]
|
||||
"""
|
||||
|
||||
test_file = self.create_test_toml(toml_content)
|
||||
flags = BuildFlags.parse(test_file, quick_build=False, strict_mode=False)
|
||||
|
||||
# Check basic flags
|
||||
self.assertEqual(flags.defines, ["-DTEST=1"])
|
||||
self.assertEqual(flags.compiler_flags, ["-Wall"])
|
||||
self.assertEqual(flags.include_flags, ["-I."])
|
||||
self.assertEqual(flags.link_flags, ["-pthread"])
|
||||
self.assertEqual(flags.strict_mode_flags, ["-Werror"])
|
||||
|
||||
# Check tools (from [tools] section) - modern command-based approach
|
||||
self.assertEqual(flags.tools.c_compiler, ["clang"])
|
||||
self.assertEqual(
|
||||
flags.tools.linker, ["uv", "run", "python", "-m", "ziglang", "c++"]
|
||||
)
|
||||
self.assertEqual(
|
||||
flags.tools.cpp_compiler,
|
||||
["uv", "run", "python", "-m", "ziglang", "c++"],
|
||||
)
|
||||
self.assertEqual(
|
||||
flags.tools.objcopy, ["uv", "run", "python", "-m", "ziglang", "objcopy"]
|
||||
)
|
||||
self.assertEqual(flags.tools.nm, ["uv", "run", "python", "-m", "ziglang", "nm"])
|
||||
self.assertEqual(
|
||||
flags.tools.strip, ["uv", "run", "python", "-m", "ziglang", "strip"]
|
||||
)
|
||||
self.assertEqual(
|
||||
flags.tools.ranlib, ["uv", "run", "python", "-m", "ziglang", "ranlib"]
|
||||
)
|
||||
self.assertEqual(
|
||||
flags.tools.archiver, ["uv", "run", "python", "-m", "ziglang", "ar"]
|
||||
)
|
||||
|
||||
def test_parse_toml_with_tools(self) -> None:
|
||||
"""Test parsing TOML file with [tools] section"""
|
||||
toml_content = """
|
||||
[all]
|
||||
defines = ["-DTEST=1"]
|
||||
compiler_flags = ["-Wall"]
|
||||
include_flags = ["-I."]
|
||||
|
||||
[tools]
|
||||
cpp_compiler = ["g++"]
|
||||
archiver = ["gcc-ar"]
|
||||
linker = ["ld.gold"]
|
||||
c_compiler = ["gcc"]
|
||||
objcopy = ["arm-objcopy"]
|
||||
nm = ["arm-nm"]
|
||||
strip = ["arm-strip"]
|
||||
ranlib = ["arm-ranlib"]
|
||||
|
||||
[archive]
|
||||
flags = "rcsD"
|
||||
|
||||
[linking.base]
|
||||
flags = ["-pthread"]
|
||||
"""
|
||||
|
||||
test_file = self.create_test_toml(toml_content)
|
||||
flags = BuildFlags.parse(test_file, quick_build=False, strict_mode=False)
|
||||
|
||||
# Check that tools were parsed correctly - modern command-based approach
|
||||
self.assertEqual(flags.tools.cpp_compiler, ["g++"])
|
||||
self.assertEqual(flags.tools.archiver, ["gcc-ar"])
|
||||
self.assertEqual(flags.tools.linker, ["ld.gold"])
|
||||
self.assertEqual(flags.tools.c_compiler, ["gcc"])
|
||||
self.assertEqual(flags.tools.objcopy, ["arm-objcopy"])
|
||||
self.assertEqual(flags.tools.nm, ["arm-nm"])
|
||||
self.assertEqual(flags.tools.strip, ["arm-strip"])
|
||||
self.assertEqual(flags.tools.ranlib, ["arm-ranlib"])
|
||||
|
||||
def test_parse_partial_tools_section(self) -> None:
|
||||
"""Test parsing TOML with all required [tools] section fields"""
|
||||
toml_content = """
|
||||
[all]
|
||||
defines = ["-DTEST=1"]
|
||||
|
||||
[tools]
|
||||
cpp_compiler = ["custom-clang++"]
|
||||
archiver = ["custom-ar"]
|
||||
linker = ["custom-linker"]
|
||||
c_compiler = ["clang"]
|
||||
objcopy = ["custom-objcopy"]
|
||||
nm = ["custom-nm"]
|
||||
strip = ["custom-strip"]
|
||||
ranlib = ["custom-ranlib"]
|
||||
# All tools must be provided - no defaults allowed
|
||||
|
||||
[archive]
|
||||
flags = "rcsD"
|
||||
"""
|
||||
|
||||
test_file = self.create_test_toml(toml_content)
|
||||
flags = BuildFlags.parse(test_file, quick_build=False, strict_mode=False)
|
||||
|
||||
# Check all tools are set as specified - strict validation
|
||||
self.assertEqual(flags.tools.cpp_compiler, ["custom-clang++"])
|
||||
self.assertEqual(flags.tools.archiver, ["custom-ar"])
|
||||
self.assertEqual(flags.tools.linker, ["custom-linker"])
|
||||
self.assertEqual(flags.tools.c_compiler, ["clang"])
|
||||
self.assertEqual(flags.tools.objcopy, ["custom-objcopy"])
|
||||
self.assertEqual(flags.tools.nm, ["custom-nm"])
|
||||
self.assertEqual(flags.tools.strip, ["custom-strip"])
|
||||
self.assertEqual(flags.tools.ranlib, ["custom-ranlib"])
|
||||
|
||||
def test_serialize_build_flags_with_tools(self) -> None:
|
||||
"""Test serializing BuildFlags with tools to TOML"""
|
||||
# Create BuildFlags with custom tools
|
||||
custom_tools = BuildTools(
|
||||
linker=["arm-none-eabi-ld"],
|
||||
c_compiler=["arm-none-eabi-gcc"],
|
||||
objcopy=["arm-none-eabi-objcopy"],
|
||||
nm=["arm-none-eabi-nm"],
|
||||
strip=["arm-none-eabi-strip"],
|
||||
ranlib=["arm-none-eabi-ranlib"],
|
||||
cpp_compiler=["arm-none-eabi-g++"],
|
||||
archiver=["arm-none-eabi-ar"],
|
||||
)
|
||||
|
||||
flags = BuildFlags(
|
||||
defines=["-DARM_BUILD=1"],
|
||||
compiler_flags=["-mcpu=cortex-m4", "-mthumb"],
|
||||
include_flags=["-I.", "-Iarm"],
|
||||
link_flags=["-nostdlib"],
|
||||
strict_mode_flags=["-Werror"],
|
||||
tools=custom_tools,
|
||||
archive=ArchiveOptions(flags="rcsD"),
|
||||
)
|
||||
|
||||
# Serialize to TOML
|
||||
toml_output = flags.serialize()
|
||||
|
||||
# Check that tools section is present with new field names
|
||||
self.assertIn("[tools]", toml_output)
|
||||
self.assertIn("cpp_compiler = ['arm-none-eabi-g++']", toml_output)
|
||||
self.assertIn("archiver = ['arm-none-eabi-ar']", toml_output)
|
||||
self.assertIn("linker = ['arm-none-eabi-ld']", toml_output)
|
||||
self.assertIn("c_compiler = ['arm-none-eabi-gcc']", toml_output)
|
||||
self.assertIn("objcopy = ['arm-none-eabi-objcopy']", toml_output)
|
||||
self.assertIn("nm = ['arm-none-eabi-nm']", toml_output)
|
||||
self.assertIn("strip = ['arm-none-eabi-strip']", toml_output)
|
||||
self.assertIn("ranlib = ['arm-none-eabi-ranlib']", toml_output)
|
||||
|
||||
def test_serialize_with_none_linker(self) -> None:
|
||||
"""Test serializing BuildFlags when linker is None"""
|
||||
flags = BuildFlags(
|
||||
defines=["-DTEST=1"],
|
||||
compiler_flags=[],
|
||||
include_flags=[],
|
||||
link_flags=[],
|
||||
strict_mode_flags=[],
|
||||
tools=BuildTools(
|
||||
linker=[], # Empty list instead of None
|
||||
cpp_compiler=["clang++"],
|
||||
c_compiler=["clang"],
|
||||
archiver=[],
|
||||
objcopy=[],
|
||||
nm=[],
|
||||
strip=[],
|
||||
ranlib=[],
|
||||
),
|
||||
archive=ArchiveOptions(flags="rcsD"),
|
||||
)
|
||||
|
||||
toml_output = flags.serialize()
|
||||
|
||||
# Check that tools section is present but linker is omitted
|
||||
self.assertIn("[tools]", toml_output)
|
||||
self.assertIn("cpp_compiler = ['clang++']", toml_output)
|
||||
self.assertNotIn("linker =", toml_output) # Should be omitted when empty
|
||||
|
||||
def test_round_trip_toml_parsing(self) -> None:
|
||||
"""Test that parse -> serialize -> parse maintains data integrity"""
|
||||
# Create original flags
|
||||
original_tools = BuildTools(
|
||||
linker=["test-ld"],
|
||||
c_compiler=["test-gcc"],
|
||||
cpp_compiler=["test-compiler"],
|
||||
archiver=["test-ar"],
|
||||
objcopy=["test-objcopy"],
|
||||
nm=["test-nm"],
|
||||
strip=["test-strip"],
|
||||
ranlib=["test-ranlib"],
|
||||
)
|
||||
|
||||
original_flags = BuildFlags(
|
||||
defines=["-DROUND_TRIP=1"],
|
||||
compiler_flags=["-Wall", "-O2"],
|
||||
include_flags=["-I.", "-Itest"],
|
||||
link_flags=["-pthread"],
|
||||
strict_mode_flags=["-Werror"],
|
||||
tools=original_tools,
|
||||
archive=ArchiveOptions(flags="rcsD"),
|
||||
)
|
||||
|
||||
# Serialize to TOML
|
||||
toml_content = original_flags.serialize()
|
||||
|
||||
# Write to temporary file
|
||||
temp_file = self.temp_dir / "roundtrip.toml"
|
||||
original_flags.to_toml_file(temp_file)
|
||||
|
||||
# Parse back from file
|
||||
parsed_flags = BuildFlags.parse(temp_file, quick_build=False, strict_mode=False)
|
||||
|
||||
# Check that all data is preserved
|
||||
self.assertEqual(parsed_flags.defines, original_flags.defines)
|
||||
self.assertEqual(parsed_flags.compiler_flags, original_flags.compiler_flags)
|
||||
self.assertEqual(parsed_flags.include_flags, original_flags.include_flags)
|
||||
self.assertEqual(parsed_flags.link_flags, original_flags.link_flags)
|
||||
self.assertEqual(
|
||||
parsed_flags.strict_mode_flags, original_flags.strict_mode_flags
|
||||
)
|
||||
|
||||
# Check tools - modern command-based approach
|
||||
self.assertEqual(
|
||||
parsed_flags.tools.cpp_compiler, original_flags.tools.cpp_compiler
|
||||
)
|
||||
self.assertEqual(parsed_flags.tools.archiver, original_flags.tools.archiver)
|
||||
self.assertEqual(parsed_flags.tools.linker, original_flags.tools.linker)
|
||||
self.assertEqual(parsed_flags.tools.c_compiler, original_flags.tools.c_compiler)
|
||||
|
||||
def test_parse_missing_file(self) -> None:
|
||||
"""Test parsing non-existent TOML file raises FileNotFoundError"""
|
||||
nonexistent_file = self.temp_dir / "does_not_exist.toml"
|
||||
|
||||
# Should raise FileNotFoundError when file is missing
|
||||
with self.assertRaises(FileNotFoundError) as context:
|
||||
BuildFlags.parse(nonexistent_file, quick_build=False, strict_mode=False)
|
||||
|
||||
self.assertIn("Required build_flags.toml not found", str(context.exception))
|
||||
|
||||
def test_from_toml_file_alias(self) -> None:
|
||||
"""Test that from_toml_file() is an alias for parse()"""
|
||||
toml_content = """
|
||||
[all]
|
||||
defines = ["-DALIAS_TEST=1"]
|
||||
|
||||
[tools]
|
||||
cpp_compiler = ["alias-compiler"]
|
||||
linker = ["alias-linker"]
|
||||
c_compiler = ["clang"]
|
||||
objcopy = ["alias-objcopy"]
|
||||
nm = ["alias-nm"]
|
||||
strip = ["alias-strip"]
|
||||
ranlib = ["alias-ranlib"]
|
||||
archiver = ["alias-archiver"]
|
||||
|
||||
[archive]
|
||||
flags = "rcsD"
|
||||
"""
|
||||
|
||||
test_file = self.create_test_toml(toml_content)
|
||||
|
||||
# Parse using both methods
|
||||
flags_parse = BuildFlags.parse(test_file, quick_build=True, strict_mode=False)
|
||||
flags_alias = BuildFlags.from_toml_file(
|
||||
test_file, quick_build=True, strict_mode=False
|
||||
)
|
||||
|
||||
# Should be identical
|
||||
self.assertEqual(flags_parse.defines, flags_alias.defines)
|
||||
self.assertEqual(flags_parse.tools.cpp_compiler, flags_alias.tools.cpp_compiler)
|
||||
self.assertEqual(flags_parse.tools.cpp_compiler, ["alias-compiler"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Tests for BuildFlags TOML parsing in ci/ directory.
|
||||
|
||||
This test discovers all top-level *.toml files under the project ci/ folder
|
||||
and verifies they can be parsed by BuildFlags.parse without throwing.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from ci.compiler.clang_compiler import BuildFlags
|
||||
|
||||
|
||||
def find_ci_toml_files(ci_dir: Path) -> List[Path]:
|
||||
"""Return a list of top-level *.toml files in ci/.
|
||||
|
||||
Args:
|
||||
ci_dir: Absolute path to the ci/ directory
|
||||
|
||||
Returns:
|
||||
List of absolute paths to .toml files directly under ci/.
|
||||
"""
|
||||
toml_files: List[Path] = []
|
||||
for entry in ci_dir.iterdir():
|
||||
if entry.is_file() and entry.suffix == ".toml":
|
||||
toml_files.append(entry.resolve())
|
||||
return toml_files
|
||||
|
||||
|
||||
class TestBuildFlagsParsing(unittest.TestCase):
|
||||
def test_parse_all_ci_tomls(self) -> None:
|
||||
project_root: Path = Path(__file__).resolve().parent.parent.parent
|
||||
ci_dir: Path = project_root / "ci"
|
||||
self.assertTrue(ci_dir.exists(), f"ci directory not found at {ci_dir}")
|
||||
|
||||
toml_files: List[Path] = find_ci_toml_files(ci_dir)
|
||||
self.assertTrue(len(toml_files) > 0, "No .toml files found in ci/")
|
||||
|
||||
for toml_path in toml_files:
|
||||
with self.subTest(toml=str(toml_path)):
|
||||
# Ensure each ci/*.toml parses without throwing
|
||||
_ = BuildFlags.parse(toml_path, quick_build=True, strict_mode=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,65 @@
|
||||
import subprocess
|
||||
import unittest
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
from ci.util.elf import dump_symbol_sizes
|
||||
from ci.util.paths import PROJECT_ROOT
|
||||
from ci.util.tools import Tools, load_tools
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent.absolute()
|
||||
UNO = HERE / "uno"
|
||||
OUTPUT = HERE / "output"
|
||||
ELF_FILE = UNO / "firmware.elf"
|
||||
BUILD_INFO_PATH = PROJECT_ROOT / ".build" / "uno" / "build_info.json"
|
||||
BUILD_INFO_PATH2 = (
|
||||
PROJECT_ROOT / ".build" / "fled" / "examples" / "uno" / "build_info.json"
|
||||
)
|
||||
|
||||
|
||||
PLATFORMIO_PATH = Path.home() / ".platformio"
|
||||
PLATFORMIO_PACKAGES_PATH = PLATFORMIO_PATH / "packages"
|
||||
TOOLCHAIN_AVR = PLATFORMIO_PACKAGES_PATH / "toolchain-atmelavr"
|
||||
|
||||
|
||||
def init() -> None:
|
||||
uno_build = PROJECT_ROOT / ".build" / "uno"
|
||||
print(f"Checking for Uno build in: {uno_build}")
|
||||
if not BUILD_INFO_PATH.exists() or not TOOLCHAIN_AVR.exists():
|
||||
print("Uno build not found. Running compilation...")
|
||||
try:
|
||||
subprocess.run(
|
||||
"uv run python -m ci.ci-compile uno --examples Blink",
|
||||
shell=True,
|
||||
check=True,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
)
|
||||
print("Compilation completed successfully.")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error during compilation: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class TestBinToElf(unittest.TestCase):
|
||||
def test_bin_to_elf_conversion(self) -> None:
|
||||
# Skip test if required UNO build directory is missing
|
||||
uno_build_dir = PROJECT_ROOT / ".build" / "uno"
|
||||
if not uno_build_dir.exists():
|
||||
warnings.warn(
|
||||
"Skipping TestBinToElf::test_bin_to_elf_conversion because .build/uno does not exist. "
|
||||
"Run 'uv run ci/ci-compile.py uno --examples Blink' to generate it."
|
||||
)
|
||||
self.skipTest(".build/uno missing; skipping ELF conversion test")
|
||||
tools: Tools
|
||||
try:
|
||||
tools = load_tools(BUILD_INFO_PATH)
|
||||
except Exception as e:
|
||||
warnings.warn(f"Error while loading tools: {e}")
|
||||
tools = load_tools(BUILD_INFO_PATH2)
|
||||
msg = dump_symbol_sizes(tools.nm_path, tools.cpp_filt_path, ELF_FILE)
|
||||
print(msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
Comprehensive test suite for fingerprint cache functionality.
|
||||
|
||||
Tests cover:
|
||||
- Core functionality (cache hits, misses, content changes)
|
||||
- Edge cases (touched files, copied files, corruption)
|
||||
- Performance requirements
|
||||
- Integration scenarios
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from unittest import TestCase
|
||||
|
||||
# Import the fingerprint cache module
|
||||
from ci.ci.fingerprint_cache import CacheEntry, FingerprintCache, has_changed
|
||||
|
||||
|
||||
class TestFingerprintCache(TestCase):
|
||||
"""Test suite for fingerprint cache functionality."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Set up test environment with temporary directory."""
|
||||
self.test_dir = Path(tempfile.mkdtemp())
|
||||
self.cache_dir = self.test_dir / "cache"
|
||||
self.cache_dir.mkdir(exist_ok=True)
|
||||
self.temp_dir = self.test_dir / "temp"
|
||||
self.temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""Clean up test environment."""
|
||||
shutil.rmtree(self.test_dir, ignore_errors=True)
|
||||
|
||||
def create_test_file(
|
||||
self, path: Path, content: str, modtime: Optional[float] = None
|
||||
) -> Path:
|
||||
"""Create a test file with specific content and optional modification time."""
|
||||
with open(path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
if modtime:
|
||||
# Set specific modification time
|
||||
os.utime(path, (modtime, modtime))
|
||||
|
||||
return path
|
||||
|
||||
def touch_file(self, path: Path) -> float:
|
||||
"""Touch a file to update its modification time without changing content."""
|
||||
# Wait a small amount to ensure modtime changes
|
||||
time.sleep(0.01)
|
||||
path.touch()
|
||||
return os.path.getmtime(path)
|
||||
|
||||
def modify_file_content(self, path: Path, new_content: str) -> float:
|
||||
"""Modify file content and return new modification time."""
|
||||
time.sleep(0.01) # Ensure modtime changes
|
||||
with open(path, "w") as f:
|
||||
f.write(new_content)
|
||||
return os.path.getmtime(path)
|
||||
|
||||
def test_cache_hit_modtime_unchanged(self) -> None:
|
||||
"""Test that identical modtime returns False immediately."""
|
||||
cache_file = self.cache_dir / "test_cache.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Create test file
|
||||
test_file = self.temp_dir / "test.txt"
|
||||
self.create_test_file(test_file, "original content")
|
||||
original_modtime = os.path.getmtime(test_file)
|
||||
|
||||
# Same modtime should return False (no change detected)
|
||||
result1 = cache.has_changed(test_file, original_modtime)
|
||||
self.assertFalse(result1, "Same modtime should return False")
|
||||
|
||||
# Verify fast performance (cache hit)
|
||||
start_time = time.time()
|
||||
result2 = cache.has_changed(test_file, original_modtime)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
self.assertFalse(result2, "Same modtime should return False")
|
||||
self.assertLess(
|
||||
elapsed, 0.002, f"Cache hit should be <2ms, took {elapsed * 1000:.2f}ms"
|
||||
)
|
||||
|
||||
def test_cache_hit_with_existing_cache(self) -> None:
|
||||
"""Test cache hit when file exists in cache with current modtime."""
|
||||
cache_file = self.cache_dir / "test_cache.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Create and cache a file
|
||||
test_file = self.temp_dir / "cached.txt"
|
||||
self.create_test_file(test_file, "cached content")
|
||||
current_modtime = os.path.getmtime(test_file)
|
||||
|
||||
# Prime the cache
|
||||
cache.has_changed(
|
||||
test_file, current_modtime - 1
|
||||
) # Different modtime to force caching
|
||||
|
||||
# Test cache hit
|
||||
result = cache.has_changed(test_file, current_modtime)
|
||||
self.assertFalse(
|
||||
result, "File with current modtime in cache should return False"
|
||||
)
|
||||
|
||||
def test_modtime_changed_content_same(self) -> None:
|
||||
"""Critical test: modtime changes but content identical should return False."""
|
||||
cache_file = self.cache_dir / "test_cache.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Create test file
|
||||
test_file = self.temp_dir / "touched.txt"
|
||||
original_content = "unchanged content"
|
||||
self.create_test_file(test_file, original_content)
|
||||
original_modtime = os.path.getmtime(test_file)
|
||||
|
||||
# Prime cache with original state
|
||||
cache.has_changed(test_file, original_modtime - 1) # Force caching
|
||||
|
||||
# Touch file (change modtime but not content)
|
||||
new_modtime = self.touch_file(test_file)
|
||||
self.assertNotEqual(
|
||||
new_modtime, original_modtime, "Touch should change modtime"
|
||||
)
|
||||
|
||||
# Verify content is still the same
|
||||
with open(test_file, "r") as f:
|
||||
current_content = f.read()
|
||||
self.assertEqual(
|
||||
current_content, original_content, "Content should be unchanged"
|
||||
)
|
||||
|
||||
# Test the critical behavior: should return False despite modtime change
|
||||
result = cache.has_changed(test_file, original_modtime)
|
||||
self.assertFalse(
|
||||
result, "File with changed modtime but same content should return False"
|
||||
)
|
||||
|
||||
def test_file_copy_different_timestamp(self) -> None:
|
||||
"""Test same file with different timestamps but identical content."""
|
||||
cache_file = self.cache_dir / "test_cache.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Create original file
|
||||
test_file = self.temp_dir / "timestamp_test.txt"
|
||||
content = "file content for timestamp testing"
|
||||
self.create_test_file(test_file, content)
|
||||
original_modtime = os.path.getmtime(test_file)
|
||||
|
||||
# Prime cache with original state
|
||||
cache.has_changed(test_file, original_modtime - 1)
|
||||
|
||||
# Touch file to change timestamp but keep content same
|
||||
time.sleep(0.01)
|
||||
new_modtime = time.time()
|
||||
os.utime(test_file, (new_modtime, new_modtime))
|
||||
|
||||
# Verify content is still the same
|
||||
with open(test_file, "r") as f:
|
||||
current_content = f.read()
|
||||
self.assertEqual(current_content, content, "Content should be unchanged")
|
||||
|
||||
# Test: different modtime, same content should return False
|
||||
result = cache.has_changed(test_file, original_modtime)
|
||||
self.assertFalse(
|
||||
result, "File with different timestamp but same content should return False"
|
||||
)
|
||||
|
||||
def test_content_actually_changed(self) -> None:
|
||||
"""Test that actual content changes are detected."""
|
||||
cache_file = self.cache_dir / "test_cache.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Create test file
|
||||
test_file = self.temp_dir / "modified.txt"
|
||||
original_content = "original content"
|
||||
self.create_test_file(test_file, original_content)
|
||||
original_modtime = os.path.getmtime(test_file)
|
||||
|
||||
# Prime cache
|
||||
cache.has_changed(test_file, original_modtime - 1)
|
||||
|
||||
# Actually modify content
|
||||
new_content = "modified content"
|
||||
new_modtime = self.modify_file_content(test_file, new_content)
|
||||
|
||||
# Should detect change
|
||||
result = cache.has_changed(test_file, original_modtime)
|
||||
self.assertTrue(result, "File with changed content should return True")
|
||||
|
||||
def test_incremental_changes(self) -> None:
|
||||
"""Test detecting changes at different points in time."""
|
||||
cache_file = self.cache_dir / "test_cache.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
test_file = self.temp_dir / "incremental.txt"
|
||||
|
||||
# Version 1 - baseline
|
||||
content_v1 = "version 1"
|
||||
self.create_test_file(test_file, content_v1)
|
||||
modtime_v1 = os.path.getmtime(test_file)
|
||||
|
||||
# Test v1 against earlier time (should be changed)
|
||||
result = cache.has_changed(test_file, modtime_v1 - 1)
|
||||
self.assertTrue(result, "File should be detected as changed from earlier time")
|
||||
|
||||
# Test v1 against same time (should be unchanged)
|
||||
result = cache.has_changed(test_file, modtime_v1)
|
||||
self.assertFalse(result, "File should be unchanged from same time")
|
||||
|
||||
# Version 2 - modify content
|
||||
content_v2 = "version 2"
|
||||
modtime_v2 = self.modify_file_content(test_file, content_v2)
|
||||
|
||||
# Test v2 against v1 time (should be changed)
|
||||
result = cache.has_changed(test_file, modtime_v1)
|
||||
self.assertTrue(result, "File should be detected as changed from v1 time")
|
||||
|
||||
# Test v2 against v2 time (should be unchanged)
|
||||
result = cache.has_changed(test_file, modtime_v2)
|
||||
self.assertFalse(result, "File should be unchanged from v2 time")
|
||||
|
||||
def test_cache_persistence(self) -> None:
|
||||
"""Test that cache persists across FingerprintCache instances."""
|
||||
cache_file = self.cache_dir / "persistent.json"
|
||||
|
||||
# First cache instance
|
||||
cache1 = FingerprintCache(cache_file)
|
||||
test_file = self.temp_dir / "persistent.txt"
|
||||
self.create_test_file(test_file, "persistent content")
|
||||
modtime = os.path.getmtime(test_file)
|
||||
|
||||
# Prime cache
|
||||
cache1.has_changed(test_file, modtime - 1)
|
||||
|
||||
# Second cache instance (reload from disk)
|
||||
cache2 = FingerprintCache(cache_file)
|
||||
|
||||
# Should use cached data
|
||||
result = cache2.has_changed(test_file, modtime)
|
||||
self.assertFalse(result, "New cache instance should load existing cache")
|
||||
|
||||
def test_cache_corruption_recovery(self) -> None:
|
||||
"""Test graceful handling of corrupted cache files."""
|
||||
cache_file = self.cache_dir / "corrupted.json"
|
||||
|
||||
# Create corrupted cache file
|
||||
with open(cache_file, "w") as f:
|
||||
f.write("{ invalid json content")
|
||||
|
||||
# Should handle corruption gracefully
|
||||
cache = FingerprintCache(cache_file)
|
||||
test_file = self.temp_dir / "recovery.txt"
|
||||
self.create_test_file(test_file, "recovery content")
|
||||
modtime = os.path.getmtime(test_file)
|
||||
|
||||
# Should work despite corrupted cache
|
||||
result = cache.has_changed(test_file, modtime - 1)
|
||||
self.assertTrue(result, "Should work with corrupted cache")
|
||||
|
||||
def test_file_not_found_error(self) -> None:
|
||||
"""Test FileNotFoundError for non-existent files."""
|
||||
cache_file = self.cache_dir / "test_cache.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
non_existent_file = self.temp_dir / "does_not_exist.txt"
|
||||
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
cache.has_changed(non_existent_file, time.time())
|
||||
|
||||
def test_performance_cache_hit(self) -> None:
|
||||
"""Benchmark cache hit performance."""
|
||||
cache_file = self.cache_dir / "perf.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Create test files
|
||||
test_files: List[Path] = []
|
||||
for i in range(10): # Reduced for faster test execution
|
||||
test_file = self.temp_dir / f"perf_{i}.txt"
|
||||
self.create_test_file(test_file, f"content {i}")
|
||||
test_files.append(test_file)
|
||||
|
||||
# Measure cache hit performance
|
||||
start_time = time.time()
|
||||
for test_file in test_files:
|
||||
modtime = os.path.getmtime(test_file)
|
||||
cache.has_changed(test_file, modtime) # Cache hit
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
avg_time = elapsed / len(test_files) * 1000 # ms per file
|
||||
self.assertLess(
|
||||
avg_time, 1.0, f"Cache hit average {avg_time:.2f}ms should be <1.0ms"
|
||||
)
|
||||
|
||||
def test_performance_large_files(self) -> None:
|
||||
"""Test performance with moderately large files."""
|
||||
cache_file = self.cache_dir / "large.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Create moderately large test file (100KB)
|
||||
large_file = self.temp_dir / "large.txt"
|
||||
large_content = "x" * (100 * 1024)
|
||||
self.create_test_file(large_file, large_content)
|
||||
modtime = os.path.getmtime(large_file)
|
||||
|
||||
# Measure MD5 computation time
|
||||
start_time = time.time()
|
||||
result = cache.has_changed(large_file, modtime - 1) # Force MD5 computation
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
self.assertTrue(result, "Large file should be detected as changed")
|
||||
self.assertLess(
|
||||
elapsed,
|
||||
0.5,
|
||||
f"Large file processing {elapsed * 1000:.2f}ms should be <500ms",
|
||||
)
|
||||
|
||||
def test_convenience_function(self) -> None:
|
||||
"""Test the convenience has_changed function."""
|
||||
cache_file = self.cache_dir / "convenience.json"
|
||||
test_file = self.temp_dir / "convenience.txt"
|
||||
self.create_test_file(test_file, "convenience test")
|
||||
modtime = os.path.getmtime(test_file)
|
||||
|
||||
# Test convenience function
|
||||
result = has_changed(test_file, modtime, cache_file)
|
||||
self.assertFalse(result, "Convenience function should work correctly")
|
||||
|
||||
def test_cache_stats(self) -> None:
|
||||
"""Test cache statistics functionality."""
|
||||
cache_file = self.cache_dir / "stats.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Initially empty cache
|
||||
stats = cache.get_cache_stats()
|
||||
self.assertEqual(stats["total_entries"], 0)
|
||||
self.assertFalse(stats["cache_file_exists"])
|
||||
|
||||
# Add some entries
|
||||
test_file = self.temp_dir / "stats.txt"
|
||||
self.create_test_file(test_file, "stats content")
|
||||
cache.has_changed(test_file, time.time() - 1)
|
||||
|
||||
# Check stats after entries
|
||||
stats = cache.get_cache_stats()
|
||||
self.assertEqual(stats["total_entries"], 1)
|
||||
self.assertTrue(stats["cache_file_exists"])
|
||||
self.assertGreater(stats["cache_file_size_bytes"], 0)
|
||||
|
||||
def test_clear_cache(self) -> None:
|
||||
"""Test cache clearing functionality."""
|
||||
cache_file = self.cache_dir / "clear.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Add an entry
|
||||
test_file = self.temp_dir / "clear.txt"
|
||||
self.create_test_file(test_file, "clear content")
|
||||
cache.has_changed(test_file, time.time() - 1)
|
||||
|
||||
# Verify cache has entry
|
||||
self.assertEqual(len(cache.cache), 1)
|
||||
self.assertTrue(cache_file.exists())
|
||||
|
||||
# Clear cache
|
||||
cache.clear_cache()
|
||||
|
||||
# Verify cache is cleared
|
||||
self.assertEqual(len(cache.cache), 0)
|
||||
self.assertFalse(cache_file.exists())
|
||||
|
||||
def test_build_system_workflow(self) -> None:
|
||||
"""Test complete build system workflow."""
|
||||
cache_file = self.cache_dir / "build.json"
|
||||
cache = FingerprintCache(cache_file)
|
||||
|
||||
# Simulate source files
|
||||
source_files = [
|
||||
self.temp_dir / "main.cpp",
|
||||
self.temp_dir / "utils.cpp",
|
||||
self.temp_dir / "config.h",
|
||||
]
|
||||
|
||||
# Create initial files
|
||||
for i, src_file in enumerate(source_files):
|
||||
self.create_test_file(src_file, f"source content {i}")
|
||||
|
||||
# First build - all files should be "changed" (new)
|
||||
changed_files: List[Path] = []
|
||||
baseline_time = time.time() - 3600 # 1 hour ago
|
||||
|
||||
for src_file in source_files:
|
||||
if cache.has_changed(src_file, baseline_time):
|
||||
changed_files.append(src_file)
|
||||
|
||||
self.assertEqual(
|
||||
len(changed_files), 3, "All new files should be detected as changed"
|
||||
)
|
||||
|
||||
# Second build - no changes
|
||||
current_modtimes = [os.path.getmtime(f) for f in source_files]
|
||||
changed_files: List[Path] = []
|
||||
|
||||
for src_file, modtime in zip(source_files, current_modtimes):
|
||||
if cache.has_changed(src_file, modtime):
|
||||
changed_files.append(src_file)
|
||||
|
||||
self.assertEqual(
|
||||
len(changed_files), 0, "Unchanged files should not be detected as changed"
|
||||
)
|
||||
|
||||
# Third build - modify one file
|
||||
self.modify_file_content(source_files[0], "modified main.cpp")
|
||||
changed_files: List[Path] = []
|
||||
|
||||
for src_file, modtime in zip(source_files, current_modtimes):
|
||||
if cache.has_changed(src_file, modtime):
|
||||
changed_files.append(src_file)
|
||||
|
||||
self.assertEqual(len(changed_files), 1, "Only modified file should be detected")
|
||||
self.assertEqual(
|
||||
changed_files[0], source_files[0], "Should detect the correct modified file"
|
||||
)
|
||||
|
||||
|
||||
def run_all_tests() -> bool:
|
||||
"""Execute all test scenarios."""
|
||||
import unittest
|
||||
|
||||
# Create test suite
|
||||
suite = unittest.TestLoader().loadTestsFromTestCase(TestFingerprintCache)
|
||||
|
||||
# Run tests
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
if result.wasSuccessful():
|
||||
print("✅ All fingerprint cache tests passed!")
|
||||
return True
|
||||
else:
|
||||
print("❌ Some tests failed!")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_all_tests()
|
||||
Loaded 100 of 1839 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user