From 54f6eee264ba2200890c63efa0d2091f6bcf3032 Mon Sep 17 00:00:00 2001 From: Scott Lahteine Date: Sun, 5 Jul 2026 19:42:00 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20Helpers=20to=20access=20config?= =?UTF-8?q?=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provides documentation for mfconfig and tools for agents to use when accessing configuration files. --- .gitignore | 1 + bin/AGENTS.md | 137 +++++++++++++ bin/README_CONFIG_HELPERS.md | 172 ++++++++++++++++ bin/config_helpers.py | 370 +++++++++++++++++++++++++++++++++++ bin/example_usage.py | 234 ++++++++++++++++++++++ bin/mfconfig | 168 +++++++++++++--- bin/test_config_helpers.py | 194 ++++++++++++++++++ 7 files changed, 1249 insertions(+), 27 deletions(-) create mode 100644 bin/AGENTS.md create mode 100644 bin/README_CONFIG_HELPERS.md create mode 100644 bin/config_helpers.py create mode 100644 bin/example_usage.py create mode 100644 bin/test_config_helpers.py diff --git a/.gitignore b/.gitignore index 77ba86927ad..04254f4186c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ applet/ .DS_Store *.sublime-workspace +*.pyc # Prerequisites *.d diff --git a/bin/AGENTS.md b/bin/AGENTS.md new file mode 100644 index 00000000000..b9e278dfec4 --- /dev/null +++ b/bin/AGENTS.md @@ -0,0 +1,137 @@ +# Configuration File Helpers + +## Overview + +Scripts here provide robust helpers to extract data from Marlin configuration files (Configuration.h and Configuration_adv.h). + +## The Problem + +The MarlinFirmware/Configurations repository uses the `mfconfig` script to manage configuration files. Previously, parsing and extracting data from these files required: +- Manual text parsing with regex +- No type safety or error handling +- Duplicated code across different scripts +- Fragile parsing logic that breaks with file format changes + +## The Solution + +Comprehensive Python module (`config_helpers.py`) providing: + +1. **ConfigParser Class** - Object-oriented parser for configuration files +2. **Helper Functions** - High-level functions for common tasks +3. **Type Hints** - Clear API with proper type annotations +4. **Error Handling** - Robust error handling and fallbacks +5. **Test Suite** - Comprehensive tests demonstrating usage + +## Files + +### 1. `config_helpers.py` +Main helper module containing: + +- **ConfigParser class** with methods: + - `get_defines()` - Extract all #define directives + - `get_define(name)` - Get specific define value + - `get_version()` - Parse configuration version + - `find_section(section_name)` - Locate section in file + - `get_enabled_features()` - List enabled features + - `get_disabled_features()` - List disabled features + - `has_error_directive()` - Check for #error directives + - `get_error_messages()` - Extract error messages + - `get_comments_for_define(name)` - Get associated comments + +- **Helper functions**: + - `parse_configuration_file(filepath)` - Parse and extract key info + - `compare_configurations(file1, file2)` - Compare two configs + - `extract_settings_by_category(filepath)` - Group settings by category + +### 2. `test_config_helpers.py` +Comprehensive test suite demonstrating: +- Basic parsing +- Feature detection +- Category extraction +- File comparison +- Advanced parsing + +### 3. `example_usage.py` +Practical examples showing: +- Checking configuration version +- Extracting printer settings +- Checking enabled features +- Error detection +- File comparison +- Batch parsing + +### 4. `README_CONFIG_HELPERS.md` +Complete documentation including: +- Usage examples +- API reference +- Integration guide +- Benefits and requirements + +## Integration with Existing Code + +### Updated `mfconfig` Script + +The `mfconfig` script has been updated to use the new helpers: + +```python +try: + from config_helpers import ConfigParser, parse_configuration_file +except ImportError: + ConfigParser = None + +# Use ConfigParser if available +if ConfigParser: + parser = ConfigParser(config_file) + defines = parser.get_defines() + # More robust parsing with error handling +``` + +The `add_path_labels()` function now uses `ConfigParser` for more reliable parsing with fallback to the original method if needed. + +## Usage Examples + +### Basic Parsing +```python +from config_helpers import ConfigParser + +parser = ConfigParser('Configuration.h') +version = parser.get_version() +extruders = parser.get_define('EXTRUDERS') +``` + +### Feature Detection +```python +enabled = parser.get_enabled_features() +disabled = parser.get_disabled_features() +``` + +### File Comparison +```python +from config_helpers import compare_configurations + +diff = compare_configurations('config1.h', 'config2.h') +print(f"Different values: {len(diff['different_values'])}") +``` + +### Batch Processing +```python +from config_helpers import parse_configuration_file + +for config_file in config_files: + data = parse_configuration_file(config_file) + print(f"{data['filename']}: {len(data['defines'])} defines") +``` + +## Testing + +Run the test suite: +```bash +cd bin +python3 test_config_helpers.py +``` + +Run the example usage: +```bash +cd bin +python3 example_usage.py +``` diff --git a/bin/README_CONFIG_HELPERS.md b/bin/README_CONFIG_HELPERS.md new file mode 100644 index 00000000000..2163b66fb41 --- /dev/null +++ b/bin/README_CONFIG_HELPERS.md @@ -0,0 +1,172 @@ +# Configuration File Helpers + +Helper functions to extract and manipulate data from Marlin configuration files (Configuration.h and Configuration_adv.h). + +These utilities provide a reliable way to parse and extract settings from Marlin firmware configuration files, avoiding the need for fragile text parsing or manual inspection. + +## Files + +- `config_helpers.py` - Main helper module with configuration parsing utilities +- `test_config_helpers.py` - Test suite demonstrating usage of the helpers +- `mfconfig` - Main configuration management script (uses config_helpers) + +## Usage + +### Basic Example + +```python +from pathlib import Path +from config_helpers import ConfigParser, parse_configuration_file + +# Parse a configuration file +config_file = Path('path/to/Configuration.h') +parser = ConfigParser(config_file) + +# Get version +version = parser.get_version() +print(f"Version: {version}") + +# Get specific setting +extruders = parser.get_define('EXTRUDERS') +print(f"Extruders: {extruders}") + +# Get all defines +defines = parser.get_defines() +print(f"Total defines: {len(defines)}") +``` + +### Parse Full Configuration + +```python +from config_helpers import parse_configuration_file + +data = parse_configuration_file(config_file) + +print(f"Version: {data['version']}") +print(f"Total defines: {len(data['defines'])}") +print(f"Enabled features: {len(data['enabled_features'])}") +print(f"Has errors: {data['has_errors']}") +``` + +### Compare Two Configuration Files + +```python +from pathlib import Path +from config_helpers import compare_configurations + +file1 = Path('config1/Configuration.h') +file2 = Path('config2/Configuration.h') + +diff = compare_configurations(file1, file2) + +print(f"Settings only in file1: {len(diff['only_in_file1'])}") +print(f"Settings only in file2: {len(diff['only_in_file2'])}") +print(f"Different values: {len(diff['different_values'])}") + +for key, (val1, val2) in diff['different_values'].items(): + print(f"{key}: '{val1}' != '{val2}'") +``` + +### Extract Settings by Category + +```python +from config_helpers import extract_settings_by_category + +categories = extract_settings_by_category(config_file) + +for category, settings in categories.items(): + if settings: + print(f"{category}: {len(settings)} settings") + for key, value in list(settings.items())[:5]: + print(f" {key} = {value}") +``` + +### Feature Detection + +```python +from config_helpers import ConfigParser + +parser = ConfigParser(config_file) + +# Get enabled features +enabled = parser.get_enabled_features() +print(f"Enabled: {enabled}") + +# Get disabled features +disabled = parser.get_disabled_features() +print(f"Disabled: {disabled}") + +# Check for error directives +if parser.has_error_directive(): + errors = parser.get_error_messages() + for msg in errors: + print(f"Error: {msg}") +``` + +## API Reference + +### ConfigParser Class + +Main class for parsing configuration files. + +#### Methods + +- `__init__(filepath: Path)` - Initialize parser with configuration file +- `get_defines() -> Dict[str, str]` - Extract all #define directives +- `get_define(name: str, default: Optional[str] = None) -> Optional[str]` - Get value of specific define +- `get_version() -> Optional[str]` - Get configuration version +- `find_section(section_name: str) -> Optional[Tuple[int, int]]` - Find line range of a section +- `get_enabled_features() -> List[str]` - Get list of enabled features +- `get_disabled_features() -> List[str]` - Get list of disabled features +- `has_error_directive() -> bool` - Check if file contains #error directives +- `get_error_messages() -> List[str]` - Extract all #error messages +- `get_comments_for_define(name: str) -> Optional[str]` - Get comment for a define + +### Helper Functions + +- `parse_configuration_file(filepath: Path) -> Dict[str, Any]` - Parse file and extract key information +- `compare_configurations(file1: Path, file2: Path) -> Dict[str, Any]` - Compare two configuration files +- `extract_settings_by_category(filepath: Path) -> Dict[str, Dict[str, str]]` - Extract settings grouped by category + +## Integration with mfconfig + +The `mfconfig` script uses `config_helpers` for robust parsing of configuration files: + +```python +try: + from config_helpers import ConfigParser, parse_configuration_file +except ImportError: + ConfigParser = None + +# Use ConfigParser if available +if ConfigParser: + parser = ConfigParser(config_file) + defines = parser.get_defines() + # ... process configuration +``` + +## Benefits + +1. **Reliability**: Proper parsing instead of fragile text manipulation +2. **Type Safety**: Clear return types and error handling +3. **Reusability**: Common functionality extracted into reusable functions +4. **Testability**: Easy to write unit tests for parsing logic +5. **Maintainability**: Centralized parsing logic that can be updated in one place + +## Testing + +Run the test suite: + +```bash +cd bin +python3 test_config_helpers.py +``` + +## Requirements + +- Python 3.6+ +- No external dependencies (uses only standard library) + +## License + +Same as the Marlin Firmware Configurations repository (GPLv3) diff --git a/bin/config_helpers.py b/bin/config_helpers.py new file mode 100644 index 00000000000..ed46325ba3e --- /dev/null +++ b/bin/config_helpers.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +""" +Configuration File Helpers + +Helper functions to extract and manipulate data from Marlin configuration files +(Configuration.h and Configuration_adv.h). + +These utilities provide a reliable way to parse and extract settings from +Marlin firmware configuration files, avoiding the need for fragile text +parsing or manual inspection. +""" + +import re +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Any + + +class ConfigParser: + """Parser for Marlin configuration files.""" + + def __init__(self, filepath: Path): + """Initialize parser with a configuration file. + + Args: + filepath: Path to the configuration file (Configuration.h or Configuration_adv.h) + """ + self.filepath = Path(filepath) + self._content = None + self._lines = None + + def _read_file(self) -> str: + """Read and cache file content.""" + if self._content is None: + with open(self.filepath, 'r') as f: + self._content = f.read() + return self._content + + def _read_lines(self) -> List[str]: + """Read and cache file lines.""" + if self._lines is None: + with open(self.filepath, 'r') as f: + self._lines = f.readlines() + return self._lines + + def get_defines(self) -> Dict[str, str]: + """Extract all #define directives from the file. + + Returns: + Dictionary mapping define names to their values (as strings). + """ + content = self._read_file() + defines = {} + + # Match #define NAME value (with optional comments) + # Handles both simple values and complex expressions + pattern = r'^\s*#define\s+(\w+)\s+(.+?)(?:\s*//.*)?$' + + for match in re.finditer(pattern, content, re.MULTILINE): + name = match.group(1) + value = match.group(2).strip() + defines[name] = value + + return defines + + def get_define(self, name: str, default: Optional[str] = None) -> Optional[str]: + """Get the value of a specific #define. + + Args: + name: The name of the define to look up. + default: Default value to return if not found. + + Returns: + The value of the define, or default if not found. + """ + defines = self.get_defines() + return defines.get(name, default) + + def get_version(self) -> Optional[str]: + """Get the configuration version from CONFIGURATION_H_VERSION. + + Returns: + Version string, or None if not found. + """ + version = self.get_define('CONFIGURATION_H_VERSION') + if version: + # Format: 02010300 -> 2.1.3.0 + if version.isdigit() and len(version) >= 8: + major = int(version[0:2]) + minor = int(version[2:4]) + patch = int(version[4:6]) + sub = int(version[6:8]) + return f"{major}.{minor}.{patch}.{sub}" + return version + + def find_section(self, section_name: str) -> Optional[Tuple[int, int]]: + """Find the line range of a configuration section. + + Args: + section_name: Name of the section to find (e.g., "Axis Settings"). + + Returns: + Tuple of (start_line, end_line) or None if not found. + """ + lines = self._read_lines() + start = None + + for i, line in enumerate(lines): + # Look for section headers (comment lines with ===) + if section_name in line and '===' in line: + start = i + break + + if start is None: + return None + + # Find the end of the section (next section or end of file) + for i in range(start + 1, len(lines)): + line = lines[i] + # Check if this is a new section header + if re.match(r'^\s*//\s*-+', line) and '===' in line: + return (start, i) + + return (start, len(lines)) + + def get_enabled_features(self) -> List[str]: + """Get a list of enabled features (defines set to true/1). + + Returns: + List of feature names that are enabled. + """ + defines = self.get_defines() + enabled = [] + + for name, value in defines.items(): + # Check if value is true, 1, or similar + if value.upper() in ('1', 'TRUE', 'YES', 'ON', 'ENABLED'): + enabled.append(name) + # Also check for defines without explicit values (just #define NAME) + elif value == '': + enabled.append(name) + + return enabled + + def get_disabled_features(self) -> List[str]: + """Get a list of disabled features (defines set to false/0). + + Returns: + List of feature names that are disabled. + """ + defines = self.get_defines() + disabled = [] + + for name, value in defines.items(): + if value.upper() in ('0', 'FALSE', 'NO', 'OFF', 'DISABLED'): + disabled.append(name) + + return disabled + + def has_error_directive(self) -> bool: + """Check if the file contains #error directives. + + Returns: + True if #error directives are present. + """ + content = self._read_file() + return bool(re.search(r'^\s*#error', content, re.MULTILINE)) + + def get_error_messages(self) -> List[str]: + """Extract all #error messages from the file. + + Returns: + List of error message strings. + """ + content = self._read_file() + errors = [] + + for match in re.finditer(r'^\s*#error\s+(.+?)$', content, re.MULTILINE): + errors.append(match.group(1).strip()) + + return errors + + def get_comments_for_define(self, name: str) -> Optional[str]: + """Get the comment associated with a specific #define. + + Args: + name: The name of the define. + + Returns: + The comment text, or None if not found. + """ + lines = self._read_lines() + + for i, line in enumerate(lines): + if re.match(rf'^\s*#define\s+{name}\b', line): + # Check for inline comment + inline_match = re.search(r'//(.+)$', line) + if inline_match: + return inline_match.group(1).strip() + + # Check previous lines for comments + for j in range(i - 1, max(-1, i - 5), -1): + prev_line = lines[j].strip() + if prev_line.startswith('//'): + return prev_line[2:].strip() + elif prev_line and not prev_line.startswith('*'): + break + + return None + + +def parse_configuration_file(filepath: Path) -> Dict[str, Any]: + """Parse a Marlin configuration file and extract key information. + + Args: + filepath: Path to the configuration file. + + Returns: + Dictionary containing parsed configuration data. + """ + # Ensure filepath is a Path object + filepath = Path(filepath) + parser = ConfigParser(filepath) + + return { + 'filepath': str(filepath), + 'filename': filepath.name, + 'version': parser.get_version(), + 'defines': parser.get_defines(), + 'enabled_features': parser.get_enabled_features(), + 'disabled_features': parser.get_disabled_features(), + 'has_errors': parser.has_error_directive(), + 'error_messages': parser.get_error_messages(), + } + + +def compare_configurations(file1: Path, file2: Path) -> Dict[str, Any]: + """Compare two configuration files and identify differences. + + Args: + file1: Path to first configuration file. + file2: Path to second configuration file. + + Returns: + Dictionary containing the differences. + """ + # Ensure file paths are Path objects + file1 = Path(file1) + file2 = Path(file2) + parser1 = ConfigParser(file1) + parser2 = ConfigParser(file2) + + defines1 = parser1.get_defines() + defines2 = parser2.get_defines() + + # Find differences + only_in_file1 = {k: v for k, v in defines1.items() if k not in defines2} + only_in_file2 = {k: v for k, v in defines2.items() if k not in defines1} + + different_values = { + k: (defines1[k], defines2[k]) + for k in defines1.keys() & defines2.keys() + if defines1[k] != defines2[k] + } + + return { + 'file1': str(file1), + 'file2': str(file2), + 'only_in_file1': only_in_file1, + 'only_in_file2': only_in_file2, + 'different_values': different_values, + 'file1_version': parser1.get_version(), + 'file2_version': parser2.get_version(), + } + + +def extract_settings_by_category(filepath: Path) -> Dict[str, Dict[str, str]]: + """Extract settings grouped by configuration category. + + Attempts to group defines based on section headers in the file. + + Args: + filepath: Path to the configuration file. + + Returns: + Dictionary mapping section names to their defines. + """ + # Ensure filepath is a Path object + filepath = Path(filepath) + parser = ConfigParser(filepath) + lines = parser._read_lines() + + categories = {} + current_category = 'Uncategorized' + categories[current_category] = {} + + for i, line in enumerate(lines): + # Check for section headers (lines with === or ---) + if re.match(r'^\s*//\s*[=-]{3,}', line): + # Look for category name in nearby lines + for j in range(max(0, i - 3), min(len(lines), i + 2)): + if j != i and lines[j].strip().startswith('//'): + cat_name = lines[j].strip()[2:].strip() + if cat_name and not cat_name.startswith('===') and not cat_name.startswith('---'): + current_category = cat_name + if current_category not in categories: + categories[current_category] = {} + break + + # Check for #define directives + define_match = re.match(r'^\s*#define\s+(\w+)\s+(.+?)(?:\s*//.*)?$', line) + if define_match: + name = define_match.group(1) + value = define_match.group(2).strip() + categories[current_category][name] = value + + return categories + + +if __name__ == '__main__': + # Example usage + import sys + + if len(sys.argv) < 2: + print("Usage: config_helpers.py [config_file2]") + sys.exit(1) + + config_file = Path(sys.argv[1]) + + if not config_file.exists(): + print(f"Error: File '{config_file}' not found") + sys.exit(1) + + print(f"\n=== Parsing {config_file} ===\n") + + data = parse_configuration_file(config_file) + + print(f"Version: {data['version']}") + print(f"Total defines: {len(data['defines'])}") + print(f"Enabled features: {len(data['enabled_features'])}") + print(f"Has errors: {data['has_errors']}") + + if data['error_messages']: + print(f"\nError messages:") + for msg in data['error_messages']: + print(f" - {msg}") + + # Show some key settings + key_settings = [ + 'EXTRUDERS', 'X_DRIVER_TYPE', 'Y_DRIVER_TYPE', 'Z_DRIVER_TYPE', + 'BAUDRATE', 'SERIAL_PORT', 'TEMP_SENSOR_0', 'TEMP_SENSOR_BED' + ] + + print(f"\nKey settings:") + for key in key_settings: + if key in data['defines']: + print(f" {key} = {data['defines'][key]}") + + # If a second file is provided, compare them + if len(sys.argv) > 2: + config_file2 = Path(sys.argv[2]) + if config_file2.exists(): + print(f"\n\n=== Comparing with {config_file2} ===\n") + + diff = compare_configurations(config_file, config_file2) + + print(f"Different values: {len(diff['different_values'])}") + for key, (val1, val2) in diff['different_values'].items(): + print(f" {key}: '{val1}' != '{val2}'") + + print() diff --git a/bin/example_usage.py b/bin/example_usage.py new file mode 100644 index 00000000000..1311ab2613d --- /dev/null +++ b/bin/example_usage.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +""" +Example usage of config_helpers module. + +This script demonstrates practical use cases for the configuration +file parsing helpers in real-world scenarios. +""" + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent)) + +from config_helpers import ConfigParser, parse_configuration_file, compare_configurations + + +def example_1_check_version(): + """Example: Check configuration version.""" + print("\n" + "=" * 60) + print("Example 1: Check Configuration Version") + print("=" * 60) + + config_file = Path('../config/default/Configuration.h') + parser = ConfigParser(config_file) + + version = parser.get_version() + print(f"\nConfiguration version: {version}") + + # Check if it's a specific version + if version and version.startswith('2.1'): + print("✓ This is a Marlin 2.1.x configuration") + else: + print("⚠️ This is not a Marlin 2.1.x configuration") + + +def example_2_extract_printer_settings(): + """Example: Extract key printer settings.""" + print("\n" + "=" * 60) + print("Example 2: Extract Key Printer Settings") + print("=" * 60) + + config_file = Path('../config/default/Configuration.h') + parser = ConfigParser(config_file) + + # Define the settings we want to extract + key_settings = { + 'EXTRUDERS': 'Number of extruders', + 'MOTHERBOARD': 'Motherboard type', + 'X_DRIVER_TYPE': 'X axis driver', + 'Y_DRIVER_TYPE': 'Y axis driver', + 'Z_DRIVER_TYPE': 'Z axis driver', + 'BAUDRATE': 'Serial baudrate', + 'TEMP_SENSOR_0': 'Hotend sensor', + 'TEMP_SENSOR_BED': 'Bed sensor', + 'DEFAULT_NOMINAL_FILAMENT_DIA': 'Filament diameter', + } + + print("\nKey Printer Settings:") + print("-" * 40) + + for setting, description in key_settings.items(): + value = parser.get_define(setting) + if value: + print(f"{description:25s}: {value}") + else: + print(f"{description:25s}: Not defined") + + +def example_3_check_enabled_features(): + """Example: Check which features are enabled.""" + print("\n" + "=" * 60) + print("Example 3: Check Enabled Features") + print("=" * 60) + + config_file = Path('../config/default/Configuration.h') + parser = ConfigParser(config_file) + + enabled = parser.get_enabled_features() + + # Features we're interested in + interesting_features = [ + 'PREVENT_COLD_EXTRUSION', + 'PREVENT_LENGTHY_EXTRUDE', + 'THERMAL_PROTECTION_HOTENDS', + 'THERMAL_PROTECTION_BED', + 'HOMING_Z_FIRST', + 'Z_SAFE_HOMING', + 'AUTO_BED_LEVELING_BILINEAR', + 'AUTO_BED_LEVELING_UBL', + 'BLTOUCH', + ] + + print("\nFeature Status:") + print("-" * 40) + + for feature in interesting_features: + status = "✓ Enabled" if feature in enabled else "✗ Disabled" + print(f"{feature:35s}: {status}") + + +def example_4_check_for_errors(): + """Example: Check for error directives in config.""" + print("\n" + "=" * 60) + print("Example 4: Check for Configuration Errors") + print("=" * 60) + + config_file = Path('../config/default/Configuration.h') + parser = ConfigParser(config_file) + + if parser.has_error_directive(): + print("\n⚠️ WARNING: Configuration file contains #error directives!\n") + + errors = parser.get_error_messages() + for i, error in enumerate(errors, 1): + print(f" Error {i}: {error}") + + print("\n These errors must be resolved before building!") + else: + print("\n✓ No #error directives found in configuration file") + + +def example_5_compare_configs(): + """Example: Compare two configuration files.""" + print("\n" + "=" * 60) + print("Example 5: Compare Configuration Files") + print("=" * 60) + + file1 = Path('../config/default/Configuration.h') + file2 = Path('../config/default/Configuration_adv.h') + + print(f"\nComparing:") + print(f" File 1: {file1.name}") + print(f" File 2: {file2.name}") + + diff = compare_configurations(file1, file2) + + print(f"\nComparison Results:") + print(f" Settings only in {file1.name}: {len(diff['only_in_file1'])}") + print(f" Settings only in {file2.name}: {len(diff['only_in_file2'])}") + print(f" Settings with different values: {len(diff['different_values'])}") + + # Show some examples of settings only in each file + if diff['only_in_file1']: + print(f"\n Examples only in {file1.name}:") + for key in list(diff['only_in_file1'].keys())[:5]: + print(f" - {key}") + + if diff['only_in_file2']: + print(f"\n Examples only in {file2.name}:") + for key in list(diff['only_in_file2'].keys())[:5]: + print(f" - {key}") + + +def example_6_get_setting_with_comment(): + """Example: Get a setting with its comment.""" + print("\n" + "=" * 60) + print("Example 6: Get Setting with Comment") + print("=" * 60) + + config_file = Path('../config/default/Configuration.h') + parser = ConfigParser(config_file) + + settings_to_check = ['EXTRUDERS', 'BAUDRATE', 'MOTHERBOARD'] + + print("\nSettings with Comments:") + print("-" * 40) + + for setting in settings_to_check: + value = parser.get_define(setting) + comment = parser.get_comments_for_define(setting) + + print(f"\n {setting} = {value}") + if comment: + print(f" Comment: {comment}") + + +def example_7_batch_parse(): + """Example: Parse multiple configuration files.""" + print("\n" + "=" * 60) + print("Example 7: Batch Parse Configuration Files") + print("=" * 60) + + config_files = [ + Path('../config/default/Configuration.h'), + Path('../config/default/Configuration_adv.h'), + ] + + print("\nParsing multiple files:") + print("-" * 40) + + for config_file in config_files: + if config_file.exists(): + data = parse_configuration_file(config_file) + + print(f"\n {data['filename']}:") + print(f" Version: {data['version']}") + print(f" Total defines: {len(data['defines'])}") + print(f" Enabled features: {len(data['enabled_features'])}") + print(f" Has errors: {data['has_errors']}") + else: + print(f"\n {config_file.name}: File not found") + + +def main(): + """Run all examples.""" + print("\n" + "=" * 60) + print("Configuration Helpers - Example Usage") + print("=" * 60) + + examples = [ + example_1_check_version, + example_2_extract_printer_settings, + example_3_check_enabled_features, + example_4_check_for_errors, + example_5_compare_configs, + example_6_get_setting_with_comment, + example_7_batch_parse, + ] + + for example in examples: + try: + example() + except Exception as e: + print(f"\n❌ Error in {example.__name__}: {e}") + import traceback + traceback.print_exc() + + print("\n" + "=" * 60) + print("All examples completed!") + print("=" * 60 + "\n") + + +if __name__ == '__main__': + main() diff --git a/bin/mfconfig b/bin/mfconfig index ba9581d3db7..0db9f685bce 100755 --- a/bin/mfconfig +++ b/bin/mfconfig @@ -26,9 +26,22 @@ # CI - Run in CI mode, using the current folder as the repo. # - For GitHub Actions to update the Configurations repo. # -import os, sys, subprocess, shutil, datetime, tempfile +import os +import sys +import subprocess +import shutil +import tempfile from pathlib import Path +# Import configuration helpers for parsing config files +try: + from config_helpers import ConfigParser, parse_configuration_file, compare_configurations +except ImportError: + # Fallback if config_helpers is not available + ConfigParser = None + parse_configuration_file = None + compare_configurations = None + # Set to 1 for extra debug commits (no deployment) DEBUG = 0 @@ -70,7 +83,8 @@ if not CONFIGREPO.exists(): # Run git within CONFIGREPO GITSTDERR = subprocess.PIPE if DEBUG else subprocess.DEVNULL def git(etc): - if DEBUG: print(f"> git {' '.join(etc)}") + if DEBUG: + print(f"> git {' '.join(etc)}") result = subprocess.run(["git"] + etc, cwd=CONFIGREPO, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) @@ -107,28 +121,138 @@ def info(msg): infotag = "[INFO] " if CI else "" print(f"- {infotag}{msg}") +def replace_in_file(fn, search, replace): + """Replace all occurrences of search text with replace text in a file.""" + with open(fn, 'r') as f: + lines = f.read() + with open(fn, 'w') as f: + f.write(lines.replace(search, replace)) + + +def strip_error_directives(filepath): + """Remove all #error directive lines from a file. + + Args: + filepath: Path to the file to process. + """ + with open(filepath, 'r') as f: + lines = f.readlines() + outlines = [line for line in lines if not line.startswith("#error")] + with open(filepath, 'w') as f: + f.writelines(outlines) + + +def find_first_blank_line(lines): + """Find the index of the first blank line in a list of lines. + + Args: + lines: List of strings (lines from a file). + + Returns: + Index of first blank line, or -1 if none found. + """ + for i, line in enumerate(lines): + if line.isspace(): + return i + return -1 + + +def insert_define_before_blank_line(lines, define_text): + """Insert a #define directive before the first blank line. + + If no blank line exists, the define is not inserted. + If the define already exists in the file, no insertion is made. + + Args: + lines: List of strings (lines from a file). + define_text: The #define line to insert (e.g., '#define CONFIG_EXAMPLES_DIR "path"'). + + Returns: + True if the define was inserted, False otherwise. + """ + # Check if define already exists + for line in lines: + if define_text.strip() in line: + return False + + emptyline = find_first_blank_line(lines) + if emptyline >= 0: + lines.insert(emptyline, f"\n{define_text}\n") + return True + return False + + +def add_path_label_to_config_file(fn, fldr, use_config_parser=True): + """Add CONFIG_EXAMPLES_DIR define to a configuration file. + + This function attempts to use ConfigParser for robust parsing if available. + Falls back to simple text parsing if ConfigParser is not available or fails. + + Args: + fn: Path to the configuration file. + fldr: The folder path to set as CONFIG_EXAMPLES_DIR value. + use_config_parser: Whether to attempt using ConfigParser (default True). + + Returns: + True if the define was added or already exists, False otherwise. + """ + define_text = f"#define CONFIG_EXAMPLES_DIR \"{fldr}\"" + + # Try using ConfigParser for robust parsing + if use_config_parser and ConfigParser: + try: + parser = ConfigParser(fn) + lines = parser._read_lines() + + # Check if CONFIG_EXAMPLES_DIR already exists + defines = parser.get_defines() + if 'CONFIG_EXAMPLES_DIR' in defines: + return True + + # Insert the define + if insert_define_before_blank_line(lines, define_text): + with open(fn, 'w') as f: + f.writelines(lines) + return True + return False + except Exception as e: + # Fall through to simple method + info(f"Warning: Could not parse {fn} with ConfigParser: {e}") + + # Fallback: Simple text parsing + try: + with open(fn, 'r') as f: + lines = f.readlines() + + # Check if define already exists + for line in lines: + if define_text.strip() in line: + return True + + # Insert the define + if insert_define_before_blank_line(lines, define_text): + with open(fn, 'w') as f: + f.writelines(lines) + return True + return False + except Exception as e: + info(f"Warning: Could not process {fn}: {e}") + return False + + def add_path_labels(): + """Add CONFIG_EXAMPLES_DIR path labels to all configuration files.""" info("Adding path labels to all configs...") + for fn in CONFIGEXA.glob("**/Configuration*.h"): fldr = str(fn.parent.relative_to(CONFIGCON)).replace("examples/", "") - with open(fn, 'r') as f: - lines = f.readlines() - emptyline = -1 - for i, line in enumerate(lines): - issp = line.isspace() - if emptyline < 0: - if issp: emptyline = i - elif not issp: - if not "CONFIG_EXAMPLES_DIR" in line: - lines.insert(emptyline, f"\n#define CONFIG_EXAMPLES_DIR \"{fldr}\"\n") - with open(fn, 'w') as f: f.writelines(lines) - break + add_path_label_to_config_file(fn, fldr) + if ACTION == "repath": add_path_labels() elif ACTION == "manual": - MARLINREPO = Path(REPOS / "MarlinFirmware") if not MARLINREPO.exists(): print("Can't find MarlinFirmware at {_REPOS}!") @@ -152,6 +276,7 @@ elif ACTION == "manual": ACTION = 'init' if ACTION == "init": + print(f"Building branch '{EXPORT}'...") info("Init WORK branch...") @@ -169,14 +294,7 @@ if ACTION == "init": # Strip #error lines from Configuration.h for fn in TEMPCON.glob("**/Configuration.h"): - with open(fn, 'r') as f: - lines = f.readlines() - outlines = [] - for line in lines: - if not line.startswith("#error"): - outlines.append(line) - with open(fn, 'w') as f: - f.writelines(outlines) + strip_error_directives(fn) # Create a fresh 'WORK' as a copy of 'init-repo' (README, LICENSE, etc.) if not CI: gitbd("WORK") @@ -195,10 +313,6 @@ if ACTION == "init": # DEBUG: Commit the reset for review if DEBUG > 1: commit("[DEBUG] Create defaults") - def replace_in_file(fn, search, replace): - with open(fn, 'r') as f: lines = f.read() - with open(fn, 'w') as f: f.write(lines.replace(search, replace)) - # Update the %VERSION% in the README.md file replace_in_file(CONFIGREPO / "README.md", "%VERSION%", EXPORT.replace("release-", "")) diff --git a/bin/test_config_helpers.py b/bin/test_config_helpers.py new file mode 100644 index 00000000000..85a1646ae4a --- /dev/null +++ b/bin/test_config_helpers.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +Test script for config_helpers module. + +Demonstrates various ways to use the configuration file parsing helpers. +""" + +from pathlib import Path +import sys + +# Add parent directory to path for imports +sys.path.insert(0, str(Path(__file__).parent)) + +from config_helpers import ( + ConfigParser, + parse_configuration_file, + compare_configurations, + extract_settings_by_category +) + + +def test_basic_parsing(): + """Test basic configuration file parsing.""" + print("=" * 60) + print("TEST 1: Basic Parsing") + print("=" * 60) + + config_file = Path('../config/default/Configuration.h') + parser = ConfigParser(config_file) + + # Get version + version = parser.get_version() + print(f"\nConfiguration Version: {version}") + + # Get specific defines + extruders = parser.get_define('EXTRUDERS') + print(f"Number of Extruders: {extruders}") + + baudrate = parser.get_define('BAUDRATE') + print(f"Baudrate: {baudrate}") + + # Get all defines + defines = parser.get_defines() + print(f"\nTotal #define directives: {len(defines)}") + + # Check for errors + if parser.has_error_directive(): + print("\n⚠️ Warning: File contains #error directives!") + for msg in parser.get_error_messages(): + print(f" - {msg}") + + print() + + +def test_feature_detection(): + """Test detection of enabled/disabled features.""" + print("=" * 60) + print("TEST 2: Feature Detection") + print("=" * 60) + + config_file = Path('../config/default/Configuration.h') + parser = ConfigParser(config_file) + + enabled = parser.get_enabled_features() + disabled = parser.get_disabled_features() + + print(f"\nEnabled features ({len(enabled)}):") + for feature in sorted(enabled)[:10]: # Show first 10 + print(f" - {feature}") + if len(enabled) > 10: + print(f" ... and {len(enabled) - 10} more") + + print(f"\nDisabled features ({len(disabled)}):") + for feature in sorted(disabled)[:10]: # Show first 10 + print(f" - {feature}") + if len(disabled) > 10: + print(f" ... and {len(disabled) - 10} more") + + print() + + +def test_category_extraction(): + """Test extraction of settings by category.""" + print("=" * 60) + print("TEST 3: Category Extraction") + print("=" * 60) + + config_file = Path('../config/default/Configuration.h') + categories = extract_settings_by_category(config_file) + + print(f"\nFound {len(categories)} categories:\n") + + for category, settings in sorted(categories.items()): + if settings: # Only show categories with settings + print(f" {category}: {len(settings)} settings") + # Show a few examples + for key, value in list(settings.items())[:3]: + print(f" {key} = {value}") + if len(settings) > 3: + print(f" ... and {len(settings) - 3} more") + print() + + +def test_file_comparison(): + """Test comparison of two configuration files.""" + print("=" * 60) + print("TEST 4: File Comparison") + print("=" * 60) + + file1 = Path('../config/default/Configuration.h') + file2 = Path('../config/default/Configuration_adv.h') + + diff = compare_configurations(file1, file2) + + print(f"\nFile 1: {diff['file1']}") + print(f" Version: {diff['file1_version']}") + print(f"\nFile 2: {diff['file2']}") + print(f" Version: {diff['file2_version']}") + + print(f"\nSettings only in {file1.name}: {len(diff['only_in_file1'])}") + print(f"Settings only in {file2.name}: {len(diff['only_in_file2'])}") + print(f"Settings with different values: {len(diff['different_values'])}") + + if diff['different_values']: + print("\nDifferent settings:") + for key, (val1, val2) in list(diff['different_values'].items())[:10]: + print(f" {key}:") + print(f" {file1.name}: {val1}") + print(f" {file2.name}: {val2}") + if len(diff['different_values']) > 10: + print(f" ... and {len(diff['different_values']) - 10} more") + + print() + + +def test_advanced_parsing(): + """Test advanced parsing features.""" + print("=" * 60) + print("TEST 5: Advanced Parsing") + print("=" * 60) + + config_file = Path('../config/default/Configuration.h') + parser = ConfigParser(config_file) + + # Get comment for a specific define + comment = parser.get_comments_for_define('EXTRUDERS') + print(f"\nComment for EXTRUDERS: {comment}") + + # Find a specific section + section = parser.find_section('Axis Settings') + if section: + start, end = section + print(f"\n'Axis Settings' section found at lines {start}-{end}") + + # Parse full configuration + print("\nParsing full configuration...") + data = parse_configuration_file(config_file) + + print(f" File: {data['filename']}") + print(f" Version: {data['version']}") + print(f" Total defines: {len(data['defines'])}") + print(f" Enabled features: {len(data['enabled_features'])}") + print(f" Disabled features: {len(data['disabled_features'])}") + print(f" Has errors: {data['has_errors']}") + + print() + + +def main(): + """Run all tests.""" + print("\n" + "=" * 60) + print("Configuration Helpers Test Suite") + print("=" * 60 + "\n") + + try: + test_basic_parsing() + test_feature_detection() + test_category_extraction() + test_file_comparison() + test_advanced_parsing() + + print("=" * 60) + print("All tests completed successfully! ✓") + print("=" * 60 + "\n") + + except Exception as e: + print(f"\n❌ Error during tests: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main()