From 10452f9ff9a856b4f4d1436a9c0805001dee15ea Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:15:35 +0200 Subject: [PATCH 01/44] Refactor configuration management: remove dataclass-based schema and simplify CONFIG structure. --- flixopt/calculation.py | 4 +- flixopt/config.py | 147 +++++++++++++++-------------------------- flixopt/config.yaml | 10 --- flixopt/elements.py | 10 +-- flixopt/features.py | 12 ++-- flixopt/interface.py | 8 +-- 6 files changed, 72 insertions(+), 119 deletions(-) delete mode 100644 flixopt/config.yaml diff --git a/flixopt/calculation.py b/flixopt/calculation.py index a695b285b..edb336886 100644 --- a/flixopt/calculation.py +++ b/flixopt/calculation.py @@ -91,13 +91,13 @@ def main_results(self) -> dict[str, Scalar | dict]: model.label_of_element: float(model.size.solution) for component in self.flow_system.components.values() for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.modeling.EPSILON + if isinstance(model, InvestmentModel) and float(model.size.solution) >= CONFIG.Modeling.epsilon }, 'Not invested': { model.label_of_element: float(model.size.solution) for component in self.flow_system.components.values() for model in component.model.all_sub_models - if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.modeling.EPSILON + if isinstance(model, InvestmentModel) and float(model.size.solution) < CONFIG.Modeling.epsilon }, }, 'Buses with excess': [ diff --git a/flixopt/config.py b/flixopt/config.py index 74e33e3ee..a0b90fb53 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -3,8 +3,7 @@ import logging import os import types -from dataclasses import dataclass, fields, is_dataclass -from typing import Annotated, Literal, get_type_hints +from typing import Literal import yaml from rich.console import Console @@ -32,110 +31,63 @@ def merge_configs(defaults: dict, overrides: dict) -> dict: return defaults -def dataclass_from_dict_with_validation(cls, data: dict): - """ - Recursively initialize a dataclass from a dictionary. - """ - if not is_dataclass(cls): - raise TypeError(f'{cls} must be a dataclass') - - # Get resolved type hints to handle postponed evaluation - type_hints = get_type_hints(cls) - - # Build kwargs for the dataclass constructor - kwargs = {} - for field in fields(cls): - field_name = field.name - # Use resolved type from get_type_hints instead of field.type - field_type = type_hints.get(field_name, field.type) - field_value = data.get(field_name) - - # If the field type is a dataclass and the value is a dict, recursively initialize - if is_dataclass(field_type) and isinstance(field_value, dict): - kwargs[field_name] = dataclass_from_dict_with_validation(field_type, field_value) - else: - kwargs[field_name] = field_value # Pass as-is if no special handling is needed - - return cls(**kwargs) - - -@dataclass() -class ValidatedConfig: - def __setattr__(self, name, value): - if field := self.__dataclass_fields__.get(name): - # Get resolved type hints to handle postponed evaluation - type_hints = get_type_hints(self.__class__, include_extras=True) - field_type = type_hints.get(name, field.type) - if metadata := getattr(field_type, '__metadata__', None): - assert metadata[0](value), f'Invalid value passed to {name!r}: {value=}' - super().__setattr__(name, value) - - -@dataclass -class LoggingConfig(ValidatedConfig): - level: Annotated[ - Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], - lambda level: level in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], - ] - file: Annotated[str, lambda file: isinstance(file, str)] - rich: Annotated[bool, lambda rich: isinstance(rich, bool)] - - -@dataclass -class ModelingConfig(ValidatedConfig): - BIG: Annotated[int, lambda x: isinstance(x, int)] - EPSILON: Annotated[float, lambda x: isinstance(x, float)] - BIG_BINARY_BOUND: Annotated[int, lambda x: isinstance(x, int)] - - -@dataclass -class ConfigSchema(ValidatedConfig): - config_name: Annotated[str, lambda x: isinstance(x, str)] - logging: LoggingConfig - modeling: ModelingConfig +class CONFIG: + """Configuration using simple nested classes.""" + class Logging: + level: str = 'INFO' + file: str = 'flixopt.log' + rich: bool = False -class CONFIG: - """ - A configuration class that stores global configuration values as class attributes. - """ + class Modeling: + big: int = 10_000_000 + epsilon: float = 1e-5 + big_binary_bound: int = 100_000 - config_name: str = None - modeling: ModelingConfig = None - logging: LoggingConfig = None + config_name: str = 'flixopt' @classmethod def load_config(cls, user_config_file: str | None = None): - """ - Initialize configuration using defaults or user-specified file. - """ - # Default config file + """Load configuration from YAML file.""" + # Load default config default_config_path = os.path.join(os.path.dirname(__file__), 'config.yaml') - if user_config_file is None: - with open(default_config_path) as file: - new_config = yaml.safe_load(file) - elif not os.path.exists(user_config_file): - raise FileNotFoundError(f'Config file not found: {user_config_file}') - else: + with open(default_config_path) as file: + config_dict = yaml.safe_load(file) + + # Merge with user config if provided + if user_config_file: + if not os.path.exists(user_config_file): + raise FileNotFoundError(f'Config file not found: {user_config_file}') + with open(user_config_file) as user_file: - new_config = yaml.safe_load(user_file) + user_config = yaml.safe_load(user_file) + config_dict = merge_configs(config_dict, user_config) - # Convert the merged config to ConfigSchema - config_data = dataclass_from_dict_with_validation(ConfigSchema, new_config) + # Apply config to class attributes + cls._apply_config_dict(config_dict) - # Store the configuration in the class as class attributes - cls.logging = config_data.logging - cls.modeling = config_data.modeling - cls.config_name = config_data.config_name + # Setup logging with new config + setup_logging(default_level=cls.Logging.level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich) - setup_logging(default_level=cls.logging.level, log_file=cls.logging.file, use_rich_handler=cls.logging.rich) + @classmethod + def _apply_config_dict(cls, config_dict: dict): + """Apply configuration dictionary to class attributes.""" + for key, value in config_dict.items(): + if hasattr(cls, key): + target = getattr(cls, key) + if hasattr(target, '__dict__') and isinstance(value, dict): + # It's a nested class, apply recursively + for nested_key, nested_value in value.items(): + setattr(target, nested_key, nested_value) + else: + # Simple attribute + setattr(cls, key, value) @classmethod def to_dict(cls): """ Convert the configuration class into a dictionary for JSON serialization. - Handles dataclasses and simple types like str, int, etc. """ config_dict = {} for attribute, value in cls.__dict__.items(): @@ -145,9 +97,20 @@ def to_dict(cls): and not isinstance(value, (types.FunctionType, types.MethodType)) and not isinstance(value, classmethod) ): - if is_dataclass(value): - config_dict[attribute] = value.__dict__ - else: # Assuming only basic types here! + if hasattr(value, '__dict__') and not isinstance(value, type): + # It's a nested class instance + config_dict[attribute] = { + k: v for k, v in value.__dict__.items() if not k.startswith('_') and not callable(v) + } + elif isinstance(value, type): + # It's a nested class definition + config_dict[attribute] = { + k: v + for k, v in value.__dict__.items() + if not k.startswith('_') and not callable(v) and not isinstance(v, classmethod) + } + else: + # Simple attribute config_dict[attribute] = value return config_dict diff --git a/flixopt/config.yaml b/flixopt/config.yaml deleted file mode 100644 index e5336eeef..000000000 --- a/flixopt/config.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# Default configuration of flixopt -config_name: flixopt # Name of the config file. This has no effect on the configuration itself. -logging: - level: INFO - file: flixopt.log - rich: false # logging output is formatted using rich. This is only advisable when using a proper terminal -modeling: - BIG: 10000000 # 1e notation not possible in yaml - EPSILON: 0.00001 - BIG_BINARY_BOUND: 100000 diff --git a/flixopt/elements.py b/flixopt/elements.py index 22256b636..21783808c 100644 --- a/flixopt/elements.py +++ b/flixopt/elements.py @@ -248,7 +248,7 @@ class Flow(Element): size: Flow capacity or nominal rating. Can be: - Scalar value for fixed capacity - InvestParameters for investment-based sizing decisions - - None to use large default value (CONFIG.modeling.BIG) + - None to use large default value (CONFIG.Modeling.big) relative_minimum: Minimum flow rate as fraction of size. Example: 0.2 means flow cannot go below 20% of rated capacity. relative_maximum: Maximum flow rate as fraction of size (typically 1.0). @@ -356,7 +356,7 @@ class Flow(Element): `relative_maximum` for upper bounds on optimization variables. Notes: - - Default size (CONFIG.modeling.BIG) is used when size=None + - Default size (CONFIG.Modeling.big) is used when size=None - list inputs for previous_flow_rate are converted to NumPy arrays - Flow direction is determined by component input/output designation @@ -383,7 +383,7 @@ def __init__( meta_data: dict | None = None, ): super().__init__(label, meta_data=meta_data) - self.size = CONFIG.modeling.BIG if size is None else size + self.size = CONFIG.Modeling.big if size is None else size self.relative_minimum = relative_minimum self.relative_maximum = relative_maximum self.fixed_relative_profile = fixed_relative_profile @@ -455,11 +455,11 @@ def _plausibility_checks(self) -> None: raise PlausibilityError(self.label_full + ': Take care, that relative_minimum <= relative_maximum!') if ( - self.size == CONFIG.modeling.BIG and self.fixed_relative_profile is not None + self.size == CONFIG.Modeling.big and self.fixed_relative_profile is not None ): # Default Size --> Most likely by accident logger.warning( f'Flow "{self.label}" has no size assigned, but a "fixed_relative_profile". ' - f'The default size is {CONFIG.modeling.BIG}. As "flow_rate = size * fixed_relative_profile", ' + f'The default size is {CONFIG.Modeling.big}. As "flow_rate = size * fixed_relative_profile", ' f'the resulting flow_rate will be very high. To fix this, assign a size to the Flow {self}.' ) diff --git a/flixopt/features.py b/flixopt/features.py index 5528917e0..7aafe242d 100644 --- a/flixopt/features.py +++ b/flixopt/features.py @@ -143,7 +143,7 @@ def _create_bounds_for_optional_investment(self): # eq2: P_invest >= isInvested * max(epsilon, investSize_min) self.add( self._model.add_constraints( - self.size >= self.is_invested * np.maximum(CONFIG.modeling.EPSILON, self.parameters.minimum_size), + self.size >= self.is_invested * np.maximum(CONFIG.Modeling.epsilon, self.parameters.minimum_size), name=f'{self.label_full}|is_invested_lb', ), 'is_invested_lb', @@ -304,7 +304,7 @@ def _add_defining_constraints(self): # Constraint: on * lower_bound <= def_var self.add( self._model.add_constraints( - self.on * np.maximum(CONFIG.modeling.EPSILON, lb) <= def_var, name=f'{self.label_full}|on_con1' + self.on * np.maximum(CONFIG.Modeling.epsilon, lb) <= def_var, name=f'{self.label_full}|on_con1' ), 'on_con1', ) @@ -314,7 +314,7 @@ def _add_defining_constraints(self): else: # Case for multiple defining variables ub = sum(bound[1] for bound in self._defining_bounds) / nr_of_def_vars - lb = CONFIG.modeling.EPSILON # TODO: Can this be a bigger value? (maybe the smallest bound?) + lb = CONFIG.Modeling.epsilon # TODO: Can this be a bigger value? (maybe the smallest bound?) # Constraint: on * epsilon <= sum(all_defining_variables) self.add( @@ -337,7 +337,7 @@ def _add_defining_constraints(self): @property def previous_states(self) -> np.ndarray: """Computes the previous states {0, 1} of defining variables as a binary array from their previous values.""" - return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.modeling.EPSILON) + return StateModel.compute_previous_states(self._previous_values, epsilon=CONFIG.Modeling.epsilon) @property def previous_on_states(self) -> np.ndarray: @@ -603,14 +603,14 @@ def compute_consecutive_hours_in_state( elif np.isscalar(binary_values) and not np.isscalar(hours_per_timestep): return binary_values * hours_per_timestep[-1] - if np.isclose(binary_values[-1], 0, atol=CONFIG.modeling.EPSILON): + if np.isclose(binary_values[-1], 0, atol=CONFIG.Modeling.epsilon): return 0 if np.isscalar(hours_per_timestep): hours_per_timestep = np.ones(len(binary_values)) * hours_per_timestep hours_per_timestep: np.ndarray - indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.modeling.EPSILON))[0] + indexes_with_zero_values = np.where(np.isclose(binary_values, 0, atol=CONFIG.Modeling.epsilon))[0] if len(indexes_with_zero_values) == 0: nr_of_indexes_with_consecutive_ones = len(binary_values) else: diff --git a/flixopt/interface.py b/flixopt/interface.py index e72e28b90..72737cc45 100644 --- a/flixopt/interface.py +++ b/flixopt/interface.py @@ -650,10 +650,10 @@ class InvestParameters(Interface): fixed_size: When specified, creates a binary investment decision at exactly this size. When None, allows continuous sizing between minimum and maximum bounds. minimum_size: Lower bound for continuous sizing decisions. Defaults to a small - positive value (CONFIG.modeling.EPSILON) to avoid numerical issues. + positive value (CONFIG.Modeling.epsilon) to avoid numerical issues. Ignored when fixed_size is specified. maximum_size: Upper bound for continuous sizing decisions. Defaults to a large - value (CONFIG.modeling.BIG) representing unlimited capacity. + value (CONFIG.Modeling.big) representing unlimited capacity. Ignored when fixed_size is specified. optional: Controls whether investment is required. When True (default), optimization can choose not to invest. When False, forces investment @@ -833,8 +833,8 @@ def __init__( self.optional = optional self.specific_effects: EffectValuesUserScalar = specific_effects or {} self.piecewise_effects = piecewise_effects - self._minimum_size = minimum_size if minimum_size is not None else CONFIG.modeling.EPSILON - self._maximum_size = maximum_size if maximum_size is not None else CONFIG.modeling.BIG # default maximum + self._minimum_size = minimum_size if minimum_size is not None else CONFIG.Modeling.epsilon + self._maximum_size = maximum_size if maximum_size is not None else CONFIG.Modeling.big # default maximum def transform_data(self, flow_system: FlowSystem): self.fix_effects = flow_system.effects.create_effect_values_dict(self.fix_effects) From 22cd3a86f8285048d6777de787a46f3ad8c7e431 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:18:19 +0200 Subject: [PATCH 02/44] Refactor configuration loading: switch from `os` to `pathlib`, streamline YAML loading logic. --- flixopt/config.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index a0b90fb53..636ec85d4 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -1,8 +1,8 @@ from __future__ import annotations import logging -import os import types +from pathlib import Path from typing import Literal import yaml @@ -47,27 +47,19 @@ class Modeling: config_name: str = 'flixopt' @classmethod - def load_config(cls, user_config_file: str | None = None): - """Load configuration from YAML file.""" - # Load default config - default_config_path = os.path.join(os.path.dirname(__file__), 'config.yaml') - - with open(default_config_path) as file: - config_dict = yaml.safe_load(file) - - # Merge with user config if provided + def load_config(cls, user_config_file: str | Path | None = None): + """Load configuration from YAML file (optional - uses class defaults if not provided).""" + # If user config is provided, load and apply it if user_config_file: - if not os.path.exists(user_config_file): + user_config_path = Path(user_config_file) + if not user_config_path.exists(): raise FileNotFoundError(f'Config file not found: {user_config_file}') - with open(user_config_file) as user_file: - user_config = yaml.safe_load(user_file) - config_dict = merge_configs(config_dict, user_config) - - # Apply config to class attributes - cls._apply_config_dict(config_dict) + with user_config_path.open() as user_file: + config_dict = yaml.safe_load(user_file) + cls._apply_config_dict(config_dict) - # Setup logging with new config + # Setup logging with current config (defaults or overridden) setup_logging(default_level=cls.Logging.level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich) @classmethod From 20c1c6828065a572feee0327ef4623fe6a9a61ed Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 09:53:54 +0200 Subject: [PATCH 03/44] Refactor logging setup: split handler creation into dedicated functions, simplify configuration logic. --- flixopt/config.py | 79 ++++++++++++++++++----------------------------- 1 file changed, 30 insertions(+), 49 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 636ec85d4..47885682a 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -150,74 +150,55 @@ def format(self, record): return '\n'.join(formatted_lines) -def _get_logging_handler(log_file: str | None = None, use_rich_handler: bool = False) -> logging.Handler: - """Returns a logging handler for the given log file.""" - if use_rich_handler and log_file is None: - # RichHandler for console output +def _create_console_handler(use_rich: bool = False) -> logging.Handler: + """Create a console (stdout) logging handler.""" + if use_rich: console = Console(width=120) - rich_handler = RichHandler( + handler = RichHandler( console=console, rich_tracebacks=True, omit_repeated_times=True, show_path=False, log_time_format='%Y-%m-%d %H:%M:%S', ) - rich_handler.setFormatter(logging.Formatter('%(message)s')) # Simplified formatting - - return rich_handler - elif log_file is None: - # Regular Logger with custom formating enabled - file_handler = logging.StreamHandler() - file_handler.setFormatter( - ColoredMultilineFormater( - fmt='%(message)s', - datefmt='%Y-%m-%d %H:%M:%S', - ) - ) - return file_handler + handler.setFormatter(logging.Formatter('%(message)s')) else: - # FileHandler for file output - file_handler = logging.FileHandler(log_file) - file_handler.setFormatter( - MultilineFormater( - fmt='%(message)s', - datefmt='%Y-%m-%d %H:%M:%S', - ) - ) - return file_handler + handler = logging.StreamHandler() + handler.setFormatter(ColoredMultilineFormater(fmt='%(message)s', datefmt='%Y-%m-%d %H:%M:%S')) + return handler + + +def _create_file_handler(log_file: str) -> logging.FileHandler: + """Create a file logging handler.""" + handler = logging.FileHandler(log_file) + handler.setFormatter(MultilineFormater(fmt='%(message)s', datefmt='%Y-%m-%d %H:%M:%S')) + return handler def setup_logging( default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', - log_file: str | None = 'flixopt.log', + log_file: str | None = None, use_rich_handler: bool = False, ): - """Setup logging configuration""" - logger = logging.getLogger('flixopt') # Use a specific logger name for your package - logger.setLevel(get_logging_level_by_name(default_level)) - # Clear existing handlers - if logger.hasHandlers(): - logger.handlers.clear() - - logger.addHandler(_get_logging_handler(use_rich_handler=use_rich_handler)) - if log_file is not None: - logger.addHandler(_get_logging_handler(log_file, use_rich_handler=False)) + """Setup logging configuration with console and optional file output.""" + logger = logging.getLogger('flixopt') + logger.setLevel(getattr(logging, default_level.upper())) + logger.handlers.clear() - return logger + # Always add console handler + logger.addHandler(_create_console_handler(use_rich=use_rich_handler)) + # Optionally add file handler + if log_file: + logger.addHandler(_create_file_handler(log_file)) -def get_logging_level_by_name(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']) -> int: - possible_logging_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] - if level_name.upper() not in possible_logging_levels: - raise ValueError(f'Invalid logging level {level_name}') - else: - logging_level = getattr(logging, level_name.upper(), logging.WARNING) - return logging_level + return logger def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']): + """Change the logging level for the flixopt logger and all its handlers.""" logger = logging.getLogger('flixopt') - logging_level = get_logging_level_by_name(level_name) - logger.setLevel(logging_level) + log_level = getattr(logging, level_name.upper()) + logger.setLevel(log_level) for handler in logger.handlers: - handler.setLevel(logging_level) + handler.setLevel(log_level) From bb153608ef59b8797eff8ef64a405d11257b3460 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:11:25 +0200 Subject: [PATCH 04/44] Improve logging configurability and safety - Add support for `RotatingFileHandler` to prevent large log files. - Introduce `console` flag for optional console logging. - Default to `NullHandler` when no handlers are configured for better library behavior. --- flixopt/config.py | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 47885682a..6b5dfc50d 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -2,6 +2,7 @@ import logging import types +from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Literal @@ -36,8 +37,9 @@ class CONFIG: class Logging: level: str = 'INFO' - file: str = 'flixopt.log' + file: str | None = None rich: bool = False + console: bool = False # Libraries should be silent by default class Modeling: big: int = 10_000_000 @@ -60,7 +62,12 @@ def load_config(cls, user_config_file: str | Path | None = None): cls._apply_config_dict(config_dict) # Setup logging with current config (defaults or overridden) - setup_logging(default_level=cls.Logging.level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich) + setup_logging( + default_level=cls.Logging.level, + log_file=cls.Logging.file, + use_rich_handler=cls.Logging.rich, + console=cls.Logging.console, + ) @classmethod def _apply_config_dict(cls, config_dict: dict): @@ -168,9 +175,14 @@ def _create_console_handler(use_rich: bool = False) -> logging.Handler: return handler -def _create_file_handler(log_file: str) -> logging.FileHandler: - """Create a file logging handler.""" - handler = logging.FileHandler(log_file) +def _create_file_handler(log_file: str) -> RotatingFileHandler: + """Create a rotating file handler to prevent huge log files.""" + handler = RotatingFileHandler( + log_file, + maxBytes=10_485_760, # 10MB max file size + backupCount=5, # Keep 5 backup files + encoding='utf-8', + ) handler.setFormatter(MultilineFormater(fmt='%(message)s', datefmt='%Y-%m-%d %H:%M:%S')) return handler @@ -179,19 +191,26 @@ def setup_logging( default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', log_file: str | None = None, use_rich_handler: bool = False, + console: bool = False, ): - """Setup logging configuration with console and optional file output.""" + """Setup logging - silent by default for library use.""" logger = logging.getLogger('flixopt') logger.setLevel(getattr(logging, default_level.upper())) + logger.propagate = False # Prevent duplicate logs logger.handlers.clear() - # Always add console handler - logger.addHandler(_create_console_handler(use_rich=use_rich_handler)) + # Only log to console if explicitly requested + if console: + logger.addHandler(_create_console_handler(use_rich=use_rich_handler)) - # Optionally add file handler + # Add file handler if specified if log_file: logger.addHandler(_create_file_handler(log_file)) + # IMPORTANT: If no handlers, use NullHandler (library best practice) + if not logger.handlers: + logger.addHandler(logging.NullHandler()) + return logger From db9a8ec983270e3ec664f41df75b9c8e9fc775ff Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:17:33 +0200 Subject: [PATCH 05/44] Temp --- flixopt/__init__.py | 3 ++- flixopt/config.py | 40 ++++++++++++++++++++++++++-------------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 34306ae32..436c92a09 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -36,4 +36,5 @@ solvers, ) -CONFIG.load_config() +# Setup logging with default configuration +CONFIG._setup_logging() diff --git a/flixopt/config.py b/flixopt/config.py index 6b5dfc50d..f59acf5b8 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import sys import types from logging.handlers import RotatingFileHandler from pathlib import Path @@ -49,24 +50,35 @@ class Modeling: config_name: str = 'flixopt' @classmethod - def load_config(cls, user_config_file: str | Path | None = None): - """Load configuration from YAML file (optional - uses class defaults if not provided).""" - # If user config is provided, load and apply it - if user_config_file: - user_config_path = Path(user_config_file) - if not user_config_path.exists(): - raise FileNotFoundError(f'Config file not found: {user_config_file}') - - with user_config_path.open() as user_file: - config_dict = yaml.safe_load(user_file) - cls._apply_config_dict(config_dict) - - # Setup logging with current config (defaults or overridden) + def load_from_file(cls, config_file: str | Path): + """Load and apply configuration from YAML file.""" + config_path = Path(config_file) + if not config_path.exists(): + raise FileNotFoundError(f'Config file not found: {config_file}') + + with config_path.open() as file: + config_dict = yaml.safe_load(file) + cls._apply_config_dict(config_dict) + + # Re-setup logging with new config + cls._setup_logging() + + @classmethod + def _setup_logging(cls): + """Setup logging based on current configuration.""" + # Auto-enable console logging for examples and scripts + console_enabled = cls.Logging.console + if not console_enabled and hasattr(sys, 'argv') and len(sys.argv) > 0: + script_path = Path(sys.argv[0]).resolve() + # Enable console if running from examples directory + if 'examples' in script_path.parts: + console_enabled = True + setup_logging( default_level=cls.Logging.level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich, - console=cls.Logging.console, + console=console_enabled, ) @classmethod From 469f46e85979355f8287974574592813109f01d2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:24:38 +0200 Subject: [PATCH 06/44] Temp --- examples/00_Minmal/minimal_example.py | 3 +++ examples/01_Simple/simple_example.py | 3 +++ examples/02_Complex/complex_example.py | 4 ++++ .../03_Calculation_types/example_calculation_types.py | 4 ++++ flixopt/config.py | 11 +---------- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index e9ef241ff..16e5156de 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -9,6 +9,9 @@ import flixopt as fx if __name__ == '__main__': + # Enable console logging to see what's happening + fx.CONFIG.Logging.console = True + fx.CONFIG._setup_logging() # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- timesteps = pd.date_range('2020-01-01', periods=3, freq='h') flow_system = fx.FlowSystem(timesteps) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 8239f805a..73a48fec5 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -8,6 +8,9 @@ import flixopt as fx if __name__ == '__main__': + # Enable console logging to see what's happening + fx.CONFIG.Logging.console = True + fx.CONFIG._setup_logging() # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices heat_demand_per_h = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 175211c26..aa9cc0bec 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -9,6 +9,10 @@ import flixopt as fx if __name__ == '__main__': + # Enable console logging to see what's happening + fx.CONFIG.Logging.console = True + fx.CONFIG._setup_logging() + # --- Experiment Options --- # Configure options for testing various parameters and behaviors check_penalty = False diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index a92a20163..c3552bbdf 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -11,6 +11,10 @@ import flixopt as fx if __name__ == '__main__': + # Enable console logging to see what's happening + fx.CONFIG.Logging.console = True + fx.CONFIG._setup_logging() + # Calculation Types full, segmented, aggregated = True, True, True diff --git a/flixopt/config.py b/flixopt/config.py index f59acf5b8..f8fb7f906 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import sys import types from logging.handlers import RotatingFileHandler from pathlib import Path @@ -66,19 +65,11 @@ def load_from_file(cls, config_file: str | Path): @classmethod def _setup_logging(cls): """Setup logging based on current configuration.""" - # Auto-enable console logging for examples and scripts - console_enabled = cls.Logging.console - if not console_enabled and hasattr(sys, 'argv') and len(sys.argv) > 0: - script_path = Path(sys.argv[0]).resolve() - # Enable console if running from examples directory - if 'examples' in script_path.parts: - console_enabled = True - setup_logging( default_level=cls.Logging.level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich, - console=console_enabled, + console=cls.Logging.console, ) @classmethod From 7620d5e5cbdf1f02e8044fa65a2b8276e1138511 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:32:36 +0200 Subject: [PATCH 07/44] Temp --- examples/00_Minmal/minimal_example.py | 1 - examples/01_Simple/simple_example.py | 1 - examples/02_Complex/complex_example.py | 1 - .../example_calculation_types.py | 1 - flixopt/config.py | 133 +++++++++++------- 5 files changed, 82 insertions(+), 55 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 16e5156de..56c5691ff 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -11,7 +11,6 @@ if __name__ == '__main__': # Enable console logging to see what's happening fx.CONFIG.Logging.console = True - fx.CONFIG._setup_logging() # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- timesteps = pd.date_range('2020-01-01', periods=3, freq='h') flow_system = fx.FlowSystem(timesteps) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 73a48fec5..6699961ef 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -10,7 +10,6 @@ if __name__ == '__main__': # Enable console logging to see what's happening fx.CONFIG.Logging.console = True - fx.CONFIG._setup_logging() # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices heat_demand_per_h = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index aa9cc0bec..6b1da85f3 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -11,7 +11,6 @@ if __name__ == '__main__': # Enable console logging to see what's happening fx.CONFIG.Logging.console = True - fx.CONFIG._setup_logging() # --- Experiment Options --- # Configure options for testing various parameters and behaviors diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index c3552bbdf..f625172b1 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -13,7 +13,6 @@ if __name__ == '__main__': # Enable console logging to see what's happening fx.CONFIG.Logging.console = True - fx.CONFIG._setup_logging() # Calculation Types full, segmented, aggregated = True, True, True diff --git a/flixopt/config.py b/flixopt/config.py index f8fb7f906..e2de7b79f 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -32,20 +32,65 @@ def merge_configs(defaults: dict, overrides: dict) -> dict: return defaults -class CONFIG: - """Configuration using simple nested classes.""" +class _LoggingConfig: + """Logging configuration that auto-updates when changed.""" + + def __init__(self): + self._level: str = 'INFO' + self._file: str | None = None + self._rich: bool = False + self._console: bool = False + + @property + def level(self) -> str: + return self._level + + @level.setter + def level(self, value: str): + self._level = value + CONFIG._setup_logging() + + @property + def file(self) -> str | None: + return self._file + + @file.setter + def file(self, value: str | None): + self._file = value + CONFIG._setup_logging() + + @property + def rich(self) -> bool: + return self._rich + + @rich.setter + def rich(self, value: bool): + self._rich = value + CONFIG._setup_logging() - class Logging: - level: str = 'INFO' - file: str | None = None - rich: bool = False - console: bool = False # Libraries should be silent by default + @property + def console(self) -> bool: + return self._console - class Modeling: - big: int = 10_000_000 - epsilon: float = 1e-5 - big_binary_bound: int = 100_000 + @console.setter + def console(self, value: bool): + self._console = value + CONFIG._setup_logging() + +class _ModelingConfig: + """Modeling configuration with constants.""" + + big: int = 10_000_000 + epsilon: float = 1e-5 + big_binary_bound: int = 100_000 + + +class CONFIG: + """Configuration using simple nested classes.""" + + Logging = _LoggingConfig() + Modeling = _ModelingConfig() config_name: str = 'flixopt' @classmethod @@ -59,9 +104,6 @@ def load_from_file(cls, config_file: str | Path): config_dict = yaml.safe_load(file) cls._apply_config_dict(config_dict) - # Re-setup logging with new config - cls._setup_logging() - @classmethod def _setup_logging(cls): """Setup logging based on current configuration.""" @@ -76,46 +118,35 @@ def _setup_logging(cls): def _apply_config_dict(cls, config_dict: dict): """Apply configuration dictionary to class attributes.""" for key, value in config_dict.items(): - if hasattr(cls, key): - target = getattr(cls, key) - if hasattr(target, '__dict__') and isinstance(value, dict): - # It's a nested class, apply recursively - for nested_key, nested_value in value.items(): - setattr(target, nested_key, nested_value) - else: - # Simple attribute - setattr(cls, key, value) + if key == 'logging' and isinstance(value, dict): + # Apply logging config (triggers auto-setup via properties) + for nested_key, nested_value in value.items(): + setattr(cls.Logging, nested_key, nested_value) + elif key == 'modeling' and isinstance(value, dict): + # Apply modeling config + for nested_key, nested_value in value.items(): + setattr(cls.Modeling, nested_key, nested_value) + elif hasattr(cls, key): + # Simple attribute + setattr(cls, key, value) @classmethod def to_dict(cls): - """ - Convert the configuration class into a dictionary for JSON serialization. - """ - config_dict = {} - for attribute, value in cls.__dict__.items(): - # Only consider attributes (not methods, etc.) - if ( - not attribute.startswith('_') - and not isinstance(value, (types.FunctionType, types.MethodType)) - and not isinstance(value, classmethod) - ): - if hasattr(value, '__dict__') and not isinstance(value, type): - # It's a nested class instance - config_dict[attribute] = { - k: v for k, v in value.__dict__.items() if not k.startswith('_') and not callable(v) - } - elif isinstance(value, type): - # It's a nested class definition - config_dict[attribute] = { - k: v - for k, v in value.__dict__.items() - if not k.startswith('_') and not callable(v) and not isinstance(v, classmethod) - } - else: - # Simple attribute - config_dict[attribute] = value - - return config_dict + """Convert the configuration class into a dictionary for JSON serialization.""" + return { + 'config_name': cls.config_name, + 'logging': { + 'level': cls.Logging.level, + 'file': cls.Logging.file, + 'rich': cls.Logging.rich, + 'console': cls.Logging.console, + }, + 'modeling': { + 'big': cls.Modeling.big, + 'epsilon': cls.Modeling.epsilon, + 'big_binary_bound': cls.Modeling.big_binary_bound, + }, + } class MultilineFormater(logging.Formatter): From 0ee41bce93ac87269d9bee2d364df41c64ef504e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:37:39 +0200 Subject: [PATCH 08/44] Temp --- examples/00_Minmal/minimal_example.py | 1 + examples/01_Simple/simple_example.py | 1 + examples/02_Complex/complex_example.py | 1 + .../example_calculation_types.py | 1 + flixopt/__init__.py | 4 +- flixopt/config.py | 97 +++++++------------ 6 files changed, 40 insertions(+), 65 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 56c5691ff..9c64fb174 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -11,6 +11,7 @@ if __name__ == '__main__': # Enable console logging to see what's happening fx.CONFIG.Logging.console = True + fx.CONFIG.apply() # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- timesteps = pd.date_range('2020-01-01', periods=3, freq='h') flow_system = fx.FlowSystem(timesteps) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 6699961ef..62eb2686d 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -10,6 +10,7 @@ if __name__ == '__main__': # Enable console logging to see what's happening fx.CONFIG.Logging.console = True + fx.CONFIG.apply() # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices heat_demand_per_h = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 6b1da85f3..7c9511a43 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -11,6 +11,7 @@ if __name__ == '__main__': # Enable console logging to see what's happening fx.CONFIG.Logging.console = True + fx.CONFIG.apply() # --- Experiment Options --- # Configure options for testing various parameters and behaviors diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index f625172b1..11b2366c7 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -13,6 +13,7 @@ if __name__ == '__main__': # Enable console logging to see what's happening fx.CONFIG.Logging.console = True + fx.CONFIG.apply() # Calculation Types full, segmented, aggregated = True, True, True diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 436c92a09..4a2f043e6 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -36,5 +36,5 @@ solvers, ) -# Setup logging with default configuration -CONFIG._setup_logging() +# Initialize logging with default (silent) configuration +CONFIG.apply() diff --git a/flixopt/config.py b/flixopt/config.py index e2de7b79f..4bb46e17b 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -2,6 +2,7 @@ import logging import types +from contextlib import contextmanager from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Literal @@ -32,70 +33,48 @@ def merge_configs(defaults: dict, overrides: dict) -> dict: return defaults -class _LoggingConfig: - """Logging configuration that auto-updates when changed.""" - - def __init__(self): - self._level: str = 'INFO' - self._file: str | None = None - self._rich: bool = False - self._console: bool = False - - @property - def level(self) -> str: - return self._level - - @level.setter - def level(self, value: str): - self._level = value - CONFIG._setup_logging() - - @property - def file(self) -> str | None: - return self._file - - @file.setter - def file(self, value: str | None): - self._file = value - CONFIG._setup_logging() - - @property - def rich(self) -> bool: - return self._rich - - @rich.setter - def rich(self, value: bool): - self._rich = value - CONFIG._setup_logging() - - @property - def console(self) -> bool: - return self._console - - @console.setter - def console(self, value: bool): - self._console = value - CONFIG._setup_logging() +class CONFIG: + """ + Configuration for flixopt library. + Library is SILENT by default (best practice for libraries). -class _ModelingConfig: - """Modeling configuration with constants.""" + Usage: + # Change config and apply + CONFIG.Logging.console = True + CONFIG.Logging.level = 'DEBUG' + CONFIG.apply() - big: int = 10_000_000 - epsilon: float = 1e-5 - big_binary_bound: int = 100_000 + # Load from YAML file (auto-applies) + CONFIG.load_from_file('config.yaml') + """ + class Logging: + level: str = 'INFO' + file: str | None = None + rich: bool = False + console: bool = False -class CONFIG: - """Configuration using simple nested classes.""" + class Modeling: + big: int = 10_000_000 + epsilon: float = 1e-5 + big_binary_bound: int = 100_000 - Logging = _LoggingConfig() - Modeling = _ModelingConfig() config_name: str = 'flixopt' + @classmethod + def apply(cls): + """Apply current configuration to logging system.""" + setup_logging( + default_level=cls.Logging.level, + log_file=cls.Logging.file, + use_rich_handler=cls.Logging.rich, + console=cls.Logging.console, + ) + @classmethod def load_from_file(cls, config_file: str | Path): - """Load and apply configuration from YAML file.""" + """Load configuration from YAML file and apply it.""" config_path = Path(config_file) if not config_path.exists(): raise FileNotFoundError(f'Config file not found: {config_file}') @@ -104,15 +83,7 @@ def load_from_file(cls, config_file: str | Path): config_dict = yaml.safe_load(file) cls._apply_config_dict(config_dict) - @classmethod - def _setup_logging(cls): - """Setup logging based on current configuration.""" - setup_logging( - default_level=cls.Logging.level, - log_file=cls.Logging.file, - use_rich_handler=cls.Logging.rich, - console=cls.Logging.console, - ) + cls.apply() @classmethod def _apply_config_dict(cls, config_dict: dict): From f9358a08efad55260a31289e9317c75a039008ae Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:41:00 +0200 Subject: [PATCH 09/44] Temp --- flixopt/__init__.py | 3 --- flixopt/config.py | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 4a2f043e6..d8ad05f19 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -35,6 +35,3 @@ results, solvers, ) - -# Initialize logging with default (silent) configuration -CONFIG.apply() diff --git a/flixopt/config.py b/flixopt/config.py index 4bb46e17b..827f89b35 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -226,3 +226,7 @@ def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR' logger.setLevel(log_level) for handler in logger.handlers: handler.setLevel(log_level) + + +# Initialize logging with default (silent) configuration +CONFIG.apply() From 845ff867505dc030d48b46e388ef75d2a9115020 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:50:19 +0200 Subject: [PATCH 10/44] Temp --- flixopt/__init__.py | 1 - flixopt/config.py | 11 +---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/flixopt/__init__.py b/flixopt/__init__.py index d8ad05f19..d10b379af 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -29,7 +29,6 @@ Storage, TimeSeriesData, Transmission, - change_logging_level, linear_converters, plotting, results, diff --git a/flixopt/config.py b/flixopt/config.py index 827f89b35..f963b7028 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -219,14 +219,5 @@ def setup_logging( return logger -def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']): - """Change the logging level for the flixopt logger and all its handlers.""" - logger = logging.getLogger('flixopt') - log_level = getattr(logging, level_name.upper()) - logger.setLevel(log_level) - for handler in logger.handlers: - handler.setLevel(log_level) - - -# Initialize logging with default (silent) configuration +# Initialize default config CONFIG.apply() From 153c64d76d5cbf6997ff12bf9600143c902b0f0b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:54:35 +0200 Subject: [PATCH 11/44] Refactor configuration and logging: remove unused `merge_configs` function, streamline logging setup, and encapsulate `_setup_logging` as an internal function. --- flixopt/config.py | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index f963b7028..d9f902016 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -1,8 +1,6 @@ from __future__ import annotations import logging -import types -from contextlib import contextmanager from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Literal @@ -11,26 +9,9 @@ from rich.console import Console from rich.logging import RichHandler -logger = logging.getLogger('flixopt') - +__all__ = ['CONFIG'] -def merge_configs(defaults: dict, overrides: dict) -> dict: - """ - Merge the default configuration with user-provided overrides. - Args: - defaults: Default configuration dictionary. - overrides: User configuration dictionary. - Returns: - Merged configuration dictionary. - """ - for key, value in overrides.items(): - if isinstance(value, dict) and key in defaults and isinstance(defaults[key], dict): - # Recursively merge nested dictionaries - defaults[key] = merge_configs(defaults[key], value) - else: - # Override the default value - defaults[key] = value - return defaults +logger = logging.getLogger('flixopt') class CONFIG: @@ -65,7 +46,7 @@ class Modeling: @classmethod def apply(cls): """Apply current configuration to logging system.""" - setup_logging( + _setup_logging( default_level=cls.Logging.level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich, @@ -90,7 +71,7 @@ def _apply_config_dict(cls, config_dict: dict): """Apply configuration dictionary to class attributes.""" for key, value in config_dict.items(): if key == 'logging' and isinstance(value, dict): - # Apply logging config (triggers auto-setup via properties) + # Apply logging config for nested_key, nested_value in value.items(): setattr(cls.Logging, nested_key, nested_value) elif key == 'modeling' and isinstance(value, dict): @@ -192,13 +173,13 @@ def _create_file_handler(log_file: str) -> RotatingFileHandler: return handler -def setup_logging( +def _setup_logging( default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', log_file: str | None = None, use_rich_handler: bool = False, console: bool = False, ): - """Setup logging - silent by default for library use.""" + """Internal function to setup logging - use CONFIG.apply() instead.""" logger = logging.getLogger('flixopt') logger.setLevel(getattr(logging, default_level.upper())) logger.propagate = False # Prevent duplicate logs From ea99bc053d42555b87ce21f6ce502e15b8d881d4 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 10:58:51 +0200 Subject: [PATCH 12/44] Remove unused `change_logging_level` import and export. --- flixopt/commons.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/flixopt/commons.py b/flixopt/commons.py index 68412d6fe..2f3e05f6c 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -13,7 +13,7 @@ Storage, Transmission, ) -from .config import CONFIG, change_logging_level +from .config import CONFIG from .core import TimeSeriesData from .effects import Effect from .elements import Bus, Flow @@ -23,7 +23,6 @@ __all__ = [ 'TimeSeriesData', 'CONFIG', - 'change_logging_level', 'Flow', 'Bus', 'Effect', From 6b76aabd9c6fb51e07aed7a401b198a90f095bf9 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:00:28 +0200 Subject: [PATCH 13/44] Add tests for config.py --- tests/test_config.py | 241 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 tests/test_config.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 000000000..f4836ebc5 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,241 @@ +"""Tests for the config module.""" + +import logging +import sys +from pathlib import Path + +import pytest + +from flixopt.config import CONFIG, _setup_logging + + +class TestConfigModule: + """Test the CONFIG class and logging setup.""" + + def setup_method(self): + """Reset CONFIG to defaults before each test.""" + CONFIG.Logging.level = 'INFO' + CONFIG.Logging.file = None + CONFIG.Logging.rich = False + CONFIG.Logging.console = False + # Clear any existing handlers + logger = logging.getLogger('flixopt') + logger.handlers.clear() + + def test_config_defaults(self): + """Test that CONFIG has correct default values.""" + assert CONFIG.Logging.level == 'INFO' + assert CONFIG.Logging.file is None + assert CONFIG.Logging.rich is False + assert CONFIG.Logging.console is False + assert CONFIG.Modeling.big == 10_000_000 + assert CONFIG.Modeling.epsilon == 1e-5 + assert CONFIG.Modeling.big_binary_bound == 100_000 + assert CONFIG.config_name == 'flixopt' + + def test_module_initialization(self): + """Test that logging is initialized on module import.""" + # Apply config to ensure handlers are initialized + CONFIG.apply() + logger = logging.getLogger('flixopt') + # Should have at least one handler (NullHandler by default) + assert len(logger.handlers) >= 1 + # Should have NullHandler when no console/file output is configured + assert any(isinstance(h, logging.NullHandler) for h in logger.handlers) + + def test_config_apply_console(self): + """Test applying config with console logging enabled.""" + CONFIG.Logging.console = True + CONFIG.Logging.level = 'DEBUG' + CONFIG.apply() + + logger = logging.getLogger('flixopt') + assert logger.level == logging.DEBUG + # Should have a StreamHandler for console output + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + # Should not have NullHandler when console is enabled + assert not any(isinstance(h, logging.NullHandler) for h in logger.handlers) + + def test_config_apply_file(self, tmp_path): + """Test applying config with file logging enabled.""" + log_file = tmp_path / 'test.log' + CONFIG.Logging.file = str(log_file) + CONFIG.Logging.level = 'WARNING' + CONFIG.apply() + + logger = logging.getLogger('flixopt') + assert logger.level == logging.WARNING + # Should have a RotatingFileHandler for file output + from logging.handlers import RotatingFileHandler + + assert any(isinstance(h, RotatingFileHandler) for h in logger.handlers) + + def test_config_apply_rich(self): + """Test applying config with rich logging enabled.""" + CONFIG.Logging.console = True + CONFIG.Logging.rich = True + CONFIG.apply() + + logger = logging.getLogger('flixopt') + # Should have a RichHandler + from rich.logging import RichHandler + + assert any(isinstance(h, RichHandler) for h in logger.handlers) + + def test_config_apply_multiple_changes(self): + """Test applying multiple config changes at once.""" + CONFIG.Logging.console = True + CONFIG.Logging.level = 'ERROR' + CONFIG.apply() + + logger = logging.getLogger('flixopt') + assert logger.level == logging.ERROR + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + + def test_config_to_dict(self): + """Test converting CONFIG to dictionary.""" + CONFIG.Logging.level = 'DEBUG' + CONFIG.Logging.console = True + + config_dict = CONFIG.to_dict() + + assert config_dict['config_name'] == 'flixopt' + assert config_dict['logging']['level'] == 'DEBUG' + assert config_dict['logging']['console'] is True + assert config_dict['logging']['file'] is None + assert config_dict['logging']['rich'] is False + assert 'modeling' in config_dict + assert config_dict['modeling']['big'] == 10_000_000 + + def test_config_load_from_file(self, tmp_path): + """Test loading configuration from YAML file.""" + config_file = tmp_path / 'config.yaml' + config_content = """ +config_name: test_config +logging: + level: DEBUG + console: true + rich: false +modeling: + big: 20000000 + epsilon: 1e-6 +""" + config_file.write_text(config_content) + + CONFIG.load_from_file(config_file) + + assert CONFIG.config_name == 'test_config' + assert CONFIG.Logging.level == 'DEBUG' + assert CONFIG.Logging.console is True + assert CONFIG.Modeling.big == 20000000 + # YAML may load epsilon as string, so convert for comparison + assert float(CONFIG.Modeling.epsilon) == 1e-6 + + def test_config_load_from_file_not_found(self): + """Test that loading from non-existent file raises error.""" + with pytest.raises(FileNotFoundError): + CONFIG.load_from_file('nonexistent_config.yaml') + + def test_config_load_from_file_partial(self, tmp_path): + """Test loading partial configuration (should merge with defaults).""" + config_file = tmp_path / 'partial_config.yaml' + config_content = """ +logging: + level: ERROR +""" + config_file.write_text(config_content) + + # Set a non-default value first + CONFIG.Logging.console = True + CONFIG.apply() + + CONFIG.load_from_file(config_file) + + # Should update level but keep other settings + assert CONFIG.Logging.level == 'ERROR' + + def test_setup_logging_silent_default(self): + """Test that _setup_logging creates silent logger by default.""" + _setup_logging() + + logger = logging.getLogger('flixopt') + # Should have NullHandler when console=False and log_file=None + assert any(isinstance(h, logging.NullHandler) for h in logger.handlers) + assert not logger.propagate + + def test_setup_logging_with_console(self): + """Test _setup_logging with console output.""" + _setup_logging(console=True, default_level='DEBUG') + + logger = logging.getLogger('flixopt') + assert logger.level == logging.DEBUG + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + + def test_setup_logging_clears_handlers(self): + """Test that _setup_logging clears existing handlers.""" + logger = logging.getLogger('flixopt') + + # Add a dummy handler + dummy_handler = logging.NullHandler() + logger.addHandler(dummy_handler) + _ = len(logger.handlers) + + _setup_logging(console=True) + + # Should have cleared old handlers and added new one + assert dummy_handler not in logger.handlers + + def test_change_logging_level_removed(self): + """Test that change_logging_level function no longer exists.""" + # This function was removed as part of the cleanup + import flixopt + + assert not hasattr(flixopt, 'change_logging_level') + + def test_public_api(self): + """Test that only CONFIG is exported from config module.""" + from flixopt import config + + # CONFIG should be accessible + assert hasattr(config, 'CONFIG') + + # _setup_logging should exist but be marked as private + assert hasattr(config, '_setup_logging') + + # merge_configs should not exist (was removed) + assert not hasattr(config, 'merge_configs') + + def test_logging_levels(self): + """Test all valid logging levels.""" + levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] + + for level in levels: + CONFIG.Logging.level = level + CONFIG.Logging.console = True + CONFIG.apply() + + logger = logging.getLogger('flixopt') + assert logger.level == getattr(logging, level) + + def test_logger_propagate_disabled(self): + """Test that logger propagation is disabled.""" + CONFIG.apply() + logger = logging.getLogger('flixopt') + assert not logger.propagate + + def test_file_handler_rotation(self, tmp_path): + """Test that file handler uses rotation.""" + log_file = tmp_path / 'rotating.log' + CONFIG.Logging.file = str(log_file) + CONFIG.apply() + + logger = logging.getLogger('flixopt') + from logging.handlers import RotatingFileHandler + + file_handlers = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)] + assert len(file_handlers) == 1 + + handler = file_handlers[0] + # Check rotation settings + assert handler.maxBytes == 10_485_760 # 10MB + assert handler.backupCount == 5 From 314b1e4f7bbac1e73c6cb86a01e9ec57b838765b Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:04:52 +0200 Subject: [PATCH 14/44] Expand `config.py` test coverage: add tests for custom config loading, logging setup, dict roundtrip, and attribute modification. --- tests/test_config.py | 152 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index f4836ebc5..20d2184ce 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -239,3 +239,155 @@ def test_file_handler_rotation(self, tmp_path): # Check rotation settings assert handler.maxBytes == 10_485_760 # 10MB assert handler.backupCount == 5 + + def test_custom_config_yaml_complete(self, tmp_path): + """Test loading a complete custom configuration.""" + config_file = tmp_path / 'custom_config.yaml' + config_content = """ +config_name: my_custom_config +logging: + level: CRITICAL + console: true + rich: true + file: /tmp/custom.log +modeling: + big: 50000000 + epsilon: 1e-4 + big_binary_bound: 200000 +""" + config_file.write_text(config_content) + + CONFIG.load_from_file(config_file) + + # Check all settings were applied + assert CONFIG.config_name == 'my_custom_config' + assert CONFIG.Logging.level == 'CRITICAL' + assert CONFIG.Logging.console is True + assert CONFIG.Logging.rich is True + assert CONFIG.Logging.file == '/tmp/custom.log' + assert CONFIG.Modeling.big == 50000000 + assert float(CONFIG.Modeling.epsilon) == 1e-4 + assert CONFIG.Modeling.big_binary_bound == 200000 + + # Verify logging was applied + logger = logging.getLogger('flixopt') + assert logger.level == logging.CRITICAL + + def test_config_file_with_console_and_file(self, tmp_path): + """Test configuration with both console and file logging enabled.""" + log_file = tmp_path / 'test.log' + config_file = tmp_path / 'config.yaml' + config_content = f""" +logging: + level: INFO + console: true + rich: false + file: {log_file} +""" + config_file.write_text(config_content) + + CONFIG.load_from_file(config_file) + + logger = logging.getLogger('flixopt') + # Should have both StreamHandler and RotatingFileHandler + from logging.handlers import RotatingFileHandler + + assert any(isinstance(h, logging.StreamHandler) for h in logger.handlers) + assert any(isinstance(h, RotatingFileHandler) for h in logger.handlers) + # Should NOT have NullHandler when console/file are enabled + assert not any(isinstance(h, logging.NullHandler) for h in logger.handlers) + + def test_config_to_dict_roundtrip(self, tmp_path): + """Test that config can be saved to dict, modified, and restored.""" + # Set custom values + CONFIG.Logging.level = 'WARNING' + CONFIG.Logging.console = True + CONFIG.Modeling.big = 99999999 + + # Save to dict + config_dict = CONFIG.to_dict() + + # Verify dict structure + assert config_dict['logging']['level'] == 'WARNING' + assert config_dict['logging']['console'] is True + assert config_dict['modeling']['big'] == 99999999 + + # Could be written to YAML and loaded back + yaml_file = tmp_path / 'saved_config.yaml' + import yaml + + with open(yaml_file, 'w') as f: + yaml.dump(config_dict, f) + + # Reset config + CONFIG.Logging.level = 'INFO' + CONFIG.Logging.console = False + CONFIG.Modeling.big = 10_000_000 + + # Load back from file + CONFIG.load_from_file(yaml_file) + + # Should match original values + assert CONFIG.Logging.level == 'WARNING' + assert CONFIG.Logging.console is True + assert CONFIG.Modeling.big == 99999999 + + def test_config_file_with_only_modeling(self, tmp_path): + """Test config file that only sets modeling parameters.""" + config_file = tmp_path / 'modeling_only.yaml' + config_content = """ +modeling: + big: 999999 + epsilon: 0.001 +""" + config_file.write_text(config_content) + + # Set logging config before loading + original_level = CONFIG.Logging.level + CONFIG.load_from_file(config_file) + + # Modeling should be updated + assert CONFIG.Modeling.big == 999999 + assert float(CONFIG.Modeling.epsilon) == 0.001 + + # Logging should keep default/previous values + assert CONFIG.Logging.level == original_level + + def test_config_attribute_modification(self): + """Test that config attributes can be modified directly.""" + # Store original values + original_big = CONFIG.Modeling.big + original_level = CONFIG.Logging.level + + # Modify attributes + CONFIG.Modeling.big = 12345678 + CONFIG.Modeling.epsilon = 1e-8 + CONFIG.Logging.level = 'DEBUG' + CONFIG.Logging.console = True + + # Verify modifications + assert CONFIG.Modeling.big == 12345678 + assert CONFIG.Modeling.epsilon == 1e-8 + assert CONFIG.Logging.level == 'DEBUG' + assert CONFIG.Logging.console is True + + # Reset + CONFIG.Modeling.big = original_big + CONFIG.Logging.level = original_level + CONFIG.Logging.console = False + + def test_logger_actually_logs(self, tmp_path): + """Test that the logger actually writes log messages.""" + log_file = tmp_path / 'actual_test.log' + CONFIG.Logging.file = str(log_file) + CONFIG.Logging.level = 'DEBUG' + CONFIG.apply() + + logger = logging.getLogger('flixopt') + test_message = 'Test log message from config test' + logger.debug(test_message) + + # Check that file was created and contains the message + assert log_file.exists() + log_content = log_file.read_text() + assert test_message in log_content From 0a7a9452b840df67d6a3aff60b0c6f028e218e56 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 11:23:15 +0200 Subject: [PATCH 15/44] Expand `test_config.py` coverage: add modeling config persistence test, refine logging reset, and improve partial config load assertions. --- tests/test_config.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 20d2184ce..2672b0340 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -18,9 +18,15 @@ def setup_method(self): CONFIG.Logging.file = None CONFIG.Logging.rich = False CONFIG.Logging.console = False - # Clear any existing handlers + + # Clear and reset logger completely logger = logging.getLogger('flixopt') logger.handlers.clear() + logger.setLevel(logging.INFO) + logger.propagate = False + + # Apply clean state + CONFIG.apply() def test_config_defaults(self): """Test that CONFIG has correct default values.""" @@ -137,7 +143,7 @@ def test_config_load_from_file_not_found(self): CONFIG.load_from_file('nonexistent_config.yaml') def test_config_load_from_file_partial(self, tmp_path): - """Test loading partial configuration (should merge with defaults).""" + """Test loading partial configuration (should keep unspecified settings).""" config_file = tmp_path / 'partial_config.yaml' config_content = """ logging: @@ -153,6 +159,8 @@ def test_config_load_from_file_partial(self, tmp_path): # Should update level but keep other settings assert CONFIG.Logging.level == 'ERROR' + # Verify console setting is preserved (not in YAML) + assert CONFIG.Logging.console is True def test_setup_logging_silent_default(self): """Test that _setup_logging creates silent logger by default.""" @@ -187,7 +195,7 @@ def test_setup_logging_clears_handlers(self): def test_change_logging_level_removed(self): """Test that change_logging_level function no longer exists.""" - # This function was removed as part of the cleanup + # This function was removed - users should use CONFIG.apply() instead import flixopt assert not hasattr(flixopt, 'change_logging_level') @@ -391,3 +399,21 @@ def test_logger_actually_logs(self, tmp_path): assert log_file.exists() log_content = log_file.read_text() assert test_message in log_content + + def test_modeling_config_persistence(self): + """Test that Modeling config is independent of Logging config.""" + # Set custom modeling values + CONFIG.Modeling.big = 99999999 + CONFIG.Modeling.epsilon = 1e-8 + + # Change and apply logging config + CONFIG.Logging.console = True + CONFIG.apply() + + # Modeling values should be unchanged + assert CONFIG.Modeling.big == 99999999 + assert CONFIG.Modeling.epsilon == 1e-8 + + # Reset for other tests + CONFIG.Modeling.big = 10_000_000 + CONFIG.Modeling.epsilon = 1e-5 From fb2ad91c87a0e516a16d680603270cda01892507 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:46:23 +0200 Subject: [PATCH 16/44] Expand `test_config.py` coverage: add teardown for state cleanup and reset modeling config in setup. --- tests/test_config.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index 2672b0340..f3a25cf54 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -14,11 +14,17 @@ class TestConfigModule: def setup_method(self): """Reset CONFIG to defaults before each test.""" + # Reset Logging config CONFIG.Logging.level = 'INFO' CONFIG.Logging.file = None CONFIG.Logging.rich = False CONFIG.Logging.console = False + # Reset Modeling config to defaults + CONFIG.Modeling.big = 10_000_000 + CONFIG.Modeling.epsilon = 1e-5 + CONFIG.Modeling.big_binary_bound = 100_000 + # Clear and reset logger completely logger = logging.getLogger('flixopt') logger.handlers.clear() @@ -28,6 +34,20 @@ def setup_method(self): # Apply clean state CONFIG.apply() + def teardown_method(self): + """Clean up after each test to prevent state leakage.""" + # Reset to absolute defaults + CONFIG.Logging.level = 'INFO' + CONFIG.Logging.file = None + CONFIG.Logging.rich = False + CONFIG.Logging.console = False + + CONFIG.Modeling.big = 10_000_000 + CONFIG.Modeling.epsilon = 1e-5 + CONFIG.Modeling.big_binary_bound = 100_000 + + CONFIG.apply() + def test_config_defaults(self): """Test that CONFIG has correct default values.""" assert CONFIG.Logging.level == 'INFO' From f105620f30e7714719c44d84a4d5675d3bf5a124 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 12:55:20 +0200 Subject: [PATCH 17/44] Add `CONFIG.reset()` method and expand test coverage to verify default restoration --- flixopt/config.py | 23 ++++++++++++++++ tests/test_config.py | 64 +++++++++++++++++++++----------------------- 2 files changed, 54 insertions(+), 33 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index d9f902016..0f389a14c 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -28,6 +28,9 @@ class CONFIG: # Load from YAML file (auto-applies) CONFIG.load_from_file('config.yaml') + + # Reset to defaults + CONFIG.reset() """ class Logging: @@ -43,6 +46,26 @@ class Modeling: config_name: str = 'flixopt' + @classmethod + def reset(cls): + """Reset all configuration values to defaults.""" + # Reset Logging config + cls.Logging.level = 'INFO' + cls.Logging.file = None + cls.Logging.rich = False + cls.Logging.console = False + + # Reset Modeling config + cls.Modeling.big = 10_000_000 + cls.Modeling.epsilon = 1e-5 + cls.Modeling.big_binary_bound = 100_000 + + # Reset config name + cls.config_name = 'flixopt' + + # Apply the reset configuration + cls.apply() + @classmethod def apply(cls): """Apply current configuration to logging system.""" diff --git a/tests/test_config.py b/tests/test_config.py index f3a25cf54..8a549806d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -14,39 +14,11 @@ class TestConfigModule: def setup_method(self): """Reset CONFIG to defaults before each test.""" - # Reset Logging config - CONFIG.Logging.level = 'INFO' - CONFIG.Logging.file = None - CONFIG.Logging.rich = False - CONFIG.Logging.console = False - - # Reset Modeling config to defaults - CONFIG.Modeling.big = 10_000_000 - CONFIG.Modeling.epsilon = 1e-5 - CONFIG.Modeling.big_binary_bound = 100_000 - - # Clear and reset logger completely - logger = logging.getLogger('flixopt') - logger.handlers.clear() - logger.setLevel(logging.INFO) - logger.propagate = False - - # Apply clean state - CONFIG.apply() + CONFIG.reset() def teardown_method(self): """Clean up after each test to prevent state leakage.""" - # Reset to absolute defaults - CONFIG.Logging.level = 'INFO' - CONFIG.Logging.file = None - CONFIG.Logging.rich = False - CONFIG.Logging.console = False - - CONFIG.Modeling.big = 10_000_000 - CONFIG.Modeling.epsilon = 1e-5 - CONFIG.Modeling.big_binary_bound = 100_000 - - CONFIG.apply() + CONFIG.reset() def test_config_defaults(self): """Test that CONFIG has correct default values.""" @@ -434,6 +406,32 @@ def test_modeling_config_persistence(self): assert CONFIG.Modeling.big == 99999999 assert CONFIG.Modeling.epsilon == 1e-8 - # Reset for other tests - CONFIG.Modeling.big = 10_000_000 - CONFIG.Modeling.epsilon = 1e-5 + def test_config_reset(self): + """Test that CONFIG.reset() restores all defaults.""" + # Modify all config values + CONFIG.Logging.level = 'DEBUG' + CONFIG.Logging.console = True + CONFIG.Logging.rich = True + CONFIG.Logging.file = '/tmp/test.log' + CONFIG.Modeling.big = 99999999 + CONFIG.Modeling.epsilon = 1e-8 + CONFIG.Modeling.big_binary_bound = 500000 + CONFIG.config_name = 'test_config' + + # Reset should restore all defaults + CONFIG.reset() + + # Verify all values are back to defaults + assert CONFIG.Logging.level == 'INFO' + assert CONFIG.Logging.console is False + assert CONFIG.Logging.rich is False + assert CONFIG.Logging.file is None + assert CONFIG.Modeling.big == 10_000_000 + assert CONFIG.Modeling.epsilon == 1e-5 + assert CONFIG.Modeling.big_binary_bound == 100_000 + assert CONFIG.config_name == 'flixopt' + + # Verify logging was also reset + logger = logging.getLogger('flixopt') + assert logger.level == logging.INFO + assert any(isinstance(h, logging.NullHandler) for h in logger.handlers) From 76fe50729a16cbb16e31fb9f7e8619db11025d6d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:00:10 +0200 Subject: [PATCH 18/44] Refactor `CONFIG` to centralize defaults in `_DEFAULTS` and ensure `reset()` aligns with them; add test to verify consistency. --- flixopt/config.py | 45 ++++++++++++++++++++++++++++---------------- tests/test_config.py | 35 +++++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 0f389a14c..8552e34a4 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -14,6 +14,19 @@ logger = logging.getLogger('flixopt') +# Default configuration values +_DEFAULTS = { + 'LOGGING_LEVEL': 'INFO', + 'LOGGING_FILE': None, + 'LOGGING_RICH': False, + 'LOGGING_CONSOLE': False, + 'MODELING_BIG': 10_000_000, + 'MODELING_EPSILON': 1e-5, + 'MODELING_BIG_BINARY_BOUND': 100_000, + 'CONFIG_NAME': 'flixopt', +} + + class CONFIG: """ Configuration for flixopt library. @@ -34,34 +47,34 @@ class CONFIG: """ class Logging: - level: str = 'INFO' - file: str | None = None - rich: bool = False - console: bool = False + level: str = _DEFAULTS['LOGGING_LEVEL'] + file: str | None = _DEFAULTS['LOGGING_FILE'] + rich: bool = _DEFAULTS['LOGGING_RICH'] + console: bool = _DEFAULTS['LOGGING_CONSOLE'] class Modeling: - big: int = 10_000_000 - epsilon: float = 1e-5 - big_binary_bound: int = 100_000 + big: int = _DEFAULTS['MODELING_BIG'] + epsilon: float = _DEFAULTS['MODELING_EPSILON'] + big_binary_bound: int = _DEFAULTS['MODELING_BIG_BINARY_BOUND'] - config_name: str = 'flixopt' + config_name: str = _DEFAULTS['CONFIG_NAME'] @classmethod def reset(cls): """Reset all configuration values to defaults.""" # Reset Logging config - cls.Logging.level = 'INFO' - cls.Logging.file = None - cls.Logging.rich = False - cls.Logging.console = False + cls.Logging.level = _DEFAULTS['LOGGING_LEVEL'] + cls.Logging.file = _DEFAULTS['LOGGING_FILE'] + cls.Logging.rich = _DEFAULTS['LOGGING_RICH'] + cls.Logging.console = _DEFAULTS['LOGGING_CONSOLE'] # Reset Modeling config - cls.Modeling.big = 10_000_000 - cls.Modeling.epsilon = 1e-5 - cls.Modeling.big_binary_bound = 100_000 + cls.Modeling.big = _DEFAULTS['MODELING_BIG'] + cls.Modeling.epsilon = _DEFAULTS['MODELING_EPSILON'] + cls.Modeling.big_binary_bound = _DEFAULTS['MODELING_BIG_BINARY_BOUND'] # Reset config name - cls.config_name = 'flixopt' + cls.config_name = _DEFAULTS['CONFIG_NAME'] # Apply the reset configuration cls.apply() diff --git a/tests/test_config.py b/tests/test_config.py index 8a549806d..203e1f0dc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,7 +6,7 @@ import pytest -from flixopt.config import CONFIG, _setup_logging +from flixopt.config import _DEFAULTS, CONFIG, _setup_logging class TestConfigModule: @@ -435,3 +435,36 @@ def test_config_reset(self): logger = logging.getLogger('flixopt') assert logger.level == logging.INFO assert any(isinstance(h, logging.NullHandler) for h in logger.handlers) + + def test_reset_matches_class_defaults(self): + """Test that reset() values match the _DEFAULTS constants. + + This ensures the reset() method and class attribute defaults + stay synchronized by using the same source of truth (_DEFAULTS). + """ + # Modify all values to something different + CONFIG.Logging.level = 'CRITICAL' + CONFIG.Logging.file = '/tmp/test.log' + CONFIG.Logging.rich = True + CONFIG.Logging.console = True + CONFIG.Modeling.big = 999999 + CONFIG.Modeling.epsilon = 1e-10 + CONFIG.Modeling.big_binary_bound = 999999 + CONFIG.config_name = 'modified' + + # Verify values are actually different from defaults + assert CONFIG.Logging.level != _DEFAULTS['LOGGING_LEVEL'] + assert CONFIG.Modeling.big != _DEFAULTS['MODELING_BIG'] + + # Now reset + CONFIG.reset() + + # Verify reset() restored exactly the _DEFAULTS values + assert CONFIG.Logging.level == _DEFAULTS['LOGGING_LEVEL'] + assert CONFIG.Logging.file == _DEFAULTS['LOGGING_FILE'] + assert CONFIG.Logging.rich == _DEFAULTS['LOGGING_RICH'] + assert CONFIG.Logging.console == _DEFAULTS['LOGGING_CONSOLE'] + assert CONFIG.Modeling.big == _DEFAULTS['MODELING_BIG'] + assert CONFIG.Modeling.epsilon == _DEFAULTS['MODELING_EPSILON'] + assert CONFIG.Modeling.big_binary_bound == _DEFAULTS['MODELING_BIG_BINARY_BOUND'] + assert CONFIG.config_name == _DEFAULTS['CONFIG_NAME'] From 485ea9189664d903ff55637a245fadbdf64458bf Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:55:40 +0200 Subject: [PATCH 19/44] Refactor `_DEFAULTS` to use `MappingProxyType` for immutability, restructure config hierarchy, and simplify `reset()` implementation for maintainability; update tests accordingly. --- flixopt/config.py | 70 ++++++++++++++++++++++++-------------------- tests/test_config.py | 20 ++++++------- 2 files changed, 48 insertions(+), 42 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 8552e34a4..034271fd1 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -3,6 +3,7 @@ import logging from logging.handlers import RotatingFileHandler from pathlib import Path +from types import MappingProxyType from typing import Literal import yaml @@ -14,17 +15,27 @@ logger = logging.getLogger('flixopt') -# Default configuration values -_DEFAULTS = { - 'LOGGING_LEVEL': 'INFO', - 'LOGGING_FILE': None, - 'LOGGING_RICH': False, - 'LOGGING_CONSOLE': False, - 'MODELING_BIG': 10_000_000, - 'MODELING_EPSILON': 1e-5, - 'MODELING_BIG_BINARY_BOUND': 100_000, - 'CONFIG_NAME': 'flixopt', -} +# SINGLE SOURCE OF TRUTH - immutable to prevent accidental modification +_DEFAULTS = MappingProxyType( + { + 'config_name': 'flixopt', + 'logging': MappingProxyType( + { + 'level': 'INFO', + 'file': None, + 'rich': False, + 'console': False, + } + ), + 'modeling': MappingProxyType( + { + 'big': 10_000_000, + 'epsilon': 1e-5, + 'big_binary_bound': 100_000, + } + ), + } +) class CONFIG: @@ -47,34 +58,29 @@ class CONFIG: """ class Logging: - level: str = _DEFAULTS['LOGGING_LEVEL'] - file: str | None = _DEFAULTS['LOGGING_FILE'] - rich: bool = _DEFAULTS['LOGGING_RICH'] - console: bool = _DEFAULTS['LOGGING_CONSOLE'] + level: str = _DEFAULTS['logging']['level'] + file: str | None = _DEFAULTS['logging']['file'] + rich: bool = _DEFAULTS['logging']['rich'] + console: bool = _DEFAULTS['logging']['console'] class Modeling: - big: int = _DEFAULTS['MODELING_BIG'] - epsilon: float = _DEFAULTS['MODELING_EPSILON'] - big_binary_bound: int = _DEFAULTS['MODELING_BIG_BINARY_BOUND'] + big: int = _DEFAULTS['modeling']['big'] + epsilon: float = _DEFAULTS['modeling']['epsilon'] + big_binary_bound: int = _DEFAULTS['modeling']['big_binary_bound'] - config_name: str = _DEFAULTS['CONFIG_NAME'] + config_name: str = _DEFAULTS['config_name'] @classmethod def reset(cls): """Reset all configuration values to defaults.""" - # Reset Logging config - cls.Logging.level = _DEFAULTS['LOGGING_LEVEL'] - cls.Logging.file = _DEFAULTS['LOGGING_FILE'] - cls.Logging.rich = _DEFAULTS['LOGGING_RICH'] - cls.Logging.console = _DEFAULTS['LOGGING_CONSOLE'] - - # Reset Modeling config - cls.Modeling.big = _DEFAULTS['MODELING_BIG'] - cls.Modeling.epsilon = _DEFAULTS['MODELING_EPSILON'] - cls.Modeling.big_binary_bound = _DEFAULTS['MODELING_BIG_BINARY_BOUND'] - - # Reset config name - cls.config_name = _DEFAULTS['CONFIG_NAME'] + # Dynamically reset from _DEFAULTS - no repetition! + for key, value in _DEFAULTS['logging'].items(): + setattr(cls.Logging, key, value) + + for key, value in _DEFAULTS['modeling'].items(): + setattr(cls.Modeling, key, value) + + cls.config_name = _DEFAULTS['config_name'] # Apply the reset configuration cls.apply() diff --git a/tests/test_config.py b/tests/test_config.py index 203e1f0dc..c417a9590 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -453,18 +453,18 @@ def test_reset_matches_class_defaults(self): CONFIG.config_name = 'modified' # Verify values are actually different from defaults - assert CONFIG.Logging.level != _DEFAULTS['LOGGING_LEVEL'] - assert CONFIG.Modeling.big != _DEFAULTS['MODELING_BIG'] + assert CONFIG.Logging.level != _DEFAULTS['logging']['level'] + assert CONFIG.Modeling.big != _DEFAULTS['modeling']['big'] # Now reset CONFIG.reset() # Verify reset() restored exactly the _DEFAULTS values - assert CONFIG.Logging.level == _DEFAULTS['LOGGING_LEVEL'] - assert CONFIG.Logging.file == _DEFAULTS['LOGGING_FILE'] - assert CONFIG.Logging.rich == _DEFAULTS['LOGGING_RICH'] - assert CONFIG.Logging.console == _DEFAULTS['LOGGING_CONSOLE'] - assert CONFIG.Modeling.big == _DEFAULTS['MODELING_BIG'] - assert CONFIG.Modeling.epsilon == _DEFAULTS['MODELING_EPSILON'] - assert CONFIG.Modeling.big_binary_bound == _DEFAULTS['MODELING_BIG_BINARY_BOUND'] - assert CONFIG.config_name == _DEFAULTS['CONFIG_NAME'] + assert CONFIG.Logging.level == _DEFAULTS['logging']['level'] + assert CONFIG.Logging.file == _DEFAULTS['logging']['file'] + assert CONFIG.Logging.rich == _DEFAULTS['logging']['rich'] + assert CONFIG.Logging.console == _DEFAULTS['logging']['console'] + assert CONFIG.Modeling.big == _DEFAULTS['modeling']['big'] + assert CONFIG.Modeling.epsilon == _DEFAULTS['modeling']['epsilon'] + assert CONFIG.Modeling.big_binary_bound == _DEFAULTS['modeling']['big_binary_bound'] + assert CONFIG.config_name == _DEFAULTS['config_name'] From 0ea4716c0109881f48bbed5ab9939486ff67590c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:59:04 +0200 Subject: [PATCH 20/44] Mark `TestConfigModule` tests to run in a single worker with `@pytest.mark.xdist_group` to prevent global config interference. --- tests/test_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index c417a9590..609ee0109 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,6 +9,8 @@ from flixopt.config import _DEFAULTS, CONFIG, _setup_logging +# All tests in this class will run in the same worker to prevent issues with global config altering +@pytest.mark.xdist_group(name='config_tests') class TestConfigModule: """Test the CONFIG class and logging setup.""" From 4fc94805e6286cba362266643a78ddbaca6d9cbb Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:06:38 +0200 Subject: [PATCH 21/44] Add default log file --- flixopt/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flixopt/config.py b/flixopt/config.py index 034271fd1..dff7fb8ba 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -22,7 +22,7 @@ 'logging': MappingProxyType( { 'level': 'INFO', - 'file': None, + 'file': 'flixopt.log', 'rich': False, 'console': False, } From c034f2f42983183f752b2b114dd07df702bd34f5 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 4 Oct 2025 14:55:20 +0200 Subject: [PATCH 22/44] Update CHANGELOG.md --- CHANGELOG.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb2562a1..c521f5abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,15 +42,21 @@ Please keep the format of the changelog consistent with the other releases, so t ## [Unreleased] - ????-??-?? ### ✨ Added +- Added `CONFIG.reset()` method to restore configuration to default values ### 💥 Breaking Changes +- Remove change_logging_level ### ♻️ Changed - Using `h5netcdf` instead of `netCDF4` for dataset I/O operations. This follows the update in `xarray==2025.09.01` +- Logging and Configuration management changed ### 🗑️ Deprecated ### 🔥 Removed +- Removed unused `change_logging_level` import and export +- Removed unused `merge_configs` function from configuration module + ### 🐛 Fixed @@ -58,12 +64,12 @@ Please keep the format of the changelog consistent with the other releases, so t ### 📦 Dependencies - Updated `renovate.config` to treat CalVer packages (xarray and dask) with more care - -### 📝 Docs +- Updated packaging configuration ### 👷 Development - -### 🚧 Known Issues +- Greatly expanded test coverage for `config.py` module +- Added `@pytest.mark.xdist_group` to `TestConfigModule` tests to prevent global config interference +- Improved Renovate configuration with automerge for dev dependencies and better CalVer handling Until here --> --- From b05dc79a9a670382681715642f0191199f50a700 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Sat, 4 Oct 2025 14:58:11 +0200 Subject: [PATCH 23/44] Readd change_logging_level() for backwards compatability --- CHANGELOG.md | 3 +-- flixopt/__init__.py | 1 + flixopt/commons.py | 3 ++- flixopt/config.py | 36 +++++++++++++++++++++++++++++++++++- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c521f5abf..9a5fc9ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,16 +45,15 @@ Please keep the format of the changelog consistent with the other releases, so t - Added `CONFIG.reset()` method to restore configuration to default values ### 💥 Breaking Changes -- Remove change_logging_level ### ♻️ Changed - Using `h5netcdf` instead of `netCDF4` for dataset I/O operations. This follows the update in `xarray==2025.09.01` - Logging and Configuration management changed ### 🗑️ Deprecated +- `change_logging_level()` function is now deprecated in favor of `CONFIG.Logging.level` and `CONFIG.apply()`. Will be removed in version 3.0.0. ### 🔥 Removed -- Removed unused `change_logging_level` import and export - Removed unused `merge_configs` function from configuration module diff --git a/flixopt/__init__.py b/flixopt/__init__.py index d10b379af..d8ad05f19 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -29,6 +29,7 @@ Storage, TimeSeriesData, Transmission, + change_logging_level, linear_converters, plotting, results, diff --git a/flixopt/commons.py b/flixopt/commons.py index 2f3e05f6c..68412d6fe 100644 --- a/flixopt/commons.py +++ b/flixopt/commons.py @@ -13,7 +13,7 @@ Storage, Transmission, ) -from .config import CONFIG +from .config import CONFIG, change_logging_level from .core import TimeSeriesData from .effects import Effect from .elements import Bus, Flow @@ -23,6 +23,7 @@ __all__ = [ 'TimeSeriesData', 'CONFIG', + 'change_logging_level', 'Flow', 'Bus', 'Effect', diff --git a/flixopt/config.py b/flixopt/config.py index dff7fb8ba..83f84857c 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +import warnings from logging.handlers import RotatingFileHandler from pathlib import Path from types import MappingProxyType @@ -10,7 +11,7 @@ from rich.console import Console from rich.logging import RichHandler -__all__ = ['CONFIG'] +__all__ = ['CONFIG', 'change_logging_level'] logger = logging.getLogger('flixopt') @@ -242,5 +243,38 @@ def _setup_logging( return logger +def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']): + """ + Change the logging level for the flixopt logger and all its handlers. + + .. deprecated:: 2.1.11 + Use ``CONFIG.Logging.level = level_name`` and ``CONFIG.apply()`` instead. + This function will be removed in version 3.0.0. + + Parameters + ---------- + level_name : {'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'} + The logging level to set. + + Examples + -------- + >>> change_logging_level('DEBUG') # deprecated + >>> # Use this instead: + >>> CONFIG.Logging.level = 'DEBUG' + >>> CONFIG.apply() + """ + warnings.warn( + 'change_logging_level is deprecated and will be removed in version 3.0.0. ' + 'Use CONFIG.Logging.level = level_name and CONFIG.apply() instead.', + DeprecationWarning, + stacklevel=2, + ) + logger = logging.getLogger('flixopt') + logging_level = getattr(logging, level_name.upper()) + logger.setLevel(logging_level) + for handler in logger.handlers: + handler.setLevel(logging_level) + + # Initialize default config CONFIG.apply() From a0828b3db4c722a36bd00edaf6a1eedef8f90b08 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 08:23:07 +0200 Subject: [PATCH 24/44] Add more options to config.py --- CHANGELOG.md | 4 ++ flixopt/config.py | 157 +++++++++++++++++++++++++++++++++++++------ tests/test_config.py | 30 ++++++--- 3 files changed, 159 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edf127cd6..6e1a57b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,10 @@ Please keep the format of the changelog consistent with the other releases, so t ### ✨ Added - Added `CONFIG.reset()` method to restore configuration to default values +- Added configurable log file rotation settings: `CONFIG.Logging.max_file_size` and `CONFIG.Logging.backup_count` +- Added configurable log format settings: `CONFIG.Logging.date_format` and `CONFIG.Logging.message_format` +- Added configurable console settings: `CONFIG.Logging.console_width` and `CONFIG.Logging.show_path` +- Added customizable log level colors via `CONFIG.Logging.colors` (works with both standard and Rich handlers) ### 💥 Breaking Changes diff --git a/flixopt/config.py b/flixopt/config.py index 83f84857c..5bc16df42 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -26,6 +26,21 @@ 'file': 'flixopt.log', 'rich': False, 'console': False, + 'max_file_size': 10_485_760, # 10MB + 'backup_count': 5, + 'date_format': '%Y-%m-%d %H:%M:%S', + 'message_format': '%(message)s', + 'console_width': 120, + 'show_path': False, + 'colors': MappingProxyType( + { + 'DEBUG': '\033[32m', # Green + 'INFO': '\033[34m', # Blue + 'WARNING': '\033[33m', # Yellow + 'ERROR': '\033[31m', # Red + 'CRITICAL': '\033[1m\033[31m', # Bold Red + } + ), } ), 'modeling': MappingProxyType( @@ -63,6 +78,13 @@ class Logging: file: str | None = _DEFAULTS['logging']['file'] rich: bool = _DEFAULTS['logging']['rich'] console: bool = _DEFAULTS['logging']['console'] + max_file_size: int = _DEFAULTS['logging']['max_file_size'] + backup_count: int = _DEFAULTS['logging']['backup_count'] + date_format: str = _DEFAULTS['logging']['date_format'] + message_format: str = _DEFAULTS['logging']['message_format'] + console_width: int = _DEFAULTS['logging']['console_width'] + show_path: bool = _DEFAULTS['logging']['show_path'] + colors: dict[str, str] = dict(_DEFAULTS['logging']['colors']) class Modeling: big: int = _DEFAULTS['modeling']['big'] @@ -94,6 +116,13 @@ def apply(cls): log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich, console=cls.Logging.console, + max_file_size=cls.Logging.max_file_size, + backup_count=cls.Logging.backup_count, + date_format=cls.Logging.date_format, + message_format=cls.Logging.message_format, + console_width=cls.Logging.console_width, + show_path=cls.Logging.show_path, + colors=cls.Logging.colors, ) @classmethod @@ -135,6 +164,13 @@ def to_dict(cls): 'file': cls.Logging.file, 'rich': cls.Logging.rich, 'console': cls.Logging.console, + 'max_file_size': cls.Logging.max_file_size, + 'backup_count': cls.Logging.backup_count, + 'date_format': cls.Logging.date_format, + 'message_format': cls.Logging.message_format, + 'console_width': cls.Logging.console_width, + 'show_path': cls.Logging.show_path, + 'colors': dict(cls.Logging.colors), # Ensure it's a regular dict }, 'modeling': { 'big': cls.Modeling.big, @@ -145,6 +181,9 @@ def to_dict(cls): class MultilineFormater(logging.Formatter): + def __init__(self, fmt=None, datefmt=None): + super().__init__(fmt=fmt, datefmt=datefmt) + def format(self, record): message_lines = record.getMessage().split('\n') @@ -164,16 +203,23 @@ def format(self, record): class ColoredMultilineFormater(MultilineFormater): - # ANSI escape codes for colors - COLORS = { - 'DEBUG': '\033[32m', # Green - 'INFO': '\033[34m', # Blue - 'WARNING': '\033[33m', # Yellow - 'ERROR': '\033[31m', # Red - 'CRITICAL': '\033[1m\033[31m', # Bold Red - } RESET = '\033[0m' + def __init__(self, fmt=None, datefmt=None, colors=None): + super().__init__(fmt=fmt, datefmt=datefmt) + # Use provided colors or fall back to defaults + self.COLORS = ( + colors + if colors is not None + else { + 'DEBUG': '\033[32m', # Green + 'INFO': '\033[34m', # Blue + 'WARNING': '\033[33m', # Yellow + 'ERROR': '\033[31m', # Red + 'CRITICAL': '\033[1m\033[31m', # Bold Red + } + ) + def format(self, record): lines = super().format(record).splitlines() log_color = self.COLORS.get(record.levelname, self.RESET) @@ -186,33 +232,78 @@ def format(self, record): return '\n'.join(formatted_lines) -def _create_console_handler(use_rich: bool = False) -> logging.Handler: +class ColoredRichHandler(RichHandler): + """RichHandler with custom color support.""" + + # ANSI to Rich color mapping + _ANSI_TO_RICH = { + '\033[32m': 'green', + '\033[34m': 'blue', + '\033[33m': 'yellow', + '\033[31m': 'red', + '\033[1m\033[31m': 'bold red', + } + + def __init__(self, *args, colors: dict[str, str] | None = None, **kwargs): + super().__init__(*args, **kwargs) + if colors: + # Convert ANSI colors to Rich styles + from rich.logging import LogRender + + self.colors = {level: self._ANSI_TO_RICH.get(code, 'default') for level, code in colors.items()} + else: + self.colors = None + + def get_level_text(self, record): + """Override to apply custom colors to level text.""" + from rich.text import Text + + level_name = record.levelname + level_text = Text.styled(level_name.ljust(8), self.colors.get(level_name, 'default') if self.colors else None) + return level_text + + +def _create_console_handler( + use_rich: bool = False, + console_width: int = 120, + show_path: bool = False, + date_format: str = '%Y-%m-%d %H:%M:%S', + message_format: str = '%(message)s', + colors: dict[str, str] | None = None, +) -> logging.Handler: """Create a console (stdout) logging handler.""" if use_rich: - console = Console(width=120) - handler = RichHandler( + console = Console(width=console_width) + handler = ColoredRichHandler( console=console, rich_tracebacks=True, omit_repeated_times=True, - show_path=False, - log_time_format='%Y-%m-%d %H:%M:%S', + show_path=show_path, + log_time_format=date_format, + colors=colors, ) - handler.setFormatter(logging.Formatter('%(message)s')) + handler.setFormatter(logging.Formatter(message_format)) else: handler = logging.StreamHandler() - handler.setFormatter(ColoredMultilineFormater(fmt='%(message)s', datefmt='%Y-%m-%d %H:%M:%S')) + handler.setFormatter(ColoredMultilineFormater(fmt=message_format, datefmt=date_format, colors=colors)) return handler -def _create_file_handler(log_file: str) -> RotatingFileHandler: +def _create_file_handler( + log_file: str, + max_file_size: int = 10_485_760, + backup_count: int = 5, + date_format: str = '%Y-%m-%d %H:%M:%S', + message_format: str = '%(message)s', +) -> RotatingFileHandler: """Create a rotating file handler to prevent huge log files.""" handler = RotatingFileHandler( log_file, - maxBytes=10_485_760, # 10MB max file size - backupCount=5, # Keep 5 backup files + maxBytes=max_file_size, + backupCount=backup_count, encoding='utf-8', ) - handler.setFormatter(MultilineFormater(fmt='%(message)s', datefmt='%Y-%m-%d %H:%M:%S')) + handler.setFormatter(MultilineFormater(fmt=message_format, datefmt=date_format)) return handler @@ -221,6 +312,13 @@ def _setup_logging( log_file: str | None = None, use_rich_handler: bool = False, console: bool = False, + max_file_size: int = 10_485_760, + backup_count: int = 5, + date_format: str = '%Y-%m-%d %H:%M:%S', + message_format: str = '%(message)s', + console_width: int = 120, + show_path: bool = False, + colors: dict[str, str] | None = None, ): """Internal function to setup logging - use CONFIG.apply() instead.""" logger = logging.getLogger('flixopt') @@ -230,11 +328,28 @@ def _setup_logging( # Only log to console if explicitly requested if console: - logger.addHandler(_create_console_handler(use_rich=use_rich_handler)) + logger.addHandler( + _create_console_handler( + use_rich=use_rich_handler, + console_width=console_width, + show_path=show_path, + date_format=date_format, + message_format=message_format, + colors=colors, + ) + ) # Add file handler if specified if log_file: - logger.addHandler(_create_file_handler(log_file)) + logger.addHandler( + _create_file_handler( + log_file=log_file, + max_file_size=max_file_size, + backup_count=backup_count, + date_format=date_format, + message_format=message_format, + ) + ) # IMPORTANT: If no handlers, use NullHandler (library best practice) if not logger.handlers: diff --git a/tests/test_config.py b/tests/test_config.py index 609ee0109..b61cf28b3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,7 +25,7 @@ def teardown_method(self): def test_config_defaults(self): """Test that CONFIG has correct default values.""" assert CONFIG.Logging.level == 'INFO' - assert CONFIG.Logging.file is None + assert CONFIG.Logging.file == 'flixopt.log' assert CONFIG.Logging.rich is False assert CONFIG.Logging.console is False assert CONFIG.Modeling.big == 10_000_000 @@ -38,10 +38,10 @@ def test_module_initialization(self): # Apply config to ensure handlers are initialized CONFIG.apply() logger = logging.getLogger('flixopt') - # Should have at least one handler (NullHandler by default) + # Should have at least one handler (file handler by default) assert len(logger.handlers) >= 1 - # Should have NullHandler when no console/file output is configured - assert any(isinstance(h, logging.NullHandler) for h in logger.handlers) + # Should have a file handler with default settings + assert any(isinstance(h, logging.handlers.RotatingFileHandler) for h in logger.handlers) def test_config_apply_console(self): """Test applying config with console logging enabled.""" @@ -102,7 +102,7 @@ def test_config_to_dict(self): assert config_dict['config_name'] == 'flixopt' assert config_dict['logging']['level'] == 'DEBUG' assert config_dict['logging']['console'] is True - assert config_dict['logging']['file'] is None + assert config_dict['logging']['file'] == 'flixopt.log' assert config_dict['logging']['rich'] is False assert 'modeling' in config_dict assert config_dict['modeling']['big'] == 10_000_000 @@ -188,19 +188,27 @@ def test_setup_logging_clears_handlers(self): assert dummy_handler not in logger.handlers def test_change_logging_level_removed(self): - """Test that change_logging_level function no longer exists.""" - # This function was removed - users should use CONFIG.apply() instead + """Test that change_logging_level function is deprecated but still exists.""" + # This function is deprecated - users should use CONFIG.apply() instead import flixopt - assert not hasattr(flixopt, 'change_logging_level') + # Function should still exist but be deprecated + assert hasattr(flixopt, 'change_logging_level') + + # Should emit deprecation warning when called + with pytest.warns(DeprecationWarning, match='change_logging_level is deprecated'): + flixopt.change_logging_level('DEBUG') def test_public_api(self): - """Test that only CONFIG is exported from config module.""" + """Test that CONFIG and change_logging_level are exported from config module.""" from flixopt import config # CONFIG should be accessible assert hasattr(config, 'CONFIG') + # change_logging_level should be accessible (but deprecated) + assert hasattr(config, 'change_logging_level') + # _setup_logging should exist but be marked as private assert hasattr(config, '_setup_logging') @@ -427,7 +435,7 @@ def test_config_reset(self): assert CONFIG.Logging.level == 'INFO' assert CONFIG.Logging.console is False assert CONFIG.Logging.rich is False - assert CONFIG.Logging.file is None + assert CONFIG.Logging.file == 'flixopt.log' assert CONFIG.Modeling.big == 10_000_000 assert CONFIG.Modeling.epsilon == 1e-5 assert CONFIG.Modeling.big_binary_bound == 100_000 @@ -436,7 +444,7 @@ def test_config_reset(self): # Verify logging was also reset logger = logging.getLogger('flixopt') assert logger.level == logging.INFO - assert any(isinstance(h, logging.NullHandler) for h in logger.handlers) + assert any(isinstance(h, logging.handlers.RotatingFileHandler) for h in logger.handlers) def test_reset_matches_class_defaults(self): """Test that reset() values match the _DEFAULTS constants. From 243a6022a0a1aacc9a804fe5820e448c9bb3a39d Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 08:25:06 +0200 Subject: [PATCH 25/44] Add a docstring to config.y --- flixopt/config.py | 122 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 9 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 5bc16df42..0c4ae92ec 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -60,17 +60,121 @@ class CONFIG: Library is SILENT by default (best practice for libraries). - Usage: - # Change config and apply - CONFIG.Logging.console = True - CONFIG.Logging.level = 'DEBUG' - CONFIG.apply() + Configuration Structure + ----------------------- + CONFIG.Logging - Logging configuration options + CONFIG.Modeling - Optimization modeling parameters + + Logging Options + --------------- + level : str + Logging level: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' (default: 'INFO') + file : str | None + Log file path (default: 'flixopt.log'). Set to None to disable file logging. + console : bool + Enable console (stdout) logging (default: False) + rich : bool + Use Rich library for enhanced console output (default: False) + max_file_size : int + Maximum log file size in bytes before rotation (default: 10485760 = 10MB) + backup_count : int + Number of backup log files to keep (default: 5) + date_format : str + Date/time format for log messages (default: '%Y-%m-%d %H:%M:%S') + message_format : str + Log message format string (default: '%(message)s') + console_width : int + Console width for Rich handler (default: 120) + show_path : bool + Show file paths in log messages (default: False) + colors : dict[str, str] + ANSI color codes for each log level. Keys: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' + Default colors: + - DEBUG: green ('\033[32m') + - INFO: blue ('\033[34m') + - WARNING: yellow ('\033[33m') + - ERROR: red ('\033[31m') + - CRITICAL: bold red ('\033[1m\033[31m') + + Modeling Options + ---------------- + big : int + Large number for optimization constraints (default: 10000000) + epsilon : float + Small tolerance value (default: 1e-5) + big_binary_bound : int + Upper bound for binary variable constraints (default: 100000) - # Load from YAML file (auto-applies) - CONFIG.load_from_file('config.yaml') + Examples + -------- + Basic configuration: + + >>> from flixopt import CONFIG + >>> CONFIG.Logging.console = True + >>> CONFIG.Logging.level = 'DEBUG' + >>> CONFIG.apply() + + Configure log file rotation: + + >>> CONFIG.Logging.file = 'myapp.log' + >>> CONFIG.Logging.max_file_size = 5_242_880 # 5 MB + >>> CONFIG.Logging.backup_count = 3 + >>> CONFIG.apply() + + Customize log colors: + + >>> CONFIG.Logging.colors['INFO'] = '\033[35m' # Magenta + >>> CONFIG.Logging.colors['DEBUG'] = '\033[36m' # Cyan + >>> CONFIG.apply() + + Use Rich handler with custom width: + + >>> CONFIG.Logging.console = True + >>> CONFIG.Logging.rich = True + >>> CONFIG.Logging.console_width = 100 + >>> CONFIG.Logging.show_path = True + >>> CONFIG.apply() + + Load from YAML file: + + >>> CONFIG.load_from_file('config.yaml') + + Example YAML config file: + + .. code-block:: yaml + + logging: + level: DEBUG + console: true + file: app.log + rich: false + max_file_size: 5242880 # 5MB + backup_count: 3 + date_format: '%H:%M:%S' + console_width: 100 + show_path: true + colors: + DEBUG: "\\033[36m" # Cyan + INFO: "\\033[32m" # Green + WARNING: "\\033[33m" + ERROR: "\\033[31m" + CRITICAL: "\\033[1m\\033[31m" + + modeling: + big: 20000000 + epsilon: 1e-6 + big_binary_bound: 200000 + + Reset to defaults: + + >>> CONFIG.reset() + + Export current configuration: - # Reset to defaults - CONFIG.reset() + >>> config_dict = CONFIG.to_dict() + >>> import yaml + >>> with open('my_config.yaml', 'w') as f: + ... yaml.dump(config_dict, f) """ class Logging: From 763d7e47684ce5340fb240002200dbc38bcc91cd Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 08:26:41 +0200 Subject: [PATCH 26/44] Add a docstring to config.y --- flixopt/config.py | 228 ++++++++++++++++++++++------------------------ 1 file changed, 111 insertions(+), 117 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 0c4ae92ec..e568d4fc5 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -55,126 +55,120 @@ class CONFIG: - """ - Configuration for flixopt library. + """Configuration for flixopt library. Library is SILENT by default (best practice for libraries). - Configuration Structure - ----------------------- - CONFIG.Logging - Logging configuration options - CONFIG.Modeling - Optimization modeling parameters - - Logging Options - --------------- - level : str - Logging level: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' (default: 'INFO') - file : str | None - Log file path (default: 'flixopt.log'). Set to None to disable file logging. - console : bool - Enable console (stdout) logging (default: False) - rich : bool - Use Rich library for enhanced console output (default: False) - max_file_size : int - Maximum log file size in bytes before rotation (default: 10485760 = 10MB) - backup_count : int - Number of backup log files to keep (default: 5) - date_format : str - Date/time format for log messages (default: '%Y-%m-%d %H:%M:%S') - message_format : str - Log message format string (default: '%(message)s') - console_width : int - Console width for Rich handler (default: 120) - show_path : bool - Show file paths in log messages (default: False) - colors : dict[str, str] - ANSI color codes for each log level. Keys: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' - Default colors: - - DEBUG: green ('\033[32m') - - INFO: blue ('\033[34m') - - WARNING: yellow ('\033[33m') - - ERROR: red ('\033[31m') - - CRITICAL: bold red ('\033[1m\033[31m') - - Modeling Options - ---------------- - big : int - Large number for optimization constraints (default: 10000000) - epsilon : float - Small tolerance value (default: 1e-5) - big_binary_bound : int - Upper bound for binary variable constraints (default: 100000) - - Examples - -------- - Basic configuration: - - >>> from flixopt import CONFIG - >>> CONFIG.Logging.console = True - >>> CONFIG.Logging.level = 'DEBUG' - >>> CONFIG.apply() - - Configure log file rotation: - - >>> CONFIG.Logging.file = 'myapp.log' - >>> CONFIG.Logging.max_file_size = 5_242_880 # 5 MB - >>> CONFIG.Logging.backup_count = 3 - >>> CONFIG.apply() - - Customize log colors: - - >>> CONFIG.Logging.colors['INFO'] = '\033[35m' # Magenta - >>> CONFIG.Logging.colors['DEBUG'] = '\033[36m' # Cyan - >>> CONFIG.apply() - - Use Rich handler with custom width: - - >>> CONFIG.Logging.console = True - >>> CONFIG.Logging.rich = True - >>> CONFIG.Logging.console_width = 100 - >>> CONFIG.Logging.show_path = True - >>> CONFIG.apply() - - Load from YAML file: - - >>> CONFIG.load_from_file('config.yaml') - - Example YAML config file: - - .. code-block:: yaml - - logging: - level: DEBUG - console: true - file: app.log - rich: false - max_file_size: 5242880 # 5MB - backup_count: 3 - date_format: '%H:%M:%S' - console_width: 100 - show_path: true - colors: - DEBUG: "\\033[36m" # Cyan - INFO: "\\033[32m" # Green - WARNING: "\\033[33m" - ERROR: "\\033[31m" - CRITICAL: "\\033[1m\\033[31m" - - modeling: - big: 20000000 - epsilon: 1e-6 - big_binary_bound: 200000 - - Reset to defaults: - - >>> CONFIG.reset() - - Export current configuration: - - >>> config_dict = CONFIG.to_dict() - >>> import yaml - >>> with open('my_config.yaml', 'w') as f: - ... yaml.dump(config_dict, f) + The CONFIG class provides centralized configuration for logging and modeling parameters. + All changes require calling ``CONFIG.apply()`` to take effect. + + Attributes: + Logging: Nested class containing all logging configuration options. + Modeling: Nested class containing optimization modeling parameters. + config_name (str): Name of the configuration (default: 'flixopt'). + + Logging Attributes: + level (str): Logging level: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. + Default: 'INFO' + file (str | None): Log file path. Default: 'flixopt.log'. + Set to None to disable file logging. + console (bool): Enable console (stdout) logging. Default: False + rich (bool): Use Rich library for enhanced console output. Default: False + max_file_size (int): Maximum log file size in bytes before rotation. + Default: 10485760 (10MB) + backup_count (int): Number of backup log files to keep. Default: 5 + date_format (str): Date/time format for log messages. + Default: '%Y-%m-%d %H:%M:%S' + message_format (str): Log message format string. Default: '%(message)s' + console_width (int): Console width for Rich handler. Default: 120 + show_path (bool): Show file paths in log messages. Default: False + colors (dict[str, str]): ANSI color codes for each log level. + Keys: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' + Default colors: + + - DEBUG: green ('\\033[32m') + - INFO: blue ('\\033[34m') + - WARNING: yellow ('\\033[33m') + - ERROR: red ('\\033[31m') + - CRITICAL: bold red ('\\033[1m\\033[31m') + + Modeling Attributes: + big (int): Large number for optimization constraints. Default: 10000000 + epsilon (float): Small tolerance value. Default: 1e-5 + big_binary_bound (int): Upper bound for binary variable constraints. + Default: 100000 + + Examples: + Basic configuration:: + + from flixopt import CONFIG + + CONFIG.Logging.console = True + CONFIG.Logging.level = 'DEBUG' + CONFIG.apply() + + Configure log file rotation:: + + CONFIG.Logging.file = 'myapp.log' + CONFIG.Logging.max_file_size = 5_242_880 # 5 MB + CONFIG.Logging.backup_count = 3 + CONFIG.apply() + + Customize log colors:: + + CONFIG.Logging.colors['INFO'] = '\\033[35m' # Magenta + CONFIG.Logging.colors['DEBUG'] = '\\033[36m' # Cyan + CONFIG.apply() + + Use Rich handler with custom width:: + + CONFIG.Logging.console = True + CONFIG.Logging.rich = True + CONFIG.Logging.console_width = 100 + CONFIG.Logging.show_path = True + CONFIG.apply() + + Load from YAML file:: + + CONFIG.load_from_file('config.yaml') + + Example YAML config file: + + .. code-block:: yaml + + logging: + level: DEBUG + console: true + file: app.log + rich: false + max_file_size: 5242880 # 5MB + backup_count: 3 + date_format: '%H:%M:%S' + console_width: 100 + show_path: true + colors: + DEBUG: "\\033[36m" # Cyan + INFO: "\\033[32m" # Green + WARNING: "\\033[33m" + ERROR: "\\033[31m" + CRITICAL: "\\033[1m\\033[31m" + + modeling: + big: 20000000 + epsilon: 1e-6 + big_binary_bound: 200000 + + Reset to defaults:: + + CONFIG.reset() + + Export current configuration:: + + config_dict = CONFIG.to_dict() + import yaml + + with open('my_config.yaml', 'w') as f: + yaml.dump(config_dict, f) """ class Logging: From 98005c3f755e42c6960dd35c98811e6c5d2ce025 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 08:33:04 +0200 Subject: [PATCH 27/44] rename parameter message_format --- flixopt/config.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index e568d4fc5..3a2c61b28 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -29,7 +29,7 @@ 'max_file_size': 10_485_760, # 10MB 'backup_count': 5, 'date_format': '%Y-%m-%d %H:%M:%S', - 'message_format': '%(message)s', + 'format': '%(message)s', 'console_width': 120, 'show_path': False, 'colors': MappingProxyType( @@ -79,7 +79,7 @@ class CONFIG: backup_count (int): Number of backup log files to keep. Default: 5 date_format (str): Date/time format for log messages. Default: '%Y-%m-%d %H:%M:%S' - message_format (str): Log message format string. Default: '%(message)s' + format (str): Log message format string. Default: '%(message)s' console_width (int): Console width for Rich handler. Default: 120 show_path (bool): Show file paths in log messages. Default: False colors (dict[str, str]): ANSI color codes for each log level. @@ -179,7 +179,7 @@ class Logging: max_file_size: int = _DEFAULTS['logging']['max_file_size'] backup_count: int = _DEFAULTS['logging']['backup_count'] date_format: str = _DEFAULTS['logging']['date_format'] - message_format: str = _DEFAULTS['logging']['message_format'] + format: str = _DEFAULTS['logging']['format'] console_width: int = _DEFAULTS['logging']['console_width'] show_path: bool = _DEFAULTS['logging']['show_path'] colors: dict[str, str] = dict(_DEFAULTS['logging']['colors']) @@ -217,7 +217,7 @@ def apply(cls): max_file_size=cls.Logging.max_file_size, backup_count=cls.Logging.backup_count, date_format=cls.Logging.date_format, - message_format=cls.Logging.message_format, + format=cls.Logging.format, console_width=cls.Logging.console_width, show_path=cls.Logging.show_path, colors=cls.Logging.colors, @@ -265,7 +265,7 @@ def to_dict(cls): 'max_file_size': cls.Logging.max_file_size, 'backup_count': cls.Logging.backup_count, 'date_format': cls.Logging.date_format, - 'message_format': cls.Logging.message_format, + 'format': cls.Logging.format, 'console_width': cls.Logging.console_width, 'show_path': cls.Logging.show_path, 'colors': dict(cls.Logging.colors), # Ensure it's a regular dict @@ -366,7 +366,7 @@ def _create_console_handler( console_width: int = 120, show_path: bool = False, date_format: str = '%Y-%m-%d %H:%M:%S', - message_format: str = '%(message)s', + format: str = '%(message)s', colors: dict[str, str] | None = None, ) -> logging.Handler: """Create a console (stdout) logging handler.""" @@ -380,10 +380,10 @@ def _create_console_handler( log_time_format=date_format, colors=colors, ) - handler.setFormatter(logging.Formatter(message_format)) + handler.setFormatter(logging.Formatter(format)) else: handler = logging.StreamHandler() - handler.setFormatter(ColoredMultilineFormater(fmt=message_format, datefmt=date_format, colors=colors)) + handler.setFormatter(ColoredMultilineFormater(fmt=format, datefmt=date_format, colors=colors)) return handler @@ -392,7 +392,7 @@ def _create_file_handler( max_file_size: int = 10_485_760, backup_count: int = 5, date_format: str = '%Y-%m-%d %H:%M:%S', - message_format: str = '%(message)s', + format: str = '%(message)s', ) -> RotatingFileHandler: """Create a rotating file handler to prevent huge log files.""" handler = RotatingFileHandler( @@ -401,7 +401,7 @@ def _create_file_handler( backupCount=backup_count, encoding='utf-8', ) - handler.setFormatter(MultilineFormater(fmt=message_format, datefmt=date_format)) + handler.setFormatter(MultilineFormater(fmt=format, datefmt=date_format)) return handler @@ -413,7 +413,7 @@ def _setup_logging( max_file_size: int = 10_485_760, backup_count: int = 5, date_format: str = '%Y-%m-%d %H:%M:%S', - message_format: str = '%(message)s', + format: str = '%(message)s', console_width: int = 120, show_path: bool = False, colors: dict[str, str] | None = None, @@ -432,7 +432,7 @@ def _setup_logging( console_width=console_width, show_path=show_path, date_format=date_format, - message_format=message_format, + format=format, colors=colors, ) ) @@ -445,7 +445,7 @@ def _setup_logging( max_file_size=max_file_size, backup_count=backup_count, date_format=date_format, - message_format=message_format, + format=format, ) ) From 36ac7d3489cdf01bc8ad9c005c0a0f85910084f2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 08:34:22 +0200 Subject: [PATCH 28/44] Improve color config --- CHANGELOG.md | 4 ++- flixopt/config.py | 66 +++++++++++++++++++++++++++++------------------ 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e1a57b2b..06db472fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,9 +44,11 @@ Please keep the format of the changelog consistent with the other releases, so t ### ✨ Added - Added `CONFIG.reset()` method to restore configuration to default values - Added configurable log file rotation settings: `CONFIG.Logging.max_file_size` and `CONFIG.Logging.backup_count` -- Added configurable log format settings: `CONFIG.Logging.date_format` and `CONFIG.Logging.message_format` +- Added configurable log format settings: `CONFIG.Logging.date_format` and `CONFIG.Logging.format` - Added configurable console settings: `CONFIG.Logging.console_width` and `CONFIG.Logging.show_path` - Added customizable log level colors via `CONFIG.Logging.colors` (works with both standard and Rich handlers) +- Added Rich theme integration with automatic ANSI-to-Rich color conversion via `DEFAULT_THEME` +- Added comprehensive Google-style docstring to `CONFIG` class with all configuration options and examples ### 💥 Breaking Changes diff --git a/flixopt/config.py b/flixopt/config.py index 3a2c61b28..afa75c49e 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -10,11 +10,23 @@ import yaml from rich.console import Console from rich.logging import RichHandler +from rich.theme import Theme __all__ = ['CONFIG', 'change_logging_level'] logger = logging.getLogger('flixopt') +# Default Rich theme for log levels +DEFAULT_THEME = Theme( + { + 'logging.level.debug': 'green', + 'logging.level.info': 'blue', + 'logging.level.warning': 'yellow', + 'logging.level.error': 'red', + 'logging.level.critical': 'bold red', + } +) + # SINGLE SOURCE OF TRUTH - immutable to prevent accidental modification _DEFAULTS = MappingProxyType( @@ -330,35 +342,30 @@ def format(self, record): return '\n'.join(formatted_lines) -class ColoredRichHandler(RichHandler): - """RichHandler with custom color support.""" +def _ansi_to_rich_style(ansi_code: str) -> str: + """Convert ANSI color codes to Rich style strings. - # ANSI to Rich color mapping - _ANSI_TO_RICH = { + Args: + ansi_code: ANSI escape sequence (e.g., '\\033[32m') + + Returns: + Rich style string (e.g., 'green') + """ + ansi_to_rich_map = { '\033[32m': 'green', '\033[34m': 'blue', '\033[33m': 'yellow', '\033[31m': 'red', + '\033[35m': 'magenta', + '\033[36m': 'cyan', '\033[1m\033[31m': 'bold red', + '\033[1m\033[32m': 'bold green', + '\033[1m\033[33m': 'bold yellow', + '\033[1m\033[34m': 'bold blue', + '\033[1m\033[35m': 'bold magenta', + '\033[1m\033[36m': 'bold cyan', } - - def __init__(self, *args, colors: dict[str, str] | None = None, **kwargs): - super().__init__(*args, **kwargs) - if colors: - # Convert ANSI colors to Rich styles - from rich.logging import LogRender - - self.colors = {level: self._ANSI_TO_RICH.get(code, 'default') for level, code in colors.items()} - else: - self.colors = None - - def get_level_text(self, record): - """Override to apply custom colors to level text.""" - from rich.text import Text - - level_name = record.levelname - level_text = Text.styled(level_name.ljust(8), self.colors.get(level_name, 'default') if self.colors else None) - return level_text + return ansi_to_rich_map.get(ansi_code, 'default') def _create_console_handler( @@ -371,14 +378,23 @@ def _create_console_handler( ) -> logging.Handler: """Create a console (stdout) logging handler.""" if use_rich: - console = Console(width=console_width) - handler = ColoredRichHandler( + # Create custom theme from ANSI colors if provided + if colors: + theme_dict = {} + for level, ansi_code in colors.items(): + rich_style = _ansi_to_rich_style(ansi_code) + theme_dict[f'logging.level.{level.lower()}'] = rich_style + theme = Theme(theme_dict) + else: + theme = DEFAULT_THEME + + console = Console(width=console_width, theme=theme) + handler = RichHandler( console=console, rich_tracebacks=True, omit_repeated_times=True, show_path=show_path, log_time_format=date_format, - colors=colors, ) handler.setFormatter(logging.Formatter(format)) else: From f1b321d94d246b8e6fd37c17f4bf1f0e322d2135 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:22:13 +0200 Subject: [PATCH 29/44] Improve color config --- flixopt/config.py | 183 ++++++++++++++++++++++++++-------------------- 1 file changed, 104 insertions(+), 79 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index afa75c49e..9c29f79c5 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -10,23 +10,13 @@ import yaml from rich.console import Console from rich.logging import RichHandler +from rich.style import Style from rich.theme import Theme __all__ = ['CONFIG', 'change_logging_level'] logger = logging.getLogger('flixopt') -# Default Rich theme for log levels -DEFAULT_THEME = Theme( - { - 'logging.level.debug': 'green', - 'logging.level.info': 'blue', - 'logging.level.warning': 'yellow', - 'logging.level.error': 'red', - 'logging.level.critical': 'bold red', - } -) - # SINGLE SOURCE OF TRUTH - immutable to prevent accidental modification _DEFAULTS = MappingProxyType( @@ -95,7 +85,10 @@ class CONFIG: console_width (int): Console width for Rich handler. Default: 120 show_path (bool): Show file paths in log messages. Default: False colors (dict[str, str]): ANSI color codes for each log level. + Works with both Rich and standard console handlers. Keys: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' + Values: ANSI escape sequences + Default colors: - DEBUG: green ('\\033[32m') @@ -104,6 +97,25 @@ class CONFIG: - ERROR: red ('\\033[31m') - CRITICAL: bold red ('\\033[1m\\033[31m') + Common ANSI codes: + + - '\\033[30m' - Black + - '\\033[31m' - Red + - '\\033[32m' - Green + - '\\033[33m' - Yellow + - '\\033[34m' - Blue + - '\\033[35m' - Magenta + - '\\033[36m' - Cyan + - '\\033[37m' - White + - '\\033[1m\\033[3Xm' - Bold color (replace X with color code 0-7) + - '\\033[2m\\033[3Xm' - Dim color (replace X with color code 0-7) + + Examples: + + - Magenta: '\\033[35m' + - Bold cyan: '\\033[1m\\033[36m' + - Dim green: '\\033[2m\\033[32m' + Modeling Attributes: big (int): Large number for optimization constraints. Default: 10000000 epsilon (float): Small tolerance value. Default: 1e-5 @@ -130,14 +142,16 @@ class CONFIG: CONFIG.Logging.colors['INFO'] = '\\033[35m' # Magenta CONFIG.Logging.colors['DEBUG'] = '\\033[36m' # Cyan + CONFIG.Logging.colors['ERROR'] = '\\033[1m\\033[31m' # Bold red CONFIG.apply() - Use Rich handler with custom width:: + Use Rich handler with custom colors:: CONFIG.Logging.console = True CONFIG.Logging.rich = True CONFIG.Logging.console_width = 100 CONFIG.Logging.show_path = True + CONFIG.Logging.colors['INFO'] = '\\033[36m' # Cyan CONFIG.apply() Load from YAML file:: @@ -152,18 +166,18 @@ class CONFIG: level: DEBUG console: true file: app.log - rich: false + rich: true max_file_size: 5242880 # 5MB backup_count: 3 date_format: '%H:%M:%S' console_width: 100 show_path: true colors: - DEBUG: "\\033[36m" # Cyan - INFO: "\\033[32m" # Green - WARNING: "\\033[33m" - ERROR: "\\033[31m" - CRITICAL: "\\033[1m\\033[31m" + DEBUG: "\\033[36m" # Cyan + INFO: "\\033[32m" # Green + WARNING: "\\033[33m" # Yellow + ERROR: "\\033[31m" # Red + CRITICAL: "\\033[1m\\033[31m" # Bold red modeling: big: 20000000 @@ -206,16 +220,16 @@ class Modeling: @classmethod def reset(cls): """Reset all configuration values to defaults.""" - # Dynamically reset from _DEFAULTS - no repetition! for key, value in _DEFAULTS['logging'].items(): - setattr(cls.Logging, key, value) + if key == 'colors': + cls.Logging.colors = dict(value) + else: + setattr(cls.Logging, key, value) for key, value in _DEFAULTS['modeling'].items(): setattr(cls.Modeling, key, value) cls.config_name = _DEFAULTS['config_name'] - - # Apply the reset configuration cls.apply() @classmethod @@ -253,15 +267,12 @@ def _apply_config_dict(cls, config_dict: dict): """Apply configuration dictionary to class attributes.""" for key, value in config_dict.items(): if key == 'logging' and isinstance(value, dict): - # Apply logging config for nested_key, nested_value in value.items(): setattr(cls.Logging, nested_key, nested_value) elif key == 'modeling' and isinstance(value, dict): - # Apply modeling config for nested_key, nested_value in value.items(): setattr(cls.Modeling, nested_key, nested_value) elif hasattr(cls, key): - # Simple attribute setattr(cls, key, value) @classmethod @@ -280,7 +291,7 @@ def to_dict(cls): 'format': cls.Logging.format, 'console_width': cls.Logging.console_width, 'show_path': cls.Logging.show_path, - 'colors': dict(cls.Logging.colors), # Ensure it's a regular dict + 'colors': dict(cls.Logging.colors), }, 'modeling': { 'big': cls.Modeling.big, @@ -291,18 +302,17 @@ def to_dict(cls): class MultilineFormater(logging.Formatter): + """Formatter that handles multi-line messages with consistent prefixes.""" + def __init__(self, fmt=None, datefmt=None): super().__init__(fmt=fmt, datefmt=datefmt) def format(self, record): message_lines = record.getMessage().split('\n') - - # Prepare the log prefix (timestamp + log level) timestamp = self.formatTime(record, self.datefmt) - log_level = record.levelname.ljust(8) # Align log levels for consistency + log_level = record.levelname.ljust(8) log_prefix = f'{timestamp} | {log_level} |' - # Format all lines first_line = [f'{log_prefix} {message_lines[0]}'] if len(message_lines) > 1: lines = first_line + [f'{log_prefix} {line}' for line in message_lines[1:]] @@ -313,61 +323,31 @@ def format(self, record): class ColoredMultilineFormater(MultilineFormater): + """Formatter that adds ANSI colors to multi-line log messages.""" + RESET = '\033[0m' def __init__(self, fmt=None, datefmt=None, colors=None): super().__init__(fmt=fmt, datefmt=datefmt) - # Use provided colors or fall back to defaults self.COLORS = ( colors if colors is not None else { - 'DEBUG': '\033[32m', # Green - 'INFO': '\033[34m', # Blue - 'WARNING': '\033[33m', # Yellow - 'ERROR': '\033[31m', # Red - 'CRITICAL': '\033[1m\033[31m', # Bold Red + 'DEBUG': '\033[32m', + 'INFO': '\033[34m', + 'WARNING': '\033[33m', + 'ERROR': '\033[31m', + 'CRITICAL': '\033[1m\033[31m', } ) def format(self, record): lines = super().format(record).splitlines() log_color = self.COLORS.get(record.levelname, self.RESET) - - # Create a formatted message for each line separately - formatted_lines = [] - for line in lines: - formatted_lines.append(f'{log_color}{line}{self.RESET}') - + formatted_lines = [f'{log_color}{line}{self.RESET}' for line in lines] return '\n'.join(formatted_lines) -def _ansi_to_rich_style(ansi_code: str) -> str: - """Convert ANSI color codes to Rich style strings. - - Args: - ansi_code: ANSI escape sequence (e.g., '\\033[32m') - - Returns: - Rich style string (e.g., 'green') - """ - ansi_to_rich_map = { - '\033[32m': 'green', - '\033[34m': 'blue', - '\033[33m': 'yellow', - '\033[31m': 'red', - '\033[35m': 'magenta', - '\033[36m': 'cyan', - '\033[1m\033[31m': 'bold red', - '\033[1m\033[32m': 'bold green', - '\033[1m\033[33m': 'bold yellow', - '\033[1m\033[34m': 'bold blue', - '\033[1m\033[35m': 'bold magenta', - '\033[1m\033[36m': 'bold cyan', - } - return ansi_to_rich_map.get(ansi_code, 'default') - - def _create_console_handler( use_rich: bool = False, console_width: int = 120, @@ -376,17 +356,35 @@ def _create_console_handler( format: str = '%(message)s', colors: dict[str, str] | None = None, ) -> logging.Handler: - """Create a console (stdout) logging handler.""" + """Create a console (stdout) logging handler. + + Args: + use_rich: If True, use RichHandler with color support. + console_width: Width of the console for Rich handler. + show_path: Show file paths in log messages (Rich only). + date_format: Date/time format string. + format: Log message format string. + colors: Dictionary of ANSI color codes for each log level. + + Returns: + Configured logging handler (RichHandler or StreamHandler). + """ if use_rich: - # Create custom theme from ANSI colors if provided + # Convert ANSI codes to Rich theme if colors: theme_dict = {} for level, ansi_code in colors.items(): - rich_style = _ansi_to_rich_style(ansi_code) - theme_dict[f'logging.level.{level.lower()}'] = rich_style - theme = Theme(theme_dict) + # Rich can parse ANSI codes directly! + try: + style = Style.from_ansi(ansi_code) + theme_dict[f'logging.level.{level.lower()}'] = style + except Exception: + # Fallback to default if parsing fails + pass + + theme = Theme(theme_dict) if theme_dict else None else: - theme = DEFAULT_THEME + theme = None console = Console(width=console_width, theme=theme) handler = RichHandler( @@ -400,6 +398,7 @@ def _create_console_handler( else: handler = logging.StreamHandler() handler.setFormatter(ColoredMultilineFormater(fmt=format, datefmt=date_format, colors=colors)) + return handler @@ -410,7 +409,18 @@ def _create_file_handler( date_format: str = '%Y-%m-%d %H:%M:%S', format: str = '%(message)s', ) -> RotatingFileHandler: - """Create a rotating file handler to prevent huge log files.""" + """Create a rotating file handler to prevent huge log files. + + Args: + log_file: Path to the log file. + max_file_size: Maximum size in bytes before rotation. + backup_count: Number of backup files to keep. + date_format: Date/time format string. + format: Log message format string. + + Returns: + Configured RotatingFileHandler (without colors). + """ handler = RotatingFileHandler( log_file, maxBytes=max_file_size, @@ -434,13 +444,29 @@ def _setup_logging( show_path: bool = False, colors: dict[str, str] | None = None, ): - """Internal function to setup logging - use CONFIG.apply() instead.""" + """Internal function to setup logging - use CONFIG.apply() instead. + + Configures the flixopt logger with console and/or file handlers. + If no handlers are configured, adds NullHandler (library best practice). + + Args: + default_level: Logging level for the logger. + log_file: Path to log file (None to disable file logging). + use_rich_handler: Use Rich for enhanced console output. + console: Enable console logging. + max_file_size: Maximum log file size before rotation. + backup_count: Number of backup log files to keep. + date_format: Date/time format for log messages. + format: Log message format string. + console_width: Console width for Rich handler. + show_path: Show file paths in log messages (Rich only). + colors: ANSI color codes for each log level. + """ logger = logging.getLogger('flixopt') logger.setLevel(getattr(logging, default_level.upper())) logger.propagate = False # Prevent duplicate logs logger.handlers.clear() - # Only log to console if explicitly requested if console: logger.addHandler( _create_console_handler( @@ -453,7 +479,6 @@ def _setup_logging( ) ) - # Add file handler if specified if log_file: logger.addHandler( _create_file_handler( @@ -465,7 +490,7 @@ def _setup_logging( ) ) - # IMPORTANT: If no handlers, use NullHandler (library best practice) + # Library best practice: NullHandler if no handlers configured if not logger.handlers: logger.addHandler(logging.NullHandler()) From c22315f1c9f17233bdb644ba649cac566367f776 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:28:55 +0200 Subject: [PATCH 30/44] Update CHANGELOG.md --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06db472fa..74e35919a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,9 +46,7 @@ Please keep the format of the changelog consistent with the other releases, so t - Added configurable log file rotation settings: `CONFIG.Logging.max_file_size` and `CONFIG.Logging.backup_count` - Added configurable log format settings: `CONFIG.Logging.date_format` and `CONFIG.Logging.format` - Added configurable console settings: `CONFIG.Logging.console_width` and `CONFIG.Logging.show_path` -- Added customizable log level colors via `CONFIG.Logging.colors` (works with both standard and Rich handlers) -- Added Rich theme integration with automatic ANSI-to-Rich color conversion via `DEFAULT_THEME` -- Added comprehensive Google-style docstring to `CONFIG` class with all configuration options and examples +- Added customizable log level colors via `CONFIG.Logging.colors` using ANSI escape codes (works with both standard and Rich handlers) ### 💥 Breaking Changes From 41fe859f343ec15465b25757cba80faabd73bd90 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:32:47 +0200 Subject: [PATCH 31/44] Improve color handling --- CHANGELOG.md | 2 +- flixopt/config.py | 125 +++++++++++++++++++++++++++------------------- 2 files changed, 75 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e35919a..7953bca48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,7 +46,7 @@ Please keep the format of the changelog consistent with the other releases, so t - Added configurable log file rotation settings: `CONFIG.Logging.max_file_size` and `CONFIG.Logging.backup_count` - Added configurable log format settings: `CONFIG.Logging.date_format` and `CONFIG.Logging.format` - Added configurable console settings: `CONFIG.Logging.console_width` and `CONFIG.Logging.show_path` -- Added customizable log level colors via `CONFIG.Logging.colors` using ANSI escape codes (works with both standard and Rich handlers) +- Added `CONFIG.Logging.Colors` nested class for customizable log level colors using ANSI escape codes (works with both standard and Rich handlers) ### 💥 Breaking Changes diff --git a/flixopt/config.py b/flixopt/config.py index 9c29f79c5..7ebb8e6bd 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -34,15 +34,15 @@ 'format': '%(message)s', 'console_width': 120, 'show_path': False, - 'colors': MappingProxyType( - { - 'DEBUG': '\033[32m', # Green - 'INFO': '\033[34m', # Blue - 'WARNING': '\033[33m', # Yellow - 'ERROR': '\033[31m', # Red - 'CRITICAL': '\033[1m\033[31m', # Bold Red - } - ), + } + ), + 'colors': MappingProxyType( + { + 'DEBUG': '\033[32m', # Green + 'INFO': '\033[34m', # Blue + 'WARNING': '\033[33m', # Yellow + 'ERROR': '\033[31m', # Red + 'CRITICAL': '\033[1m\033[31m', # Bold Red } ), 'modeling': MappingProxyType( @@ -66,6 +66,7 @@ class CONFIG: Attributes: Logging: Nested class containing all logging configuration options. + Colors: Nested subclass under Logging containing ANSI color codes for log levels. Modeling: Nested class containing optimization modeling parameters. config_name (str): Name of the configuration (default: 'flixopt'). @@ -84,37 +85,35 @@ class CONFIG: format (str): Log message format string. Default: '%(message)s' console_width (int): Console width for Rich handler. Default: 120 show_path (bool): Show file paths in log messages. Default: False - colors (dict[str, str]): ANSI color codes for each log level. - Works with both Rich and standard console handlers. - Keys: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL' - Values: ANSI escape sequences - - Default colors: - - - DEBUG: green ('\\033[32m') - - INFO: blue ('\\033[34m') - - WARNING: yellow ('\\033[33m') - - ERROR: red ('\\033[31m') - - CRITICAL: bold red ('\\033[1m\\033[31m') - - Common ANSI codes: - - - '\\033[30m' - Black - - '\\033[31m' - Red - - '\\033[32m' - Green - - '\\033[33m' - Yellow - - '\\033[34m' - Blue - - '\\033[35m' - Magenta - - '\\033[36m' - Cyan - - '\\033[37m' - White - - '\\033[1m\\033[3Xm' - Bold color (replace X with color code 0-7) - - '\\033[2m\\033[3Xm' - Dim color (replace X with color code 0-7) - - Examples: - - - Magenta: '\\033[35m' - - Bold cyan: '\\033[1m\\033[36m' - - Dim green: '\\033[2m\\033[32m' + + Colors Attributes: + DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[32m' (green) + INFO (str): ANSI color code for INFO level. Default: '\\033[34m' (blue) + WARNING (str): ANSI color code for WARNING level. Default: '\\033[33m' (yellow) + ERROR (str): ANSI color code for ERROR level. Default: '\\033[31m' (red) + CRITICAL (str): ANSI color code for CRITICAL level. Default: '\\033[1m\\033[31m' (bold red) + + Works with both Rich and standard console handlers. + Rich automatically converts ANSI codes using Style.from_ansi(). + + Common ANSI codes: + + - '\\033[30m' - Black + - '\\033[31m' - Red + - '\\033[32m' - Green + - '\\033[33m' - Yellow + - '\\033[34m' - Blue + - '\\033[35m' - Magenta + - '\\033[36m' - Cyan + - '\\033[37m' - White + - '\\033[1m\\033[3Xm' - Bold color (replace X with color code 0-7) + - '\\033[2m\\033[3Xm' - Dim color (replace X with color code 0-7) + + Examples: + + - Magenta: '\\033[35m' + - Bold cyan: '\\033[1m\\033[36m' + - Dim green: '\\033[2m\\033[32m' Modeling Attributes: big (int): Large number for optimization constraints. Default: 10000000 @@ -140,9 +139,9 @@ class CONFIG: Customize log colors:: - CONFIG.Logging.colors['INFO'] = '\\033[35m' # Magenta - CONFIG.Logging.colors['DEBUG'] = '\\033[36m' # Cyan - CONFIG.Logging.colors['ERROR'] = '\\033[1m\\033[31m' # Bold red + CONFIG.Logging.Colors.INFO = '\\033[35m' # Magenta + CONFIG.Logging.Colors.DEBUG = '\\033[36m' # Cyan + CONFIG.Logging.Colors.ERROR = '\\033[1m\\033[31m' # Bold red CONFIG.apply() Use Rich handler with custom colors:: @@ -151,7 +150,7 @@ class CONFIG: CONFIG.Logging.rich = True CONFIG.Logging.console_width = 100 CONFIG.Logging.show_path = True - CONFIG.Logging.colors['INFO'] = '\\033[36m' # Cyan + CONFIG.Logging.Colors.INFO = '\\033[36m' # Cyan CONFIG.apply() Load from YAML file:: @@ -208,7 +207,13 @@ class Logging: format: str = _DEFAULTS['logging']['format'] console_width: int = _DEFAULTS['logging']['console_width'] show_path: bool = _DEFAULTS['logging']['show_path'] - colors: dict[str, str] = dict(_DEFAULTS['logging']['colors']) + + class Colors: + DEBUG: str = _DEFAULTS['colors']['DEBUG'] + INFO: str = _DEFAULTS['colors']['INFO'] + WARNING: str = _DEFAULTS['colors']['WARNING'] + ERROR: str = _DEFAULTS['colors']['ERROR'] + CRITICAL: str = _DEFAULTS['colors']['CRITICAL'] class Modeling: big: int = _DEFAULTS['modeling']['big'] @@ -221,10 +226,10 @@ class Modeling: def reset(cls): """Reset all configuration values to defaults.""" for key, value in _DEFAULTS['logging'].items(): - if key == 'colors': - cls.Logging.colors = dict(value) - else: - setattr(cls.Logging, key, value) + setattr(cls.Logging, key, value) + + for key, value in _DEFAULTS['colors'].items(): + setattr(cls.Logging.Colors, key, value) for key, value in _DEFAULTS['modeling'].items(): setattr(cls.Modeling, key, value) @@ -235,6 +240,15 @@ def reset(cls): @classmethod def apply(cls): """Apply current configuration to logging system.""" + # Convert Colors class attributes to dict + colors_dict = { + 'DEBUG': cls.Logging.Colors.DEBUG, + 'INFO': cls.Logging.Colors.INFO, + 'WARNING': cls.Logging.Colors.WARNING, + 'ERROR': cls.Logging.Colors.ERROR, + 'CRITICAL': cls.Logging.Colors.CRITICAL, + } + _setup_logging( default_level=cls.Logging.level, log_file=cls.Logging.file, @@ -246,7 +260,7 @@ def apply(cls): format=cls.Logging.format, console_width=cls.Logging.console_width, show_path=cls.Logging.show_path, - colors=cls.Logging.colors, + colors=colors_dict, ) @classmethod @@ -269,6 +283,9 @@ def _apply_config_dict(cls, config_dict: dict): if key == 'logging' and isinstance(value, dict): for nested_key, nested_value in value.items(): setattr(cls.Logging, nested_key, nested_value) + elif key == 'colors' and isinstance(value, dict): + for nested_key, nested_value in value.items(): + setattr(cls.Logging.Colors, nested_key, nested_value) elif key == 'modeling' and isinstance(value, dict): for nested_key, nested_value in value.items(): setattr(cls.Modeling, nested_key, nested_value) @@ -291,7 +308,13 @@ def to_dict(cls): 'format': cls.Logging.format, 'console_width': cls.Logging.console_width, 'show_path': cls.Logging.show_path, - 'colors': dict(cls.Logging.colors), + }, + 'colors': { + 'DEBUG': cls.Logging.Colors.DEBUG, + 'INFO': cls.Logging.Colors.INFO, + 'WARNING': cls.Logging.Colors.WARNING, + 'ERROR': cls.Logging.Colors.ERROR, + 'CRITICAL': cls.Logging.Colors.CRITICAL, }, 'modeling': { 'big': cls.Modeling.big, From 804774ef08e033e2a781f6d2420ea30bb9d4b5be Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:37:02 +0200 Subject: [PATCH 32/44] Improve color handling --- flixopt/config.py | 62 +++++++++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 7ebb8e6bd..a13583b23 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -34,15 +34,15 @@ 'format': '%(message)s', 'console_width': 120, 'show_path': False, - } - ), - 'colors': MappingProxyType( - { - 'DEBUG': '\033[32m', # Green - 'INFO': '\033[34m', # Blue - 'WARNING': '\033[33m', # Yellow - 'ERROR': '\033[31m', # Red - 'CRITICAL': '\033[1m\033[31m', # Bold Red + 'colors': MappingProxyType( + { + 'DEBUG': '\033[32m', # Green + 'INFO': '\033[34m', # Blue + 'WARNING': '\033[33m', # Yellow + 'ERROR': '\033[31m', # Red + 'CRITICAL': '\033[1m\033[31m', # Bold Red + } + ), } ), 'modeling': MappingProxyType( @@ -209,11 +209,11 @@ class Logging: show_path: bool = _DEFAULTS['logging']['show_path'] class Colors: - DEBUG: str = _DEFAULTS['colors']['DEBUG'] - INFO: str = _DEFAULTS['colors']['INFO'] - WARNING: str = _DEFAULTS['colors']['WARNING'] - ERROR: str = _DEFAULTS['colors']['ERROR'] - CRITICAL: str = _DEFAULTS['colors']['CRITICAL'] + DEBUG: str = _DEFAULTS['logging']['colors']['DEBUG'] + INFO: str = _DEFAULTS['logging']['colors']['INFO'] + WARNING: str = _DEFAULTS['logging']['colors']['WARNING'] + ERROR: str = _DEFAULTS['logging']['colors']['ERROR'] + CRITICAL: str = _DEFAULTS['logging']['colors']['CRITICAL'] class Modeling: big: int = _DEFAULTS['modeling']['big'] @@ -226,10 +226,12 @@ class Modeling: def reset(cls): """Reset all configuration values to defaults.""" for key, value in _DEFAULTS['logging'].items(): - setattr(cls.Logging, key, value) - - for key, value in _DEFAULTS['colors'].items(): - setattr(cls.Logging.Colors, key, value) + if key == 'colors': + # Reset nested Colors class + for color_key, color_value in value.items(): + setattr(cls.Logging.Colors, color_key, color_value) + else: + setattr(cls.Logging, key, value) for key, value in _DEFAULTS['modeling'].items(): setattr(cls.Modeling, key, value) @@ -282,10 +284,12 @@ def _apply_config_dict(cls, config_dict: dict): for key, value in config_dict.items(): if key == 'logging' and isinstance(value, dict): for nested_key, nested_value in value.items(): - setattr(cls.Logging, nested_key, nested_value) - elif key == 'colors' and isinstance(value, dict): - for nested_key, nested_value in value.items(): - setattr(cls.Logging.Colors, nested_key, nested_value) + if nested_key == 'colors' and isinstance(nested_value, dict): + # Handle nested colors under logging + for color_key, color_value in nested_value.items(): + setattr(cls.Logging.Colors, color_key, color_value) + else: + setattr(cls.Logging, nested_key, nested_value) elif key == 'modeling' and isinstance(value, dict): for nested_key, nested_value in value.items(): setattr(cls.Modeling, nested_key, nested_value) @@ -308,13 +312,13 @@ def to_dict(cls): 'format': cls.Logging.format, 'console_width': cls.Logging.console_width, 'show_path': cls.Logging.show_path, - }, - 'colors': { - 'DEBUG': cls.Logging.Colors.DEBUG, - 'INFO': cls.Logging.Colors.INFO, - 'WARNING': cls.Logging.Colors.WARNING, - 'ERROR': cls.Logging.Colors.ERROR, - 'CRITICAL': cls.Logging.Colors.CRITICAL, + 'colors': { + 'DEBUG': cls.Logging.Colors.DEBUG, + 'INFO': cls.Logging.Colors.INFO, + 'WARNING': cls.Logging.Colors.WARNING, + 'ERROR': cls.Logging.Colors.ERROR, + 'CRITICAL': cls.Logging.Colors.CRITICAL, + }, }, 'modeling': { 'big': cls.Modeling.big, From 5295e817eff784be18a3cb47cd34f35ad86d9cf1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:44:44 +0200 Subject: [PATCH 33/44] Remove console Logging explicityl from examples --- examples/00_Minmal/minimal_example.py | 3 --- examples/01_Simple/simple_example.py | 3 --- examples/02_Complex/complex_example.py | 4 ---- examples/03_Calculation_types/example_calculation_types.py | 4 ---- 4 files changed, 14 deletions(-) diff --git a/examples/00_Minmal/minimal_example.py b/examples/00_Minmal/minimal_example.py index 9c64fb174..e9ef241ff 100644 --- a/examples/00_Minmal/minimal_example.py +++ b/examples/00_Minmal/minimal_example.py @@ -9,9 +9,6 @@ import flixopt as fx if __name__ == '__main__': - # Enable console logging to see what's happening - fx.CONFIG.Logging.console = True - fx.CONFIG.apply() # --- Define the Flow System, that will hold all elements, and the time steps you want to model --- timesteps = pd.date_range('2020-01-01', periods=3, freq='h') flow_system = fx.FlowSystem(timesteps) diff --git a/examples/01_Simple/simple_example.py b/examples/01_Simple/simple_example.py index 62eb2686d..8239f805a 100644 --- a/examples/01_Simple/simple_example.py +++ b/examples/01_Simple/simple_example.py @@ -8,9 +8,6 @@ import flixopt as fx if __name__ == '__main__': - # Enable console logging to see what's happening - fx.CONFIG.Logging.console = True - fx.CONFIG.apply() # --- Create Time Series Data --- # Heat demand profile (e.g., kW) over time and corresponding power prices heat_demand_per_h = np.array([30, 0, 90, 110, 110, 20, 20, 20, 20]) diff --git a/examples/02_Complex/complex_example.py b/examples/02_Complex/complex_example.py index 7c9511a43..175211c26 100644 --- a/examples/02_Complex/complex_example.py +++ b/examples/02_Complex/complex_example.py @@ -9,10 +9,6 @@ import flixopt as fx if __name__ == '__main__': - # Enable console logging to see what's happening - fx.CONFIG.Logging.console = True - fx.CONFIG.apply() - # --- Experiment Options --- # Configure options for testing various parameters and behaviors check_penalty = False diff --git a/examples/03_Calculation_types/example_calculation_types.py b/examples/03_Calculation_types/example_calculation_types.py index 11b2366c7..a92a20163 100644 --- a/examples/03_Calculation_types/example_calculation_types.py +++ b/examples/03_Calculation_types/example_calculation_types.py @@ -11,10 +11,6 @@ import flixopt as fx if __name__ == '__main__': - # Enable console logging to see what's happening - fx.CONFIG.Logging.console = True - fx.CONFIG.apply() - # Calculation Types full, segmented, aggregated = True, True, True From 50d7d00d79a2c366e514487ee38393368a8f4a36 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:46:21 +0200 Subject: [PATCH 34/44] Make log to console the default --- flixopt/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index a13583b23..2ec5bf88c 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -27,7 +27,7 @@ 'level': 'INFO', 'file': 'flixopt.log', 'rich': False, - 'console': False, + 'console': True, 'max_file_size': 10_485_760, # 10MB 'backup_count': 5, 'date_format': '%Y-%m-%d %H:%M:%S', @@ -59,11 +59,11 @@ class CONFIG: """Configuration for flixopt library. - Library is SILENT by default (best practice for libraries). - The CONFIG class provides centralized configuration for logging and modeling parameters. All changes require calling ``CONFIG.apply()`` to take effect. + By default, logging outputs to both console and file ('flixopt.log'). + Attributes: Logging: Nested class containing all logging configuration options. Colors: Nested subclass under Logging containing ANSI color codes for log levels. @@ -75,7 +75,7 @@ class CONFIG: Default: 'INFO' file (str | None): Log file path. Default: 'flixopt.log'. Set to None to disable file logging. - console (bool): Enable console (stdout) logging. Default: False + console (bool): Enable console (stdout) logging. Default: True rich (bool): Use Rich library for enhanced console output. Default: False max_file_size (int): Maximum log file size in bytes before rotation. Default: 10485760 (10MB) From 882d27af29638adf49716beddb9fbf0993c03e8e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:46:27 +0200 Subject: [PATCH 35/44] Make log to console the default --- tests/test_config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index b61cf28b3..c486d22c6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -27,7 +27,7 @@ def test_config_defaults(self): assert CONFIG.Logging.level == 'INFO' assert CONFIG.Logging.file == 'flixopt.log' assert CONFIG.Logging.rich is False - assert CONFIG.Logging.console is False + assert CONFIG.Logging.console is True assert CONFIG.Modeling.big == 10_000_000 assert CONFIG.Modeling.epsilon == 1e-5 assert CONFIG.Modeling.big_binary_bound == 100_000 @@ -420,7 +420,7 @@ def test_config_reset(self): """Test that CONFIG.reset() restores all defaults.""" # Modify all config values CONFIG.Logging.level = 'DEBUG' - CONFIG.Logging.console = True + CONFIG.Logging.console = False CONFIG.Logging.rich = True CONFIG.Logging.file = '/tmp/test.log' CONFIG.Modeling.big = 99999999 @@ -433,7 +433,7 @@ def test_config_reset(self): # Verify all values are back to defaults assert CONFIG.Logging.level == 'INFO' - assert CONFIG.Logging.console is False + assert CONFIG.Logging.console is True assert CONFIG.Logging.rich is False assert CONFIG.Logging.file == 'flixopt.log' assert CONFIG.Modeling.big == 10_000_000 From 19f81c9e05065de7dcf74bf4750f653d51a2ecbe Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:52:24 +0200 Subject: [PATCH 36/44] Add individual level parameters for console and file --- CHANGELOG.md | 1 + flixopt/config.py | 74 +++++++++++++++++++++++++---------- tests/test_config.py | 91 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7953bca48..98176b571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ Please keep the format of the changelog consistent with the other releases, so t - Added configurable log format settings: `CONFIG.Logging.date_format` and `CONFIG.Logging.format` - Added configurable console settings: `CONFIG.Logging.console_width` and `CONFIG.Logging.show_path` - Added `CONFIG.Logging.Colors` nested class for customizable log level colors using ANSI escape codes (works with both standard and Rich handlers) +- Added individual log level control: `CONFIG.Logging.console_level` and `CONFIG.Logging.file_level` to set different logging levels for console and file handlers independently ### 💥 Breaking Changes diff --git a/flixopt/config.py b/flixopt/config.py index 2ec5bf88c..2676c0fc6 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -25,6 +25,8 @@ 'logging': MappingProxyType( { 'level': 'INFO', + 'console_level': None, # If None, uses 'level' + 'file_level': None, # If None, uses 'level' 'file': 'flixopt.log', 'rich': False, 'console': True, @@ -71,8 +73,12 @@ class CONFIG: config_name (str): Name of the configuration (default: 'flixopt'). Logging Attributes: - level (str): Logging level: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. - Default: 'INFO' + level (str): Default logging level for both console and file: 'DEBUG', 'INFO', + 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' + console_level (str | None): Override logging level for console output. + If None, uses 'level'. Default: None + file_level (str | None): Override logging level for file output. + If None, uses 'level'. Default: None file (str | None): Log file path. Default: 'flixopt.log'. Set to None to disable file logging. console (bool): Enable console (stdout) logging. Default: True @@ -137,6 +143,13 @@ class CONFIG: CONFIG.Logging.backup_count = 3 CONFIG.apply() + Use different log levels for console and file:: + + CONFIG.Logging.level = 'INFO' # Default level + CONFIG.Logging.console_level = 'DEBUG' # Console shows DEBUG+ + CONFIG.Logging.file_level = 'WARNING' # File only logs WARNING+ + CONFIG.apply() + Customize log colors:: CONFIG.Logging.Colors.INFO = '\\033[35m' # Magenta @@ -163,6 +176,8 @@ class CONFIG: logging: level: DEBUG + console_level: INFO # Override console level + file_level: WARNING # Override file level console: true file: app.log rich: true @@ -198,6 +213,8 @@ class CONFIG: class Logging: level: str = _DEFAULTS['logging']['level'] + console_level: str | None = _DEFAULTS['logging']['console_level'] + file_level: str | None = _DEFAULTS['logging']['file_level'] file: str | None = _DEFAULTS['logging']['file'] rich: bool = _DEFAULTS['logging']['rich'] console: bool = _DEFAULTS['logging']['console'] @@ -253,6 +270,8 @@ def apply(cls): _setup_logging( default_level=cls.Logging.level, + console_level=cls.Logging.console_level, + file_level=cls.Logging.file_level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich, console=cls.Logging.console, @@ -460,6 +479,8 @@ def _create_file_handler( def _setup_logging( default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', + console_level: str | None = None, + file_level: str | None = None, log_file: str | None = None, use_rich_handler: bool = False, console: bool = False, @@ -477,7 +498,9 @@ def _setup_logging( If no handlers are configured, adds NullHandler (library best practice). Args: - default_level: Logging level for the logger. + default_level: Logging level for the logger (used if console_level/file_level not set). + console_level: Override logging level for console handler (None uses default_level). + file_level: Override logging level for file handler (None uses default_level). log_file: Path to log file (None to disable file logging). use_rich_handler: Use Rich for enhanced console output. console: Enable console logging. @@ -490,32 +513,41 @@ def _setup_logging( colors: ANSI color codes for each log level. """ logger = logging.getLogger('flixopt') - logger.setLevel(getattr(logging, default_level.upper())) + # Set logger to the most permissive level needed + effective_console_level = console_level if console_level else default_level + effective_file_level = file_level if file_level else default_level + + # Logger needs to be at the lowest level to allow handlers to filter + min_level = min( + getattr(logging, effective_console_level.upper()) if console else logging.CRITICAL, + getattr(logging, effective_file_level.upper()) if log_file else logging.CRITICAL, + ) + logger.setLevel(min_level) logger.propagate = False # Prevent duplicate logs logger.handlers.clear() if console: - logger.addHandler( - _create_console_handler( - use_rich=use_rich_handler, - console_width=console_width, - show_path=show_path, - date_format=date_format, - format=format, - colors=colors, - ) + handler = _create_console_handler( + use_rich=use_rich_handler, + console_width=console_width, + show_path=show_path, + date_format=date_format, + format=format, + colors=colors, ) + handler.setLevel(getattr(logging, effective_console_level.upper())) + logger.addHandler(handler) if log_file: - logger.addHandler( - _create_file_handler( - log_file=log_file, - max_file_size=max_file_size, - backup_count=backup_count, - date_format=date_format, - format=format, - ) + handler = _create_file_handler( + log_file=log_file, + max_file_size=max_file_size, + backup_count=backup_count, + date_format=date_format, + format=format, ) + handler.setLevel(getattr(logging, effective_file_level.upper())) + logger.addHandler(handler) # Library best practice: NullHandler if no handlers configured if not logger.handlers: diff --git a/tests/test_config.py b/tests/test_config.py index c486d22c6..c49581188 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,6 +25,8 @@ def teardown_method(self): def test_config_defaults(self): """Test that CONFIG has correct default values.""" assert CONFIG.Logging.level == 'INFO' + assert CONFIG.Logging.console_level is None + assert CONFIG.Logging.file_level is None assert CONFIG.Logging.file == 'flixopt.log' assert CONFIG.Logging.rich is False assert CONFIG.Logging.console is True @@ -478,3 +480,92 @@ def test_reset_matches_class_defaults(self): assert CONFIG.Modeling.epsilon == _DEFAULTS['modeling']['epsilon'] assert CONFIG.Modeling.big_binary_bound == _DEFAULTS['modeling']['big_binary_bound'] assert CONFIG.config_name == _DEFAULTS['config_name'] + + def test_individual_handler_levels(self, tmp_path): + """Test that console and file handlers can have different log levels.""" + log_file = tmp_path / 'test_levels.log' + + # Set console to DEBUG, file to WARNING + CONFIG.Logging.level = 'INFO' # Default level + CONFIG.Logging.console = True + CONFIG.Logging.console_level = 'DEBUG' + CONFIG.Logging.file = str(log_file) + CONFIG.Logging.file_level = 'WARNING' + CONFIG.apply() + + logger = logging.getLogger('flixopt') + + # Logger should be set to DEBUG (most permissive) + assert logger.level == logging.DEBUG + + # Check handler levels + from logging.handlers import RotatingFileHandler + + console_handlers = [ + h + for h in logger.handlers + if isinstance(h, logging.StreamHandler) and not isinstance(h, RotatingFileHandler) + ] + file_handlers = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)] + + assert len(console_handlers) == 1 + assert len(file_handlers) == 1 + + # Console handler should be at DEBUG level + assert console_handlers[0].level == logging.DEBUG + + # File handler should be at WARNING level + assert file_handlers[0].level == logging.WARNING + + # Test actual logging behavior + logger.debug('DEBUG message') + logger.info('INFO message') + logger.warning('WARNING message') + logger.error('ERROR message') + + # File should only contain WARNING and ERROR + log_content = log_file.read_text() + assert 'DEBUG message' not in log_content + assert 'INFO message' not in log_content + assert 'WARNING message' in log_content + assert 'ERROR message' in log_content + + def test_console_level_defaults_to_level(self): + """Test that console_level defaults to level when not specified.""" + CONFIG.Logging.level = 'ERROR' + CONFIG.Logging.console = True + CONFIG.Logging.console_level = None # Explicitly None + CONFIG.apply() + + logger = logging.getLogger('flixopt') + + # Find console handler + from logging.handlers import RotatingFileHandler + + console_handlers = [ + h + for h in logger.handlers + if isinstance(h, logging.StreamHandler) and not isinstance(h, RotatingFileHandler) + ] + + assert len(console_handlers) == 1 + assert console_handlers[0].level == logging.ERROR + + def test_file_level_defaults_to_level(self, tmp_path): + """Test that file_level defaults to level when not specified.""" + log_file = tmp_path / 'test.log' + + CONFIG.Logging.level = 'CRITICAL' + CONFIG.Logging.file = str(log_file) + CONFIG.Logging.file_level = None # Explicitly None + CONFIG.apply() + + logger = logging.getLogger('flixopt') + + # Find file handler + from logging.handlers import RotatingFileHandler + + file_handlers = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)] + + assert len(file_handlers) == 1 + assert file_handlers[0].level == logging.CRITICAL From a133cc87c3567f0d4e40b43f60943afe7f6b9aaa Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:24:36 +0200 Subject: [PATCH 37/44] Add extra Handler section --- flixopt/config.py | 166 +++++++++++++++++++--------------------------- 1 file changed, 68 insertions(+), 98 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 2676c0fc6..7eefdced3 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -24,9 +24,7 @@ 'config_name': 'flixopt', 'logging': MappingProxyType( { - 'level': 'INFO', - 'console_level': None, # If None, uses 'level' - 'file_level': None, # If None, uses 'level' + 'level': 'INFO', # Logger level (most permissive needed) 'file': 'flixopt.log', 'rich': False, 'console': True, @@ -36,6 +34,12 @@ 'format': '%(message)s', 'console_width': 120, 'show_path': False, + 'handlers': MappingProxyType( + { + 'console_level': 'INFO', + 'file_level': 'INFO', + } + ), 'colors': MappingProxyType( { 'DEBUG': '\033[32m', # Green @@ -64,21 +68,19 @@ class CONFIG: The CONFIG class provides centralized configuration for logging and modeling parameters. All changes require calling ``CONFIG.apply()`` to take effect. - By default, logging outputs to both console and file ('flixopt.log'). + By default, logging outputs to console at INFO level and to file at INFO level. Attributes: Logging: Nested class containing all logging configuration options. - Colors: Nested subclass under Logging containing ANSI color codes for log levels. + Handlers: Nested subclass for handler-specific levels + Colors: Nested subclass for ANSI color codes Modeling: Nested class containing optimization modeling parameters. config_name (str): Name of the configuration (default: 'flixopt'). Logging Attributes: - level (str): Default logging level for both console and file: 'DEBUG', 'INFO', - 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' - console_level (str | None): Override logging level for console output. - If None, uses 'level'. Default: None - file_level (str | None): Override logging level for file output. - If None, uses 'level'. Default: None + level (str): Logger level - sets the minimum level the logger processes. + Should be set to the lowest level needed by any handler. + Default: 'INFO' file (str | None): Log file path. Default: 'flixopt.log'. Set to None to disable file logging. console (bool): Enable console (stdout) logging. Default: True @@ -92,6 +94,12 @@ class CONFIG: console_width (int): Console width for Rich handler. Default: 120 show_path (bool): Show file paths in log messages. Default: False + Handlers Attributes: + console_level (str): Logging level for console handler. + 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' + file_level (str): Logging level for file handler. + 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' + Colors Attributes: DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[32m' (green) INFO (str): ANSI color code for INFO level. Default: '\\033[34m' (blue) @@ -115,12 +123,6 @@ class CONFIG: - '\\033[1m\\033[3Xm' - Bold color (replace X with color code 0-7) - '\\033[2m\\033[3Xm' - Dim color (replace X with color code 0-7) - Examples: - - - Magenta: '\\033[35m' - - Bold cyan: '\\033[1m\\033[36m' - - Dim green: '\\033[2m\\033[32m' - Modeling Attributes: big (int): Large number for optimization constraints. Default: 10000000 epsilon (float): Small tolerance value. Default: 1e-5 @@ -136,34 +138,17 @@ class CONFIG: CONFIG.Logging.level = 'DEBUG' CONFIG.apply() - Configure log file rotation:: - - CONFIG.Logging.file = 'myapp.log' - CONFIG.Logging.max_file_size = 5_242_880 # 5 MB - CONFIG.Logging.backup_count = 3 - CONFIG.apply() - Use different log levels for console and file:: - CONFIG.Logging.level = 'INFO' # Default level - CONFIG.Logging.console_level = 'DEBUG' # Console shows DEBUG+ - CONFIG.Logging.file_level = 'WARNING' # File only logs WARNING+ + CONFIG.Logging.Handlers.console_level = 'DEBUG' # Console shows DEBUG+ + CONFIG.Logging.Handlers.file_level = 'WARNING' # File only logs WARNING+ + CONFIG.Logging.level = 'DEBUG' # Logger must be at lowest level CONFIG.apply() Customize log colors:: - CONFIG.Logging.Colors.INFO = '\\033[35m' # Magenta - CONFIG.Logging.Colors.DEBUG = '\\033[36m' # Cyan - CONFIG.Logging.Colors.ERROR = '\\033[1m\\033[31m' # Bold red - CONFIG.apply() - - Use Rich handler with custom colors:: - - CONFIG.Logging.console = True - CONFIG.Logging.rich = True - CONFIG.Logging.console_width = 100 - CONFIG.Logging.show_path = True - CONFIG.Logging.Colors.INFO = '\\033[36m' # Cyan + CONFIG.Logging.Colors.INFO = '\033[35m' # Magenta (no 'r' prefix!) + CONFIG.Logging.Colors.DEBUG = '\033[36m' # Cyan CONFIG.apply() Load from YAML file:: @@ -176,45 +161,30 @@ class CONFIG: logging: level: DEBUG - console_level: INFO # Override console level - file_level: WARNING # Override file level console: true file: app.log rich: true - max_file_size: 5242880 # 5MB - backup_count: 3 - date_format: '%H:%M:%S' - console_width: 100 - show_path: true + handlers: + console_level: INFO + file_level: WARNING colors: - DEBUG: "\\033[36m" # Cyan - INFO: "\\033[32m" # Green - WARNING: "\\033[33m" # Yellow - ERROR: "\\033[31m" # Red - CRITICAL: "\\033[1m\\033[31m" # Bold red + DEBUG: "\033[36m" + INFO: "\033[32m" + WARNING: "\033[33m" + ERROR: "\033[31m" + CRITICAL: "\033[1m\033[31m" modeling: big: 20000000 epsilon: 1e-6 - big_binary_bound: 200000 Reset to defaults:: CONFIG.reset() - - Export current configuration:: - - config_dict = CONFIG.to_dict() - import yaml - - with open('my_config.yaml', 'w') as f: - yaml.dump(config_dict, f) """ class Logging: level: str = _DEFAULTS['logging']['level'] - console_level: str | None = _DEFAULTS['logging']['console_level'] - file_level: str | None = _DEFAULTS['logging']['file_level'] file: str | None = _DEFAULTS['logging']['file'] rich: bool = _DEFAULTS['logging']['rich'] console: bool = _DEFAULTS['logging']['console'] @@ -225,7 +195,15 @@ class Logging: console_width: int = _DEFAULTS['logging']['console_width'] show_path: bool = _DEFAULTS['logging']['show_path'] + class Handlers: + """Handler-specific logging levels.""" + + console_level: str = _DEFAULTS['logging']['handlers']['console_level'] + file_level: str = _DEFAULTS['logging']['handlers']['file_level'] + class Colors: + """ANSI color codes for each log level.""" + DEBUG: str = _DEFAULTS['logging']['colors']['DEBUG'] INFO: str = _DEFAULTS['logging']['colors']['INFO'] WARNING: str = _DEFAULTS['logging']['colors']['WARNING'] @@ -244,9 +222,11 @@ def reset(cls): """Reset all configuration values to defaults.""" for key, value in _DEFAULTS['logging'].items(): if key == 'colors': - # Reset nested Colors class for color_key, color_value in value.items(): setattr(cls.Logging.Colors, color_key, color_value) + elif key == 'handlers': + for handler_key, handler_value in value.items(): + setattr(cls.Logging.Handlers, handler_key, handler_value) else: setattr(cls.Logging, key, value) @@ -259,19 +239,10 @@ def reset(cls): @classmethod def apply(cls): """Apply current configuration to logging system.""" - # Convert Colors class attributes to dict - colors_dict = { - 'DEBUG': cls.Logging.Colors.DEBUG, - 'INFO': cls.Logging.Colors.INFO, - 'WARNING': cls.Logging.Colors.WARNING, - 'ERROR': cls.Logging.Colors.ERROR, - 'CRITICAL': cls.Logging.Colors.CRITICAL, - } - _setup_logging( default_level=cls.Logging.level, - console_level=cls.Logging.console_level, - file_level=cls.Logging.file_level, + console_level=cls.Logging.Handlers.console_level, + file_level=cls.Logging.Handlers.file_level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich, console=cls.Logging.console, @@ -281,7 +252,13 @@ def apply(cls): format=cls.Logging.format, console_width=cls.Logging.console_width, show_path=cls.Logging.show_path, - colors=colors_dict, + colors={ + 'DEBUG': cls.Logging.Colors.DEBUG, + 'INFO': cls.Logging.Colors.INFO, + 'WARNING': cls.Logging.Colors.WARNING, + 'ERROR': cls.Logging.Colors.ERROR, + 'CRITICAL': cls.Logging.Colors.CRITICAL, + }, ) @classmethod @@ -304,9 +281,11 @@ def _apply_config_dict(cls, config_dict: dict): if key == 'logging' and isinstance(value, dict): for nested_key, nested_value in value.items(): if nested_key == 'colors' and isinstance(nested_value, dict): - # Handle nested colors under logging for color_key, color_value in nested_value.items(): setattr(cls.Logging.Colors, color_key, color_value) + elif nested_key == 'handlers' and isinstance(nested_value, dict): + for handler_key, handler_value in nested_value.items(): + setattr(cls.Logging.Handlers, handler_key, handler_value) else: setattr(cls.Logging, nested_key, nested_value) elif key == 'modeling' and isinstance(value, dict): @@ -331,6 +310,10 @@ def to_dict(cls): 'format': cls.Logging.format, 'console_width': cls.Logging.console_width, 'show_path': cls.Logging.show_path, + 'handlers': { + 'console_level': cls.Logging.Handlers.console_level, + 'file_level': cls.Logging.Handlers.file_level, + }, 'colors': { 'DEBUG': cls.Logging.Colors.DEBUG, 'INFO': cls.Logging.Colors.INFO, @@ -479,8 +462,8 @@ def _create_file_handler( def _setup_logging( default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', - console_level: str | None = None, - file_level: str | None = None, + console_level: str = 'INFO', + file_level: str = 'INFO', log_file: str | None = None, use_rich_handler: bool = False, console: bool = False, @@ -494,13 +477,10 @@ def _setup_logging( ): """Internal function to setup logging - use CONFIG.apply() instead. - Configures the flixopt logger with console and/or file handlers. - If no handlers are configured, adds NullHandler (library best practice). - Args: - default_level: Logging level for the logger (used if console_level/file_level not set). - console_level: Override logging level for console handler (None uses default_level). - file_level: Override logging level for file handler (None uses default_level). + default_level: Logger level (should be lowest level needed by any handler). + console_level: Logging level for console handler. + file_level: Logging level for file handler. log_file: Path to log file (None to disable file logging). use_rich_handler: Use Rich for enhanced console output. console: Enable console logging. @@ -513,17 +493,8 @@ def _setup_logging( colors: ANSI color codes for each log level. """ logger = logging.getLogger('flixopt') - # Set logger to the most permissive level needed - effective_console_level = console_level if console_level else default_level - effective_file_level = file_level if file_level else default_level - - # Logger needs to be at the lowest level to allow handlers to filter - min_level = min( - getattr(logging, effective_console_level.upper()) if console else logging.CRITICAL, - getattr(logging, effective_file_level.upper()) if log_file else logging.CRITICAL, - ) - logger.setLevel(min_level) - logger.propagate = False # Prevent duplicate logs + logger.setLevel(getattr(logging, default_level.upper())) + logger.propagate = False logger.handlers.clear() if console: @@ -535,7 +506,7 @@ def _setup_logging( format=format, colors=colors, ) - handler.setLevel(getattr(logging, effective_console_level.upper())) + handler.setLevel(getattr(logging, console_level.upper())) logger.addHandler(handler) if log_file: @@ -546,10 +517,9 @@ def _setup_logging( date_format=date_format, format=format, ) - handler.setLevel(getattr(logging, effective_file_level.upper())) + handler.setLevel(getattr(logging, file_level.upper())) logger.addHandler(handler) - # Library best practice: NullHandler if no handlers configured if not logger.handlers: logger.addHandler(logging.NullHandler()) From ed0542bcb0db2d36e5aaec9f1f1e38aa5bc0c6b2 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:27:47 +0200 Subject: [PATCH 38/44] Use dedicated levels for both handlers --- flixopt/config.py | 116 +++++++++++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 32 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 7eefdced3..87f138f07 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -24,7 +24,6 @@ 'config_name': 'flixopt', 'logging': MappingProxyType( { - 'level': 'INFO', # Logger level (most permissive needed) 'file': 'flixopt.log', 'rich': False, 'console': True, @@ -78,12 +77,9 @@ class CONFIG: config_name (str): Name of the configuration (default: 'flixopt'). Logging Attributes: - level (str): Logger level - sets the minimum level the logger processes. - Should be set to the lowest level needed by any handler. - Default: 'INFO' file (str | None): Log file path. Default: 'flixopt.log'. Set to None to disable file logging. - console (bool): Enable console (stdout) logging. Default: True + console (bool): Enable console (stdout) logging. Default: False rich (bool): Use Rich library for enhanced console output. Default: False max_file_size (int): Maximum log file size in bytes before rotation. Default: 10485760 (10MB) @@ -123,6 +119,16 @@ class CONFIG: - '\\033[1m\\033[3Xm' - Bold color (replace X with color code 0-7) - '\\033[2m\\033[3Xm' - Dim color (replace X with color code 0-7) + Examples: + + - Magenta: '\\033[35m' + - Bold cyan: '\\033[1m\\033[36m' + - Dim green: '\\033[2m\\033[32m' + + Note: Use regular strings, not raw strings (r'...'). ANSI Codes start with a SINGLE BACKSLASH \ + Correct: '\\033[35m' + Incorrect: r'\\033[35m' + Modeling Attributes: big (int): Large number for optimization constraints. Default: 10000000 epsilon (float): Small tolerance value. Default: 1e-5 @@ -135,20 +141,29 @@ class CONFIG: from flixopt import CONFIG CONFIG.Logging.console = True - CONFIG.Logging.level = 'DEBUG' + CONFIG.Logging.Handlers.console_level = 'DEBUG' CONFIG.apply() Use different log levels for console and file:: CONFIG.Logging.Handlers.console_level = 'DEBUG' # Console shows DEBUG+ - CONFIG.Logging.Handlers.file_level = 'WARNING' # File only logs WARNING+ - CONFIG.Logging.level = 'DEBUG' # Logger must be at lowest level + CONFIG.Logging.Handlers.file_level = 'WARNING' # File only logs WARNING+ CONFIG.apply() Customize log colors:: - CONFIG.Logging.Colors.INFO = '\033[35m' # Magenta (no 'r' prefix!) - CONFIG.Logging.Colors.DEBUG = '\033[36m' # Cyan + CONFIG.Logging.Colors.INFO = '\\033[35m' # Magenta + CONFIG.Logging.Colors.DEBUG = '\\033[36m' # Cyan + CONFIG.Logging.Colors.ERROR = '\\033[1m\\033[31m' # Bold red + CONFIG.apply() + + Use Rich handler with custom colors:: + + CONFIG.Logging.console = True + CONFIG.Logging.rich = True + CONFIG.Logging.console_width = 100 + CONFIG.Logging.show_path = True + CONFIG.Logging.Colors.INFO = '\\033[36m' # Cyan CONFIG.apply() Load from YAML file:: @@ -160,31 +175,43 @@ class CONFIG: .. code-block:: yaml logging: - level: DEBUG console: true file: app.log rich: true + max_file_size: 5242880 # 5MB + backup_count: 3 + date_format: '%H:%M:%S' + console_width: 100 + show_path: true handlers: - console_level: INFO + console_level: DEBUG file_level: WARNING colors: - DEBUG: "\033[36m" - INFO: "\033[32m" - WARNING: "\033[33m" - ERROR: "\033[31m" - CRITICAL: "\033[1m\033[31m" + DEBUG: "\\033[36m" # Cyan + INFO: "\\033[32m" # Green + WARNING: "\\033[33m" # Yellow + ERROR: "\\033[31m" # Red + CRITICAL: "\\033[1m\\033[31m" # Bold red modeling: big: 20000000 epsilon: 1e-6 + big_binary_bound: 200000 Reset to defaults:: CONFIG.reset() + + Export current configuration:: + + config_dict = CONFIG.to_dict() + import yaml + + with open('my_config.yaml', 'w') as f: + yaml.dump(config_dict, f) """ class Logging: - level: str = _DEFAULTS['logging']['level'] file: str | None = _DEFAULTS['logging']['file'] rich: bool = _DEFAULTS['logging']['rich'] console: bool = _DEFAULTS['logging']['console'] @@ -202,7 +229,10 @@ class Handlers: file_level: str = _DEFAULTS['logging']['handlers']['file_level'] class Colors: - """ANSI color codes for each log level.""" + """ANSI color codes for each log level. + + These colors work with both Rich and standard console handlers. + """ DEBUG: str = _DEFAULTS['logging']['colors']['DEBUG'] INFO: str = _DEFAULTS['logging']['colors']['INFO'] @@ -240,7 +270,6 @@ def reset(cls): def apply(cls): """Apply current configuration to logging system.""" _setup_logging( - default_level=cls.Logging.level, console_level=cls.Logging.Handlers.console_level, file_level=cls.Logging.Handlers.file_level, log_file=cls.Logging.file, @@ -286,6 +315,17 @@ def _apply_config_dict(cls, config_dict: dict): elif nested_key == 'handlers' and isinstance(nested_value, dict): for handler_key, handler_value in nested_value.items(): setattr(cls.Logging.Handlers, handler_key, handler_value) + elif nested_key == 'level': + # DEPRECATED: Handle old 'level' attribute for backward compatibility + warnings.warn( + "The 'level' attribute in config files is deprecated and will be removed in version 3.0.0. " + "Use 'handlers.console_level' and 'handlers.file_level' instead.", + DeprecationWarning, + stacklevel=2, + ) + # Apply to both handlers for backward compatibility + cls.Logging.Handlers.console_level = nested_value + cls.Logging.Handlers.file_level = nested_value else: setattr(cls.Logging, nested_key, nested_value) elif key == 'modeling' and isinstance(value, dict): @@ -300,7 +340,6 @@ def to_dict(cls): return { 'config_name': cls.config_name, 'logging': { - 'level': cls.Logging.level, 'file': cls.Logging.file, 'rich': cls.Logging.rich, 'console': cls.Logging.console, @@ -461,7 +500,6 @@ def _create_file_handler( def _setup_logging( - default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', console_level: str = 'INFO', file_level: str = 'INFO', log_file: str | None = None, @@ -477,8 +515,11 @@ def _setup_logging( ): """Internal function to setup logging - use CONFIG.apply() instead. + Configures the flixopt logger with console and/or file handlers. + The logger level is automatically set to the minimum level needed by any handler. + If no handlers are configured, adds NullHandler (library best practice). + Args: - default_level: Logger level (should be lowest level needed by any handler). console_level: Logging level for console handler. file_level: Logging level for file handler. log_file: Path to log file (None to disable file logging). @@ -493,7 +534,16 @@ def _setup_logging( colors: ANSI color codes for each log level. """ logger = logging.getLogger('flixopt') - logger.setLevel(getattr(logging, default_level.upper())) + + # Calculate minimum logger level needed + levels = [] + if console: + levels.append(getattr(logging, console_level.upper())) + if log_file: + levels.append(getattr(logging, file_level.upper())) + + logger_level = min(levels) if levels else logging.CRITICAL + logger.setLevel(logger_level) logger.propagate = False logger.handlers.clear() @@ -520,6 +570,7 @@ def _setup_logging( handler.setLevel(getattr(logging, file_level.upper())) logger.addHandler(handler) + # Library best practice: NullHandler if no handlers configured if not logger.handlers: logger.addHandler(logging.NullHandler()) @@ -531,7 +582,7 @@ def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR' Change the logging level for the flixopt logger and all its handlers. .. deprecated:: 2.1.11 - Use ``CONFIG.Logging.level = level_name`` and ``CONFIG.apply()`` instead. + Use ``CONFIG.Logging.Handlers.console_level`` and ``CONFIG.Logging.Handlers.file_level`` instead. This function will be removed in version 3.0.0. Parameters @@ -543,20 +594,21 @@ def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR' -------- >>> change_logging_level('DEBUG') # deprecated >>> # Use this instead: - >>> CONFIG.Logging.level = 'DEBUG' + >>> CONFIG.Logging.Handlers.console_level = 'DEBUG' + >>> CONFIG.Logging.Handlers.file_level = 'DEBUG' >>> CONFIG.apply() """ warnings.warn( 'change_logging_level is deprecated and will be removed in version 3.0.0. ' - 'Use CONFIG.Logging.level = level_name and CONFIG.apply() instead.', + 'Use CONFIG.Logging.Handlers.console_level and CONFIG.Logging.Handlers.file_level instead.', DeprecationWarning, stacklevel=2, ) - logger = logging.getLogger('flixopt') - logging_level = getattr(logging, level_name.upper()) - logger.setLevel(logging_level) - for handler in logger.handlers: - handler.setLevel(logging_level) + + # Apply to both handlers for backward compatibility + CONFIG.Logging.Handlers.console_level = level_name + CONFIG.Logging.Handlers.file_level = level_name + CONFIG.apply() # Initialize default config From 05bbccb5d3d750e1b972799ae004d433bb798406 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:58:09 +0200 Subject: [PATCH 39/44] Switch back to not use Handlers --- flixopt/config.py | 148 ++++++++++++++-------------------------------- 1 file changed, 44 insertions(+), 104 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 87f138f07..775384392 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -33,12 +33,8 @@ 'format': '%(message)s', 'console_width': 120, 'show_path': False, - 'handlers': MappingProxyType( - { - 'console_level': 'INFO', - 'file_level': 'INFO', - } - ), + 'console_level': 'INFO', + 'file_level': 'INFO', 'colors': MappingProxyType( { 'DEBUG': '\033[32m', # Green @@ -71,7 +67,6 @@ class CONFIG: Attributes: Logging: Nested class containing all logging configuration options. - Handlers: Nested subclass for handler-specific levels Colors: Nested subclass for ANSI color codes Modeling: Nested class containing optimization modeling parameters. config_name (str): Name of the configuration (default: 'flixopt'). @@ -80,6 +75,10 @@ class CONFIG: file (str | None): Log file path. Default: 'flixopt.log'. Set to None to disable file logging. console (bool): Enable console (stdout) logging. Default: False + console_level (str): Logging level for console handler. + 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' + file_level (str): Logging level for file handler. + 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' rich (bool): Use Rich library for enhanced console output. Default: False max_file_size (int): Maximum log file size in bytes before rotation. Default: 10485760 (10MB) @@ -90,12 +89,6 @@ class CONFIG: console_width (int): Console width for Rich handler. Default: 120 show_path (bool): Show file paths in log messages. Default: False - Handlers Attributes: - console_level (str): Logging level for console handler. - 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' - file_level (str): Logging level for file handler. - 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' - Colors Attributes: DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[32m' (green) INFO (str): ANSI color code for INFO level. Default: '\\033[34m' (blue) @@ -141,13 +134,13 @@ class CONFIG: from flixopt import CONFIG CONFIG.Logging.console = True - CONFIG.Logging.Handlers.console_level = 'DEBUG' + CONFIG.Logging.console_level = 'DEBUG' CONFIG.apply() Use different log levels for console and file:: - CONFIG.Logging.Handlers.console_level = 'DEBUG' # Console shows DEBUG+ - CONFIG.Logging.Handlers.file_level = 'WARNING' # File only logs WARNING+ + CONFIG.Logging.console_level = 'DEBUG' # Console shows DEBUG+ + CONFIG.Logging.file_level = 'WARNING' # File only logs WARNING+ CONFIG.apply() Customize log colors:: @@ -221,12 +214,8 @@ class Logging: format: str = _DEFAULTS['logging']['format'] console_width: int = _DEFAULTS['logging']['console_width'] show_path: bool = _DEFAULTS['logging']['show_path'] - - class Handlers: - """Handler-specific logging levels.""" - - console_level: str = _DEFAULTS['logging']['handlers']['console_level'] - file_level: str = _DEFAULTS['logging']['handlers']['file_level'] + console_level: str = _DEFAULTS['logging']['console_level'] + file_level: str = _DEFAULTS['logging']['file_level'] class Colors: """ANSI color codes for each log level. @@ -254,9 +243,6 @@ def reset(cls): if key == 'colors': for color_key, color_value in value.items(): setattr(cls.Logging.Colors, color_key, color_value) - elif key == 'handlers': - for handler_key, handler_value in value.items(): - setattr(cls.Logging.Handlers, handler_key, handler_value) else: setattr(cls.Logging, key, value) @@ -268,10 +254,9 @@ def reset(cls): @classmethod def apply(cls): - """Apply current configuration to logging system.""" _setup_logging( - console_level=cls.Logging.Handlers.console_level, - file_level=cls.Logging.Handlers.file_level, + console_level=cls.Logging.console_level, + file_level=cls.Logging.file_level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich, console=cls.Logging.console, @@ -292,40 +277,33 @@ def apply(cls): @classmethod def load_from_file(cls, config_file: str | Path): - """Load configuration from YAML file and apply it.""" config_path = Path(config_file) if not config_path.exists(): raise FileNotFoundError(f'Config file not found: {config_file}') - with config_path.open() as file: config_dict = yaml.safe_load(file) cls._apply_config_dict(config_dict) - cls.apply() @classmethod def _apply_config_dict(cls, config_dict: dict): - """Apply configuration dictionary to class attributes.""" for key, value in config_dict.items(): if key == 'logging' and isinstance(value, dict): for nested_key, nested_value in value.items(): if nested_key == 'colors' and isinstance(nested_value, dict): for color_key, color_value in nested_value.items(): setattr(cls.Logging.Colors, color_key, color_value) - elif nested_key == 'handlers' and isinstance(nested_value, dict): - for handler_key, handler_value in nested_value.items(): - setattr(cls.Logging.Handlers, handler_key, handler_value) elif nested_key == 'level': # DEPRECATED: Handle old 'level' attribute for backward compatibility warnings.warn( "The 'level' attribute in config files is deprecated and will be removed in version 3.0.0. " - "Use 'handlers.console_level' and 'handlers.file_level' instead.", + "Use 'logging.console_level' and 'logging.file_level' instead.", DeprecationWarning, stacklevel=2, ) # Apply to both handlers for backward compatibility - cls.Logging.Handlers.console_level = nested_value - cls.Logging.Handlers.file_level = nested_value + cls.Logging.console_level = nested_value + cls.Logging.file_level = nested_value else: setattr(cls.Logging, nested_key, nested_value) elif key == 'modeling' and isinstance(value, dict): @@ -336,7 +314,6 @@ def _apply_config_dict(cls, config_dict: dict): @classmethod def to_dict(cls): - """Convert the configuration class into a dictionary for JSON serialization.""" return { 'config_name': cls.config_name, 'logging': { @@ -349,10 +326,8 @@ def to_dict(cls): 'format': cls.Logging.format, 'console_width': cls.Logging.console_width, 'show_path': cls.Logging.show_path, - 'handlers': { - 'console_level': cls.Logging.Handlers.console_level, - 'file_level': cls.Logging.Handlers.file_level, - }, + 'console_level': cls.Logging.console_level, + 'file_level': cls.Logging.file_level, 'colors': { 'DEBUG': cls.Logging.Colors.DEBUG, 'INFO': cls.Logging.Colors.INFO, @@ -369,51 +344,33 @@ def to_dict(cls): } -class MultilineFormater(logging.Formatter): - """Formatter that handles multi-line messages with consistent prefixes.""" - - def __init__(self, fmt=None, datefmt=None): - super().__init__(fmt=fmt, datefmt=datefmt) - +class MultilineFormatter(logging.Formatter): def format(self, record): message_lines = record.getMessage().split('\n') timestamp = self.formatTime(record, self.datefmt) log_level = record.levelname.ljust(8) - log_prefix = f'{timestamp} | {log_level} |' - - first_line = [f'{log_prefix} {message_lines[0]}'] - if len(message_lines) > 1: - lines = first_line + [f'{log_prefix} {line}' for line in message_lines[1:]] - else: - lines = first_line - + prefix = f'{timestamp} | {log_level} |' + lines = [f'{prefix} {line}' for line in message_lines] return '\n'.join(lines) -class ColoredMultilineFormater(MultilineFormater): - """Formatter that adds ANSI colors to multi-line log messages.""" - +class ColoredMultilineFormatter(MultilineFormatter): RESET = '\033[0m' def __init__(self, fmt=None, datefmt=None, colors=None): super().__init__(fmt=fmt, datefmt=datefmt) - self.COLORS = ( - colors - if colors is not None - else { - 'DEBUG': '\033[32m', - 'INFO': '\033[34m', - 'WARNING': '\033[33m', - 'ERROR': '\033[31m', - 'CRITICAL': '\033[1m\033[31m', - } - ) + self.COLORS = colors or { + 'DEBUG': '\033[32m', + 'INFO': '\033[34m', + 'WARNING': '\033[33m', + 'ERROR': '\033[31m', + 'CRITICAL': '\033[1m\033[31m', + } def format(self, record): lines = super().format(record).splitlines() - log_color = self.COLORS.get(record.levelname, self.RESET) - formatted_lines = [f'{log_color}{line}{self.RESET}' for line in lines] - return '\n'.join(formatted_lines) + color = self.COLORS.get(record.levelname, self.RESET) + return '\n'.join(f'{color}{line}{self.RESET}' for line in lines) def _create_console_handler( @@ -438,21 +395,15 @@ def _create_console_handler( Configured logging handler (RichHandler or StreamHandler). """ if use_rich: - # Convert ANSI codes to Rich theme + theme = None if colors: theme_dict = {} for level, ansi_code in colors.items(): - # Rich can parse ANSI codes directly! try: - style = Style.from_ansi(ansi_code) - theme_dict[f'logging.level.{level.lower()}'] = style + theme_dict[f'logging.level.{level.lower()}'] = Style.from_ansi(ansi_code) except Exception: - # Fallback to default if parsing fails pass - theme = Theme(theme_dict) if theme_dict else None - else: - theme = None console = Console(width=console_width, theme=theme) handler = RichHandler( @@ -465,8 +416,7 @@ def _create_console_handler( handler.setFormatter(logging.Formatter(format)) else: handler = logging.StreamHandler() - handler.setFormatter(ColoredMultilineFormater(fmt=format, datefmt=date_format, colors=colors)) - + handler.setFormatter(ColoredMultilineFormatter(fmt=format, datefmt=date_format, colors=colors)) return handler @@ -489,13 +439,8 @@ def _create_file_handler( Returns: Configured RotatingFileHandler (without colors). """ - handler = RotatingFileHandler( - log_file, - maxBytes=max_file_size, - backupCount=backup_count, - encoding='utf-8', - ) - handler.setFormatter(MultilineFormater(fmt=format, datefmt=date_format)) + handler = RotatingFileHandler(log_file, maxBytes=max_file_size, backupCount=backup_count, encoding='utf-8') + handler.setFormatter(MultilineFormatter(fmt=format, datefmt=date_format)) return handler @@ -534,16 +479,12 @@ def _setup_logging( colors: ANSI color codes for each log level. """ logger = logging.getLogger('flixopt') - - # Calculate minimum logger level needed levels = [] if console: levels.append(getattr(logging, console_level.upper())) if log_file: levels.append(getattr(logging, file_level.upper())) - - logger_level = min(levels) if levels else logging.CRITICAL - logger.setLevel(logger_level) + logger.setLevel(min(levels) if levels else logging.CRITICAL) logger.propagate = False logger.handlers.clear() @@ -573,7 +514,6 @@ def _setup_logging( # Library best practice: NullHandler if no handlers configured if not logger.handlers: logger.addHandler(logging.NullHandler()) - return logger @@ -582,7 +522,7 @@ def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR' Change the logging level for the flixopt logger and all its handlers. .. deprecated:: 2.1.11 - Use ``CONFIG.Logging.Handlers.console_level`` and ``CONFIG.Logging.Handlers.file_level`` instead. + Use ``CONFIG.Logging.console_level`` and ``CONFIG.Logging.file_level`` instead. This function will be removed in version 3.0.0. Parameters @@ -594,20 +534,20 @@ def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR' -------- >>> change_logging_level('DEBUG') # deprecated >>> # Use this instead: - >>> CONFIG.Logging.Handlers.console_level = 'DEBUG' - >>> CONFIG.Logging.Handlers.file_level = 'DEBUG' + >>> CONFIG.Logging.console_level = 'DEBUG' + >>> CONFIG.Logging.file_level = 'DEBUG' >>> CONFIG.apply() """ warnings.warn( 'change_logging_level is deprecated and will be removed in version 3.0.0. ' - 'Use CONFIG.Logging.Handlers.console_level and CONFIG.Logging.Handlers.file_level instead.', + 'Use CONFIG.Logging.console_level and CONFIG.Logging.file_level instead.', DeprecationWarning, stacklevel=2, ) - # Apply to both handlers for backward compatibility - CONFIG.Logging.Handlers.console_level = level_name - CONFIG.Logging.Handlers.file_level = level_name + # Apply to both for backward compatibility + CONFIG.Logging.console_level = level_name + CONFIG.Logging.file_level = level_name CONFIG.apply() From c09d20809e91148f0937479a9655b561f4afad57 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:14:09 +0200 Subject: [PATCH 40/44] Revert "Switch back to not use Handlers" This reverts commit 05bbccb5d3d750e1b972799ae004d433bb798406. --- flixopt/config.py | 148 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 104 insertions(+), 44 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 775384392..87f138f07 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -33,8 +33,12 @@ 'format': '%(message)s', 'console_width': 120, 'show_path': False, - 'console_level': 'INFO', - 'file_level': 'INFO', + 'handlers': MappingProxyType( + { + 'console_level': 'INFO', + 'file_level': 'INFO', + } + ), 'colors': MappingProxyType( { 'DEBUG': '\033[32m', # Green @@ -67,6 +71,7 @@ class CONFIG: Attributes: Logging: Nested class containing all logging configuration options. + Handlers: Nested subclass for handler-specific levels Colors: Nested subclass for ANSI color codes Modeling: Nested class containing optimization modeling parameters. config_name (str): Name of the configuration (default: 'flixopt'). @@ -75,10 +80,6 @@ class CONFIG: file (str | None): Log file path. Default: 'flixopt.log'. Set to None to disable file logging. console (bool): Enable console (stdout) logging. Default: False - console_level (str): Logging level for console handler. - 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' - file_level (str): Logging level for file handler. - 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' rich (bool): Use Rich library for enhanced console output. Default: False max_file_size (int): Maximum log file size in bytes before rotation. Default: 10485760 (10MB) @@ -89,6 +90,12 @@ class CONFIG: console_width (int): Console width for Rich handler. Default: 120 show_path (bool): Show file paths in log messages. Default: False + Handlers Attributes: + console_level (str): Logging level for console handler. + 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' + file_level (str): Logging level for file handler. + 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' + Colors Attributes: DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[32m' (green) INFO (str): ANSI color code for INFO level. Default: '\\033[34m' (blue) @@ -134,13 +141,13 @@ class CONFIG: from flixopt import CONFIG CONFIG.Logging.console = True - CONFIG.Logging.console_level = 'DEBUG' + CONFIG.Logging.Handlers.console_level = 'DEBUG' CONFIG.apply() Use different log levels for console and file:: - CONFIG.Logging.console_level = 'DEBUG' # Console shows DEBUG+ - CONFIG.Logging.file_level = 'WARNING' # File only logs WARNING+ + CONFIG.Logging.Handlers.console_level = 'DEBUG' # Console shows DEBUG+ + CONFIG.Logging.Handlers.file_level = 'WARNING' # File only logs WARNING+ CONFIG.apply() Customize log colors:: @@ -214,8 +221,12 @@ class Logging: format: str = _DEFAULTS['logging']['format'] console_width: int = _DEFAULTS['logging']['console_width'] show_path: bool = _DEFAULTS['logging']['show_path'] - console_level: str = _DEFAULTS['logging']['console_level'] - file_level: str = _DEFAULTS['logging']['file_level'] + + class Handlers: + """Handler-specific logging levels.""" + + console_level: str = _DEFAULTS['logging']['handlers']['console_level'] + file_level: str = _DEFAULTS['logging']['handlers']['file_level'] class Colors: """ANSI color codes for each log level. @@ -243,6 +254,9 @@ def reset(cls): if key == 'colors': for color_key, color_value in value.items(): setattr(cls.Logging.Colors, color_key, color_value) + elif key == 'handlers': + for handler_key, handler_value in value.items(): + setattr(cls.Logging.Handlers, handler_key, handler_value) else: setattr(cls.Logging, key, value) @@ -254,9 +268,10 @@ def reset(cls): @classmethod def apply(cls): + """Apply current configuration to logging system.""" _setup_logging( - console_level=cls.Logging.console_level, - file_level=cls.Logging.file_level, + console_level=cls.Logging.Handlers.console_level, + file_level=cls.Logging.Handlers.file_level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich, console=cls.Logging.console, @@ -277,33 +292,40 @@ def apply(cls): @classmethod def load_from_file(cls, config_file: str | Path): + """Load configuration from YAML file and apply it.""" config_path = Path(config_file) if not config_path.exists(): raise FileNotFoundError(f'Config file not found: {config_file}') + with config_path.open() as file: config_dict = yaml.safe_load(file) cls._apply_config_dict(config_dict) + cls.apply() @classmethod def _apply_config_dict(cls, config_dict: dict): + """Apply configuration dictionary to class attributes.""" for key, value in config_dict.items(): if key == 'logging' and isinstance(value, dict): for nested_key, nested_value in value.items(): if nested_key == 'colors' and isinstance(nested_value, dict): for color_key, color_value in nested_value.items(): setattr(cls.Logging.Colors, color_key, color_value) + elif nested_key == 'handlers' and isinstance(nested_value, dict): + for handler_key, handler_value in nested_value.items(): + setattr(cls.Logging.Handlers, handler_key, handler_value) elif nested_key == 'level': # DEPRECATED: Handle old 'level' attribute for backward compatibility warnings.warn( "The 'level' attribute in config files is deprecated and will be removed in version 3.0.0. " - "Use 'logging.console_level' and 'logging.file_level' instead.", + "Use 'handlers.console_level' and 'handlers.file_level' instead.", DeprecationWarning, stacklevel=2, ) # Apply to both handlers for backward compatibility - cls.Logging.console_level = nested_value - cls.Logging.file_level = nested_value + cls.Logging.Handlers.console_level = nested_value + cls.Logging.Handlers.file_level = nested_value else: setattr(cls.Logging, nested_key, nested_value) elif key == 'modeling' and isinstance(value, dict): @@ -314,6 +336,7 @@ def _apply_config_dict(cls, config_dict: dict): @classmethod def to_dict(cls): + """Convert the configuration class into a dictionary for JSON serialization.""" return { 'config_name': cls.config_name, 'logging': { @@ -326,8 +349,10 @@ def to_dict(cls): 'format': cls.Logging.format, 'console_width': cls.Logging.console_width, 'show_path': cls.Logging.show_path, - 'console_level': cls.Logging.console_level, - 'file_level': cls.Logging.file_level, + 'handlers': { + 'console_level': cls.Logging.Handlers.console_level, + 'file_level': cls.Logging.Handlers.file_level, + }, 'colors': { 'DEBUG': cls.Logging.Colors.DEBUG, 'INFO': cls.Logging.Colors.INFO, @@ -344,33 +369,51 @@ def to_dict(cls): } -class MultilineFormatter(logging.Formatter): +class MultilineFormater(logging.Formatter): + """Formatter that handles multi-line messages with consistent prefixes.""" + + def __init__(self, fmt=None, datefmt=None): + super().__init__(fmt=fmt, datefmt=datefmt) + def format(self, record): message_lines = record.getMessage().split('\n') timestamp = self.formatTime(record, self.datefmt) log_level = record.levelname.ljust(8) - prefix = f'{timestamp} | {log_level} |' - lines = [f'{prefix} {line}' for line in message_lines] + log_prefix = f'{timestamp} | {log_level} |' + + first_line = [f'{log_prefix} {message_lines[0]}'] + if len(message_lines) > 1: + lines = first_line + [f'{log_prefix} {line}' for line in message_lines[1:]] + else: + lines = first_line + return '\n'.join(lines) -class ColoredMultilineFormatter(MultilineFormatter): +class ColoredMultilineFormater(MultilineFormater): + """Formatter that adds ANSI colors to multi-line log messages.""" + RESET = '\033[0m' def __init__(self, fmt=None, datefmt=None, colors=None): super().__init__(fmt=fmt, datefmt=datefmt) - self.COLORS = colors or { - 'DEBUG': '\033[32m', - 'INFO': '\033[34m', - 'WARNING': '\033[33m', - 'ERROR': '\033[31m', - 'CRITICAL': '\033[1m\033[31m', - } + self.COLORS = ( + colors + if colors is not None + else { + 'DEBUG': '\033[32m', + 'INFO': '\033[34m', + 'WARNING': '\033[33m', + 'ERROR': '\033[31m', + 'CRITICAL': '\033[1m\033[31m', + } + ) def format(self, record): lines = super().format(record).splitlines() - color = self.COLORS.get(record.levelname, self.RESET) - return '\n'.join(f'{color}{line}{self.RESET}' for line in lines) + log_color = self.COLORS.get(record.levelname, self.RESET) + formatted_lines = [f'{log_color}{line}{self.RESET}' for line in lines] + return '\n'.join(formatted_lines) def _create_console_handler( @@ -395,15 +438,21 @@ def _create_console_handler( Configured logging handler (RichHandler or StreamHandler). """ if use_rich: - theme = None + # Convert ANSI codes to Rich theme if colors: theme_dict = {} for level, ansi_code in colors.items(): + # Rich can parse ANSI codes directly! try: - theme_dict[f'logging.level.{level.lower()}'] = Style.from_ansi(ansi_code) + style = Style.from_ansi(ansi_code) + theme_dict[f'logging.level.{level.lower()}'] = style except Exception: + # Fallback to default if parsing fails pass + theme = Theme(theme_dict) if theme_dict else None + else: + theme = None console = Console(width=console_width, theme=theme) handler = RichHandler( @@ -416,7 +465,8 @@ def _create_console_handler( handler.setFormatter(logging.Formatter(format)) else: handler = logging.StreamHandler() - handler.setFormatter(ColoredMultilineFormatter(fmt=format, datefmt=date_format, colors=colors)) + handler.setFormatter(ColoredMultilineFormater(fmt=format, datefmt=date_format, colors=colors)) + return handler @@ -439,8 +489,13 @@ def _create_file_handler( Returns: Configured RotatingFileHandler (without colors). """ - handler = RotatingFileHandler(log_file, maxBytes=max_file_size, backupCount=backup_count, encoding='utf-8') - handler.setFormatter(MultilineFormatter(fmt=format, datefmt=date_format)) + handler = RotatingFileHandler( + log_file, + maxBytes=max_file_size, + backupCount=backup_count, + encoding='utf-8', + ) + handler.setFormatter(MultilineFormater(fmt=format, datefmt=date_format)) return handler @@ -479,12 +534,16 @@ def _setup_logging( colors: ANSI color codes for each log level. """ logger = logging.getLogger('flixopt') + + # Calculate minimum logger level needed levels = [] if console: levels.append(getattr(logging, console_level.upper())) if log_file: levels.append(getattr(logging, file_level.upper())) - logger.setLevel(min(levels) if levels else logging.CRITICAL) + + logger_level = min(levels) if levels else logging.CRITICAL + logger.setLevel(logger_level) logger.propagate = False logger.handlers.clear() @@ -514,6 +573,7 @@ def _setup_logging( # Library best practice: NullHandler if no handlers configured if not logger.handlers: logger.addHandler(logging.NullHandler()) + return logger @@ -522,7 +582,7 @@ def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR' Change the logging level for the flixopt logger and all its handlers. .. deprecated:: 2.1.11 - Use ``CONFIG.Logging.console_level`` and ``CONFIG.Logging.file_level`` instead. + Use ``CONFIG.Logging.Handlers.console_level`` and ``CONFIG.Logging.Handlers.file_level`` instead. This function will be removed in version 3.0.0. Parameters @@ -534,20 +594,20 @@ def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR' -------- >>> change_logging_level('DEBUG') # deprecated >>> # Use this instead: - >>> CONFIG.Logging.console_level = 'DEBUG' - >>> CONFIG.Logging.file_level = 'DEBUG' + >>> CONFIG.Logging.Handlers.console_level = 'DEBUG' + >>> CONFIG.Logging.Handlers.file_level = 'DEBUG' >>> CONFIG.apply() """ warnings.warn( 'change_logging_level is deprecated and will be removed in version 3.0.0. ' - 'Use CONFIG.Logging.console_level and CONFIG.Logging.file_level instead.', + 'Use CONFIG.Logging.Handlers.console_level and CONFIG.Logging.Handlers.file_level instead.', DeprecationWarning, stacklevel=2, ) - # Apply to both for backward compatibility - CONFIG.Logging.console_level = level_name - CONFIG.Logging.file_level = level_name + # Apply to both handlers for backward compatibility + CONFIG.Logging.Handlers.console_level = level_name + CONFIG.Logging.Handlers.file_level = level_name CONFIG.apply() From 78ce6ba53032ca2ddd712ef7691480589e55e93a Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:14:09 +0200 Subject: [PATCH 41/44] Revert "Use dedicated levels for both handlers" This reverts commit ed0542bcb0db2d36e5aaec9f1f1e38aa5bc0c6b2. --- flixopt/config.py | 116 +++++++++++++--------------------------------- 1 file changed, 32 insertions(+), 84 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 87f138f07..7eefdced3 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -24,6 +24,7 @@ 'config_name': 'flixopt', 'logging': MappingProxyType( { + 'level': 'INFO', # Logger level (most permissive needed) 'file': 'flixopt.log', 'rich': False, 'console': True, @@ -77,9 +78,12 @@ class CONFIG: config_name (str): Name of the configuration (default: 'flixopt'). Logging Attributes: + level (str): Logger level - sets the minimum level the logger processes. + Should be set to the lowest level needed by any handler. + Default: 'INFO' file (str | None): Log file path. Default: 'flixopt.log'. Set to None to disable file logging. - console (bool): Enable console (stdout) logging. Default: False + console (bool): Enable console (stdout) logging. Default: True rich (bool): Use Rich library for enhanced console output. Default: False max_file_size (int): Maximum log file size in bytes before rotation. Default: 10485760 (10MB) @@ -119,16 +123,6 @@ class CONFIG: - '\\033[1m\\033[3Xm' - Bold color (replace X with color code 0-7) - '\\033[2m\\033[3Xm' - Dim color (replace X with color code 0-7) - Examples: - - - Magenta: '\\033[35m' - - Bold cyan: '\\033[1m\\033[36m' - - Dim green: '\\033[2m\\033[32m' - - Note: Use regular strings, not raw strings (r'...'). ANSI Codes start with a SINGLE BACKSLASH \ - Correct: '\\033[35m' - Incorrect: r'\\033[35m' - Modeling Attributes: big (int): Large number for optimization constraints. Default: 10000000 epsilon (float): Small tolerance value. Default: 1e-5 @@ -141,29 +135,20 @@ class CONFIG: from flixopt import CONFIG CONFIG.Logging.console = True - CONFIG.Logging.Handlers.console_level = 'DEBUG' + CONFIG.Logging.level = 'DEBUG' CONFIG.apply() Use different log levels for console and file:: CONFIG.Logging.Handlers.console_level = 'DEBUG' # Console shows DEBUG+ - CONFIG.Logging.Handlers.file_level = 'WARNING' # File only logs WARNING+ + CONFIG.Logging.Handlers.file_level = 'WARNING' # File only logs WARNING+ + CONFIG.Logging.level = 'DEBUG' # Logger must be at lowest level CONFIG.apply() Customize log colors:: - CONFIG.Logging.Colors.INFO = '\\033[35m' # Magenta - CONFIG.Logging.Colors.DEBUG = '\\033[36m' # Cyan - CONFIG.Logging.Colors.ERROR = '\\033[1m\\033[31m' # Bold red - CONFIG.apply() - - Use Rich handler with custom colors:: - - CONFIG.Logging.console = True - CONFIG.Logging.rich = True - CONFIG.Logging.console_width = 100 - CONFIG.Logging.show_path = True - CONFIG.Logging.Colors.INFO = '\\033[36m' # Cyan + CONFIG.Logging.Colors.INFO = '\033[35m' # Magenta (no 'r' prefix!) + CONFIG.Logging.Colors.DEBUG = '\033[36m' # Cyan CONFIG.apply() Load from YAML file:: @@ -175,43 +160,31 @@ class CONFIG: .. code-block:: yaml logging: + level: DEBUG console: true file: app.log rich: true - max_file_size: 5242880 # 5MB - backup_count: 3 - date_format: '%H:%M:%S' - console_width: 100 - show_path: true handlers: - console_level: DEBUG + console_level: INFO file_level: WARNING colors: - DEBUG: "\\033[36m" # Cyan - INFO: "\\033[32m" # Green - WARNING: "\\033[33m" # Yellow - ERROR: "\\033[31m" # Red - CRITICAL: "\\033[1m\\033[31m" # Bold red + DEBUG: "\033[36m" + INFO: "\033[32m" + WARNING: "\033[33m" + ERROR: "\033[31m" + CRITICAL: "\033[1m\033[31m" modeling: big: 20000000 epsilon: 1e-6 - big_binary_bound: 200000 Reset to defaults:: CONFIG.reset() - - Export current configuration:: - - config_dict = CONFIG.to_dict() - import yaml - - with open('my_config.yaml', 'w') as f: - yaml.dump(config_dict, f) """ class Logging: + level: str = _DEFAULTS['logging']['level'] file: str | None = _DEFAULTS['logging']['file'] rich: bool = _DEFAULTS['logging']['rich'] console: bool = _DEFAULTS['logging']['console'] @@ -229,10 +202,7 @@ class Handlers: file_level: str = _DEFAULTS['logging']['handlers']['file_level'] class Colors: - """ANSI color codes for each log level. - - These colors work with both Rich and standard console handlers. - """ + """ANSI color codes for each log level.""" DEBUG: str = _DEFAULTS['logging']['colors']['DEBUG'] INFO: str = _DEFAULTS['logging']['colors']['INFO'] @@ -270,6 +240,7 @@ def reset(cls): def apply(cls): """Apply current configuration to logging system.""" _setup_logging( + default_level=cls.Logging.level, console_level=cls.Logging.Handlers.console_level, file_level=cls.Logging.Handlers.file_level, log_file=cls.Logging.file, @@ -315,17 +286,6 @@ def _apply_config_dict(cls, config_dict: dict): elif nested_key == 'handlers' and isinstance(nested_value, dict): for handler_key, handler_value in nested_value.items(): setattr(cls.Logging.Handlers, handler_key, handler_value) - elif nested_key == 'level': - # DEPRECATED: Handle old 'level' attribute for backward compatibility - warnings.warn( - "The 'level' attribute in config files is deprecated and will be removed in version 3.0.0. " - "Use 'handlers.console_level' and 'handlers.file_level' instead.", - DeprecationWarning, - stacklevel=2, - ) - # Apply to both handlers for backward compatibility - cls.Logging.Handlers.console_level = nested_value - cls.Logging.Handlers.file_level = nested_value else: setattr(cls.Logging, nested_key, nested_value) elif key == 'modeling' and isinstance(value, dict): @@ -340,6 +300,7 @@ def to_dict(cls): return { 'config_name': cls.config_name, 'logging': { + 'level': cls.Logging.level, 'file': cls.Logging.file, 'rich': cls.Logging.rich, 'console': cls.Logging.console, @@ -500,6 +461,7 @@ def _create_file_handler( def _setup_logging( + default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', console_level: str = 'INFO', file_level: str = 'INFO', log_file: str | None = None, @@ -515,11 +477,8 @@ def _setup_logging( ): """Internal function to setup logging - use CONFIG.apply() instead. - Configures the flixopt logger with console and/or file handlers. - The logger level is automatically set to the minimum level needed by any handler. - If no handlers are configured, adds NullHandler (library best practice). - Args: + default_level: Logger level (should be lowest level needed by any handler). console_level: Logging level for console handler. file_level: Logging level for file handler. log_file: Path to log file (None to disable file logging). @@ -534,16 +493,7 @@ def _setup_logging( colors: ANSI color codes for each log level. """ logger = logging.getLogger('flixopt') - - # Calculate minimum logger level needed - levels = [] - if console: - levels.append(getattr(logging, console_level.upper())) - if log_file: - levels.append(getattr(logging, file_level.upper())) - - logger_level = min(levels) if levels else logging.CRITICAL - logger.setLevel(logger_level) + logger.setLevel(getattr(logging, default_level.upper())) logger.propagate = False logger.handlers.clear() @@ -570,7 +520,6 @@ def _setup_logging( handler.setLevel(getattr(logging, file_level.upper())) logger.addHandler(handler) - # Library best practice: NullHandler if no handlers configured if not logger.handlers: logger.addHandler(logging.NullHandler()) @@ -582,7 +531,7 @@ def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR' Change the logging level for the flixopt logger and all its handlers. .. deprecated:: 2.1.11 - Use ``CONFIG.Logging.Handlers.console_level`` and ``CONFIG.Logging.Handlers.file_level`` instead. + Use ``CONFIG.Logging.level = level_name`` and ``CONFIG.apply()`` instead. This function will be removed in version 3.0.0. Parameters @@ -594,21 +543,20 @@ def change_logging_level(level_name: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR' -------- >>> change_logging_level('DEBUG') # deprecated >>> # Use this instead: - >>> CONFIG.Logging.Handlers.console_level = 'DEBUG' - >>> CONFIG.Logging.Handlers.file_level = 'DEBUG' + >>> CONFIG.Logging.level = 'DEBUG' >>> CONFIG.apply() """ warnings.warn( 'change_logging_level is deprecated and will be removed in version 3.0.0. ' - 'Use CONFIG.Logging.Handlers.console_level and CONFIG.Logging.Handlers.file_level instead.', + 'Use CONFIG.Logging.level = level_name and CONFIG.apply() instead.', DeprecationWarning, stacklevel=2, ) - - # Apply to both handlers for backward compatibility - CONFIG.Logging.Handlers.console_level = level_name - CONFIG.Logging.Handlers.file_level = level_name - CONFIG.apply() + logger = logging.getLogger('flixopt') + logging_level = getattr(logging, level_name.upper()) + logger.setLevel(logging_level) + for handler in logger.handlers: + handler.setLevel(logging_level) # Initialize default config From 00e93d731c9d9cb788f29bdad8069745907c133e Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:14:09 +0200 Subject: [PATCH 42/44] Revert "Add extra Handler section" This reverts commit a133cc87c3567f0d4e40b43f60943afe7f6b9aaa. --- flixopt/config.py | 166 +++++++++++++++++++++++++++------------------- 1 file changed, 98 insertions(+), 68 deletions(-) diff --git a/flixopt/config.py b/flixopt/config.py index 7eefdced3..2676c0fc6 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -24,7 +24,9 @@ 'config_name': 'flixopt', 'logging': MappingProxyType( { - 'level': 'INFO', # Logger level (most permissive needed) + 'level': 'INFO', + 'console_level': None, # If None, uses 'level' + 'file_level': None, # If None, uses 'level' 'file': 'flixopt.log', 'rich': False, 'console': True, @@ -34,12 +36,6 @@ 'format': '%(message)s', 'console_width': 120, 'show_path': False, - 'handlers': MappingProxyType( - { - 'console_level': 'INFO', - 'file_level': 'INFO', - } - ), 'colors': MappingProxyType( { 'DEBUG': '\033[32m', # Green @@ -68,19 +64,21 @@ class CONFIG: The CONFIG class provides centralized configuration for logging and modeling parameters. All changes require calling ``CONFIG.apply()`` to take effect. - By default, logging outputs to console at INFO level and to file at INFO level. + By default, logging outputs to both console and file ('flixopt.log'). Attributes: Logging: Nested class containing all logging configuration options. - Handlers: Nested subclass for handler-specific levels - Colors: Nested subclass for ANSI color codes + Colors: Nested subclass under Logging containing ANSI color codes for log levels. Modeling: Nested class containing optimization modeling parameters. config_name (str): Name of the configuration (default: 'flixopt'). Logging Attributes: - level (str): Logger level - sets the minimum level the logger processes. - Should be set to the lowest level needed by any handler. - Default: 'INFO' + level (str): Default logging level for both console and file: 'DEBUG', 'INFO', + 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' + console_level (str | None): Override logging level for console output. + If None, uses 'level'. Default: None + file_level (str | None): Override logging level for file output. + If None, uses 'level'. Default: None file (str | None): Log file path. Default: 'flixopt.log'. Set to None to disable file logging. console (bool): Enable console (stdout) logging. Default: True @@ -94,12 +92,6 @@ class CONFIG: console_width (int): Console width for Rich handler. Default: 120 show_path (bool): Show file paths in log messages. Default: False - Handlers Attributes: - console_level (str): Logging level for console handler. - 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' - file_level (str): Logging level for file handler. - 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' - Colors Attributes: DEBUG (str): ANSI color code for DEBUG level. Default: '\\033[32m' (green) INFO (str): ANSI color code for INFO level. Default: '\\033[34m' (blue) @@ -123,6 +115,12 @@ class CONFIG: - '\\033[1m\\033[3Xm' - Bold color (replace X with color code 0-7) - '\\033[2m\\033[3Xm' - Dim color (replace X with color code 0-7) + Examples: + + - Magenta: '\\033[35m' + - Bold cyan: '\\033[1m\\033[36m' + - Dim green: '\\033[2m\\033[32m' + Modeling Attributes: big (int): Large number for optimization constraints. Default: 10000000 epsilon (float): Small tolerance value. Default: 1e-5 @@ -138,17 +136,34 @@ class CONFIG: CONFIG.Logging.level = 'DEBUG' CONFIG.apply() + Configure log file rotation:: + + CONFIG.Logging.file = 'myapp.log' + CONFIG.Logging.max_file_size = 5_242_880 # 5 MB + CONFIG.Logging.backup_count = 3 + CONFIG.apply() + Use different log levels for console and file:: - CONFIG.Logging.Handlers.console_level = 'DEBUG' # Console shows DEBUG+ - CONFIG.Logging.Handlers.file_level = 'WARNING' # File only logs WARNING+ - CONFIG.Logging.level = 'DEBUG' # Logger must be at lowest level + CONFIG.Logging.level = 'INFO' # Default level + CONFIG.Logging.console_level = 'DEBUG' # Console shows DEBUG+ + CONFIG.Logging.file_level = 'WARNING' # File only logs WARNING+ CONFIG.apply() Customize log colors:: - CONFIG.Logging.Colors.INFO = '\033[35m' # Magenta (no 'r' prefix!) - CONFIG.Logging.Colors.DEBUG = '\033[36m' # Cyan + CONFIG.Logging.Colors.INFO = '\\033[35m' # Magenta + CONFIG.Logging.Colors.DEBUG = '\\033[36m' # Cyan + CONFIG.Logging.Colors.ERROR = '\\033[1m\\033[31m' # Bold red + CONFIG.apply() + + Use Rich handler with custom colors:: + + CONFIG.Logging.console = True + CONFIG.Logging.rich = True + CONFIG.Logging.console_width = 100 + CONFIG.Logging.show_path = True + CONFIG.Logging.Colors.INFO = '\\033[36m' # Cyan CONFIG.apply() Load from YAML file:: @@ -161,30 +176,45 @@ class CONFIG: logging: level: DEBUG + console_level: INFO # Override console level + file_level: WARNING # Override file level console: true file: app.log rich: true - handlers: - console_level: INFO - file_level: WARNING + max_file_size: 5242880 # 5MB + backup_count: 3 + date_format: '%H:%M:%S' + console_width: 100 + show_path: true colors: - DEBUG: "\033[36m" - INFO: "\033[32m" - WARNING: "\033[33m" - ERROR: "\033[31m" - CRITICAL: "\033[1m\033[31m" + DEBUG: "\\033[36m" # Cyan + INFO: "\\033[32m" # Green + WARNING: "\\033[33m" # Yellow + ERROR: "\\033[31m" # Red + CRITICAL: "\\033[1m\\033[31m" # Bold red modeling: big: 20000000 epsilon: 1e-6 + big_binary_bound: 200000 Reset to defaults:: CONFIG.reset() + + Export current configuration:: + + config_dict = CONFIG.to_dict() + import yaml + + with open('my_config.yaml', 'w') as f: + yaml.dump(config_dict, f) """ class Logging: level: str = _DEFAULTS['logging']['level'] + console_level: str | None = _DEFAULTS['logging']['console_level'] + file_level: str | None = _DEFAULTS['logging']['file_level'] file: str | None = _DEFAULTS['logging']['file'] rich: bool = _DEFAULTS['logging']['rich'] console: bool = _DEFAULTS['logging']['console'] @@ -195,15 +225,7 @@ class Logging: console_width: int = _DEFAULTS['logging']['console_width'] show_path: bool = _DEFAULTS['logging']['show_path'] - class Handlers: - """Handler-specific logging levels.""" - - console_level: str = _DEFAULTS['logging']['handlers']['console_level'] - file_level: str = _DEFAULTS['logging']['handlers']['file_level'] - class Colors: - """ANSI color codes for each log level.""" - DEBUG: str = _DEFAULTS['logging']['colors']['DEBUG'] INFO: str = _DEFAULTS['logging']['colors']['INFO'] WARNING: str = _DEFAULTS['logging']['colors']['WARNING'] @@ -222,11 +244,9 @@ def reset(cls): """Reset all configuration values to defaults.""" for key, value in _DEFAULTS['logging'].items(): if key == 'colors': + # Reset nested Colors class for color_key, color_value in value.items(): setattr(cls.Logging.Colors, color_key, color_value) - elif key == 'handlers': - for handler_key, handler_value in value.items(): - setattr(cls.Logging.Handlers, handler_key, handler_value) else: setattr(cls.Logging, key, value) @@ -239,10 +259,19 @@ def reset(cls): @classmethod def apply(cls): """Apply current configuration to logging system.""" + # Convert Colors class attributes to dict + colors_dict = { + 'DEBUG': cls.Logging.Colors.DEBUG, + 'INFO': cls.Logging.Colors.INFO, + 'WARNING': cls.Logging.Colors.WARNING, + 'ERROR': cls.Logging.Colors.ERROR, + 'CRITICAL': cls.Logging.Colors.CRITICAL, + } + _setup_logging( default_level=cls.Logging.level, - console_level=cls.Logging.Handlers.console_level, - file_level=cls.Logging.Handlers.file_level, + console_level=cls.Logging.console_level, + file_level=cls.Logging.file_level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich, console=cls.Logging.console, @@ -252,13 +281,7 @@ def apply(cls): format=cls.Logging.format, console_width=cls.Logging.console_width, show_path=cls.Logging.show_path, - colors={ - 'DEBUG': cls.Logging.Colors.DEBUG, - 'INFO': cls.Logging.Colors.INFO, - 'WARNING': cls.Logging.Colors.WARNING, - 'ERROR': cls.Logging.Colors.ERROR, - 'CRITICAL': cls.Logging.Colors.CRITICAL, - }, + colors=colors_dict, ) @classmethod @@ -281,11 +304,9 @@ def _apply_config_dict(cls, config_dict: dict): if key == 'logging' and isinstance(value, dict): for nested_key, nested_value in value.items(): if nested_key == 'colors' and isinstance(nested_value, dict): + # Handle nested colors under logging for color_key, color_value in nested_value.items(): setattr(cls.Logging.Colors, color_key, color_value) - elif nested_key == 'handlers' and isinstance(nested_value, dict): - for handler_key, handler_value in nested_value.items(): - setattr(cls.Logging.Handlers, handler_key, handler_value) else: setattr(cls.Logging, nested_key, nested_value) elif key == 'modeling' and isinstance(value, dict): @@ -310,10 +331,6 @@ def to_dict(cls): 'format': cls.Logging.format, 'console_width': cls.Logging.console_width, 'show_path': cls.Logging.show_path, - 'handlers': { - 'console_level': cls.Logging.Handlers.console_level, - 'file_level': cls.Logging.Handlers.file_level, - }, 'colors': { 'DEBUG': cls.Logging.Colors.DEBUG, 'INFO': cls.Logging.Colors.INFO, @@ -462,8 +479,8 @@ def _create_file_handler( def _setup_logging( default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', - console_level: str = 'INFO', - file_level: str = 'INFO', + console_level: str | None = None, + file_level: str | None = None, log_file: str | None = None, use_rich_handler: bool = False, console: bool = False, @@ -477,10 +494,13 @@ def _setup_logging( ): """Internal function to setup logging - use CONFIG.apply() instead. + Configures the flixopt logger with console and/or file handlers. + If no handlers are configured, adds NullHandler (library best practice). + Args: - default_level: Logger level (should be lowest level needed by any handler). - console_level: Logging level for console handler. - file_level: Logging level for file handler. + default_level: Logging level for the logger (used if console_level/file_level not set). + console_level: Override logging level for console handler (None uses default_level). + file_level: Override logging level for file handler (None uses default_level). log_file: Path to log file (None to disable file logging). use_rich_handler: Use Rich for enhanced console output. console: Enable console logging. @@ -493,8 +513,17 @@ def _setup_logging( colors: ANSI color codes for each log level. """ logger = logging.getLogger('flixopt') - logger.setLevel(getattr(logging, default_level.upper())) - logger.propagate = False + # Set logger to the most permissive level needed + effective_console_level = console_level if console_level else default_level + effective_file_level = file_level if file_level else default_level + + # Logger needs to be at the lowest level to allow handlers to filter + min_level = min( + getattr(logging, effective_console_level.upper()) if console else logging.CRITICAL, + getattr(logging, effective_file_level.upper()) if log_file else logging.CRITICAL, + ) + logger.setLevel(min_level) + logger.propagate = False # Prevent duplicate logs logger.handlers.clear() if console: @@ -506,7 +535,7 @@ def _setup_logging( format=format, colors=colors, ) - handler.setLevel(getattr(logging, console_level.upper())) + handler.setLevel(getattr(logging, effective_console_level.upper())) logger.addHandler(handler) if log_file: @@ -517,9 +546,10 @@ def _setup_logging( date_format=date_format, format=format, ) - handler.setLevel(getattr(logging, file_level.upper())) + handler.setLevel(getattr(logging, effective_file_level.upper())) logger.addHandler(handler) + # Library best practice: NullHandler if no handlers configured if not logger.handlers: logger.addHandler(logging.NullHandler()) From 516da6de84605ea3689113d4063999a5d920966c Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:14:09 +0200 Subject: [PATCH 43/44] Revert "Add individual level parameters for console and file" This reverts commit 19f81c9e05065de7dcf74bf4750f653d51a2ecbe. --- CHANGELOG.md | 1 - flixopt/config.py | 74 ++++++++++------------------------- tests/test_config.py | 91 -------------------------------------------- 3 files changed, 21 insertions(+), 145 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98176b571..7953bca48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,6 @@ Please keep the format of the changelog consistent with the other releases, so t - Added configurable log format settings: `CONFIG.Logging.date_format` and `CONFIG.Logging.format` - Added configurable console settings: `CONFIG.Logging.console_width` and `CONFIG.Logging.show_path` - Added `CONFIG.Logging.Colors` nested class for customizable log level colors using ANSI escape codes (works with both standard and Rich handlers) -- Added individual log level control: `CONFIG.Logging.console_level` and `CONFIG.Logging.file_level` to set different logging levels for console and file handlers independently ### 💥 Breaking Changes diff --git a/flixopt/config.py b/flixopt/config.py index 2676c0fc6..2ec5bf88c 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -25,8 +25,6 @@ 'logging': MappingProxyType( { 'level': 'INFO', - 'console_level': None, # If None, uses 'level' - 'file_level': None, # If None, uses 'level' 'file': 'flixopt.log', 'rich': False, 'console': True, @@ -73,12 +71,8 @@ class CONFIG: config_name (str): Name of the configuration (default: 'flixopt'). Logging Attributes: - level (str): Default logging level for both console and file: 'DEBUG', 'INFO', - 'WARNING', 'ERROR', 'CRITICAL'. Default: 'INFO' - console_level (str | None): Override logging level for console output. - If None, uses 'level'. Default: None - file_level (str | None): Override logging level for file output. - If None, uses 'level'. Default: None + level (str): Logging level: 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'. + Default: 'INFO' file (str | None): Log file path. Default: 'flixopt.log'. Set to None to disable file logging. console (bool): Enable console (stdout) logging. Default: True @@ -143,13 +137,6 @@ class CONFIG: CONFIG.Logging.backup_count = 3 CONFIG.apply() - Use different log levels for console and file:: - - CONFIG.Logging.level = 'INFO' # Default level - CONFIG.Logging.console_level = 'DEBUG' # Console shows DEBUG+ - CONFIG.Logging.file_level = 'WARNING' # File only logs WARNING+ - CONFIG.apply() - Customize log colors:: CONFIG.Logging.Colors.INFO = '\\033[35m' # Magenta @@ -176,8 +163,6 @@ class CONFIG: logging: level: DEBUG - console_level: INFO # Override console level - file_level: WARNING # Override file level console: true file: app.log rich: true @@ -213,8 +198,6 @@ class CONFIG: class Logging: level: str = _DEFAULTS['logging']['level'] - console_level: str | None = _DEFAULTS['logging']['console_level'] - file_level: str | None = _DEFAULTS['logging']['file_level'] file: str | None = _DEFAULTS['logging']['file'] rich: bool = _DEFAULTS['logging']['rich'] console: bool = _DEFAULTS['logging']['console'] @@ -270,8 +253,6 @@ def apply(cls): _setup_logging( default_level=cls.Logging.level, - console_level=cls.Logging.console_level, - file_level=cls.Logging.file_level, log_file=cls.Logging.file, use_rich_handler=cls.Logging.rich, console=cls.Logging.console, @@ -479,8 +460,6 @@ def _create_file_handler( def _setup_logging( default_level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'] = 'INFO', - console_level: str | None = None, - file_level: str | None = None, log_file: str | None = None, use_rich_handler: bool = False, console: bool = False, @@ -498,9 +477,7 @@ def _setup_logging( If no handlers are configured, adds NullHandler (library best practice). Args: - default_level: Logging level for the logger (used if console_level/file_level not set). - console_level: Override logging level for console handler (None uses default_level). - file_level: Override logging level for file handler (None uses default_level). + default_level: Logging level for the logger. log_file: Path to log file (None to disable file logging). use_rich_handler: Use Rich for enhanced console output. console: Enable console logging. @@ -513,41 +490,32 @@ def _setup_logging( colors: ANSI color codes for each log level. """ logger = logging.getLogger('flixopt') - # Set logger to the most permissive level needed - effective_console_level = console_level if console_level else default_level - effective_file_level = file_level if file_level else default_level - - # Logger needs to be at the lowest level to allow handlers to filter - min_level = min( - getattr(logging, effective_console_level.upper()) if console else logging.CRITICAL, - getattr(logging, effective_file_level.upper()) if log_file else logging.CRITICAL, - ) - logger.setLevel(min_level) + logger.setLevel(getattr(logging, default_level.upper())) logger.propagate = False # Prevent duplicate logs logger.handlers.clear() if console: - handler = _create_console_handler( - use_rich=use_rich_handler, - console_width=console_width, - show_path=show_path, - date_format=date_format, - format=format, - colors=colors, + logger.addHandler( + _create_console_handler( + use_rich=use_rich_handler, + console_width=console_width, + show_path=show_path, + date_format=date_format, + format=format, + colors=colors, + ) ) - handler.setLevel(getattr(logging, effective_console_level.upper())) - logger.addHandler(handler) if log_file: - handler = _create_file_handler( - log_file=log_file, - max_file_size=max_file_size, - backup_count=backup_count, - date_format=date_format, - format=format, + logger.addHandler( + _create_file_handler( + log_file=log_file, + max_file_size=max_file_size, + backup_count=backup_count, + date_format=date_format, + format=format, + ) ) - handler.setLevel(getattr(logging, effective_file_level.upper())) - logger.addHandler(handler) # Library best practice: NullHandler if no handlers configured if not logger.handlers: diff --git a/tests/test_config.py b/tests/test_config.py index c49581188..c486d22c6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,8 +25,6 @@ def teardown_method(self): def test_config_defaults(self): """Test that CONFIG has correct default values.""" assert CONFIG.Logging.level == 'INFO' - assert CONFIG.Logging.console_level is None - assert CONFIG.Logging.file_level is None assert CONFIG.Logging.file == 'flixopt.log' assert CONFIG.Logging.rich is False assert CONFIG.Logging.console is True @@ -480,92 +478,3 @@ def test_reset_matches_class_defaults(self): assert CONFIG.Modeling.epsilon == _DEFAULTS['modeling']['epsilon'] assert CONFIG.Modeling.big_binary_bound == _DEFAULTS['modeling']['big_binary_bound'] assert CONFIG.config_name == _DEFAULTS['config_name'] - - def test_individual_handler_levels(self, tmp_path): - """Test that console and file handlers can have different log levels.""" - log_file = tmp_path / 'test_levels.log' - - # Set console to DEBUG, file to WARNING - CONFIG.Logging.level = 'INFO' # Default level - CONFIG.Logging.console = True - CONFIG.Logging.console_level = 'DEBUG' - CONFIG.Logging.file = str(log_file) - CONFIG.Logging.file_level = 'WARNING' - CONFIG.apply() - - logger = logging.getLogger('flixopt') - - # Logger should be set to DEBUG (most permissive) - assert logger.level == logging.DEBUG - - # Check handler levels - from logging.handlers import RotatingFileHandler - - console_handlers = [ - h - for h in logger.handlers - if isinstance(h, logging.StreamHandler) and not isinstance(h, RotatingFileHandler) - ] - file_handlers = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)] - - assert len(console_handlers) == 1 - assert len(file_handlers) == 1 - - # Console handler should be at DEBUG level - assert console_handlers[0].level == logging.DEBUG - - # File handler should be at WARNING level - assert file_handlers[0].level == logging.WARNING - - # Test actual logging behavior - logger.debug('DEBUG message') - logger.info('INFO message') - logger.warning('WARNING message') - logger.error('ERROR message') - - # File should only contain WARNING and ERROR - log_content = log_file.read_text() - assert 'DEBUG message' not in log_content - assert 'INFO message' not in log_content - assert 'WARNING message' in log_content - assert 'ERROR message' in log_content - - def test_console_level_defaults_to_level(self): - """Test that console_level defaults to level when not specified.""" - CONFIG.Logging.level = 'ERROR' - CONFIG.Logging.console = True - CONFIG.Logging.console_level = None # Explicitly None - CONFIG.apply() - - logger = logging.getLogger('flixopt') - - # Find console handler - from logging.handlers import RotatingFileHandler - - console_handlers = [ - h - for h in logger.handlers - if isinstance(h, logging.StreamHandler) and not isinstance(h, RotatingFileHandler) - ] - - assert len(console_handlers) == 1 - assert console_handlers[0].level == logging.ERROR - - def test_file_level_defaults_to_level(self, tmp_path): - """Test that file_level defaults to level when not specified.""" - log_file = tmp_path / 'test.log' - - CONFIG.Logging.level = 'CRITICAL' - CONFIG.Logging.file = str(log_file) - CONFIG.Logging.file_level = None # Explicitly None - CONFIG.apply() - - logger = logging.getLogger('flixopt') - - # Find file handler - from logging.handlers import RotatingFileHandler - - file_handlers = [h for h in logger.handlers if isinstance(h, RotatingFileHandler)] - - assert len(file_handlers) == 1 - assert file_handlers[0].level == logging.CRITICAL From 931971ef9b69c7a263bcc63067c0069fb40648e1 Mon Sep 17 00:00:00 2001 From: FBumann <117816358+FBumann@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:29:48 +0200 Subject: [PATCH 44/44] Fix CHANGELOG.md --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7953bca48..70660c6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,7 +71,6 @@ Please keep the format of the changelog consistent with the other releases, so t ### 👷 Development - Greatly expanded test coverage for `config.py` module - Added `@pytest.mark.xdist_group` to `TestConfigModule` tests to prevent global config interference -- Improved Renovate configuration with automerge for dev dependencies and better CalVer handling ### 🚧 Known Issues