Singularity
Getting Started

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

  1. load_dotenv() reads the .env file from the project root and populates os.environ.
  2. BaseSettings from pydantic-settings then resolves each field by checking environment variables first (case-insensitive match), then falling back to the default value.
  3. The settings singleton is created at module import time, so it is available immediately when any other module imports it.
  4. The inner Config class tells Pydantic to also look for a .env file 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.

devbetaprod
Logging levelDEBUGDEBUGINFO
Log outputsstderr + filestderrstderr
File logging10 MB rotationNoNo
Hot reloadAvailable (--dev)NoNo
Intended forLocal developmentStaging / QAProduction

Set the mode via the ENVIRONMENT environment variable in your .env file:

ENVIRONMENT=dev   # or beta, prod

Environment Variables Reference

The following table lists every setting field, its corresponding environment variable, type, default value, and description.

FieldEnv VariableTypeDefaultDescription
environmentENVIRONMENTstr"dev"Application mode: dev, beta, or prod. Controls logging behavior and other environment-specific settings.
database_urlDATABASE_URLstr"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_urlCELERY_BROKER_URLstr"redis://localhost:6379/0"Redis URL used as the Celery message broker. Tasks are enqueued here.
celery_result_backendCELERY_RESULT_BACKENDstr"redis://localhost:6379/0"Redis URL where Celery stores task results. Can be the same as the broker.
jwt_secretJWT_SECRETstr"dummy_value"Secret key used for signing and verifying JWT tokens. Must be changed in production.
rpc_base_urlRPC_BASE_URLstr"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:8000

Logging 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 = logger

The summary of logging behavior per environment:

devbetaprod
LevelDEBUGDEBUGINFO
stderrYesYesYes
File (logs/app.log)Yes (10 MB rotation)NoNo

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.environment

Accessing 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=true

Using 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 v

If 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