Configuration
Walkthrough of the Pydantic Settings class, environment modes, all environment variables, logging configuration, and how to extend settings with custom fields.
Overview
Singularity uses Pydantic Settings (pydantic-settings) for configuration management. All settings are defined in a single Settings class, loaded from environment variables and a .env file, and exposed as a module-level singleton. Every part of the application -- services, middleware, the RPC layer, the logger -- reads from this one source of truth.
The settings module lives at singularity/config.py and is re-exported from singularity/__init__.py.
The Settings Class
Here is the full Settings class as defined in the project:
# singularity/config.py
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
load_dotenv()
class Settings(BaseSettings):
"""Settings class."""
environment: str = "dev" # can be dev | beta | prod
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
celery_broker_url: str = "redis://localhost:6379/0"
celery_result_backend: str = "redis://localhost:6379/0"
jwt_secret: str = "dummy_value"
rpc_base_url: str = "http://localhost:8000"
# MongoDB (requires the [mongo] extra). Both are None unless configured.
mongo_url: str | None = None
mongo_db_name: str | None = None
class Config:
"""Config class."""
env_file = ".env"
env_file_encoding = "utf-8"
# Singleton pattern to ensure only one instance of Settings is used
settings = Settings()How It Works
load_dotenv()reads the.envfile from the project root and populatesos.environ.BaseSettingsfrompydantic-settingsthen resolves each field by checking environment variables first (case-insensitive match), then falling back to the default value.- The
settingssingleton is created at module import time, so it is available immediately when any other module imports it. - The inner
Configclass tells Pydantic to also look for a.envfile directly (as a secondary source), with UTF-8 encoding.
Environment Modes
The environment field controls the application's behavior across three modes. Set it via the ENVIRONMENT environment variable in your .env file.
| dev | beta | prod | |
|---|---|---|---|
| Logging level | DEBUG | DEBUG | INFO |
| Log outputs | stderr + file | stderr | stderr |
| File logging | 10 MB rotation | No | No |
| Hot reload | Available (--dev) | No | No |
| Intended for | Local development | Staging / QA | Production |
Set the mode via the ENVIRONMENT environment variable in your .env file:
ENVIRONMENT=dev # or beta, prodEnvironment Variables Reference
The following table lists every setting field, its corresponding environment variable, type, default value, and description.
| Field | Env Variable | Type | Default | Description |
|---|---|---|---|---|
environment | ENVIRONMENT | str | "dev" | Application mode: dev, beta, or prod. Controls logging behavior and other environment-specific settings. |
database_url | DATABASE_URL | str | "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres" | PostgreSQL connection URL using the asyncpg driver. Must use the postgresql+asyncpg:// scheme for async SQLAlchemy. |
celery_broker_url | CELERY_BROKER_URL | str | "redis://localhost:6379/0" | Redis URL used as the Celery message broker. Tasks are enqueued here. |
celery_result_backend | CELERY_RESULT_BACKEND | str | "redis://localhost:6379/0" | Redis URL where Celery stores task results. Can be the same as the broker. |
jwt_secret | JWT_SECRET | str | "dummy_value" | Secret key used for signing and verifying JWT tokens. Must be changed in production. |
rpc_base_url | RPC_BASE_URL | str | "http://localhost:8000" | The base URL of this deployment, used when publishing RPC service specs to Redis for remote discovery. |
Example .env File
# Application mode
ENVIRONMENT=dev
# PostgreSQL (async)
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/myapp
# Redis (used by Celery and RPC heartbeat)
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
# Authentication
JWT_SECRET=change-this-to-a-real-secret-in-production
# RPC Discovery
RPC_BASE_URL=http://localhost:8000Logging Configuration
Logging is configured in singularity/common/logger.py using Loguru. The logger reads the environment setting at import time and configures handlers accordingly.
# singularity/common/logger.py
import sys
from loguru import logger
from singularity.config import settings
env = settings.environment
# Remove default handler to configure custom log levels
logger.remove()
# Configure logging based on environment
if env == "prod":
# Production: console only with INFO level
logger.add(sys.stderr, level="INFO")
elif env == "beta":
# Beta: console only with DEBUG level
logger.add(sys.stderr, level="DEBUG")
else:
# Dev: console with DEBUG + file logging
logger.add(sys.stderr, level="DEBUG")
logger.add("logs/app.log", rotation="10 MB", level="DEBUG")
log = loggerThe summary of logging behavior per environment:
| dev | beta | prod | |
|---|---|---|---|
| Level | DEBUG | DEBUG | INFO |
| stderr | Yes | Yes | Yes |
| File (logs/app.log) | Yes (10 MB rotation) | No | No |
To use the logger anywhere in the application:
from singularity.common.logger import log
log.info("Server started")
log.debug("Processing request", request_id="abc123")
log.error("Something went wrong", exc_info=True)Accessing Settings
The settings singleton can be imported from anywhere in the codebase. There are two equivalent import paths:
# Option 1: Import from the package
from config import settings
# Option 2: Import from the module directly
from singularity.config import settings
# Both return the same singleton instance
db_url = settings.database_url
env = settings.environmentAccessing Settings in a Service
Services receive an Acquire container in their constructor, but the settings singleton is also directly importable. Both approaches work:
from singularity.core import Service
from config import settings
class PaymentsService(Service):
def __init__(self, acquire):
super().__init__(acquire)
self.db = acquire.async_session
async def get_config(self):
return {
"environment": settings.environment,
"rpc_base_url": settings.rpc_base_url,
}Environment-Specific Behavior
You can branch on the environment mode to change service behavior:
from config import settings
from singularity.common.logger import log
async def send_notification(user_id: str, message: str):
if settings.environment == "dev":
# In development, just log instead of sending real notifications
log.debug(f"[DEV] Would notify {user_id}: {message}")
return {"status": "skipped", "reason": "dev mode"}
# In beta/prod, send the real notification
await push_service.send(user_id, message)
return {"status": "sent"}Extending Settings
To add custom configuration fields for your application, add new fields to the Settings class in singularity/config.py. Pydantic will automatically look for a matching environment variable (case-insensitive).
Adding a New Field
# singularity/config.py
class Settings(BaseSettings):
"""Settings class."""
# Existing fields
environment: str = "dev"
database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
celery_broker_url: str = "redis://localhost:6379/0"
celery_result_backend: str = "redis://localhost:6379/0"
jwt_secret: str = "dummy_value"
rpc_base_url: str = "http://localhost:8000"
# Your custom fields
stripe_api_key: str = ""
stripe_webhook_secret: str = ""
max_upload_size: int = 10_485_760 # 10 MB in bytes
feature_flag_new_checkout: bool = False
class Config:
env_file = ".env"
env_file_encoding = "utf-8"Then add the corresponding variables to your .env:
STRIPE_API_KEY=sk_test_abc123
STRIPE_WEBHOOK_SECRET=whsec_xyz789
MAX_UPLOAD_SIZE=52428800
FEATURE_FLAG_NEW_CHECKOUT=trueUsing Custom Fields
from config import settings
# Type-safe access with IDE autocompletion
api_key = settings.stripe_api_key
max_size = settings.max_upload_size
if settings.feature_flag_new_checkout:
# Use the new checkout flow
...Validation with Pydantic
Since Settings extends BaseSettings, you can use any Pydantic feature for validation:
from pydantic import field_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
environment: str = "dev"
max_upload_size: int = 10_485_760
@field_validator("environment")
@classmethod
def validate_environment(cls, v: str) -> str:
allowed = {"dev", "beta", "prod"}
if v not in allowed:
raise ValueError(f"environment must be one of {allowed}, got '{v}'")
return v
@field_validator("max_upload_size")
@classmethod
def validate_upload_size(cls, v: int) -> int:
if v <= 0:
raise ValueError("max_upload_size must be positive")
return vIf an invalid value is provided in the environment or .env file, Pydantic will raise a ValidationError at startup, before the application begins handling requests.
Next Steps
- Project Structure -- Understand how the codebase is organized
- Installation -- Set up and run the project