Spring Boot-style configuration for Python applications
Common questions about SprigConfig, organized by topic.
SprigConfig is a lightweight, production-grade configuration system for Python applications. It brings Spring Boot-style configuration management to Python with layered YAML loading, profile overlays, recursive imports, secure secret handling, and complete provenance tracking.
Environment variables are great for deployment secrets and simple values, but they don’t handle:
SprigConfig works with environment variables (${VAR} expansion) while providing structure for complex configuration.
SprigConfig has a specific philosophy:
If these principles align with your needs, SprigConfig is a good fit. Other libraries may be better for different use cases.
SprigConfig requires Python 3.13 or later.
This is a core design principle. If profiles came from files, you’d have circular logic:
Runtime-driven profiles ensure:
The active profile is always available at app.profile:
cfg = load_config()
print(cfg["app.profile"]) # dev, test, or prod
Or in metadata:
print(cfg["sprigconfig._meta.profile"])
Yes. Use any name you want:
cfg = load_config(profile="staging")
cfg = load_config(profile="qa")
cfg = load_config(profile="local-docker")
Just create the corresponding overlay file (e.g., application-staging.yml).
SprigConfig detects pytest execution and defaults to test profile. This prevents accidentally running tests with production settings.
Override if needed:
cfg = load_config(profile="dev") # Explicit override
.yml, .yaml) — Default and recommended.json) — Strict, portable.toml) — Python 3.11+ stdlibNo. All files in a single configuration load must use the same format. This is intentional—mixing formats would introduce ambiguity in merge semantics.
Flat formats require inventing behavior for:
This invented behavior would violate SprigConfig’s principle of explicit, predictable configuration. See Philosophy for details.
# Via parameter
loader = ConfigLoader(config_dir=Path("config"), profile="dev", ext="json")
# Via environment variable
export SPRIGCONFIG_FORMAT=json
List appending creates ambiguity:
Replacement is explicit: you see exactly what the final list contains. If you want all items from base plus overlay, include them all in the overlay.
sprigconfig dump --config-dir=config --profile=dev
for source in cfg["sprigconfig._meta.sources"]:
print(f"Loaded: {source}")
import logging
logging.basicConfig(level=logging.DEBUG)
SprigConfig warns when an overlay might unintentionally lose keys. For example, if base has five keys and overlay mentions two, you’ll see a warning.
Suppress if intentional:
suppress_config_merge_warnings: true
No. SprigConfig detects cycles and raises ConfigLoadError with a clear message showing the cycle path.
No. Imports cannot escape the configuration directory. Path traversal like ../secrets.yml is blocked for security.
Root imports merge at the configuration root:
imports:
- database.yml # Merges at root level
Positional imports merge at their location:
database:
imports:
- connection.yml # Merges under database:
SprigConfig uses Fernet encryption from the cryptography library. Fernet provides:
This is industry-standard symmetric encryption suitable for configuration secrets.
Encrypted values cannot be recovered without the key. This is by design—there’s no backdoor.
Best practices:
Yes, and you should. Generate a separate key for each environment to limit blast radius if a key is compromised.
Lazy decryption means:
Configuration is typically loaded once at application startup. SprigConfig’s loading time is negligible for most applications.
If you’re loading configuration in a hot path, use ConfigSingleton to load once and reuse.
ConfigSingleton caches the loaded configuration. Direct load_config() calls load fresh each time.
Yes. ConfigSingleton uses locking to ensure thread-safe initialization and access.
The profile overlay file doesn’t exist:
application-staging.yml not found
Either create the file or use a different profile.
Set the encryption key before loading:
export APP_SECRET_KEY="your-key"
Or in code:
from sprigconfig.lazy_secret import set_global_key
set_global_key("your-key")
Your imports form a cycle. Check the error message for the cycle path and break it.
This is expected behavior. Remove app.profile from your YAML files—runtime determines the profile.
SprigConfig handles this automatically. Files are read with utf-8-sig encoding which strips BOM markers.
SprigConfig provides three Spring Boot-style dependency injection patterns:
ConfigValue — Field-level descriptor for lazy config binding with type conversion@ConfigurationProperties — Class-level decorator for auto-binding config sections@config_inject — Function parameter injection decorator with override supportfrom sprigconfig import ConfigValue, ConfigurationProperties, config_inject
class MyService:
db_url: str = ConfigValue("database.url")
db_port: int = ConfigValue("database.port", default=5432)
@ConfigurationProperties(prefix="database")
class DatabaseConfig:
url: str
port: int
_target_ and how does instantiate() work?SprigConfig supports Hydra-style dynamic class instantiation from configuration:
database:
_target_: app.adapters.PostgresAdapter
host: localhost
port: 5432
from sprigconfig import ConfigSingleton, instantiate
cfg = ConfigSingleton.get()
db = instantiate(cfg.database) # Returns PostgresAdapter instance
This enables hexagonal architecture patterns where adapters are swapped via configuration.
Yes. Load configuration, then validate with Pydantic:
from pydantic import BaseModel
from sprigconfig import load_config
class DatabaseConfig(BaseModel):
host: str
port: int
pool_size: int
cfg = load_config(profile="prod")
db_config = DatabaseConfig(**cfg["database"])
Yes:
from fastapi import FastAPI
from sprigconfig import ConfigSingleton
ConfigSingleton.initialize(profile="prod", config_dir="config")
app = FastAPI()
@app.get("/config")
def get_config():
cfg = ConfigSingleton.get()
return {"port": cfg["server.port"]}
Yes, in settings.py:
from sprigconfig import load_config
cfg = load_config(profile=os.getenv("DJANGO_ENV", "dev"))
DEBUG = cfg.get("django.debug", False)
DATABASES = {
"default": {
"HOST": cfg["database.host"],
"PORT": cfg["database.port"],
}
}
Open an issue at GitLab with:
Format support may be considered if:
See CONTRIBUTING.md for guidelines.