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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,13 @@ If upgrading from v2.x, see the [v3.0.0 release notes](https://github.com/flixOp
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/).

### ✨ Added

- Exported SUCCESS log level (`SUCCESS_LEVEL`) for use with `logger.log(SUCCESS_LEVEL, ...)`
- Added proper deprecation tests

### 💥 Breaking Changes

### ♻️ Changed
- logger coloring improved

### 🗑️ Deprecated

Expand Down
6 changes: 3 additions & 3 deletions flixopt/calculation.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from . import io as fx_io
from .aggregation import Aggregation, AggregationModel, AggregationParameters
from .components import Storage
from .config import CONFIG, DEPRECATION_REMOVAL_VERSION
from .config import CONFIG, DEPRECATION_REMOVAL_VERSION, SUCCESS_LEVEL
from .core import DataConverter, TimeSeriesData, drop_constant_arrays
from .features import InvestmentModel
from .flow_system import FlowSystem
Expand Down Expand Up @@ -242,7 +242,7 @@ def solve(
**solver.options,
)
self.durations['solving'] = round(timeit.default_timer() - t_start, 2)
logger.success(f'Model solved with {solver.name} in {self.durations["solving"]:.2f} seconds.')
logger.log(SUCCESS_LEVEL, f'Model solved with {solver.name} in {self.durations["solving"]:.2f} seconds.')
logger.info(f'Model status after solve: {self.model.status}')

if self.model.status == 'warning':
Expand Down Expand Up @@ -673,7 +673,7 @@ def do_modeling_and_solve(
for key, value in calc.durations.items():
self.durations[key] += value

logger.success(f'Model solved with {solver.name} in {self.durations["solving"]:.2f} seconds.')
logger.log(SUCCESS_LEVEL, f'Model solved with {solver.name} in {self.durations["solving"]:.2f} seconds.')

self.results = SegmentedCalculationResults.from_calculation(self)

Expand Down
59 changes: 39 additions & 20 deletions flixopt/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
from logging.handlers import RotatingFileHandler
from pathlib import Path
from types import MappingProxyType
from typing import Literal
from typing import TYPE_CHECKING, Literal

if TYPE_CHECKING:
from typing import TextIO

try:
import colorlog
Expand All @@ -17,7 +20,7 @@
COLORLOG_AVAILABLE = False
escape_codes = None

__all__ = ['CONFIG', 'change_logging_level', 'MultilineFormatter']
__all__ = ['CONFIG', 'change_logging_level', 'MultilineFormatter', 'SUCCESS_LEVEL']

if COLORLOG_AVAILABLE:
__all__.append('ColoredMultilineFormatter')
Expand All @@ -30,16 +33,6 @@
DEPRECATION_REMOVAL_VERSION = '5.0.0'


def _success(self, message, *args, **kwargs):
"""Log a message with severity 'SUCCESS'."""
if self.isEnabledFor(SUCCESS_LEVEL):
self._log(SUCCESS_LEVEL, message, args, **kwargs)


# Add success() method to Logger class
logging.Logger.success = _success


class MultilineFormatter(logging.Formatter):
"""Custom formatter that handles multi-line messages with box-style borders."""

Expand Down Expand Up @@ -124,11 +117,11 @@ def format(self, record):
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}┌─ {lines[0]}{reset}'
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}│ {line}{reset}'
result += f'\n{dim}{indent}{reset} {" " * 8} │ {color}└─ {lines[-1]}{reset}'
result += f'\n{dim}{indent}{reset} {" " * 8} │ {color}│{reset} {line}'
result += f'\n{dim}{indent}{reset} {" " * 8} │ {color}└─{reset} {lines[-1]}'

return result

Expand Down Expand Up @@ -224,12 +217,32 @@ class Logging:
- ``disable()`` - Remove all handlers
- ``set_colors(log_colors)`` - Customize level colors

Log Levels:
Standard levels plus custom SUCCESS level (between INFO and WARNING):
- DEBUG (10): Detailed debugging information
- INFO (20): General informational messages
- SUCCESS (25): Success messages (custom level)
- WARNING (30): Warning messages
- ERROR (40): Error messages
- CRITICAL (50): Critical error messages

