Files
Tasmota/pio-tools/ldf_cache.py
2025-08-07 21:09:27 +02:00

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()