From 3da41a48feff5af42bdbf543853e7f1f6b713a24 Mon Sep 17 00:00:00 2001 From: pierrocknroll Date: Fri, 4 Apr 2025 18:10:24 +0200 Subject: [PATCH 1/3] :sparkles: Config system --- docs/CHANGELOG.md | 12 +++++ docs/README.md | 84 +++++++++++++++++++++++++++++-- pyproject.toml | 3 +- requirements-dev.lock | 16 ++++++ requirements.lock | 16 ++++++ src/configcore/__init__.py | 14 +++++- src/configcore/__main__.py | 1 - src/configcore/contract.py | 31 ++++++++++++ src/configcore/environment.py | 9 ++++ src/configcore/loglevel.py | 34 +++++++++++++ src/configcore/settings.py | 36 +++++++++++++ tests/configcore/settings_test.py | 83 ++++++++++++++++++++++++++++++ tests/conftest.py | 15 ++++++ 13 files changed, 346 insertions(+), 8 deletions(-) delete mode 100644 src/configcore/__main__.py create mode 100644 src/configcore/contract.py create mode 100644 src/configcore/environment.py create mode 100644 src/configcore/loglevel.py create mode 100644 src/configcore/settings.py create mode 100644 tests/configcore/settings_test.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3a919b3..511517b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,3 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0] - 2025-03-20 + +### Added + +- Initial release of the configuration library +- ConfigContract abstract base class defining the configuration interface +- Environment enum with DEVELOPMENT, STAGING, and PRODUCTION options +- LogLevel enum mapping to standard Python logging levels +- Settings class using Pydantic for environment variable loading +- Support for .env file configuration +- Type-safe configuration with validation +- Helper methods for environment checking diff --git a/docs/README.md b/docs/README.md index e98ba4d..7c1e8b1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,17 @@ ## Overview -ConfigCore +ConfigCore provides a minimal, type-safe foundation for building configuration +systems in Python applications. Rather than being a complete configuration +solution, it defines contracts and base classes that can be extended to create +application-specific configuration systems. + +The library focuses on: + +- 🔄 Providing a standardized way to handle environment selection +- 📊 Managing log levels consistently across projects +- 🧩 Offering a base Settings class for extension +- 📝 Using Pydantic for type validation and .env file loading ## Setup and installation @@ -24,18 +34,82 @@ ConfigCore ### Installation +1. Clone the repository + +2. Install dependencies + ```sh + make init + ``` + ## Usage +### Extending the Base Settings + +ConfigCore is designed to be extended in your projects. Here's how to use it: + +```python +from configcore import Settings +from pydantic import Field + + +class MyAppSettings(Settings): + """Application-specific settings extending the base Settings class.""" + + # Add your application-specific settings + api_url: str = Field(default="https://api.example.com") + max_retry_count: int = Field(default=3, ge=1) + timeout_seconds: float = Field(default=30.0) + + # Add custom methods as needed + def get_api_timeout(self) -> float: + if self.is_env_production(): + return self.timeout_seconds + return self.timeout_seconds * 2 +``` + +### Using Your Configuration + +```python +# In your application +settings = MyAppSettings() # Loads from environment variables / .env + +# Access standard settings from the base class +env = settings.get_environment() # Returns Environment enum +log_level = settings.get_log_level() # Returns LogLevel enum +is_prod = settings.is_env_production() # Convenience method + +# Access your custom settings +api_url = settings.api_url +timeout = settings.get_api_timeout() +``` + ## Development ### Code Formatting and Linting -### Environment Variables +This project uses ruff for formatting and linting: -| Variable | Description | Required | Default Value | Possible Values | -|----------|-------------|----------|---------------|-----------------| +```sh +# Format code +make format + +# Run linters +make lint +``` + +### Running Tests + +```sh +# Run tests with coverage +make test +``` + +### Environment Variables -### Architecture +| Variable | Description | Default Value | Possible Values | +|-------------|-------------------------|---------------|---------------------------------------| +| ENVIRONMENT | Application environment | PRODUCTION | DEVELOPMENT, STAGING, PRODUCTION | +| LOG_LEVEL | Logging level | INFO | DEBUG, INFO, WARNING, ERROR, CRITICAL | ## Contributing diff --git a/pyproject.toml b/pyproject.toml index 451222c..6f1ff90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "configcore" version = "0.1.0" -description = "ConfigCore" +description = "A flexible, type-safe configuration management library for Python applications." authors = [ { name = "Inokufu", email = "contact@inokufu.com" } ] @@ -10,6 +10,7 @@ dependencies = [ "mkdocstrings-python~=1.16.6", "mkdocs-material~=9.6.9", "griffe-inherited-docstrings~=1.1.1", + "pydantic-settings~=2.8.1", ] readme = "docs/README.md" requires-python = ">= 3.12" diff --git a/requirements-dev.lock b/requirements-dev.lock index 56d4376..ee87b7d 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -10,6 +10,8 @@ # universal: false -e file:. +annotated-types==0.7.0 + # via pydantic babel==2.17.0 # via mkdocs-material backrefs==5.8 @@ -100,6 +102,12 @@ pluggy==1.5.0 # via diff-cover # via pytest pre-commit==4.2.0 +pydantic==2.11.2 + # via pydantic-settings +pydantic-core==2.33.1 + # via pydantic +pydantic-settings==2.8.1 + # via configcore pygments==2.19.1 # via diff-cover # via mkdocs-material @@ -113,6 +121,8 @@ pytest-cov==6.0.0 pytest-mock==3.14.0 python-dateutil==2.9.0.post0 # via ghp-import +python-dotenv==1.1.0 + # via pydantic-settings pyyaml==6.0.2 # via mkdocs # via mkdocs-get-deps @@ -125,6 +135,12 @@ requests==2.32.3 # via mkdocs-material six==1.17.0 # via python-dateutil +typing-extensions==4.13.1 + # via pydantic + # via pydantic-core + # via typing-inspection +typing-inspection==0.4.0 + # via pydantic urllib3==2.3.0 # via requests virtualenv==20.30.0 diff --git a/requirements.lock b/requirements.lock index ccf18ca..2f71fe5 100644 --- a/requirements.lock +++ b/requirements.lock @@ -10,6 +10,8 @@ # universal: false -e file:. +annotated-types==0.7.0 + # via pydantic babel==2.17.0 # via mkdocs-material backrefs==5.8 @@ -76,6 +78,12 @@ pathspec==0.12.1 # via mkdocs platformdirs==4.3.7 # via mkdocs-get-deps +pydantic==2.11.2 + # via pydantic-settings +pydantic-core==2.33.1 + # via pydantic +pydantic-settings==2.8.1 + # via configcore pygments==2.19.1 # via mkdocs-material pymdown-extensions==10.14.3 @@ -83,6 +91,8 @@ pymdown-extensions==10.14.3 # via mkdocstrings python-dateutil==2.9.0.post0 # via ghp-import +python-dotenv==1.1.0 + # via pydantic-settings pyyaml==6.0.2 # via mkdocs # via mkdocs-get-deps @@ -94,6 +104,12 @@ requests==2.32.3 # via mkdocs-material six==1.17.0 # via python-dateutil +typing-extensions==4.13.1 + # via pydantic + # via pydantic-core + # via typing-inspection +typing-inspection==0.4.0 + # via pydantic urllib3==2.3.0 # via requests watchdog==6.0.0 diff --git a/src/configcore/__init__.py b/src/configcore/__init__.py index 5016d4c..63c149a 100644 --- a/src/configcore/__init__.py +++ b/src/configcore/__init__.py @@ -1 +1,13 @@ -"""Package init file.""" +"""Config package for configuration management in Python apps.""" + +from .contract import ConfigContract +from .environment import Environment +from .loglevel import LogLevel +from .settings import Settings + +__all__ = [ + "ConfigContract", + "Environment", + "LogLevel", + "Settings", +] diff --git a/src/configcore/__main__.py b/src/configcore/__main__.py deleted file mode 100644 index a7d956b..0000000 --- a/src/configcore/__main__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package main file.""" diff --git a/src/configcore/contract.py b/src/configcore/contract.py new file mode 100644 index 0000000..47e6164 --- /dev/null +++ b/src/configcore/contract.py @@ -0,0 +1,31 @@ +from abc import ABC, abstractmethod + +from .environment import Environment +from .loglevel import LogLevel + + +class ConfigContract(ABC): + """Abstract base class defining the contract for configuration management.""" + + @abstractmethod + def get_log_level(self) -> LogLevel: + """Get the log level. + + :return: The log level as a string (LogLevel type). + """ + raise NotImplementedError + + @abstractmethod + def get_environment(self) -> Environment: + """Get the current environment. + + :return: The current environment as a string (Environment type). + """ + raise NotImplementedError + + def is_env_production(self) -> bool: + """Check if the current environment is Production. + + :return: True if current environment is Production + """ + return self.get_environment() == Environment.PRODUCTION diff --git a/src/configcore/environment.py b/src/configcore/environment.py new file mode 100644 index 0000000..fa0d6c3 --- /dev/null +++ b/src/configcore/environment.py @@ -0,0 +1,9 @@ +from enum import Enum, auto + + +class Environment(Enum): + """Represents the different environments in which the application can run.""" + + DEVELOPMENT = auto() + STAGING = auto() + PRODUCTION = auto() diff --git a/src/configcore/loglevel.py b/src/configcore/loglevel.py new file mode 100644 index 0000000..06fd4d2 --- /dev/null +++ b/src/configcore/loglevel.py @@ -0,0 +1,34 @@ +import logging +from enum import IntEnum +from typing import Self + + +class LogLevel(IntEnum): + """Represents the different log levels.""" + + DEBUG = logging.DEBUG + INFO = logging.INFO + WARNING = logging.WARNING + ERROR = logging.ERROR + CRITICAL = logging.CRITICAL + + @classmethod + def from_str(cls, value: str) -> Self: + """Create a LogLevel from a string, case-insensitive. + + Args: + value: String representation of the log level. + + Returns: + LogLevel enum value. + + Raises: + ValueError: If the string doesn't match any log level. + """ + try: + return cls[value.upper()] + except KeyError as e: + valid_values = [e.name for e in cls] + raise ValueError( + f"Invalid log level '{value}'. Must be one of: {valid_values}", + ) from e diff --git a/src/configcore/settings.py b/src/configcore/settings.py new file mode 100644 index 0000000..4f783fd --- /dev/null +++ b/src/configcore/settings.py @@ -0,0 +1,36 @@ +from typing import Annotated + +from pydantic import BeforeValidator, Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from .contract import ConfigContract +from .environment import Environment +from .loglevel import LogLevel + + +class Settings(BaseSettings, ConfigContract): + """Application settings loaded from environment variables, via Pydantic model.""" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + environment: Annotated[ + Environment, + BeforeValidator(lambda v: Environment[v.upper()] if isinstance(v, str) else v), + ] = Field(default=Environment.PRODUCTION.value) + + log_level: Annotated[ + LogLevel, + BeforeValidator(lambda v: LogLevel.from_str(v) if isinstance(v, str) else v), + ] = Field(default=LogLevel.INFO.name) + + def get_environment(self) -> Environment: + """Inherited from ConfigContract.get_environment.""" + return self.environment + + def get_log_level(self) -> LogLevel: + """Inherited from ConfigContract.get_log_level.""" + return self.log_level diff --git a/tests/configcore/settings_test.py b/tests/configcore/settings_test.py new file mode 100644 index 0000000..1f31e6c --- /dev/null +++ b/tests/configcore/settings_test.py @@ -0,0 +1,83 @@ +import os + +import pytest +from pydantic import ValidationError +from pydantic_settings import SettingsConfigDict + +from src.configcore import Environment, LogLevel, Settings + + +class TestSettings: + """Test suite for Settings class.""" + + def setup_method(self) -> None: + """Set up test environment by clearing env vars and preventing .env loading.""" + self.original_env = dict(os.environ) + os.environ.clear() + + # Save the original configuration + self.original_config = Settings.model_config + + # Disable .env loading during tests + Settings.model_config = SettingsConfigDict(env_file=None) + + def teardown_method(self) -> None: + """Restore original environment and configuration.""" + Settings.model_config = self.original_config + os.environ.update(self.original_env) + + def test_default_values(self) -> None: + """Test that settings loads with correct default values.""" + settings = Settings() + assert settings.get_environment() == Environment.PRODUCTION + assert settings.get_log_level() == LogLevel.INFO + + def test_environment_override(self) -> None: + """Test that environment variables override defaults.""" + os.environ.update( + { + "ENVIRONMENT": "development", + "LOG_LEVEL": "debug", + }, + ) + + settings = Settings() + assert settings.get_environment() == Environment.DEVELOPMENT + assert settings.get_log_level() == LogLevel.DEBUG + + def test_invalid_environment(self) -> None: + """Test that invalid environment raises error.""" + os.environ["ENVIRONMENT"] = "invalid" + + with pytest.raises(KeyError): + Settings() + + def test_invalid_log_level(self) -> None: + """Test that invalid log level raises error.""" + os.environ["LOG_LEVEL"] = "invalid" + + with pytest.raises(ValidationError) as exc_info: + Settings() + assert "log_level" in str(exc_info.value) + assert len(exc_info.value.errors()) == 1 + + def test_is_env_production(self) -> None: + """Test is_env_production helper method.""" + settings = Settings(environment=Environment.PRODUCTION) + assert settings.is_env_production() is True + + settings = Settings(environment=Environment.DEVELOPMENT) + assert settings.is_env_production() is False + + def test_case_insensitive_values(self) -> None: + """Test case insensitivity for environment and log level.""" + os.environ.update( + { + "ENVIRONMENT": "DeVelOpMeNt", + "LOG_LEVEL": "DeBuG", + }, + ) + + settings = Settings() + assert settings.get_environment() == Environment.DEVELOPMENT + assert settings.get_log_level() == LogLevel.DEBUG diff --git a/tests/conftest.py b/tests/conftest.py index 66ed7d9..224ba5d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,3 +4,18 @@ pytest automatically discovers this file and makes fixtures available to all test modules without needing to import them. """ + +from unittest.mock import Mock + +import pytest + +from src.configcore.contract import ConfigContract + + +@pytest.fixture +def mock_config() -> Mock: + """Create a mock config. + + :return: A mock config conforming to ConfigContract + """ + return Mock(spec=ConfigContract) From a0e6c5aa3a8f1f0b8fa68739df8d9520d7719e37 Mon Sep 17 00:00:00 2001 From: pierrocknroll Date: Mon, 7 Apr 2025 11:15:48 +0200 Subject: [PATCH 2/3] :memo: Add mkdocs --- docs/api/environment.md | 3 +++ docs/api/loglevel.md | 3 +++ docs/api/settings.md | 3 +++ src/configcore/contract.py | 9 ++++++--- src/configcore/environment.py | 5 ++++- src/configcore/settings.py | 6 +++--- 6 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 docs/api/environment.md create mode 100644 docs/api/loglevel.md create mode 100644 docs/api/settings.md diff --git a/docs/api/environment.md b/docs/api/environment.md new file mode 100644 index 0000000..e7015ed --- /dev/null +++ b/docs/api/environment.md @@ -0,0 +1,3 @@ +# Environment + +::: src.configcore.environment.Environment diff --git a/docs/api/loglevel.md b/docs/api/loglevel.md new file mode 100644 index 0000000..70d0dcc --- /dev/null +++ b/docs/api/loglevel.md @@ -0,0 +1,3 @@ +# LogLevel + +::: src.configcore.loglevel.LogLevel diff --git a/docs/api/settings.md b/docs/api/settings.md new file mode 100644 index 0000000..b9b2d06 --- /dev/null +++ b/docs/api/settings.md @@ -0,0 +1,3 @@ +# Settings + +::: src.configcore.settings.Settings diff --git a/src/configcore/contract.py b/src/configcore/contract.py index 47e6164..ca5e949 100644 --- a/src/configcore/contract.py +++ b/src/configcore/contract.py @@ -11,7 +11,8 @@ class ConfigContract(ABC): def get_log_level(self) -> LogLevel: """Get the log level. - :return: The log level as a string (LogLevel type). + Returns: + LogLevel: The log level. """ raise NotImplementedError @@ -19,13 +20,15 @@ def get_log_level(self) -> LogLevel: def get_environment(self) -> Environment: """Get the current environment. - :return: The current environment as a string (Environment type). + Returns: + Environment: The current environment. """ raise NotImplementedError def is_env_production(self) -> bool: """Check if the current environment is Production. - :return: True if current environment is Production + Returns: + bool: True if current environment is Production. """ return self.get_environment() == Environment.PRODUCTION diff --git a/src/configcore/environment.py b/src/configcore/environment.py index fa0d6c3..569795b 100644 --- a/src/configcore/environment.py +++ b/src/configcore/environment.py @@ -2,7 +2,10 @@ class Environment(Enum): - """Represents the different environments in which the application can run.""" + """Represents the different environments in which the application can run. + + DEVELOPMENT, STAGING or PRODUCTION. + """ DEVELOPMENT = auto() STAGING = auto() diff --git a/src/configcore/settings.py b/src/configcore/settings.py index 4f783fd..9f7cb79 100644 --- a/src/configcore/settings.py +++ b/src/configcore/settings.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, override from pydantic import BeforeValidator, Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -27,10 +27,10 @@ class Settings(BaseSettings, ConfigContract): BeforeValidator(lambda v: LogLevel.from_str(v) if isinstance(v, str) else v), ] = Field(default=LogLevel.INFO.name) + @override def get_environment(self) -> Environment: - """Inherited from ConfigContract.get_environment.""" return self.environment + @override def get_log_level(self) -> LogLevel: - """Inherited from ConfigContract.get_log_level.""" return self.log_level From ab58996a430295826739146c7b1c8881c9f17dfd Mon Sep 17 00:00:00 2001 From: pierrocknroll Date: Tue, 8 Apr 2025 19:21:45 +0200 Subject: [PATCH 3/3] :memo: Change docstring format in test files --- tests/conftest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 224ba5d..ff2eb65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ def mock_config() -> Mock: """Create a mock config. - :return: A mock config conforming to ConfigContract + Returns: + A mock config conforming to ConfigContract """ return Mock(spec=ConfigContract)