Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ 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
- exporting logger.SUCESS 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
55 changes: 37 additions & 18 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,7 +280,7 @@ 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:
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 @@ -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
Loading