diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc705575..265248ec9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,17 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp Until here --> +## [4.1.4] - 2025-11-25 + +**Summary**: Added file logging encoding to prevent issues + +If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOpt/flixOpt/releases/tag/v3.0.0) and [Migration Guide](https://flixopt.github.io/flixopt/latest/user-guide/migration-guide-v3/). + +### 🐛 Fixed +- Issues with windows file system when logging to file due to non ASCII characters + +--- + ## [4.1.3] - 2025-11-25 **Summary**: Re-add mistakenly removed method for loading a config from file diff --git a/flixopt/config.py b/flixopt/config.py index 104e64b3c..dbe2bf3c5 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -34,7 +34,11 @@ class MultilineFormatter(logging.Formatter): - """Custom formatter that handles multi-line messages with box-style borders.""" + """Custom formatter that handles multi-line messages with box-style borders. + + Uses Unicode box-drawing characters for prettier output, with a fallback + to simple formatting if any encoding issues occur. + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -45,85 +49,99 @@ def __init__(self, *args, **kwargs): def format(self, record): """Format multi-line messages with box-style borders for better readability.""" - # Split into lines - lines = record.getMessage().split('\n') + try: + # Split into lines + lines = record.getMessage().split('\n') + + # Add exception info if present (critical for logger.exception()) + if record.exc_info: + lines.extend(self.formatException(record.exc_info).split('\n')) + if record.stack_info: + lines.extend(record.stack_info.rstrip().split('\n')) - # Add exception info if present (critical for logger.exception()) - if record.exc_info: - lines.extend(self.formatException(record.exc_info).split('\n')) - if record.stack_info: - lines.extend(record.stack_info.rstrip().split('\n')) + # Format time with date and milliseconds (YYYY-MM-DD HH:MM:SS.mmm) + # formatTime doesn't support %f, so use datetime directly + import datetime - # Format time with date and milliseconds (YYYY-MM-DD HH:MM:SS.mmm) - # formatTime doesn't support %f, so use datetime directly - import datetime + dt = datetime.datetime.fromtimestamp(record.created) + time_str = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] - dt = datetime.datetime.fromtimestamp(record.created) - time_str = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + # Single line - return standard format + if len(lines) == 1: + level_str = f'{record.levelname: <8}' + return f'{time_str} {level_str} │ {lines[0]}' - # Single line - return standard format - if len(lines) == 1: + # Multi-line - use box format level_str = f'{record.levelname: <8}' - return f'{time_str} {level_str} │ {lines[0]}' + result = f'{time_str} {level_str} │ ┌─ {lines[0]}' + indent = ' ' * 23 # 23 spaces for time with date (YYYY-MM-DD HH:MM:SS.mmm) + for line in lines[1:-1]: + result += f'\n{indent} {" " * 8} │ │ {line}' + result += f'\n{indent} {" " * 8} │ └─ {lines[-1]}' - # Multi-line - use box format - level_str = f'{record.levelname: <8}' - result = f'{time_str} {level_str} │ ┌─ {lines[0]}' - indent = ' ' * 23 # 23 spaces for time with date (YYYY-MM-DD HH:MM:SS.mmm) - for line in lines[1:-1]: - result += f'\n{indent} {" " * 8} │ │ {line}' - result += f'\n{indent} {" " * 8} │ └─ {lines[-1]}' + return result - return result + except Exception as e: + # Fallback to simple formatting if anything goes wrong (e.g., encoding issues) + return f'{record.created} {record.levelname} - {record.getMessage()} [Formatting Error: {e}]' if COLORLOG_AVAILABLE: class ColoredMultilineFormatter(colorlog.ColoredFormatter): - """Colored formatter with multi-line message support.""" + """Colored formatter with multi-line message support. + + Uses Unicode box-drawing characters for prettier output, with a fallback + to simple formatting if any encoding issues occur. + """ def format(self, record): """Format multi-line messages with colors and box-style borders.""" - # Split into lines - lines = record.getMessage().split('\n') + try: + # Split into lines + lines = record.getMessage().split('\n') - # Add exception info if present (critical for logger.exception()) - if record.exc_info: - lines.extend(self.formatException(record.exc_info).split('\n')) - if record.stack_info: - lines.extend(record.stack_info.rstrip().split('\n')) + # Add exception info if present (critical for logger.exception()) + if record.exc_info: + lines.extend(self.formatException(record.exc_info).split('\n')) + if record.stack_info: + lines.extend(record.stack_info.rstrip().split('\n')) - # Format time with date and milliseconds (YYYY-MM-DD HH:MM:SS.mmm) - import datetime + # Format time with date and milliseconds (YYYY-MM-DD HH:MM:SS.mmm) + import datetime - # Use thin attribute for timestamp - dim = escape_codes['thin'] - reset = escape_codes['reset'] - # formatTime doesn't support %f, so use datetime directly - dt = datetime.datetime.fromtimestamp(record.created) - time_str = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] - time_formatted = f'{dim}{time_str}{reset}' + # Use thin attribute for timestamp + dim = escape_codes['thin'] + reset = escape_codes['reset'] + # formatTime doesn't support %f, so use datetime directly + dt = datetime.datetime.fromtimestamp(record.created) + time_str = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + time_formatted = f'{dim}{time_str}{reset}' - # Get the color for this level - log_colors = self.log_colors - level_name = record.levelname - color_name = log_colors.get(level_name, '') - color = escape_codes.get(color_name, '') + # Get the color for this level + log_colors = self.log_colors + level_name = record.levelname + color_name = log_colors.get(level_name, '') + color = escape_codes.get(color_name, '') - level_str = f'{level_name: <8}' + level_str = f'{level_name: <8}' - # Single line - return standard colored format - if len(lines) == 1: - return f'{time_formatted} {color}{level_str}{reset} │ {lines[0]}' + # Single line - return standard colored format + if len(lines) == 1: + return f'{time_formatted} {color}{level_str}{reset} │ {lines[0]}' - # Multi-line - use box format with colors - result = f'{time_formatted} {color}{level_str}{reset} │ {color}┌─{reset} {lines[0]}' - indent = ' ' * 23 # 23 spaces for time with date (YYYY-MM-DD HH:MM:SS.mmm) - for line in lines[1:-1]: - result += f'\n{dim}{indent}{reset} {" " * 8} │ {color}│{reset} {line}' - result += f'\n{dim}{indent}{reset} {" " * 8} │ {color}└─{reset} {lines[-1]}' + # Multi-line - use box format with colors + result = f'{time_formatted} {color}{level_str}{reset} │ {color}┌─{reset} {lines[0]}' + indent = ' ' * 23 # 23 spaces for time with date (YYYY-MM-DD HH:MM:SS.mmm) + for line in lines[1:-1]: + result += f'\n{dim}{indent}{reset} {" " * 8} │ {color}│{reset} {line}' + result += f'\n{dim}{indent}{reset} {" " * 8} │ {color}└─{reset} {lines[-1]}' - return result + return result + + except Exception as e: + # Fallback to simple formatting if anything goes wrong (e.g., encoding issues) + return f'{record.created} {record.levelname} - {record.getMessage()} [Formatting Error: {e}]' # SINGLE SOURCE OF TRUTH - immutable to prevent accidental modification @@ -363,6 +381,7 @@ def enable_file( path: str | Path = 'flixopt.log', max_bytes: int = 10 * 1024 * 1024, backup_count: int = 5, + encoding: str = 'utf-8', ) -> None: """Enable file logging with rotation. Removes all existing file handlers! @@ -371,6 +390,7 @@ def enable_file( path: Path to log file (default: 'flixopt.log') max_bytes: Maximum file size before rotation in bytes (default: 10MB) backup_count: Number of backup files to keep (default: 5) + encoding: File encoding (default: 'utf-8'). Use 'utf-8' for maximum compatibility. Note: For full control over formatting and handlers, use logging module directly. @@ -382,6 +402,9 @@ def enable_file( # With custom rotation CONFIG.Logging.enable_file('DEBUG', 'debug.log', max_bytes=50 * 1024 * 1024, backup_count=10) + + # With explicit encoding + CONFIG.Logging.enable_file('INFO', 'app.log', encoding='utf-8') ``` """ logger = logging.getLogger('flixopt') @@ -404,7 +427,7 @@ def enable_file( log_path = Path(path) log_path.parent.mkdir(parents=True, exist_ok=True) - handler = RotatingFileHandler(path, maxBytes=max_bytes, backupCount=backup_count) + handler = RotatingFileHandler(path, maxBytes=max_bytes, backupCount=backup_count, encoding=encoding) handler.setFormatter(MultilineFormatter()) logger.addHandler(handler)