diff --git a/CHANGELOG.md b/CHANGELOG.md index baf95cbda..70660c6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,15 +42,23 @@ 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 +- 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 `CONFIG.Logging.Colors` nested class for customizable log level colors using ANSI escape codes (works with both standard and Rich handlers) ### 💥 Breaking Changes ### ♻️ 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 `config.merge_configs` function from configuration module + ### 🐛 Fixed @@ -61,6 +69,8 @@ Please keep the format of the changelog consistent with the other releases, so t ### 📝 Docs ### 👷 Development +- Greatly expanded test coverage for `config.py` module +- Added `@pytest.mark.xdist_group` to `TestConfigModule` tests to prevent global config interference ### 🚧 Known Issues @@ -78,6 +88,7 @@ Until here --> ### 📦 Dependencies - Updated `renovate.config` to treat CalVer packages (xarray and dask) with more care +- Updated packaging configuration --- diff --git a/flixopt/__init__.py b/flixopt/__init__.py index 34306ae32..d8ad05f19 100644 --- a/flixopt/__init__.py +++ b/flixopt/__init__.py @@ -35,5 +35,3 @@ results, solvers, ) - -CONFIG.load_config() diff --git a/flixopt/calculation.py b/flixopt/calculation.py index c912b083b..4dc13889c 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..2ec5bf88c 100644 --- a/flixopt/config.py +++ b/flixopt/config.py @@ -1,168 +1,345 @@ from __future__ import annotations import logging -import os -import types -from dataclasses import dataclass, fields, is_dataclass -from typing import Annotated, Literal, get_type_hints +import warnings +from logging.handlers import RotatingFileHandler +from pathlib import Path +from types import MappingProxyType +from typing import Literal import yaml from rich.console import Console from rich.logging import RichHandler +from rich.style import Style +from rich.theme import Theme -logger = logging.getLogger('flixopt') - - -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 - - -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) +__all__ = ['CONFIG', 'change_logging_level'] +logger = logging.getLogger('flixopt') -@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) +# SINGLE SOURCE OF TRUTH - immutable to prevent accidental modification +_DEFAULTS = MappingProxyType( + { + 'config_name': 'flixopt', + 'logging': MappingProxyType( + { + 'level': 'INFO', + 'file': 'flixopt.log', + 'rich': False, + 'console': True, + 'max_file_size': 10_485_760, # 10MB + 'backup_count': 5, + 'date_format': '%Y-%m-%d %H:%M:%S', + '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( + { + 'big': 10_000_000, + 'epsilon': 1e-5, + 'big_binary_bound': 100_000, + } + ), + } +) -@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)] +class CONFIG: + """Configuration for flixopt library. + + 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. + 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: 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) + 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' + 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 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 + 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.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:: + + CONFIG.load_from_file('config.yaml') + + Example YAML config file: + + .. 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 + colors: + 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:: -@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)] + CONFIG.reset() + + Export current configuration:: + config_dict = CONFIG.to_dict() + import yaml -@dataclass -class ConfigSchema(ValidatedConfig): - config_name: Annotated[str, lambda x: isinstance(x, str)] - logging: LoggingConfig - modeling: ModelingConfig + 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'] + max_file_size: int = _DEFAULTS['logging']['max_file_size'] + backup_count: int = _DEFAULTS['logging']['backup_count'] + date_format: str = _DEFAULTS['logging']['date_format'] + format: str = _DEFAULTS['logging']['format'] + console_width: int = _DEFAULTS['logging']['console_width'] + show_path: bool = _DEFAULTS['logging']['show_path'] + + class Colors: + 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'] + epsilon: float = _DEFAULTS['modeling']['epsilon'] + big_binary_bound: int = _DEFAULTS['modeling']['big_binary_bound'] + + config_name: str = _DEFAULTS['config_name'] -class CONFIG: - """ - A configuration class that stores global configuration values as class attributes. - """ + @classmethod + 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) + else: + setattr(cls.Logging, key, value) + + for key, value in _DEFAULTS['modeling'].items(): + setattr(cls.Modeling, key, value) + + cls.config_name = _DEFAULTS['config_name'] + cls.apply() - config_name: str = None - modeling: ModelingConfig = None - logging: LoggingConfig = None + @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, + 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, + format=cls.Logging.format, + console_width=cls.Logging.console_width, + show_path=cls.Logging.show_path, + colors=colors_dict, + ) @classmethod - def load_config(cls, user_config_file: str | None = None): - """ - Initialize configuration using defaults or user-specified file. - """ - # Default config file - 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(user_config_file) as user_file: - new_config = yaml.safe_load(user_file) + 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}') - # Convert the merged config to ConfigSchema - config_data = dataclass_from_dict_with_validation(ConfigSchema, new_config) + with config_path.open() as file: + config_dict = yaml.safe_load(file) + 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 + cls.apply() - 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 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) + 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) + elif hasattr(cls, key): + 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(): - # Only consider attributes (not methods, etc.) - if ( - not attribute.startswith('_') - 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! - 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, + 'max_file_size': cls.Logging.max_file_size, + 'backup_count': cls.Logging.backup_count, + 'date_format': cls.Logging.date_format, + '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, + }, + }, + 'modeling': { + 'big': cls.Modeling.big, + 'epsilon': cls.Modeling.epsilon, + 'big_binary_bound': cls.Modeling.big_binary_bound, + }, + } 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:]] @@ -173,96 +350,212 @@ 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 - } + """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 + 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() 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) - # Create a formatted message for each line separately - formatted_lines = [] - for line in lines: - formatted_lines.append(f'{log_color}{line}{self.RESET}') - return '\n'.join(formatted_lines) +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', + format: str = '%(message)s', + colors: dict[str, str] | None = None, +) -> 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. -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 - console = Console(width=120) - rich_handler = RichHandler( + Returns: + Configured logging handler (RichHandler or StreamHandler). + """ + if use_rich: + # Convert ANSI codes to Rich theme + 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 + 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( 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', - ) + show_path=show_path, + log_time_format=date_format, ) - return file_handler + handler.setFormatter(logging.Formatter(format)) 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=format, datefmt=date_format, colors=colors)) + + return handler + + +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', + format: str = '%(message)s', +) -> RotatingFileHandler: + """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, + backupCount=backup_count, + encoding='utf-8', + ) + handler.setFormatter(MultilineFormater(fmt=format, datefmt=date_format)) + return handler -def setup_logging( + +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, + console: bool = False, + max_file_size: int = 10_485_760, + backup_count: int = 5, + date_format: str = '%Y-%m-%d %H:%M:%S', + format: str = '%(message)s', + console_width: int = 120, + show_path: bool = False, + colors: dict[str, str] | None = None, ): - """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() + """Internal function to setup logging - use CONFIG.apply() instead. - 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)) + Configures the flixopt logger with console and/or file handlers. + If no handlers are configured, adds NullHandler (library best practice). - return logger + 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() + + 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, + ) + ) + + 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, + ) + ) + # Library best practice: NullHandler if no handlers configured + if not logger.handlers: + logger.addHandler(logging.NullHandler()) -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. + + .. 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 = get_logging_level_by_name(level_name) + 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() 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) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 000000000..c486d22c6 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,480 @@ +"""Tests for the config module.""" + +import logging +import sys +from pathlib import Path + +import pytest + +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.""" + + def setup_method(self): + """Reset CONFIG to defaults before each test.""" + CONFIG.reset() + + def teardown_method(self): + """Clean up after each test to prevent state leakage.""" + CONFIG.reset() + + def test_config_defaults(self): + """Test that CONFIG has correct default values.""" + assert CONFIG.Logging.level == 'INFO' + assert CONFIG.Logging.file == 'flixopt.log' + assert CONFIG.Logging.rich 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 + 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 (file handler by default) + assert len(logger.handlers) >= 1 + # 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.""" + 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'] == 'flixopt.log' + 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 keep unspecified settings).""" + 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' + # 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.""" + _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 is deprecated but still exists.""" + # This function is deprecated - users should use CONFIG.apply() instead + import flixopt + + # 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 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') + + # 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 + + 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 + + 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 + + def test_config_reset(self): + """Test that CONFIG.reset() restores all defaults.""" + # Modify all config values + CONFIG.Logging.level = 'DEBUG' + CONFIG.Logging.console = False + 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 True + assert CONFIG.Logging.rich is False + 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 + assert CONFIG.config_name == 'flixopt' + + # Verify logging was also reset + logger = logging.getLogger('flixopt') + assert logger.level == logging.INFO + 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. + + 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']