From 86fecb7511b7ac796b4c5190a9d87ea3e3db7c12 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Nov 2025 20:21:11 +0100 Subject: [PATCH 1/5] Improve logging coloring and replace logging.success() --- CHANGELOG.md | 2 ++ flixopt/calculation.py | 6 ++--- flixopt/config.py | 55 ++++++++++++++++++++++++++++-------------- flixopt/network_app.py | 3 ++- flixopt/results.py | 4 +-- tests/test_config.py | 6 ----- 6 files changed, 46 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b214a02e..c720ecb6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/flixopt/calculation.py b/flixopt/calculation.py index 640d4c181..9e8a7ca78 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -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 @@ -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': @@ -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) diff --git a/flixopt/config.py b/flixopt/config.py index 824f80b75..caf376e7b 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -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 @@ -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') @@ -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.""" @@ -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 @@ -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( { @@ -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: @@ -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) @@ -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) diff --git a/flixopt/network_app.py b/flixopt/network_app.py index d18bc44a8..32b0af2cd 100644 --- a/flixopt/network_app.py +++ b/flixopt/network_app.py @@ -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: @@ -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 diff --git a/flixopt/results.py b/flixopt/results.py index f5e598fcb..9d5148266 100644 --- a/flixopt/results.py +++ b/flixopt/results.py @@ -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 @@ -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: diff --git a/tests/test_config.py b/tests/test_config.py index b09e0c5d9..11402293c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -72,12 +72,6 @@ def test_disable_logging(self, capfd): logger.info('should not appear') assert 'should not appear' not in capfd.readouterr().out - def test_custom_success_level(self, capfd): - """Test custom SUCCESS log level.""" - CONFIG.Logging.enable_console('INFO') - logger.success('success message') - assert 'success message' in capfd.readouterr().out - def test_multiline_formatting(self): """Test that multi-line messages get box borders.""" formatter = MultilineFormatter() From ef2153ef59d0ad9c6b27890acf535a1df8ce0045 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:09:20 +0100 Subject: [PATCH 2/5] Add tests --- tests/test_config.py | 68 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/tests/test_config.py b/tests/test_config.py index 11402293c..9c4f423ee 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,7 @@ import pytest -from flixopt.config import CONFIG, MultilineFormatter +from flixopt.config import CONFIG, SUCCESS_LEVEL, MultilineFormatter logger = logging.getLogger('flixopt') @@ -72,6 +72,72 @@ def test_disable_logging(self, capfd): logger.info('should not appear') assert 'should not appear' not in capfd.readouterr().out + def test_custom_success_level(self, capfd): + """Test custom SUCCESS log level.""" + CONFIG.Logging.enable_console('INFO') + 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() From 6047f531692c3b11e1a024b08295b0ff8880232c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:32:38 +0100 Subject: [PATCH 3/5] updated the deprecation tests to use pytest.warns() context managers to capture expected warnings, preventing them from appearing in the test output. What was fixed: 1. test_both_old_and_new_raises_error (lines 122-174): Wrapped each test case with pytest.warns() to capture the deprecation warnings that occur before the ValueError is raised. 2. test_optional_parameter_deprecation (lines 289-306): Changed from pytest.warns() to warnings.catch_warnings(record=True) because the optional parameter triggers two separate deprecation warnings (one from interface.py and one from structure.py). 3. test_both_optional_and_mandatory_no_error (lines 325-349): Same fix as above - using warnings.catch_warnings() to capture both warnings. The warnings were showing up because: - Some tests used pytest.raises() without capturing the warnings that occurred before the exception - The optional parameter uniquely triggers two warnings from different code locations, and pytest.warns() with a match pattern only catches one --- tests/test_invest_parameters_deprecation.py | 86 +++++++++++++-------- 1 file changed, 52 insertions(+), 34 deletions(-) diff --git a/tests/test_invest_parameters_deprecation.py b/tests/test_invest_parameters_deprecation.py index 438d7f4b8..cb7fd009a 100644 --- a/tests/test_invest_parameters_deprecation.py +++ b/tests/test_invest_parameters_deprecation.py @@ -122,32 +122,35 @@ def test_all_old_parameters_together(self): def test_both_old_and_new_raises_error(self): """Test that specifying both old and new parameter names raises ValueError.""" # fix_effects + effects_of_investment - with pytest.raises( - ValueError, match='Either fix_effects or effects_of_investment can be specified, but not both' - ): - InvestParameters( - fix_effects={'cost': 10000}, - effects_of_investment={'cost': 25000}, - ) + with pytest.warns(DeprecationWarning, match='fix_effects'): + with pytest.raises( + ValueError, match='Either fix_effects or effects_of_investment can be specified, but not both' + ): + InvestParameters( + fix_effects={'cost': 10000}, + effects_of_investment={'cost': 25000}, + ) # specific_effects + effects_of_investment_per_size - with pytest.raises( - ValueError, - match='Either specific_effects or effects_of_investment_per_size can be specified, but not both', - ): - InvestParameters( - specific_effects={'cost': 1200}, - effects_of_investment_per_size={'cost': 1500}, - ) + with pytest.warns(DeprecationWarning, match='specific_effects'): + with pytest.raises( + ValueError, + match='Either specific_effects or effects_of_investment_per_size can be specified, but not both', + ): + InvestParameters( + specific_effects={'cost': 1200}, + effects_of_investment_per_size={'cost': 1500}, + ) # divest_effects + effects_of_retirement - with pytest.raises( - ValueError, match='Either divest_effects or effects_of_retirement can be specified, but not both' - ): - InvestParameters( - divest_effects={'cost': 5000}, - effects_of_retirement={'cost': 6000}, - ) + with pytest.warns(DeprecationWarning, match='divest_effects'): + with pytest.raises( + ValueError, match='Either divest_effects or effects_of_retirement can be specified, but not both' + ): + InvestParameters( + divest_effects={'cost': 5000}, + effects_of_retirement={'cost': 6000}, + ) # piecewise_effects + piecewise_effects_of_investment from flixopt.interface import Piece, Piecewise, PiecewiseEffects @@ -160,14 +163,15 @@ def test_both_old_and_new_raises_error(self): piecewise_origin=Piecewise([Piece(0, 200)]), piecewise_shares={'cost': Piecewise([Piece(900, 700)])}, ) - with pytest.raises( - ValueError, - match='Either piecewise_effects or piecewise_effects_of_investment can be specified, but not both', - ): - InvestParameters( - piecewise_effects=test_piecewise1, - piecewise_effects_of_investment=test_piecewise2, - ) + with pytest.warns(DeprecationWarning, match='piecewise_effects'): + with pytest.raises( + ValueError, + match='Either piecewise_effects or piecewise_effects_of_investment can be specified, but not both', + ): + InvestParameters( + piecewise_effects=test_piecewise1, + piecewise_effects_of_investment=test_piecewise2, + ) def test_piecewise_effects_of_investment_new_parameter(self): """Test that piecewise_effects_of_investment works correctly.""" @@ -285,14 +289,21 @@ def test_unexpected_keyword_arguments(self): def test_optional_parameter_deprecation(self): """Test that optional parameter triggers deprecation warning and maps to mandatory.""" # Test optional=True (should map to mandatory=False) - with pytest.warns(DeprecationWarning, match='optional.*deprecated.*mandatory'): + # Note: Two warnings are expected (one from interface.py, one from structure.py) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always', DeprecationWarning) params = InvestParameters(optional=True) assert params.mandatory is False + # Verify at least one deprecation warning was raised + assert any(issubclass(warning.category, DeprecationWarning) for warning in w) # Test optional=False (should map to mandatory=True) - with pytest.warns(DeprecationWarning, match='optional.*deprecated.*mandatory'): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always', DeprecationWarning) params = InvestParameters(optional=False) assert params.mandatory is True + # Verify at least one deprecation warning was raised + assert any(issubclass(warning.category, DeprecationWarning) for warning in w) def test_mandatory_parameter_no_warning(self): """Test that mandatory parameter doesn't trigger warnings.""" @@ -320,15 +331,22 @@ def test_both_optional_and_mandatory_no_error(self): parameter will take precedence when both are specified. """ # When both are specified, optional takes precedence (with deprecation warning) - with pytest.warns(DeprecationWarning, match='optional.*deprecated.*mandatory'): + # Note: Two warnings are expected (one from interface.py, one from structure.py) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always', DeprecationWarning) params = InvestParameters(optional=True, mandatory=False) # optional=True should result in mandatory=False assert params.mandatory is False + # Verify at least one deprecation warning was raised + assert any(issubclass(warning.category, DeprecationWarning) for warning in w) - with pytest.warns(DeprecationWarning, match='optional.*deprecated.*mandatory'): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always', DeprecationWarning) params = InvestParameters(optional=False, mandatory=True) # optional=False should result in mandatory=True (optional takes precedence) assert params.mandatory is True + # Verify at least one deprecation warning was raised + assert any(issubclass(warning.category, DeprecationWarning) for warning in w) def test_optional_property_deprecation(self): """Test that accessing optional property triggers deprecation warning.""" From 6f1ac6e9ceb2ef88da8a4515ca265a45d03ae0f6 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:34:37 +0100 Subject: [PATCH 4/5] Fix CHANGELOG.md --- CHANGELOG.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c720ecb6a..e6f3037e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,8 +56,7 @@ 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 - +- Exported SUCCESS log level (`SUCCESS_LEVEL`) for use with `logger.log(SUCCESS_LEVEL, ...)` - Added proper deprecation tests ### 💥 Breaking Changes From 6dbeef3a4c0fbc2ad76cd97a9a1977467fd00f15 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sun, 23 Nov 2025 21:51:15 +0100 Subject: [PATCH 5/5] e updated the docstrings in flixopt/config.py for both enable_console() and enable_file() --- flixopt/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index caf376e7b..a1d0faba1 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -284,7 +284,7 @@ def enable_console(cls, level: str | int = 'INFO', colored: bool = True, stream: """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. @@ -367,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)