Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ _build/
/worktrees/
/.ruff_cache/
.DS_Store
.benchmarks/
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,22 +138,28 @@ class AppSettings(BaseSettings):

## Performance

msgspec-ext leverages msgspec's high-performance serialization for fast settings loading with full type validation.
msgspec-ext leverages msgspec's high-performance serialization with bulk JSON decoding for maximum speed.

**Benchmark Results** (1000 iterations, Python 3.13):
**Benchmark Results** (1000 iterations, Python 3.12):

| Library | Time per load | Relative Performance |
|---------|---------------|---------------------|
| msgspec-ext | 0.933ms | Baseline |
| pydantic-settings | 2.694ms | 2.9x slower |
| msgspec-ext | 0.702ms | Baseline |
| pydantic-settings | 2.694ms | 3.8x slower |

msgspec-ext is **2.9x faster** than pydantic-settings while providing the same level of type safety and validation.
msgspec-ext is **3.8x faster** than pydantic-settings while providing the same level of type safety and validation.

**Key optimizations:**
- Bulk JSON decoding in C (via msgspec)
- Cached encoders and decoders
- Automatic field ordering
- Zero Python loops for validation

*Benchmark measures complete settings initialization including .env file parsing and type validation. Run `python benchmark.py` to reproduce.*

## Why msgspec-ext?

- **Performance** - 2.9x faster than pydantic-settings
- **Performance** - 3.8x faster than pydantic-settings
- **Lightweight** - 4x smaller package size (0.49 MB vs 1.95 MB)
- **Type safety** - Full type validation with modern Python type checkers
- **Minimal dependencies** - Only msgspec and python-dotenv
Expand All @@ -164,7 +170,7 @@ msgspec-ext is **2.9x faster** than pydantic-settings while providing the same l
|---------|------------|-------------------|
| .env support | ✅ | ✅ |
| Type validation | ✅ | ✅ |
| Performance | 2.9x faster | Baseline |
| Performance | **3.8x faster** ⚡ | Baseline |
| Package size | 0.49 MB | 1.95 MB |
| Nested config | ✅ | ✅ |
| Field aliases | ✅ | ✅ |
Expand Down
64 changes: 1 addition & 63 deletions benchmark.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
#!/usr/bin/env python3
"""Benchmark comparing msgspec-ext, pydantic-settings, and dynaconf."""
"""Benchmark comparing msgspec-ext and pydantic-settings."""

import os
import tempfile
import time
from pathlib import Path


# Test with msgspec-ext
Expand Down Expand Up @@ -110,49 +108,6 @@ class Config:
os.unlink(".env.benchmark")


def benchmark_dynaconf(iterations: int = 1000) -> float:
"""Benchmark dynaconf settings loading."""
from dynaconf import Dynaconf

# Create settings files
with tempfile.TemporaryDirectory() as tmpdir:
settings_file = Path(tmpdir) / "settings.toml"
settings_file.write_text("""
[default]
app_name = "benchmark-app"
debug = true
api_key = "test-api-key-12345"
max_connections = 200
timeout = 60.0
allowed_hosts = ["localhost", "127.0.0.1"]

[default.database]
host = "db.example.com"
port = 5433
username = "dbuser"
password = "dbpass123"
database = "production"
""")

# Warm up
for _ in range(10):
settings = Dynaconf(
settings_files=[str(settings_file)],
environments=True,
)

# Actual benchmark
start = time.perf_counter()
for _ in range(iterations):
settings = Dynaconf(
settings_files=[str(settings_file)],
environments=True,
)
end = time.perf_counter()

return (end - start) / iterations * 1000 # ms per iteration


def main():
"""Run benchmarks and display results."""
print("=" * 70)
Expand All @@ -179,14 +134,6 @@ def main():
print(f"ERROR: {e}")
pydantic_time = None

try:
print("⏱ dynaconf...", end=" ", flush=True)
dynaconf_time = benchmark_dynaconf()
print(f"{dynaconf_time:.3f}ms")
except Exception as e:
print(f"ERROR: {e}")
dynaconf_time = None

print()
print("=" * 70)
print("Results Summary")
Expand All @@ -197,19 +144,13 @@ def main():
print(f"msgspec-ext: {msgspec_time:.3f}ms per load")
if pydantic_time:
print(f"pydantic-settings: {pydantic_time:.3f}ms per load")
if dynaconf_time:
print(f"dynaconf: {dynaconf_time:.3f}ms per load")

print()

if msgspec_time and pydantic_time:
speedup = pydantic_time / msgspec_time
print(f"msgspec-ext is {speedup:.1f}x faster than pydantic-settings")

if msgspec_time and dynaconf_time:
speedup = dynaconf_time / msgspec_time
print(f"msgspec-ext is {speedup:.1f}x faster than dynaconf")

print()
print("=" * 70)

Expand All @@ -224,9 +165,6 @@ def main():
if pydantic_time:
rel = pydantic_time / msgspec_time if msgspec_time else 1.0
print(f"| pydantic-settings | {pydantic_time:.3f}ms | {rel:.1f}x slower |")
if dynaconf_time:
rel = dynaconf_time / msgspec_time if msgspec_time else 1.0
print(f"| dynaconf | {dynaconf_time:.3f}ms | {rel:.1f}x slower |")
print()


Expand Down
67 changes: 67 additions & 0 deletions examples/01_basic_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""
Basic usage of msgspec-ext for settings management.