Examples:
```python
import logging
from flixopt.config import CONFIG, SUCCESS_LEVEL

# Console and file logging
CONFIG.Logging.enable_console('INFO')
CONFIG.Logging.enable_file('DEBUG', 'debug.log')

# Use SUCCESS level with logger.log()
logger = logging.getLogger('flixopt')
CONFIG.Logging.enable_console('SUCCESS') # Shows SUCCESS, WARNING, ERROR, CRITICAL
logger.log(SUCCESS_LEVEL, 'Operation completed successfully!')

# Or use numeric level directly
logger.log(25, 'Also works with numeric level')

# Customize colors
CONFIG.Logging.set_colors(
{
Expand Down Expand Up @@ -267,11 +280,11 @@ class Logging:
"""

@classmethod
def enable_console(cls, level: str | int = 'INFO', colored: bool = True, stream=None) -> None:
def enable_console(cls, level: str | int = 'INFO', colored: bool = True, stream: TextIO | None = None) -> None:
"""Enable colored console logging.

Args:
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL or logging constant)
level: Log level (DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL or numeric level)
colored: Use colored output if colorlog is available (default: True)
stream: Output stream (default: sys.stdout). Can be sys.stdout or sys.stderr.

Expand Down Expand Up @@ -303,7 +316,10 @@ def enable_console(cls, level: str | int = 'INFO', colored: bool = True, stream=

# Convert string level to logging constant
if isinstance(level, str):
level = getattr(logging, level.upper())
if level.upper().strip() == 'SUCCESS':
level = SUCCESS_LEVEL
else:
level = getattr(logging, level.upper())

logger.setLevel(level)

Expand Down Expand Up @@ -351,7 +367,7 @@ def enable_file(
"""Enable file logging with rotation. Removes all existing file handlers!

Args:
level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL or logging constant)
level: Log level (DEBUG, INFO, SUCCESS, WARNING, ERROR, CRITICAL or numeric level)
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)
Expand All @@ -372,7 +388,10 @@ def enable_file(

# Convert string level to logging constant
if isinstance(level, str):
level = getattr(logging, level.upper())
if level.upper().strip() == 'SUCCESS':
level = SUCCESS_LEVEL
else:
level = getattr(logging, level.upper())

logger.setLevel(level)

Expand Down
3 changes: 2 additions & 1 deletion flixopt/network_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
VISUALIZATION_ERROR = str(e)

from .components import LinearConverter, Sink, Source, SourceAndSink, Storage
from .config import SUCCESS_LEVEL
from .elements import Bus

if TYPE_CHECKING:
Expand Down Expand Up @@ -780,7 +781,7 @@ def find_free_port(start_port=8050, end_port=8100):
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()

logger.success(f'Network visualization started on http://127.0.0.1:{port}/')
logger.log(SUCCESS_LEVEL, f'Network visualization started on http://127.0.0.1:{port}/')

# Store server reference for cleanup
app.server_instance = server
Expand Down
4 changes: 2 additions & 2 deletions flixopt/results.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from . import io as fx_io
from . import plotting
from .color_processing import process_colors
from .config import CONFIG, DEPRECATION_REMOVAL_VERSION
from .config import CONFIG, DEPRECATION_REMOVAL_VERSION, SUCCESS_LEVEL
from .flow_system import FlowSystem
from .structure import CompositeContainerMixin, ResultsContainer

Expand Down Expand Up @@ -1095,7 +1095,7 @@ def to_file(
else:
fx_io.document_linopy_model(self.model, path=paths.model_documentation)

logger.success(f'Saved calculation results "{name}" to {paths.model_documentation.parent}')
logger.log(SUCCESS_LEVEL, f'Saved calculation results "{name}" to {paths.model_documentation.parent}')


class _ElementResults:
Expand Down
64 changes: 62 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import pytest

from flixopt.config import CONFIG, MultilineFormatter
from flixopt.config import CONFIG, SUCCESS_LEVEL, MultilineFormatter

logger = logging.getLogger('flixopt')

Expand Down Expand Up @@ -75,9 +75,69 @@ def test_disable_logging(self, capfd):
def test_custom_success_level(self, capfd):
"""Test custom SUCCESS log level."""
CONFIG.Logging.enable_console('INFO')
logger.success('success message')
logger.log(SUCCESS_LEVEL, 'success message')
assert 'success message' in capfd.readouterr().out

def test_success_level_as_minimum(self, capfd):
"""Test setting SUCCESS as minimum log level."""
CONFIG.Logging.enable_console('SUCCESS')

# INFO should not appear (level 20 < 25)
logger.info('info message')
assert 'info message' not in capfd.readouterr().out

# SUCCESS should appear (level 25)
logger.log(SUCCESS_LEVEL, 'success message')
assert 'success message' in capfd.readouterr().out

# WARNING should appear (level 30 > 25)
logger.warning('warning message')
assert 'warning message' in capfd.readouterr().out

def test_success_level_numeric(self, capfd):
"""Test setting SUCCESS level using numeric value."""
CONFIG.Logging.enable_console(25)
logger.log(25, 'success with numeric level')
assert 'success with numeric level' in capfd.readouterr().out

def test_success_level_constant(self, capfd):
"""Test using SUCCESS_LEVEL constant."""
CONFIG.Logging.enable_console(SUCCESS_LEVEL)
logger.log(SUCCESS_LEVEL, 'success with constant')
assert 'success with constant' in capfd.readouterr().out
assert SUCCESS_LEVEL == 25

def test_success_file_logging(self, tmp_path):
"""Test SUCCESS level with file logging."""
log_file = tmp_path / 'test_success.log'
CONFIG.Logging.enable_file('SUCCESS', str(log_file))

# INFO should not be logged
logger.info('info not logged')

# SUCCESS should be logged
logger.log(SUCCESS_LEVEL, 'success logged to file')

content = log_file.read_text()
assert 'info not logged' not in content
assert 'success logged to file' in content

def test_success_color_customization(self, capfd):
"""Test customizing SUCCESS level color."""
CONFIG.Logging.enable_console('SUCCESS')

# Customize SUCCESS color
CONFIG.Logging.set_colors(
{
'SUCCESS': 'bold_green,bg_black',
'WARNING': 'yellow',
}
)

logger.log(SUCCESS_LEVEL, 'colored success')
output = capfd.readouterr().out
assert 'colored success' in output

def test_multiline_formatting(self):
"""Test that multi-line messages get box borders."""
formatter = MultilineFormatter()
Expand Down
Loading