Merge pull request #57 from pselamy/fix/53
feat: implement configuration management service with Pydantic Settings (#53)
This commit is contained in:
@@ -12,6 +12,7 @@ dependencies = [
|
||||
"sqlalchemy>=2.0.0",
|
||||
"alembic>=1.13.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pydantic-settings>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"websockets>=12.0",
|
||||
"prometheus-client>=0.19.0",
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Configuration management service with Pydantic Settings.
|
||||
|
||||
This module provides centralized configuration management for the
|
||||
Polymarket Insider Tracker application, loading and validating
|
||||
environment variables at startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, SecretStr, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class DatabaseSettings(BaseSettings):
|
||||
"""Database connection settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="")
|
||||
|
||||
url: str = Field(
|
||||
alias="DATABASE_URL",
|
||||
description="PostgreSQL connection string",
|
||||
)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def validate_url(cls, v: str) -> str:
|
||||
"""Validate database URL format."""
|
||||
if not v.startswith(("postgresql://", "postgresql+asyncpg://")):
|
||||
raise ValueError("DATABASE_URL must be a PostgreSQL connection string")
|
||||
return v
|
||||
|
||||
|
||||
class RedisSettings(BaseSettings):
|
||||
"""Redis connection settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="")
|
||||
|
||||
url: str = Field(
|
||||
default="redis://localhost:6379",
|
||||
alias="REDIS_URL",
|
||||
description="Redis connection string",
|
||||
)
|
||||
|
||||
@field_validator("url")
|
||||
@classmethod
|
||||
def validate_url(cls, v: str) -> str:
|
||||
"""Validate Redis URL format."""
|
||||
if not v.startswith("redis://"):
|
||||
raise ValueError("REDIS_URL must start with redis://")
|
||||
return v
|
||||
|
||||
|
||||
class PolygonSettings(BaseSettings):
|
||||
"""Polygon blockchain RPC settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="POLYGON_")
|
||||
|
||||
rpc_url: str = Field(
|
||||
default="https://polygon-rpc.com",
|
||||
alias="POLYGON_RPC_URL",
|
||||
description="Primary Polygon RPC endpoint",
|
||||
)
|
||||
fallback_rpc_url: str | None = Field(
|
||||
default=None,
|
||||
alias="POLYGON_FALLBACK_RPC_URL",
|
||||
description="Fallback Polygon RPC endpoint",
|
||||
)
|
||||
|
||||
@field_validator("rpc_url", "fallback_rpc_url")
|
||||
@classmethod
|
||||
def validate_url(cls, v: str | None) -> str | None:
|
||||
"""Validate RPC URL format."""
|
||||
if v is None:
|
||||
return v
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("RPC URL must be an HTTP(S) endpoint")
|
||||
return v
|
||||
|
||||
|
||||
class PolymarketSettings(BaseSettings):
|
||||
"""Polymarket API settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="POLYMARKET_")
|
||||
|
||||
ws_url: str = Field(
|
||||
default="wss://ws-subscriptions-clob.polymarket.com/ws/market",
|
||||
alias="POLYMARKET_WS_URL",
|
||||
description="Polymarket WebSocket URL for live data",
|
||||
)
|
||||
api_key: SecretStr | None = Field(
|
||||
default=None,
|
||||
alias="POLYMARKET_API_KEY",
|
||||
description="Optional Polymarket API key",
|
||||
)
|
||||
|
||||
@field_validator("ws_url")
|
||||
@classmethod
|
||||
def validate_ws_url(cls, v: str) -> str:
|
||||
"""Validate WebSocket URL format."""
|
||||
if not v.startswith(("ws://", "wss://")):
|
||||
raise ValueError("WebSocket URL must start with ws:// or wss://")
|
||||
return v
|
||||
|
||||
|
||||
class DiscordSettings(BaseSettings):
|
||||
"""Discord notification settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="DISCORD_")
|
||||
|
||||
webhook_url: SecretStr | None = Field(
|
||||
default=None,
|
||||
alias="DISCORD_WEBHOOK_URL",
|
||||
description="Discord webhook URL for alerts",
|
||||
)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if Discord notifications are enabled."""
|
||||
return self.webhook_url is not None
|
||||
|
||||
|
||||
class TelegramSettings(BaseSettings):
|
||||
"""Telegram notification settings."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="TELEGRAM_")
|
||||
|
||||
bot_token: SecretStr | None = Field(
|
||||
default=None,
|
||||
alias="TELEGRAM_BOT_TOKEN",
|
||||
description="Telegram bot token",
|
||||
)
|
||||
chat_id: str | None = Field(
|
||||
default=None,
|
||||
alias="TELEGRAM_CHAT_ID",
|
||||
description="Telegram chat ID for alerts",
|
||||
)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Check if Telegram notifications are enabled."""
|
||||
return self.bot_token is not None and self.chat_id is not None
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Main application settings.
|
||||
|
||||
Loads configuration from environment variables with support for
|
||||
.env files via python-dotenv.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from polymarket_insider_tracker.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
print(settings.database.url)
|
||||
print(settings.log_level)
|
||||
```
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Nested configuration groups
|
||||
database: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||
redis: RedisSettings = Field(default_factory=RedisSettings)
|
||||
polygon: PolygonSettings = Field(default_factory=PolygonSettings)
|
||||
polymarket: PolymarketSettings = Field(default_factory=PolymarketSettings)
|
||||
discord: DiscordSettings = Field(default_factory=DiscordSettings)
|
||||
telegram: TelegramSettings = Field(default_factory=TelegramSettings)
|
||||
|
||||
# Application settings
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(
|
||||
default="INFO",
|
||||
alias="LOG_LEVEL",
|
||||
description="Logging level",
|
||||
)
|
||||
health_port: int = Field(
|
||||
default=8080,
|
||||
alias="HEALTH_PORT",
|
||||
description="HTTP port for health check endpoints",
|
||||
ge=1,
|
||||
le=65535,
|
||||
)
|
||||
dry_run: bool = Field(
|
||||
default=False,
|
||||
alias="DRY_RUN",
|
||||
description="Run without sending actual alerts",
|
||||
)
|
||||
|
||||
def get_logging_level(self) -> int:
|
||||
"""Get the numeric logging level."""
|
||||
level: int = getattr(logging, self.log_level)
|
||||
return level
|
||||
|
||||
def redacted_summary(self) -> dict[str, str | dict[str, str]]:
|
||||
"""Get a summary of settings with secrets redacted.
|
||||
|
||||
Returns:
|
||||
Dictionary of settings with sensitive values masked.
|
||||
"""
|
||||
return {
|
||||
"database_url": self._redact_url(self.database.url),
|
||||
"redis_url": self._redact_url(self.redis.url),
|
||||
"polygon": {
|
||||
"rpc_url": self.polygon.rpc_url,
|
||||
"fallback_rpc_url": self.polygon.fallback_rpc_url or "(not set)",
|
||||
},
|
||||
"polymarket": {
|
||||
"ws_url": self.polymarket.ws_url,
|
||||
"api_key": "(set)" if self.polymarket.api_key else "(not set)",
|
||||
},
|
||||
"discord_enabled": str(self.discord.enabled),
|
||||
"telegram_enabled": str(self.telegram.enabled),
|
||||
"log_level": self.log_level,
|
||||
"health_port": str(self.health_port),
|
||||
"dry_run": str(self.dry_run),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _redact_url(url: str) -> str:
|
||||
"""Redact password from URL if present."""
|
||||
if "@" in url and "://" in url:
|
||||
# URL has credentials - redact the password
|
||||
protocol_end = url.index("://") + 3
|
||||
at_pos = url.index("@")
|
||||
creds_part = url[protocol_end:at_pos]
|
||||
if ":" in creds_part:
|
||||
username = creds_part.split(":")[0]
|
||||
return f"{url[:protocol_end]}{username}:***@{url[at_pos + 1 :]}"
|
||||
return url
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Get the application settings singleton.
|
||||
|
||||
Uses LRU cache to ensure settings are loaded only once and
|
||||
reused across the application.
|
||||
|
||||
Returns:
|
||||
The Settings instance.
|
||||
|
||||
Raises:
|
||||
ValidationError: If required environment variables are missing
|
||||
or have invalid values.
|
||||
"""
|
||||
return Settings()
|
||||
|
||||
|
||||
def clear_settings_cache() -> None:
|
||||
"""Clear the settings cache.
|
||||
|
||||
Useful for testing when you need to reload settings with
|
||||
different environment variables.
|
||||
"""
|
||||
get_settings.cache_clear()
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Tests for configuration management service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from polymarket_insider_tracker.config import (
|
||||
DatabaseSettings,
|
||||
DiscordSettings,
|
||||
PolygonSettings,
|
||||
PolymarketSettings,
|
||||
RedisSettings,
|
||||
Settings,
|
||||
TelegramSettings,
|
||||
clear_settings_cache,
|
||||
get_settings,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_cache() -> Iterator[None]:
|
||||
"""Clear settings cache before and after each test."""
|
||||
clear_settings_cache()
|
||||
yield
|
||||
clear_settings_cache()
|
||||
|
||||
|
||||
class TestDatabaseSettings:
|
||||
"""Tests for DatabaseSettings."""
|
||||
|
||||
def test_valid_postgresql_url(self) -> None:
|
||||
"""Test valid PostgreSQL URL."""
|
||||
with patch.dict(os.environ, {"DATABASE_URL": "postgresql://user:pass@localhost/db"}):
|
||||
settings = DatabaseSettings()
|
||||
assert settings.url == "postgresql://user:pass@localhost/db"
|
||||
|
||||
def test_valid_asyncpg_url(self) -> None:
|
||||
"""Test valid asyncpg URL."""
|
||||
with patch.dict(
|
||||
os.environ, {"DATABASE_URL": "postgresql+asyncpg://user:pass@localhost/db"}
|
||||
):
|
||||
settings = DatabaseSettings()
|
||||
assert settings.url == "postgresql+asyncpg://user:pass@localhost/db"
|
||||
|
||||
def test_invalid_url_raises(self) -> None:
|
||||
"""Test that invalid database URL raises validation error."""
|
||||
with (
|
||||
patch.dict(os.environ, {"DATABASE_URL": "mysql://user:pass@localhost/db"}),
|
||||
pytest.raises(ValidationError, match="PostgreSQL connection string"),
|
||||
):
|
||||
DatabaseSettings()
|
||||
|
||||
|
||||
class TestRedisSettings:
|
||||
"""Tests for RedisSettings."""
|
||||
|
||||
def test_default_url(self) -> None:
|
||||
"""Test default Redis URL."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
settings = RedisSettings()
|
||||
assert settings.url == "redis://localhost:6379"
|
||||
|
||||
def test_custom_url(self) -> None:
|
||||
"""Test custom Redis URL."""
|
||||
with patch.dict(os.environ, {"REDIS_URL": "redis://redis:6380"}):
|
||||
settings = RedisSettings()
|
||||
assert settings.url == "redis://redis:6380"
|
||||
|
||||
def test_invalid_url_raises(self) -> None:
|
||||
"""Test that invalid Redis URL raises validation error."""
|
||||
with (
|
||||
patch.dict(os.environ, {"REDIS_URL": "http://localhost:6379"}),
|
||||
pytest.raises(ValidationError, match="redis://"),
|
||||
):
|
||||
RedisSettings()
|
||||
|
||||
|
||||
class TestPolygonSettings:
|
||||
"""Tests for PolygonSettings."""
|
||||
|
||||
def test_default_rpc_url(self) -> None:
|
||||
"""Test default Polygon RPC URL."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
settings = PolygonSettings()
|
||||
assert settings.rpc_url == "https://polygon-rpc.com"
|
||||
assert settings.fallback_rpc_url is None
|
||||
|
||||
def test_custom_urls(self) -> None:
|
||||
"""Test custom Polygon RPC URLs."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"POLYGON_RPC_URL": "https://alchemy.io/polygon",
|
||||
"POLYGON_FALLBACK_RPC_URL": "https://backup.polygon.io",
|
||||
},
|
||||
):
|
||||
settings = PolygonSettings()
|
||||
assert settings.rpc_url == "https://alchemy.io/polygon"
|
||||
assert settings.fallback_rpc_url == "https://backup.polygon.io"
|
||||
|
||||
def test_invalid_url_raises(self) -> None:
|
||||
"""Test that invalid RPC URL raises validation error."""
|
||||
with (
|
||||
patch.dict(os.environ, {"POLYGON_RPC_URL": "ws://polygon.io"}),
|
||||
pytest.raises(ValidationError, match="HTTP"),
|
||||
):
|
||||
PolygonSettings()
|
||||
|
||||
|
||||
class TestPolymarketSettings:
|
||||
"""Tests for PolymarketSettings."""
|
||||
|
||||
def test_default_ws_url(self) -> None:
|
||||
"""Test default Polymarket WebSocket URL."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
settings = PolymarketSettings()
|
||||
assert "polymarket.com" in settings.ws_url
|
||||
assert settings.api_key is None
|
||||
|
||||
def test_custom_api_key(self) -> None:
|
||||
"""Test custom API key (secret)."""
|
||||
with patch.dict(os.environ, {"POLYMARKET_API_KEY": "secret-key-123"}):
|
||||
settings = PolymarketSettings()
|
||||
assert settings.api_key is not None
|
||||
assert settings.api_key.get_secret_value() == "secret-key-123"
|
||||
|
||||
def test_invalid_ws_url_raises(self) -> None:
|
||||
"""Test that invalid WebSocket URL raises validation error."""
|
||||
with (
|
||||
patch.dict(os.environ, {"POLYMARKET_WS_URL": "http://polymarket.com"}),
|
||||
pytest.raises(ValidationError, match="ws://"),
|
||||
):
|
||||
PolymarketSettings()
|
||||
|
||||
|
||||
class TestDiscordSettings:
|
||||
"""Tests for DiscordSettings."""
|
||||
|
||||
def test_disabled_by_default(self) -> None:
|
||||
"""Test Discord is disabled when no webhook URL."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
settings = DiscordSettings()
|
||||
assert not settings.enabled
|
||||
assert settings.webhook_url is None
|
||||
|
||||
def test_enabled_with_webhook(self) -> None:
|
||||
"""Test Discord is enabled with webhook URL."""
|
||||
with patch.dict(os.environ, {"DISCORD_WEBHOOK_URL": "https://discord.com/webhook/123"}):
|
||||
settings = DiscordSettings()
|
||||
assert settings.enabled
|
||||
assert settings.webhook_url is not None
|
||||
|
||||
|
||||
class TestTelegramSettings:
|
||||
"""Tests for TelegramSettings."""
|
||||
|
||||
def test_disabled_by_default(self) -> None:
|
||||
"""Test Telegram is disabled when no credentials."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
settings = TelegramSettings()
|
||||
assert not settings.enabled
|
||||
|
||||
def test_disabled_with_partial_config(self) -> None:
|
||||
"""Test Telegram is disabled with only token or chat_id."""
|
||||
with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": "token123"}):
|
||||
settings = TelegramSettings()
|
||||
assert not settings.enabled
|
||||
|
||||
with patch.dict(os.environ, {"TELEGRAM_CHAT_ID": "12345"}):
|
||||
settings = TelegramSettings()
|
||||
assert not settings.enabled
|
||||
|
||||
def test_enabled_with_full_config(self) -> None:
|
||||
"""Test Telegram is enabled with both token and chat_id."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"TELEGRAM_BOT_TOKEN": "token123",
|
||||
"TELEGRAM_CHAT_ID": "12345",
|
||||
},
|
||||
):
|
||||
settings = TelegramSettings()
|
||||
assert settings.enabled
|
||||
|
||||
|
||||
class TestSettings:
|
||||
"""Tests for main Settings class."""
|
||||
|
||||
def test_loads_with_required_vars(self) -> None:
|
||||
"""Test settings load with required environment variables."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DATABASE_URL": "postgresql://user:pass@localhost/db",
|
||||
"REDIS_URL": "redis://localhost:6379",
|
||||
},
|
||||
):
|
||||
settings = Settings()
|
||||
assert settings.database.url == "postgresql://user:pass@localhost/db"
|
||||
assert settings.redis.url == "redis://localhost:6379"
|
||||
|
||||
def test_default_log_level(self) -> None:
|
||||
"""Test default log level is INFO."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"DATABASE_URL": "postgresql://user:pass@localhost/db"},
|
||||
):
|
||||
settings = Settings()
|
||||
assert settings.log_level == "INFO"
|
||||
|
||||
def test_custom_log_level(self) -> None:
|
||||
"""Test custom log level."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DATABASE_URL": "postgresql://user:pass@localhost/db",
|
||||
"LOG_LEVEL": "DEBUG",
|
||||
},
|
||||
):
|
||||
settings = Settings()
|
||||
assert settings.log_level == "DEBUG"
|
||||
|
||||
def test_invalid_log_level_raises(self) -> None:
|
||||
"""Test invalid log level raises validation error."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DATABASE_URL": "postgresql://user:pass@localhost/db",
|
||||
"LOG_LEVEL": "TRACE",
|
||||
},
|
||||
),
|
||||
pytest.raises(ValidationError),
|
||||
):
|
||||
Settings()
|
||||
|
||||
def test_health_port_validation(self) -> None:
|
||||
"""Test health port must be valid port number."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DATABASE_URL": "postgresql://user:pass@localhost/db",
|
||||
"HEALTH_PORT": "99999",
|
||||
},
|
||||
),
|
||||
pytest.raises(ValidationError, match="65535"),
|
||||
):
|
||||
Settings()
|
||||
|
||||
def test_get_logging_level(self) -> None:
|
||||
"""Test get_logging_level returns numeric level."""
|
||||
import logging
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DATABASE_URL": "postgresql://user:pass@localhost/db",
|
||||
"LOG_LEVEL": "WARNING",
|
||||
},
|
||||
):
|
||||
settings = Settings()
|
||||
assert settings.get_logging_level() == logging.WARNING
|
||||
|
||||
def test_redacted_summary(self) -> None:
|
||||
"""Test redacted_summary masks sensitive data."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DATABASE_URL": "postgresql://user:secretpass@localhost/db",
|
||||
"REDIS_URL": "redis://localhost:6379",
|
||||
},
|
||||
):
|
||||
settings = Settings()
|
||||
summary = settings.redacted_summary()
|
||||
|
||||
# Database password should be redacted
|
||||
db_url = summary["database_url"]
|
||||
assert isinstance(db_url, str)
|
||||
assert "secretpass" not in db_url
|
||||
assert "***" in db_url
|
||||
assert "user" in db_url
|
||||
|
||||
|
||||
class TestGetSettings:
|
||||
"""Tests for get_settings singleton."""
|
||||
|
||||
def test_returns_same_instance(self) -> None:
|
||||
"""Test get_settings returns cached instance."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"DATABASE_URL": "postgresql://user:pass@localhost/db"},
|
||||
):
|
||||
settings1 = get_settings()
|
||||
settings2 = get_settings()
|
||||
assert settings1 is settings2
|
||||
|
||||
def test_clear_cache_allows_reload(self) -> None:
|
||||
"""Test clear_settings_cache allows reloading settings."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DATABASE_URL": "postgresql://user:pass@localhost/db",
|
||||
"LOG_LEVEL": "INFO",
|
||||
},
|
||||
):
|
||||
settings1 = get_settings()
|
||||
assert settings1.log_level == "INFO"
|
||||
|
||||
clear_settings_cache()
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"DATABASE_URL": "postgresql://user:pass@localhost/db",
|
||||
"LOG_LEVEL": "DEBUG",
|
||||
},
|
||||
):
|
||||
settings2 = get_settings()
|
||||
assert settings2.log_level == "DEBUG"
|
||||
assert settings1 is not settings2
|
||||
@@ -1547,6 +1547,7 @@ dependencies = [
|
||||
{ name = "prometheus-client" },
|
||||
{ name = "py-clob-client" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "redis" },
|
||||
{ name = "scikit-learn" },
|
||||
@@ -1578,6 +1579,7 @@ requires-dist = [
|
||||
{ name = "prometheus-client", specifier = ">=0.19.0" },
|
||||
{ name = "py-clob-client", specifier = ">=0.1.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" },
|
||||
@@ -1903,6 +1905,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
|
||||
Reference in New Issue
Block a user