Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
139 changes: 81 additions & 58 deletions flixopt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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!

Expand All @@ -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.
Expand All @@ -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')
Expand All @@ -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)
Expand Down