This example shows the simplest use case: defining settings with defaults
and loading from environment variables.
"""

import os

from msgspec_ext import BaseSettings


class AppSettings(BaseSettings):
"""Application settings with sensible defaults."""

app_name: str = "my-app"
debug: bool = False
port: int = 8000
host: str = "0.0.0.0"


def main():
print("=" * 60)
print("Example 1: Basic Usage")
print("=" * 60)
print()

# Create settings with defaults
print("1. Using defaults:")
settings = AppSettings()
print(f" App Name: {settings.app_name}")
print(f" Debug: {settings.debug}")
print(f" Port: {settings.port}")
print(f" Host: {settings.host}")
print()

# Override with environment variables
print("2. Override with environment variables:")
os.environ["APP_NAME"] = "production-app"
os.environ["PORT"] = "9000"
os.environ["DEBUG"] = "true"

settings2 = AppSettings()
print(f" App Name: {settings2.app_name}")
print(f" Debug: {settings2.debug}")
print(f" Port: {settings2.port}")
print(f" Host: {settings2.host}")
print()

# Clean up
os.environ.pop("APP_NAME", None)
os.environ.pop("PORT", None)
os.environ.pop("DEBUG", None)

# Override with explicit values
print("3. Override with explicit values:")
settings3 = AppSettings(app_name="test-app", port=3000, debug=True)
print(f" App Name: {settings3.app_name}")
print(f" Debug: {settings3.debug}")
print(f" Port: {settings3.port}")
print()

print("✅ Basic usage complete!")


if __name__ == "__main__":
main()
92 changes: 92 additions & 0 deletions examples/02_env_prefix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""
Using env_prefix to namespace environment variables.

This example shows how to use env_prefix to avoid naming conflicts
when multiple applications share the same environment.
"""

import os

from msgspec_ext import BaseSettings, SettingsConfigDict


class DatabaseSettings(BaseSettings):
"""Database settings with DB_ prefix."""

model_config = SettingsConfigDict(env_prefix="DB_")

host: str = "localhost"
port: int = 5432
username: str = "admin"
password: str = "secret"
database: str = "myapp"


class RedisSettings(BaseSettings):
"""Redis settings with REDIS_ prefix."""

model_config = SettingsConfigDict(env_prefix="REDIS_")

host: str = "localhost"
port: int = 6379
database: int = 0


def main():
print("=" * 60)
print("Example 2: Environment Variable Prefixes")
print("=" * 60)
print()

# Set environment variables with different prefixes
os.environ["DB_HOST"] = "db.example.com"
os.environ["DB_PORT"] = "5433"
os.environ["DB_USERNAME"] = "dbuser"
os.environ["DB_PASSWORD"] = "dbpass123"
os.environ["DB_DATABASE"] = "production"

os.environ["REDIS_HOST"] = "redis.example.com"
os.environ["REDIS_PORT"] = "6380"
os.environ["REDIS_DATABASE"] = "1"

try:
# Load database settings
db_settings = DatabaseSettings()
print("Database Settings (DB_ prefix):")
print(f" Host: {db_settings.host}")
print(f" Port: {db_settings.port}")
print(f" Username: {db_settings.username}")
print(f" Password: {db_settings.password}")
print(f" Database: {db_settings.database}")
print()

# Load Redis settings
redis_settings = RedisSettings()
print("Redis Settings (REDIS_ prefix):")
print(f" Host: {redis_settings.host}")
print(f" Port: {redis_settings.port}")
print(f" Database: {redis_settings.database}")
print()

print("✅ Environment prefixes working correctly!")
print()
print("💡 Tip: Use prefixes to organize settings for different")
print(" services in microservice architectures.")

finally:
# Clean up
for key in [
"DB_HOST",
"DB_PORT",
"DB_USERNAME",
"DB_PASSWORD",
"DB_DATABASE",
"REDIS_HOST",
"REDIS_PORT",
"REDIS_DATABASE",
]:
os.environ.pop(key, None)


if __name__ == "__main__":
main()
80 changes: 80 additions & 0 deletions examples/03_dotenv_file.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
Loading settings from .env files.

This example shows how to load settings from .env files,
which is useful for local development and deployment.
"""

import tempfile
from pathlib import Path

from msgspec_ext import BaseSettings, SettingsConfigDict


class AppSettings(BaseSettings):
"""Application settings loaded from .env file."""

model_config = SettingsConfigDict(
env_file=".env.example", env_file_encoding="utf-8"
)

app_name: str
environment: str = "development"
api_key: str
database_url: str
max_connections: int = 100
enable_logging: bool = True


def main():
print("=" * 60)
print("Example 3: Loading from .env Files")
print("=" * 60)
print()

# Create a temporary .env file
env_content = """# Application Configuration
APP_NAME=my-awesome-app
ENVIRONMENT=production
API_KEY=sk-1234567890abcdef
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
MAX_CONNECTIONS=200
ENABLE_LOGGING=false
"""

env_file = Path(".env.example")
env_file.write_text(env_content)

try:
print("Created .env.example file:")
print("-" * 60)
print(env_content)
print("-" * 60)
print()

# Load settings from .env file
settings = AppSettings()

print("Loaded Settings:")
print(f" App Name: {settings.app_name}")
print(f" Environment: {settings.environment}")
print(f" API Key: {settings.api_key}")
print(f" Database URL: {settings.database_url}")
print(f" Max Connections: {settings.max_connections}")
print(f" Enable Logging: {settings.enable_logging}")
print()

print("✅ Settings loaded from .env file!")
print()
print("💡 Tips:")
print(" - Use .env.local for local overrides (add to .gitignore)")
print(" - Use .env.production for production settings")
print(" - Never commit secrets to version control")

finally:
# Clean up
env_file.unlink(missing_ok=True)


if __name__ == "__main__":
main()
Loading
Loading