mirror of
https://github.com/arendst/Tasmota.git
synced 2026-03-03 05:54:42 +01:00
1311 lines
48 KiB
Python
1311 lines
48 KiB
Python
"""
|
|
PlatformIO Advanced Script for intelligent LDF caching.
|
|
|
|
This script implements a two-phase caching system:
|
|
1. First run: Performs verbose build, collects dependencies, creates cache
|
|
2. Second run: Applies cached dependencies with lib_ldf_mode=off for faster builds
|
|
|
|
Features:
|
|
- Intelligent cache invalidation based on file hashes
|
|
- Build order preservation for correct symbol resolution
|
|
|
|
Copyright: Jason2866
|
|
"""
|
|
|
|
Import("env")
|
|
|
|
import os
|
|
import hashlib
|
|
import datetime
|
|
import re
|
|
import pprint
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import shlex
|
|
from pathlib import Path
|
|
from platformio.builder.tools.piolib import LibBuilderBase
|
|
from platformio.builder.tools.piobuild import SRC_HEADER_EXT, SRC_C_EXT, SRC_CXX_EXT, SRC_ASM_EXT, SRC_BUILD_EXT
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
# INFO PlatformIO Core constants
|
|
# SRC_HEADER_EXT = ["h", "hpp", "hxx", "h++", "hh", "inc", "tpp", "tcc"]
|
|
# SRC_ASM_EXT = ["S", "spp", "SPP", "sx", "s", "asm", "ASM"]
|
|
# SRC_C_EXT = ["c"]
|
|
# SRC_CXX_EXT = ["cc", "cpp", "cxx", "c++"]
|
|
# SRC_BUILD_EXT = SRC_C_EXT + SRC_CXX_EXT + SRC_ASM_EXT
|
|
|
|
github_actions = os.getenv('GITHUB_ACTIONS')
|
|
project_dir = env.subst("$PROJECT_DIR")
|
|
env_name = env.subst("$PIOENV")
|
|
compiledb_path = Path(project_dir) / ".pio" / "compiledb" / f"compile_commands_{env_name}.json"
|
|
logfile_path = Path(project_dir) / ".pio" / "compiledb" / f"compile_commands_{env_name}.log"
|
|
cache_base = Path(project_dir) / ".pio" / "ldf_cache"
|
|
cache_file = cache_base / f"ldf_cache_{env_name}.py"
|
|
build_dir = Path(env.subst("$BUILD_DIR"))
|
|
src_dir = Path(env.subst("$PROJECT_SRC_DIR"))
|
|
config = env.GetProjectConfig()
|
|
flag_custom_sdkconfig = False
|
|
if config.has_option("env:"+env["PIOENV"], "custom_sdkconfig") or env.BoardConfig().get("espidf.custom_sdkconfig", ""):
|
|
flag_custom_sdkconfig = True
|
|
|
|
# Ensure log directory exists
|
|
logfile_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
def set_lib_ldf_mode_off():
|
|
"""
|
|
Set lib_ldf_mode = off using PlatformIO's project configuration API.
|
|
|
|
This function directly modifies the project configuration without
|
|
touching the platformio.ini file.
|
|
"""
|
|
projectconfig = env.GetProjectConfig()
|
|
env_section = "env:" + env["PIOENV"]
|
|
if not projectconfig.has_section(env_section):
|
|
projectconfig.add_section(env_section)
|
|
projectconfig.set(env_section, "lib_ldf_mode", "off")
|
|
|
|
def is_first_run_needed():
|
|
"""
|
|
Determines if the first run (full verbose build) is needed based on file dependencies.
|
|
|
|
Checks for essential build artifacts and compile database existence.
|
|
|
|
Returns:
|
|
bool: True if first run is needed, False if cache can be used
|
|
"""
|
|
if not compiledb_path.exists() or compiledb_path.stat().st_size == 0:
|
|
return True
|
|
|
|
lib_dirs = list(build_dir.glob("lib*"))
|
|
if not lib_dirs:
|
|
return True
|
|
|
|
return False
|
|
|
|
def is_build_environment_ready():
|
|
"""
|
|
Checks if the build environment is complete and ready for cache application.
|
|
|
|
Validates that all necessary build artifacts exist for second run.
|
|
|
|
Returns:
|
|
bool: True if build environment is ready for cache application
|
|
"""
|
|
# Compile database must exist
|
|
if not compiledb_path.exists():
|
|
return False
|
|
|
|
# At least one library directory must exist
|
|
lib_dirs = list(build_dir.glob("lib*"))
|
|
if not lib_dirs:
|
|
return False
|
|
|
|
return True
|
|
|
|
def should_trigger_verbose_build():
|
|
"""
|
|
Determines if a verbose build should be triggered for first run.
|
|
|
|
Considers environment variables, cache existence, and build targets.
|
|
|
|
Returns:
|
|
bool: True if verbose build should be triggered
|
|
"""
|
|
# Prevent recursive calls
|
|
if os.environ.get('_PIO_RECURSIVE_CALL') == 'true':
|
|
return False
|
|
if os.environ.get('PLATFORMIO_SETTING_FORCE_VERBOSE') == 'true':
|
|
return False
|
|
|
|
# Check for return code from previous recursive call
|
|
if os.environ.get('_PIO_REC_CALL_RETURN_CODE') is not None:
|
|
return False
|
|
|
|
# If cache exists, no need for verbose build
|
|
if cache_file.exists():
|
|
return False
|
|
|
|
# Debug: Print all sys.argv values
|
|
# print(f"Debug - sys.argv values: {sys.argv}")
|
|
# for i, arg in enumerate(sys.argv):
|
|
# print(f" sys.argv[{i}]: {arg}")
|
|
|
|
# Check sys.argv for "clean" target
|
|
if any("clean" in str(arg).lower() for arg in sys.argv):
|
|
return False
|
|
|
|
if any("nobuild" in str(arg).lower() for arg in sys.argv):
|
|
return False
|
|
|
|
if any("erase" in str(arg).lower() for arg in sys.argv):
|
|
return False
|
|
|
|
return is_first_run_needed()
|
|
|
|
# Integrated log2compdb components for compile_commands.json generation
|
|
DIRCHANGE_PATTERN = re.compile(r"(?P<action>\w+) directory '(?P<path>.+)'")
|
|
INFILE_PATTERN = re.compile(r"(?P<path>.+\.(cpp|cxx|cc|c|hpp|hxx|h))", re.IGNORECASE)
|
|
|
|
@dataclass
|
|
class CompileCommand:
|
|
"""
|
|
Represents a single compile command extracted from build logs.
|
|
|
|
Attributes:
|
|
file: Source file path
|
|
output: Output object file path
|
|
directory: Working directory for compilation
|
|
arguments: Complete compiler command line arguments
|
|
"""
|
|
file: str
|
|
output: str
|
|
directory: str
|
|
arguments: list
|
|
|
|
@classmethod
|
|
def from_cmdline(cls, cc_cmd: Path, cmd_args: list[str], directory=None) -> Optional["CompileCommand"]:
|
|
"""
|
|
Create a CompileCommand from a command line.
|
|
|
|
Parses compiler command line to extract source file, output file, and arguments.
|
|
|
|
Args:
|
|
cc_cmd: Path to the compiler executable
|
|
cmd_args: List of command line arguments
|
|
directory: Optional working directory
|
|
|
|
Returns:
|
|
CompileCommand or None if no valid input file found
|
|
"""
|
|
if cc_cmd.name not in cmd_args[0]:
|
|
return None
|
|
|
|
cmd_args = cmd_args[:]
|
|
cmd_args[0] = str(cc_cmd)
|
|
|
|
if directory is None:
|
|
directory = Path.cwd()
|
|
else:
|
|
directory = Path(directory)
|
|
|
|
input_path = None
|
|
|
|
# Try to find output file (-o flag)
|
|
try:
|
|
output_index = cmd_args.index("-o")
|
|
output_arg = cmd_args[output_index + 1]
|
|
if output_arg == "/dev/null":
|
|
output_path = None
|
|
else:
|
|
output_path = directory / Path(output_arg)
|
|
except (ValueError, IndexError):
|
|
output_index = None
|
|
output_path = None
|
|
|
|
# Find input file based on output file stem or pattern matching
|
|
if output_index is not None and output_path is not None:
|
|
stem_matches = [item for item in cmd_args if Path(item).stem == output_path.stem]
|
|
for item in stem_matches:
|
|
if input_file_match := INFILE_PATTERN.search(item):
|
|
input_path = input_file_match.group("path")
|
|
break
|
|
if not input_path and stem_matches:
|
|
input_path = stem_matches[0]
|
|
if not input_path:
|
|
return None
|
|
input_path = directory / Path(input_path)
|
|
else:
|
|
# Fallback: search for source file patterns
|
|
match = None
|
|
for item in cmd_args:
|
|
match = INFILE_PATTERN.search(item)
|
|
if match:
|
|
break
|
|
if not match:
|
|
return None
|
|
input_path = Path(match.group("path"))
|
|
output_path = None
|
|
|
|
return cls(
|
|
file=str(input_path),
|
|
arguments=cmd_args,
|
|
directory=str(directory),
|
|
output=str(output_path) if output_path else "",
|
|
)
|
|
|
|
@dataclass
|
|
class Compiler:
|
|
"""
|
|
Represents a compiler toolchain.
|
|
|
|
Attributes:
|
|
name: Compiler name (e.g., 'gcc', 'g++')
|
|
path: Path to compiler executable
|
|
"""
|
|
name: str
|
|
path: Path
|
|
|
|
@classmethod
|
|
def from_name(cls, compiler_name: str) -> "Compiler":
|
|
"""
|
|
Create Compiler from name string.
|
|
|
|
Args:
|
|
compiler_name: Name of the compiler
|
|
|
|
Returns:
|
|
Compiler instance
|
|
"""
|
|
path = Path(compiler_name)
|
|
return cls(name=compiler_name, path=path)
|
|
|
|
def find_invocation_start(self, cmd_args: list[str]) -> int:
|
|
"""
|
|
Find compiler invocation in argument list.
|
|
|
|
Args:
|
|
cmd_args: List of command line arguments
|
|
|
|
Returns:
|
|
int: Index of compiler invocation
|
|
|
|
Raises:
|
|
ValueError: If compiler invocation not found
|
|
"""
|
|
for index, arg in enumerate(cmd_args):
|
|
if self.name in arg or Path(arg).stem == self.name:
|
|
return index
|
|
raise ValueError(f"compiler invocation for {self.name} not found")
|
|
|
|
def parse_build_log_to_compile_commands(logfile_path: Path, compiler_names: list[str]) -> list[CompileCommand]:
|
|
"""
|
|
Parse build log to extract compile commands for compile_commands.json generation.
|
|
|
|
Processes verbose build log to extract compiler invocations and create
|
|
compile database entries for IDE integration.
|
|
|
|
Args:
|
|
logfile_path: Path to build log file
|
|
compiler_names: List of compiler names to look for
|
|
|
|
Returns:
|
|
List of CompileCommand objects
|
|
"""
|
|
if not logfile_path.exists():
|
|
return []
|
|
|
|
compilers = [Compiler.from_name(name) for name in compiler_names]
|
|
entries = []
|
|
file_entries = {}
|
|
dirstack = [os.getcwd()]
|
|
|
|
try:
|
|
with logfile_path.open('r', encoding='utf-8', errors='ignore') as logfile:
|
|
for line in logfile:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
# Handle directory changes (make-style output)
|
|
if dirchange_match := DIRCHANGE_PATTERN.search(line):
|
|
action = dirchange_match.group("action")
|
|
path = dirchange_match.group("path")
|
|
if action == "Leaving":
|
|
if len(dirstack) > 1:
|
|
dirstack.pop()
|
|
elif action == "Entering":
|
|
dirstack.append(path)
|
|
continue
|
|
|
|
# Parse command line
|
|
try:
|
|
cmd_args = shlex.split(line)
|
|
except ValueError:
|
|
continue
|
|
|
|
if not cmd_args:
|
|
continue
|
|
|
|
# Try to match against known compilers
|
|
for compiler in compilers:
|
|
try:
|
|
compiler_invocation_start = compiler.find_invocation_start(cmd_args)
|
|
entry = CompileCommand.from_cmdline(
|
|
compiler.path,
|
|
cmd_args[compiler_invocation_start:],
|
|
dirstack[-1]
|
|
)
|
|
|
|
# Avoid duplicate entries for the same file
|
|
if entry is not None and entry.file not in file_entries:
|
|
entries.append(entry)
|
|
file_entries[entry.file] = entry
|
|
break
|
|
except ValueError:
|
|
continue
|
|
|
|
except Exception as e:
|
|
print(f"⚠ Error parsing build log: {e}")
|
|
|
|
return entries
|
|
|
|
class LDFCacheOptimizer:
|
|
"""
|
|
PlatformIO LDF cache optimizer to avoid unnecessary LDF runs.
|
|
|
|
This class implements intelligent caching of Library Dependency Finder (LDF)
|
|
results to significantly speed up subsequent builds.
|
|
|
|
The optimizer works in two phases:
|
|
1. First run: Collects dependencies, creates cache
|
|
2. Second run: Applies cached dependencies with lib_ldf_mode=off
|
|
|
|
Attributes:
|
|
env: PlatformIO SCons environment
|
|
env_name: Current environment name
|
|
project_dir: Project root directory
|
|
src_dir: Source directory path
|
|
build_dir: Build output directory
|
|
cache_file: Path to cache file
|
|
_cache_applied_successfully: Flag indicating successful cache application
|
|
"""
|
|
|
|
# File extensions relevant for LDF processing
|
|
HEADER_EXTENSIONS = set(SRC_HEADER_EXT)
|
|
SOURCE_EXTENSIONS = set(SRC_BUILD_EXT + SRC_C_EXT + SRC_CXX_EXT + SRC_ASM_EXT)
|
|
CONFIG_EXTENSIONS = {'.json', '.properties', '.txt', '.ini'}
|
|
ALL_RELEVANT_EXTENSIONS = HEADER_EXTENSIONS | SOURCE_EXTENSIONS | CONFIG_EXTENSIONS
|
|
|
|
# Directories to ignore during file scanning (optimized for ESP/Tasmota projects)
|
|
IGNORE_DIRS = frozenset([
|
|
'.git', '.github', '.cache', '.vscode', '.pio', 'api', 'boards', 'info',
|
|
'data', 'build', 'build_output', 'pio-tools', 'tools', '__pycache__', 'variants',
|
|
'partitions', 'berry', 'berry_tasmota', 'berry_matter', 'berry_custom', 'zigbee',
|
|
'berry_animate', 'berry_mapping', 'berry_int64', 'displaydesc', 'language',
|
|
'html_compressed', 'html_uncompressed', 'language', 'energy_modbus_configs'
|
|
])
|
|
|
|
def __init__(self, environment):
|
|
"""
|
|
Initialize the LDF cache optimizer with lazy initialization.
|
|
|
|
Sets up paths, initializes PlatformIO integration, and determines
|
|
whether to execute second run logic based on build environment state.
|
|
|
|
Args:
|
|
environment: PlatformIO SCons environment
|
|
"""
|
|
self.env = environment
|
|
self.env_name = self.env.get("PIOENV")
|
|
self.project_dir = Path(self.env.subst("$PROJECT_DIR")).resolve()
|
|
self.src_dir = Path(self.env.subst("$PROJECT_SRC_DIR")).resolve()
|
|
self.build_dir = Path(self.env.subst("$BUILD_DIR")).resolve()
|
|
|
|
# Setup cache and backup file paths
|
|
cache_base = Path(self.project_dir) / ".pio" / "ldf_cache"
|
|
self.cache_file = cache_base / f"ldf_cache_{self.env_name}.py"
|
|
|
|
# Setup compile database paths
|
|
compiledb_base = Path(self.project_dir) / ".pio" / "compiledb"
|
|
self.compiledb_dir = compiledb_base
|
|
self.compile_commands_file = compiledb_base / f"compile_commands_{self.env_name}.json"
|
|
self.compile_commands_log_file = compiledb_base / f"compile_commands_{self.env_name}.log"
|
|
|
|
self.lib_build_dir = Path(self.project_dir) / ".pio" / "build" / self.env_name
|
|
self.ALL_RELEVANT_EXTENSIONS = self.HEADER_EXTENSIONS | self.SOURCE_EXTENSIONS | self.CONFIG_EXTENSIONS
|
|
|
|
# Cache application status tracking
|
|
self._cache_applied_successfully = False
|
|
|
|
# Determine if second run should be executed (lazy initialization)
|
|
if is_build_environment_ready() and not is_first_run_needed():
|
|
print("🔄 Second run: Cache application mode")
|
|
self.execute_second_run()
|
|
|
|
def execute_second_run(self):
|
|
"""
|
|
Execute second run logic: Apply cached dependencies with LDF disabled.
|
|
|
|
Loads and validates cache, applies cached dependencies to SCons environment,
|
|
and handles fallback to normal build if cache application fails.
|
|
"""
|
|
self._cache_applied_successfully = False
|
|
|
|
try:
|
|
# Load and validate cache data
|
|
cache_data = self.load_cache()
|
|
if cache_data and self.validate_cache(cache_data):
|
|
# Apply cached dependencies to build environment
|
|
success = self.apply_ldf_cache_with_build_order(cache_data)
|
|
if success:
|
|
# Set lib_ldf_mode = off using the new function
|
|
set_lib_ldf_mode_off()
|
|
self._cache_applied_successfully = True
|
|
print("✅ Cache applied successfully - lib_ldf_mode=off")
|
|
#print(f" CPPPATH: {len(self.env.get('CPPPATH', []))} entries")
|
|
#print(f" LIBS: {len(self.env.get('LIBS', []))} entries")
|
|
#print(f" OBJECTS: {len(self.env.get('OBJECTS', []))} objects")
|
|
else:
|
|
print("❌ Cache application failed")
|
|
else:
|
|
print("⚠ No valid cache found, falling back to normal build")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error in second run: {e}")
|
|
self._cache_applied_successfully = False
|
|
|
|
def apply_ldf_cache_with_build_order(self, cache_data):
|
|
"""
|
|
Apply cached dependencies with correct build order preservation.
|
|
|
|
Coordinates application of build order and SCons variables to ensure
|
|
correct dependency resolution and linking order.
|
|
|
|
Args:
|
|
cache_data: Dictionary containing cached build data
|
|
|
|
Returns:
|
|
bool: True if cache application succeeded
|
|
"""
|
|
try:
|
|
self._current_cache_data = cache_data
|
|
|
|
artifacts = cache_data.get('artifacts', {})
|
|
build_order = artifacts
|
|
|
|
if not build_order:
|
|
print("❌ No build order data in cache")
|
|
return False
|
|
# Apply build order (OBJECTS, linker configuration)
|
|
build_order_success = self.apply_build_order_to_environment(build_order)
|
|
# Apply SCons variables (include paths, libraries)
|
|
scons_vars_success = self.apply_cache_to_scons_vars(cache_data)
|
|
if build_order_success and scons_vars_success:
|
|
return True
|
|
else:
|
|
print("❌ Partial cache application failure")
|
|
return False
|
|
|
|
except Exception as e:
|
|
print(f"✗ Error applying LDF cache: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return False
|
|
|
|
def validate_ldf_mode_compatibility(self):
|
|
"""
|
|
Validate that the current LDF mode is compatible with caching.
|
|
|
|
Uses PlatformIO Core's native LDF mode validation to ensure
|
|
compatibility with the caching system.
|
|
|
|
Returns:
|
|
bool: True if LDF mode is compatible with caching
|
|
"""
|
|
try:
|
|
current_mode = self.env.GetProjectOption("lib_ldf_mode", "chain")
|
|
validated_mode = LibBuilderBase.validate_ldf_mode(current_mode)
|
|
|
|
# Check against supported modes
|
|
compatible_modes = ["chain", "off"]
|
|
if validated_mode.lower() in compatible_modes:
|
|
print(f"✅ LDF mode '{validated_mode}' is compatible with caching")
|
|
return True
|
|
else:
|
|
print(f"⚠ LDF mode '{validated_mode}' not optimal for caching")
|
|
print(f"💡 Recommended modes: {', '.join(compatible_modes)}")
|
|
return False
|
|
except Exception as e:
|
|
print(f"⚠ Could not determine LDF mode: {e}")
|
|
return True
|
|
|
|
def create_compiledb_integrated(self):
|
|
"""
|
|
Create compile_commands.json using integrated log parsing functionality.
|
|
|
|
Generates compile database from verbose build log for IDE integration
|
|
and IntelliSense support.
|
|
|
|
Returns:
|
|
bool: True if compile_commands.json was created successfully
|
|
"""
|
|
# Check if compile_commands.json already exists
|
|
if self.compile_commands_file.exists():
|
|
print(f"✅ {self.compile_commands_file} exists")
|
|
return True
|
|
|
|
# Search for build log files
|
|
build_log = None
|
|
possible_logs = [
|
|
self.compile_commands_log_file,
|
|
Path(self.project_dir) / f"build_{self.env_name}.log",
|
|
Path(self.build_dir) / f"build_{self.env_name}.log",
|
|
Path(self.build_dir) / "build.log"
|
|
]
|
|
|
|
for log_path in possible_logs:
|
|
if log_path.exists() and log_path.stat().st_size > 0:
|
|
build_log = log_path
|
|
break
|
|
|
|
if not build_log:
|
|
print("⚠ No build log found for compile_commands.json generation")
|
|
return False
|
|
|
|
# Define supported compiler toolchains
|
|
compiler_names = [
|
|
"xtensa-esp32-elf-gcc", "xtensa-esp32-elf-g++",
|
|
"xtensa-esp32s2-elf-gcc", "xtensa-esp32s2-elf-g++",
|
|
"xtensa-esp32s3-elf-gcc", "xtensa-esp32s3-elf-g++",
|
|
"riscv32-esp-elf-gcc", "riscv32-esp-elf-g++",
|
|
"xtensa-lx106-elf-gcc", "xtensa-lx106-elf-g++",
|
|
"arm-none-eabi-gcc", "arm-none-eabi-g++"
|
|
]
|
|
|
|
try:
|
|
# Parse build log to extract compile commands
|
|
compile_commands = parse_build_log_to_compile_commands(build_log, compiler_names)
|
|
if not compile_commands:
|
|
print("❌ No compiler commands found in build log")
|
|
return False
|
|
|
|
# Create output directory
|
|
self.compiledb_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Convert to JSON format
|
|
json_entries = []
|
|
for cmd in compile_commands:
|
|
json_entries.append({
|
|
'file': cmd.file,
|
|
'output': cmd.output,
|
|
'directory': cmd.directory,
|
|
'arguments': cmd.arguments
|
|
})
|
|
|
|
# Write compile_commands.json
|
|
with self.compile_commands_file.open('w') as f:
|
|
json.dump(json_entries, f, indent=2)
|
|
|
|
file_size = self.compile_commands_file.stat().st_size
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error creating compile_commands.json: {e}")
|
|
return False
|
|
|
|
def get_correct_build_order(self):
|
|
"""
|
|
Extract build order from compile_commands.json with build artifacts.
|
|
|
|
Combines compile_commands.json (which preserves compilation order)
|
|
with build artifact paths to create comprehensive build order data.
|
|
|
|
Returns:
|
|
dict: Build order data with sources, objects, and include paths
|
|
None: If compile_commands.json doesn't exist or is invalid
|
|
"""
|
|
if not self.compile_commands_file.exists():
|
|
print(f"⚠ compile_commands_{self.env_name}.json not found")
|
|
return None
|
|
|
|
try:
|
|
# Load compile database
|
|
with self.compile_commands_file.open("r", encoding='utf-8') as f:
|
|
compile_db = json.load(f)
|
|
except Exception as e:
|
|
print(f"✗ Error reading compile_commands.json: {e}")
|
|
return None
|
|
|
|
# Initialize data structures for build order extraction
|
|
object_paths = []
|
|
include_paths = set()
|
|
|
|
# Process each compile command entry
|
|
for i, entry in enumerate(compile_db, 1):
|
|
source_file = entry.get('file', '')
|
|
|
|
if source_file.endswith(('.elf', '.bin', '.hex', '.map')):
|
|
# not a source file, skip
|
|
continue
|
|
|
|
if not source_file.endswith(tuple(SRC_BUILD_EXT + SRC_HEADER_EXT)):
|
|
# not a recognized source file, skip
|
|
continue
|
|
|
|
# Handle both 'arguments' and 'command' formats
|
|
if 'arguments' in entry:
|
|
command = ' '.join(entry['arguments'])
|
|
elif 'command' in entry:
|
|
command = entry['command']
|
|
else:
|
|
print(f"⚠ Unsupported entry format in compile_commands.json (index {i})")
|
|
continue
|
|
|
|
# Extract object file from -o flag
|
|
obj_match = re.search(r'-o\s+(\S+\.o)', command)
|
|
if obj_match:
|
|
obj_file = obj_match.group(1)
|
|
object_paths.append({
|
|
'order': i,
|
|
'source': source_file,
|
|
'object': obj_file,
|
|
})
|
|
|
|
# Extract include paths from -I flags
|
|
include_matches = re.findall(r'-I\s*([^\s]+)', command)
|
|
for inc_path in include_matches:
|
|
inc_path = inc_path.strip('"\'')
|
|
if Path(inc_path).exists():
|
|
include_paths.add(str(Path(inc_path)))
|
|
|
|
return {
|
|
'object_paths': object_paths,
|
|
'include_paths': sorted(include_paths)
|
|
}
|
|
|
|
def collect_build_artifacts_paths(self):
|
|
"""
|
|
Collect paths to build artifacts without copying them.
|
|
|
|
Scans build directory for library (.a) and object (.o) files,
|
|
collecting their paths for cache storage and later reuse.
|
|
|
|
Returns:
|
|
dict: Artifact paths organized by type with metadata
|
|
"""
|
|
if not self.lib_build_dir.exists():
|
|
print(f"⚠ Build directory not found: {self.lib_build_dir}")
|
|
return {}
|
|
|
|
library_paths = []
|
|
object_paths = []
|
|
|
|
# Walk through build directory to find artifacts
|
|
for root, dirs, files in os.walk(self.lib_build_dir):
|
|
root_path = Path(root)
|
|
for file in files:
|
|
if file.endswith('.a'):
|
|
file_path = root_path / file
|
|
library_paths.append(str(file_path))
|
|
elif file.endswith('.o'):
|
|
file_path = root_path / file
|
|
object_paths.append(str(file_path))
|
|
|
|
total_count = len(library_paths) + len(object_paths)
|
|
|
|
return {
|
|
'library_paths': library_paths,
|
|
'object_paths': object_paths,
|
|
'total_count': total_count
|
|
}
|
|
|
|
def get_project_hash_with_details(self):
|
|
"""
|
|
Calculate comprehensive project hash for cache invalidation.
|
|
|
|
Computes hash based on all LDF-relevant files to detect changes
|
|
that would require cache invalidation. Only includes files that
|
|
can affect dependency resolution.
|
|
|
|
Returns:
|
|
dict: Hash details including file hashes and final combined hash
|
|
"""
|
|
file_hashes = {}
|
|
src_path = Path(self.src_dir)
|
|
|
|
# Process all files in source directory
|
|
for file_path in src_path.rglob('*'):
|
|
# Skip directories and ignored directories
|
|
if file_path.is_dir() or self._is_ignored_directory(file_path.parent):
|
|
continue
|
|
|
|
# Only process LDF-relevant file extensions
|
|
if file_path.suffix in self.ALL_RELEVANT_EXTENSIONS:
|
|
try:
|
|
rel_path = self._get_relative_path_from_project(file_path)
|
|
|
|
# Hash source files based on their include dependencies
|
|
if file_path.suffix in self.SOURCE_EXTENSIONS:
|
|
includes = self._extract_includes(file_path)
|
|
include_hash = hashlib.md5(str(sorted(includes)).encode()).hexdigest()
|
|
file_hashes[rel_path] = include_hash
|
|
# Hash header files based on content
|
|
elif file_path.suffix in self.HEADER_EXTENSIONS:
|
|
file_content = file_path.read_bytes()
|
|
file_hash = hashlib.md5(file_content).hexdigest()
|
|
file_hashes[rel_path] = file_hash
|
|
# Hash config files based on content
|
|
elif file_path.suffix in self.CONFIG_EXTENSIONS:
|
|
file_content = file_path.read_bytes()
|
|
file_hash = hashlib.md5(file_content).hexdigest()
|
|
file_hashes[rel_path] = file_hash
|
|
except (IOError, OSError) as e:
|
|
print(f"⚠ Could not hash {file_path}: {e}")
|
|
continue
|
|
|
|
# Process platformio.ini files
|
|
project_path = Path(self.project_dir)
|
|
for ini_path in project_path.glob('platformio*.ini'):
|
|
if ini_path.exists() and ini_path.is_file():
|
|
try:
|
|
platformio_hash = self._hash_platformio_ini(ini_path)
|
|
if platformio_hash:
|
|
rel_ini_path = self._get_relative_path_from_project(ini_path)
|
|
file_hashes[rel_ini_path] = platformio_hash
|
|
except (IOError, OSError) as e:
|
|
print(f"⚠ Could not hash {ini_path}: {e}")
|
|
|
|
# Compute final combined hash
|
|
combined_content = json.dumps(file_hashes, sort_keys=True)
|
|
final_hash = hashlib.sha256(combined_content.encode()).hexdigest()
|
|
|
|
return {
|
|
'file_hashes': file_hashes,
|
|
'final_hash': final_hash,
|
|
'file_count': len(file_hashes)
|
|
}
|
|
|
|
def create_comprehensive_cache(self):
|
|
"""
|
|
Create comprehensive cache data including all build information.
|
|
|
|
Combines project hash, build order, and artifact information into
|
|
a complete cache that can be used for subsequent builds.
|
|
|
|
Returns:
|
|
dict: Complete cache data with signature
|
|
None: If cache creation failed
|
|
"""
|
|
try:
|
|
print("🔧 Creating comprehensive cache...")
|
|
|
|
# Collect all cache components
|
|
project_hash = self.get_project_hash_with_details()
|
|
build_order = self.get_correct_build_order()
|
|
artifacts = self.collect_build_artifacts_paths()
|
|
|
|
if not build_order:
|
|
print("⚠ No build order data available")
|
|
return None
|
|
|
|
# Create cache data structure
|
|
cache_data = {
|
|
'version': '1.0',
|
|
'env_name': self.env_name,
|
|
'timestamp': datetime.datetime.now().isoformat(),
|
|
'project_hash': project_hash['final_hash'],
|
|
'file_hashes': project_hash['file_hashes'],
|
|
'build_order': build_order,
|
|
'artifacts': artifacts
|
|
}
|
|
|
|
# Add signature for integrity verification
|
|
cache_data['signature'] = self.compute_signature(cache_data)
|
|
return cache_data
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error creating cache: {e}")
|
|
return None
|
|
|
|
def save_cache(self, cache_data):
|
|
"""
|
|
Save cache data to file in Python format.
|
|
|
|
Saves cache as executable Python code for easy loading and
|
|
human-readable format for debugging.
|
|
|
|
Args:
|
|
cache_data: Dictionary containing cache data
|
|
|
|
Returns:
|
|
bool: True if cache was saved successfully
|
|
"""
|
|
try:
|
|
# Ensure cache directory exists
|
|
self.cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Write cache as Python code
|
|
with self.cache_file.open('w') as f:
|
|
f.write("# LDF Cache Data - Auto-generated\n")
|
|
f.write("# Do not edit manually, this will break the Hash checksum\n\n")
|
|
f.write("cache_data = ")
|
|
f.write(pprint.pformat(cache_data, width=120, depth=None))
|
|
f.write("\n")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error saving cache: {e}")
|
|
return False
|
|
|
|
def compute_signature(self, data):
|
|
"""
|
|
Compute SHA256 signature for cache data validation.
|
|
|
|
Creates tamper-evident signature to ensure cache integrity
|
|
and detect corruption or manual modifications.
|
|
|
|
Args:
|
|
data: Dictionary to sign
|
|
|
|
Returns:
|
|
str: SHA256 signature hex string
|
|
"""
|
|
# Create copy without signature field to avoid circular reference
|
|
data_copy = data.copy()
|
|
data_copy.pop('signature', None)
|
|
|
|
# Create deterministic JSON representation
|
|
data_str = json.dumps(data_copy, sort_keys=True)
|
|
return hashlib.sha256(data_str.encode()).hexdigest()
|
|
|
|
def load_cache(self):
|
|
"""
|
|
Load cache data from file.
|
|
|
|
Executes Python cache file to load cache data into memory
|
|
for validation and application.
|
|
|
|
Returns:
|
|
dict: Cache data if loaded successfully
|
|
None: If cache file doesn't exist or loading failed
|
|
"""
|
|
if not self.cache_file.exists():
|
|
return None
|
|
|
|
try:
|
|
# Execute Python cache file in isolated namespace
|
|
cache_globals = {}
|
|
with self.cache_file.open('r') as f:
|
|
exec(f.read(), cache_globals)
|
|
|
|
# Extract cache data from executed namespace
|
|
cache_data = cache_globals.get('cache_data')
|
|
if cache_data:
|
|
return cache_data
|
|
else:
|
|
print("⚠ No cache_data found in cache file")
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error loading cache: {e}")
|
|
return None
|
|
|
|
def validate_cache(self, cache_data):
|
|
"""
|
|
Validate cache integrity and freshness.
|
|
|
|
Performs comprehensive validation including signature verification
|
|
and project hash comparison to ensure cache is valid and current.
|
|
|
|
Args:
|
|
cache_data: Cache data to validate
|
|
|
|
Returns:
|
|
bool: True if cache is valid and current
|
|
"""
|
|
if not cache_data:
|
|
return False
|
|
|
|
try:
|
|
# Verify signature integrity
|
|
stored_signature = cache_data.get('signature')
|
|
if not stored_signature:
|
|
print("⚠ Cache missing signature")
|
|
return False
|
|
|
|
computed_signature = self.compute_signature(cache_data)
|
|
if stored_signature != computed_signature:
|
|
print("⚠ Cache signature mismatch")
|
|
return False
|
|
|
|
# Verify project hasn't changed
|
|
current_hash = self.get_project_hash_with_details()
|
|
if cache_data.get('project_hash') != current_hash['final_hash']:
|
|
print("⚠ Project files changed, cache invalid")
|
|
return False
|
|
|
|
print("✅ Cache validation successful")
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"⚠ Cache validation error: {e}")
|
|
return False
|
|
|
|
def apply_build_order_to_environment(self, build_order_data):
|
|
"""
|
|
Apply correct build order to SCons environment.
|
|
|
|
Set OBJECTS variable in correct order and configures
|
|
linker for optimal symbol resolution.
|
|
|
|
Args:
|
|
build_order_data: Dictionary containing build order information
|
|
|
|
Returns:
|
|
bool: True if build order was applied successfully
|
|
"""
|
|
if not build_order_data:
|
|
return False
|
|
|
|
try:
|
|
# Apply object file order
|
|
object_paths = build_order_data.get('object_paths', [])
|
|
if object_paths:
|
|
# Use ALL cached object files
|
|
object_file_paths = []
|
|
for obj_entry in object_paths:
|
|
if isinstance(obj_entry, dict):
|
|
obj_path = obj_entry.get('object', obj_entry.get('path', ''))
|
|
else:
|
|
obj_path = str(obj_entry)
|
|
|
|
if obj_path and Path(obj_path).exists():
|
|
object_file_paths.append(obj_path)
|
|
|
|
if object_file_paths:
|
|
self.env.Replace(OBJECTS=object_file_paths)
|
|
else:
|
|
print("⚠ No valid object files found")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"✗ Error applying build order: {e}")
|
|
return False
|
|
|
|
def apply_cache_to_scons_vars(self, cache_data):
|
|
"""
|
|
Apply cache data to SCons variables.
|
|
|
|
Uses PlatformIO's ParseFlagsExtended for robust flag processing
|
|
and applies cached include paths and library paths to build environment.
|
|
|
|
Args:
|
|
cache_ Dictionary containing cached build data
|
|
|
|
Returns:
|
|
bool: True if cache variables were applied successfully
|
|
"""
|
|
try:
|
|
build_order = cache_data.get('build_order', {})
|
|
|
|
# Apply include paths using PlatformIO's native flag processing
|
|
if 'include_paths' in build_order:
|
|
include_flags = [f"-I{path}" for path in build_order['include_paths']]
|
|
parsed_flags = self.env.ParseFlagsExtended(include_flags)
|
|
self.env.Append(**parsed_flags)
|
|
|
|
# Apply library paths and convert to proper LIBS/LIBPATH format
|
|
artifacts = cache_data.get('artifacts', {})
|
|
if 'library_paths' in artifacts:
|
|
library_paths = artifacts['library_paths']
|
|
if library_paths:
|
|
lib_flags = []
|
|
lib_directories = set()
|
|
|
|
for lib_path in library_paths:
|
|
lib_path_obj = Path(lib_path)
|
|
filename = lib_path_obj.name
|
|
|
|
# Extrahiere Verzeichnis für LIBPATH
|
|
directory = str(lib_path_obj.parent)
|
|
if directory and directory != '.':
|
|
lib_directories.add(directory)
|
|
|
|
# Convert to -l Flag for LIBS
|
|
if filename.startswith('lib') and filename.endswith('.a'):
|
|
lib_name = filename[3:-2] # Remove 'lib' und '.a'
|
|
lib_flags.append(f"-l{lib_name}")
|
|
elif filename.endswith('.a'):
|
|
lib_name = filename[:-2] # Remove only '.a'
|
|
lib_flags.append(f"-l{lib_name}")
|
|
else:
|
|
lib_flags.append(f"-l{filename}")
|
|
|
|
if lib_directories:
|
|
current_libpath = self.env.get('LIBPATH', [])
|
|
if isinstance(current_libpath, str):
|
|
current_libpath = [current_libpath]
|
|
|
|
updated_libpath = list(current_libpath)
|
|
for path in lib_directories:
|
|
if path not in updated_libpath:
|
|
updated_libpath.append(path)
|
|
|
|
self.env['LIBPATH'] = updated_libpath
|
|
|
|
if lib_flags:
|
|
current_libs = self.env.get('LIBS', [])
|
|
if isinstance(current_libs, str):
|
|
current_libs = [current_libs]
|
|
|
|
updated_libs = list(current_libs) + lib_flags
|
|
self.env['LIBS'] = updated_libs
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error applying cache to SCons vars: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return False
|
|
|
|
def _get_relative_path_from_project(self, file_path):
|
|
"""
|
|
Calculate relative path from project root with consistent path handling.
|
|
|
|
Normalizes paths to use forward slashes for cross-platform compatibility.
|
|
|
|
Args:
|
|
file_path: File path to make relative
|
|
|
|
Returns:
|
|
str: Relative path from project root with forward slashes
|
|
"""
|
|
try:
|
|
file_path = Path(file_path).resolve()
|
|
project_dir = Path(self.project_dir).resolve()
|
|
rel_path = file_path.relative_to(project_dir)
|
|
return str(rel_path).replace(os.sep, '/')
|
|
except (ValueError, OSError):
|
|
return str(Path(file_path)).replace(os.sep, '/')
|
|
|
|
def _extract_includes(self, file_path):
|
|
"""
|
|
Extract #include directives from source files.
|
|
|
|
Parses source files to extract include dependencies for
|
|
hash calculation and dependency tracking.
|
|
|
|
Args:
|
|
file_path: Source file to analyze
|
|
|
|
Returns:
|
|
set: Set of normalized include paths
|
|
"""
|
|
includes = set()
|
|
try:
|
|
file_path = Path(file_path)
|
|
with file_path.open('r', encoding='utf-8', errors='ignore') as f:
|
|
for line_num, line in enumerate(f, 1):
|
|
line = line.strip()
|
|
if line.startswith('#include'):
|
|
include_match = re.search(r'#include\s*[<"]([^>"]+)[>"]', line)
|
|
if include_match:
|
|
include_path = include_match.group(1)
|
|
normalized_include = str(Path(include_path)).replace(os.sep, '/')
|
|
includes.add(normalized_include)
|
|
except (IOError, OSError, UnicodeDecodeError) as e:
|
|
print(f"⚠ Could not read {file_path}: {e}")
|
|
return includes
|
|
|
|
def _hash_platformio_ini(self, ini_path=None):
|
|
"""
|
|
Hash the complete platformio.ini file
|
|
|
|
Creates hash of the entire platformio.ini content to detect any changes
|
|
that might affect the build configuration.
|
|
|
|
Args:
|
|
ini_path: Path to ini file (defaults to self.platformio_ini)
|
|
|
|
Returns:
|
|
str: MD5 hash of the entire platformio.ini content
|
|
"""
|
|
if ini_path is None:
|
|
ini_path = self.platformio_ini
|
|
if not ini_path.exists():
|
|
return ""
|
|
|
|
try:
|
|
with ini_path.open('r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
# Hash entire content as is
|
|
return hashlib.md5(content.encode()).hexdigest()
|
|
except (IOError, OSError) as e:
|
|
print(f"⚠ Could not read {ini_path}: {e}")
|
|
return ""
|
|
|
|
def _is_ignored_directory(self, dir_path):
|
|
"""
|
|
Check if a directory should be ignored during file scanning.
|
|
|
|
Uses predefined ignore patterns optimized for ESP/Tasmota projects
|
|
to skip irrelevant directories during cache creation.
|
|
|
|
Args:
|
|
dir_path: Directory path to check
|
|
|
|
Returns:
|
|
bool: True if directory should be ignored
|
|
"""
|
|
if not dir_path:
|
|
return False
|
|
|
|
path_obj = Path(dir_path)
|
|
|
|
# Check if directory name is in ignore list
|
|
if path_obj.name in self.IGNORE_DIRS:
|
|
return True
|
|
|
|
# Check if any parent directory is in ignore list
|
|
for part in path_obj.parts:
|
|
if part in self.IGNORE_DIRS:
|
|
return True
|
|
return False
|
|
|
|
def execute_first_run_post_actions():
|
|
"""
|
|
Execute post-build actions after successful first run.
|
|
|
|
Creates cache data, generates compile_commands.json, validates LDF mode,
|
|
and modifies platformio.ini for subsequent cached builds.
|
|
|
|
Returns:
|
|
bool: True if all post-actions completed successfully
|
|
"""
|
|
print("🎯 First run completed successfully - executing post-build actions...")
|
|
|
|
try:
|
|
# Initialize optimizer for first run tasks
|
|
optimizer = LDFCacheOptimizer(env)
|
|
|
|
# Create compile_commands.json if needed
|
|
if not compiledb_path.exists() or compiledb_path.stat().st_size == 0:
|
|
success_compiledb = optimizer.create_compiledb_integrated()
|
|
if not success_compiledb:
|
|
print("❌ Failed to create compile_commands.json")
|
|
return False
|
|
else:
|
|
print(f"✅ compile_commands.json already exists: {compiledb_path}")
|
|
|
|
# Create cache if it doesn't exist
|
|
if not cache_file.exists():
|
|
cache_data = optimizer.create_comprehensive_cache()
|
|
|
|
if not cache_data:
|
|
print("❌ Failed to create cache data")
|
|
return False
|
|
|
|
# Save cache to file
|
|
success_save = optimizer.save_cache(cache_data)
|
|
if not success_save:
|
|
print("❌ Failed to save cache")
|
|
return False
|
|
|
|
print(f"✅ LDF cache created: {cache_file}")
|
|
else:
|
|
print(f"✅ LDF cache already exists: {cache_file}")
|
|
|
|
# Check current LDF mode
|
|
current_ldf_mode = optimizer.env.GetProjectOption("lib_ldf_mode", "chain")
|
|
|
|
if optimizer.validate_ldf_mode_compatibility():
|
|
print("🎉 First run post-build actions completed successfully!")
|
|
print("🚀 Next build will be using cached dependencies")
|
|
else:
|
|
print(f"⚠ lib_ldf_mode '{current_ldf_mode}' not supported for caching")
|
|
print("💡 Supported modes: chain, off")
|
|
return False
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error in first run post-build actions: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return False
|
|
|
|
def terminal_size():
|
|
"""
|
|
Get terminal width and lines from stty size command.
|
|
VSC does not provide an easy way to get terminal width.
|
|
Returns:
|
|
tuple: (columns, lines) if available, otherwise (80, 16)
|
|
"""
|
|
try:
|
|
result = subprocess.run(['stty', 'size'], capture_output=True, text=True, timeout=2)
|
|
if result.returncode == 0:
|
|
parts = result.stdout.strip().split()
|
|
if len(parts) >= 2 and parts[0].isdigit() and parts[1].isdigit():
|
|
lines = int(parts[0])
|
|
columns = int(parts[1])
|
|
return columns, lines
|
|
except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
|
|
pass
|
|
return 80, 16
|
|
|
|
# FIRST RUN LOGIC - Execute verbose build and create cache
|
|
if should_trigger_verbose_build() and not github_actions and not flag_custom_sdkconfig:
|
|
print("🔄 Starting LDF Cache Optimizer...")
|
|
print(f"🔄 First run needed - starting verbose build for {env_name}...")
|
|
print("📋 Reasons:")
|
|
|
|
# Report reasons for first run
|
|
if not compiledb_path.exists():
|
|
print(" - compile_commands.json missing")
|
|
elif compiledb_path.stat().st_size == 0:
|
|
print(" - compile_commands.json is empty")
|
|
|
|
if not is_build_environment_ready():
|
|
print(" - Build environment incomplete")
|
|
|
|
# Setup environment for verbose build
|
|
env_vars = os.environ.copy()
|
|
env_vars['PLATFORMIO_SETTING_FORCE_VERBOSE'] = 'true'
|
|
env_vars['_PIO_RECURSIVE_CALL'] = 'true'
|
|
|
|
terminal_columns, terminal_lines = terminal_size()
|
|
terminal_width = terminal_columns - 3 # Adjust for emoji and padding
|
|
|
|
# Handle recursive call return codes
|
|
if os.environ.get('_PIO_REC_CALL_RETURN_CODE') is not None:
|
|
sys.exit(int(os.environ.get('_PIO_REC_CALL_RETURN_CODE')))
|
|
|
|
# Execute verbose build with output capture
|
|
with open(logfile_path, "w") as logfile:
|
|
process = subprocess.Popen(
|
|
['pio', 'run', '-e', env_name, '--disable-auto-clean'],
|
|
env=env_vars,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
universal_newlines=True,
|
|
bufsize=1
|
|
)
|
|
print(f"🔄 Running verbose build... full log in {logfile_path}")
|
|
for line in process.stdout:
|
|
print("🔄 " + line[:terminal_width].splitlines()[0], end='\r')
|
|
sys.stdout.write("\033[F")
|
|
logfile.write(line)
|
|
logfile.flush()
|
|
sys.stdout.write("\x1b[2K")
|
|
sys.stdout.write("\033[F")
|
|
logfile.flush()
|
|
print("\n✅ Build process completed, waiting for process to finish...")
|
|
process.wait()
|
|
|
|
if process.returncode == 0:
|
|
post_actions_success = execute_first_run_post_actions()
|
|
if post_actions_success:
|
|
print("✅ All first run actions completed successfully")
|
|
else:
|
|
print("⚠ Some first run actions failed")
|
|
else:
|
|
print(f"❌ First run failed with return code: {process.returncode}")
|
|
|
|
sys.exit(process.returncode)
|
|
|
|
# SECOND RUN LOGIC
|
|
try:
|
|
if (not should_trigger_verbose_build() and
|
|
is_build_environment_ready()):
|
|
optimizer = LDFCacheOptimizer(env)
|
|
except Exception as e:
|
|
print(f"❌ Error initializing LDF Cache Optimizer: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|