"""Environment variable loading and .env schema."""
import os
from pathlib import Path
from typing import Any, Type, Union
from config.constants import (
LANGUAGE_ALIASES,
SUPPORTED_LANGUAGE_CODES,
VALID_LANGUAGE_INPUTS,
)
# Type for env schema cast_type (str, int, float, bool)
_EnvCastType = Type[Union[str, int, float, bool]]
try:
from dotenv import load_dotenv
# Project root: __file__ is src/config/env.py -> parent=config, parent.parent=src, parent.parent.parent=project root
_env_path = Path(__file__).resolve().parent.parent.parent / ".env"
load_dotenv(dotenv_path=_env_path, override=True)
except ImportError:
pass
def _validate_env_value(
key: str, value: Any, schema_item: dict[str, Any]
) -> tuple[bool, Any]:
"""
Validate an environment variable value according to its schema.
Args:
key: Environment variable name.
value: The value to validate (already cast to the correct type).
schema_item: Schema item from ENV_SCHEMA containing validation rules.
Returns:
Tuple of (is_valid, corrected_value). If valid, corrected_value is the
original value. If invalid, corrected_value is the default.
"""
default = schema_item["default"]
cast_type = schema_item["cast_type"]
# Check if value is None
if value is None:
return False, default
# Special validation for LANGUAGE
if key == "LANGUAGE" and cast_type is str:
try:
str_value = str(value).strip()
lang_lower = str_value.lower()
if lang_lower not in VALID_LANGUAGE_INPUTS:
return False, default
# Normalize to canonical code
normalized = LANGUAGE_ALIASES.get(lang_lower, lang_lower)
return True, normalized
except (AttributeError, TypeError, ValueError):
return False, default
# Special validation for LOG_LEVEL
if key == "LOG_LEVEL" and cast_type is str:
try:
str_value = str(value).strip()
if str_value.upper() not in (
"DEBUG",
"INFO",
"WARNING",
"ERROR",
"CRITICAL",
):
return False, default
return True, str_value.upper()
except (AttributeError, TypeError, ValueError):
return False, default
# Validate options if specified
if "options" in schema_item:
options = schema_item["options"]
try:
if cast_type is str:
if str(value).lower() not in [opt.lower() for opt in options]:
return False, default
else:
if value not in options:
return False, default
except (AttributeError, TypeError, ValueError):
return False, default
# Validate integer ranges
if cast_type is int:
try:
int_value = int(value)
except (TypeError, ValueError, OverflowError):
return False, default
if key in _SIZE_FIELDS:
# All size fields must be positive
if int_value <= 0:
return False, default
# Apply specific upper bounds
if "FONT" in key or key.endswith("_SIZE"):
if int_value > 200:
return False, default
elif "WIDTH" in key or "HEIGHT" in key:
if int_value > 1000:
return False, default
# Special validation for DPI
if key == "DPI":
if int_value < 50 or int_value > 1000:
return False, default
# Validate float ranges
elif cast_type is float:
try:
float_value = float(value)
except (TypeError, ValueError, OverflowError):
return False, default
# Validate line width
if key == "PLOT_LINE_WIDTH":
if float_value <= 0 or float_value > 20:
return False, default
# Validate strings
elif cast_type is str:
try:
str_value = str(value).strip()
except (AttributeError, TypeError):
return False, default
# Optional fields that can be empty
optional_fields = {"DONATIONS_URL", "UPDATE_CHECK_URL"}
# Require non-empty strings for all other fields
if not str_value and key not in optional_fields:
return False, default
return True, value
def _was_value_corrected(
key: str, current_value: Any, cast_type: _EnvCastType, schema_item: dict[str, Any]
) -> bool:
"""
Check if an environment value was corrected during validation.
Args:
key: Environment variable name.
current_value: The validated/corrected value.
cast_type: Type to cast the value to.
schema_item: Schema definition for this environment variable.
Returns:
True if the original value was invalid or different from current_value.
"""
original_value = os.getenv(key)
# If no original value exists, it wasn't corrected (just defaulted)
if original_value is None:
return False
# Try to cast and validate the original value
try:
if cast_type is bool:
original_casted = original_value.lower() in ("true", "1", "yes")
else:
original_casted = cast_type(original_value)
except (ValueError, TypeError):
# Casting failed, so it was corrected
return True
# Check if validation would have failed or changed the value
is_valid, validated_value = _validate_env_value(key, original_casted, schema_item)
return not is_valid or validated_value != current_value
# Logging defaults (single source of truth for ENV_SCHEMA and utils.logger)
DEFAULT_LOG_LEVEL = "INFO"
DEFAULT_LOG_FILE = "regressionlab.log"
# Env keys for integer size/dimension fields (positive, bounded validation)
_SIZE_FIELDS: frozenset[str] = frozenset(
{
"UI_PADDING",
"UI_BUTTON_WIDTH",
"UI_FONT_SIZE",
"UI_SPINBOX_WIDTH",
"UI_ENTRY_WIDTH",
"PLOT_FIGSIZE_WIDTH",
"PLOT_FIGSIZE_HEIGHT",
"PLOT_MARKER_SIZE",
"FONT_AXIS_SIZE",
"FONT_TICK_SIZE",
}
)
# Order defines display order in config dialog. Within each section (ui, plot, font, etc.)
# related options are grouped (e.g. all button settings together).
ENV_SCHEMA: list[dict[str, Any]] = [
# --- language ---
{
"key": "LANGUAGE",
"default": "es",
"cast_type": str,
"options": SUPPORTED_LANGUAGE_CODES,
},
# --- ui: general window / background ---
{"key": "UI_BACKGROUND", "default": "#181818", "cast_type": str},
{"key": "UI_FOREGROUND", "default": "#CCCCCC", "cast_type": str},
# --- ui: buttons (all together) ---
{"key": "UI_BUTTON_BG", "default": "#1F1F1F", "cast_type": str},
{"key": "UI_BUTTON_WIDTH", "default": 12, "cast_type": int},
{"key": "UI_BUTTON_FG", "default": "lime green", "cast_type": str},
{"key": "UI_BUTTON_FG_CANCEL", "default": "red2", "cast_type": str},
{"key": "UI_BUTTON_FG_ACCENT2", "default": "yellow", "cast_type": str},
# --- ui: text / inputs ---
{"key": "UI_TEXT_SELECT_BG", "default": "steel blue", "cast_type": str},
{"key": "UI_FONT_SIZE", "default": 18, "cast_type": int},
{"key": "UI_FONT_FAMILY", "default": "Bahnschrift", "cast_type": str},
{"key": "UI_PADDING", "default": 8, "cast_type": int},
{"key": "UI_SPINBOX_WIDTH", "default": 10, "cast_type": int},
{"key": "UI_ENTRY_WIDTH", "default": 25, "cast_type": int},
# --- plot: size ---
{"key": "PLOT_FIGSIZE_WIDTH", "default": 12, "cast_type": int},
{"key": "PLOT_FIGSIZE_HEIGHT", "default": 6, "cast_type": int},
{"key": "DPI", "default": 100, "cast_type": int},
{"key": "PLOT_SHOW_TITLE", "default": False, "cast_type": bool},
{"key": "PLOT_SHOW_GRID", "default": False, "cast_type": bool},
# --- plot: line ---
{"key": "PLOT_LINE_COLOR", "default": "black", "cast_type": str},
{"key": "PLOT_LINE_WIDTH", "default": 1.0, "cast_type": float},
{
"key": "PLOT_LINE_STYLE",
"default": "-",
"cast_type": str,
"options": ("-", "--", "-.", ":"),
},
# --- plot: markers ---
{
"key": "PLOT_MARKER_FORMAT",
"default": "o",
"cast_type": str,
"options": ("o", "s", "^", "d", "*"),
},
{"key": "PLOT_MARKER_SIZE", "default": 5, "cast_type": int},
{"key": "PLOT_MARKER_FACE_COLOR", "default": "crimson", "cast_type": str},
{"key": "PLOT_MARKER_EDGE_COLOR", "default": "crimson", "cast_type": str},
{"key": "PLOT_ERROR_COLOR", "default": "crimson", "cast_type": str},
# --- font (plots) ---
{
"key": "FONT_FAMILY",
"default": "serif",
"cast_type": str,
"options": ("serif", "sans-serif", "monospace", "cursive", "fantasy"),
},
{
"key": "FONT_TITLE_SIZE",
"default": "xx-large",
"cast_type": str,
"options": (
"xx-small",
"x-small",
"small",
"medium",
"large",
"x-large",
"xx-large",
),
},
{
"key": "FONT_TITLE_WEIGHT",
"default": "semibold",
"cast_type": str,
"options": ("normal", "bold", "light", "semibold", "heavy"),
},
{"key": "FONT_AXIS_SIZE", "default": 30, "cast_type": int},
{
"key": "FONT_AXIS_STYLE",
"default": "italic",
"cast_type": str,
"options": ("normal", "italic", "oblique"),
},
{"key": "FONT_TICK_SIZE", "default": 16, "cast_type": int},
# --- paths ---
{"key": "FILE_INPUT_DIR", "default": "input", "cast_type": str},
{"key": "FILE_OUTPUT_DIR", "default": "output", "cast_type": str},
{"key": "FILE_FILENAME_TEMPLATE", "default": "fit_{}", "cast_type": str},
{
"key": "FILE_PLOT_FORMAT",
"default": "png",
"cast_type": str,
"options": ("png", "jpg", "pdf"),
},
# --- links ---
{
"key": "DONATIONS_URL",
"default": "https://www.youtube.com/@whenphysics",
"cast_type": str,
},
# --- updates (tkinter) ---
{"key": "CHECK_UPDATES", "default": True, "cast_type": bool},
{"key": "CHECK_UPDATES_FORCE", "default": False, "cast_type": bool},
{
"key": "UPDATE_CHECK_URL",
"default": "https://raw.githubusercontent.com/DOKOS-TAYOS/RegressionLab/main/pyproject.toml",
"cast_type": str,
},
# --- logging ---
{
"key": "LOG_LEVEL",
"default": DEFAULT_LOG_LEVEL,
"cast_type": str,
"options": ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"),
},
{"key": "LOG_FILE", "default": DEFAULT_LOG_FILE, "cast_type": str},
{"key": "LOG_CONSOLE", "default": False, "cast_type": bool},
]
# O(1) lookup by key for get_env and related functions
_ENV_SCHEMA_BY_KEY: dict[str, dict[str, Any]] = {
item["key"]: item for item in ENV_SCHEMA
}
[docs]
def get_env_from_schema(key: str) -> Any:
"""
Get environment variable using ENV_SCHEMA: default and cast_type come from
the schema. Use this when the key is defined in ENV_SCHEMA to avoid
duplicating defaults.
Args:
key: Environment variable name (must exist in ENV_SCHEMA).
Returns:
The validated value from get_env(key, default, cast_type).
Raises:
KeyError: If key is not in ENV_SCHEMA.
"""
item = _ENV_SCHEMA_BY_KEY.get(key)
if item is None:
raise KeyError(f"Unknown env key: {key}")
return get_env(key, item["default"], item["cast_type"])
[docs]
def get_env(
key: str, default: Any, cast_type: Type[Union[str, int, float, bool]] = str
) -> Union[str, int, float, bool]:
"""
Get environment variable with type casting, validation, and default value.
This function validates the value according to ENV_SCHEMA rules. If validation
fails, the default value is returned.
Args:
key: Environment variable name.
default: Default value if variable not found or invalid.
cast_type: Type to cast the value to (str, int, float, bool).
Returns:
The environment variable value cast to the specified type, validated,
or default if invalid or missing.
"""
value = os.getenv(key)
if value is None:
return default
schema_item = _ENV_SCHEMA_BY_KEY.get(key)
# If no schema found, use basic casting without validation
if schema_item is None:
try:
if cast_type is bool:
return value.lower() in ("true", "1", "yes")
return cast_type(value)
except (ValueError, TypeError):
return default
# Cast the value first
try:
if cast_type is bool:
casted_value = value.lower() in ("true", "1", "yes")
else:
casted_value = cast_type(value)
except (ValueError, TypeError):
return default
# Validate the casted value
_, corrected_value = _validate_env_value(key, casted_value, schema_item)
return corrected_value
def _validate_all_env_values() -> dict[str, tuple[Any, bool]]:
"""
Validate all environment values according to ENV_SCHEMA and return
validation results. Internal use by initialize_and_validate_config.
Returns:
Dictionary mapping environment keys to tuples of (corrected_value, was_corrected).
was_corrected is True if the value was invalid and had to be corrected.
"""
results: dict[str, tuple[Any, bool]] = {}
for item in ENV_SCHEMA:
key = item["key"]
default = item["default"]
cast_type = item["cast_type"]
# Get validated current value
current_value = get_env(key, default, cast_type)
# Determine if the original value was corrected
was_corrected = _was_value_corrected(key, current_value, cast_type, item)
results[key] = (current_value, was_corrected)
return results
[docs]
def get_current_env_values() -> dict[str, str]:
"""
Collect current environment values for all keys defined in ``ENV_SCHEMA``.
Values are read using :func:`get_env` so casting, defaults and boolean
handling are applied consistently. Booleans are converted to the strings
``"true"`` or ``"false"`` so they can be written back to ``.env`` files
without ambiguity.
Returns:
Dictionary mapping environment keys to their string representation.
Examples:
>>> values = get_current_env_values()
>>> values["LANGUAGE"]
'es'
"""
result: dict[str, str] = {}
for item in ENV_SCHEMA:
key = item["key"]
default = item["default"]
cast_type = item["cast_type"]
val = get_env(key, default, cast_type)
if cast_type is bool:
result[key] = "true" if val else "false"
else:
result[key] = str(val)
return result
[docs]
def write_env_file(env_path: Path, values: dict[str, str]) -> None:
"""
Write a ``.env`` file with the given key=value pairs.
Only keys present in :data:`ENV_SCHEMA` are written, and values are quoted
when they contain spaces, ``#`` or line breaks so they remain parseable by
``dotenv`` and similar tools.
Args:
env_path: Destination path for the ``.env`` file.
values: Mapping from environment keys to their desired string values.
Examples:
>>> from pathlib import Path
>>> write_env_file(Path(".env"), {"LANGUAGE": "en", "LOG_LEVEL": "DEBUG"})
"""
lines = [
"# RegressionLab Configuration - generated by the application",
"# Edit this file or use the configuration dialog from the main menu.",
"",
]
for item in ENV_SCHEMA:
key = item["key"]
if key not in values:
continue
value = values[key].strip()
if " " in value or "#" in value or "\n" in value:
value = f'"{value}"'
lines.append(f"{key}={value}")
env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
[docs]
def initialize_and_validate_config() -> None:
"""
Initialize configuration and validate all environment values.
This function should be called at application startup to ensure all
configuration values are valid. Invalid values are automatically corrected
to their defaults, and warnings are logged if any corrections were made.
Examples:
>>> initialize_and_validate_config()
# All config values are now validated and corrected if needed
"""
try:
from utils import get_logger
logger = get_logger(__name__)
except ImportError:
# Logger not available, skip logging
logger = None
validation_results = _validate_all_env_values()
corrected_keys = [
key for key, (_, was_corrected) in validation_results.items() if was_corrected
]
if corrected_keys and logger:
logger.warning(
f"Found {len(corrected_keys)} invalid environment variable(s) that were corrected to defaults: "
f"{', '.join(corrected_keys)}"
)
for key in corrected_keys:
original = os.getenv(key, "<missing>")
corrected = validation_results[key][0]
logger.info(f" {key}: '{original}' -> '{corrected}' (default)")
DONATIONS_URL = get_env("DONATIONS_URL", "https://www.youtube.com/@whenphysics").strip()