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/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/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..ca5e949 --- /dev/null +++ b/src/configcore/contract.py @@ -0,0 +1,34 @@ +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. + + Returns: + LogLevel: The log level. + """ + raise NotImplementedError + + @abstractmethod + def get_environment(self) -> Environment: + """Get the current environment. + + Returns: + Environment: The current environment. + """ + raise NotImplementedError + + def is_env_production(self) -> bool: + """Check if the 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 new file mode 100644 index 0000000..569795b --- /dev/null +++ b/src/configcore/environment.py @@ -0,0 +1,12 @@ +from enum import Enum, auto + + +class Environment(Enum): + """Represents the different environments in which the application can run. + + DEVELOPMENT, STAGING or PRODUCTION. + """ + + 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..9f7cb79 --- /dev/null +++ b/src/configcore/settings.py @@ -0,0 +1,36 @@ +from typing import Annotated, override + +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) + + @override + def get_environment(self) -> Environment: + return self.environment + + @override + def get_log_level(self) -> LogLevel: + 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..ff2eb65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,3 +4,19 @@ 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. + + Returns: + A mock config conforming to ConfigContract + """ + return Mock(spec=ConfigContract)