This guide covers everything about configuring a PyFly application: file formats, the layered loading strategy, profiles, environment variable overrides, typed config binding, and the full reference of framework defaults.
- Introduction
- Config Class API
- Runtime Configuration Refresh
- YAML Configuration
- TOML Configuration
- Profile System
- Configuration Layering
- Environment Variable Overrides
- @config_properties
- @Value (Field-Level Config Injection)
- SpEL-lite Expressions
- Framework Defaults Reference
- Complete Example: Multi-Environment Setup
PyFly's configuration philosophy is convention over configuration with full override capability. The framework ships with sensible defaults for every setting. You only need to configure what differs from the defaults.
Key principles:
- Layered: four layers of configuration are deeply merged so you can override at any granularity.
- File-format agnostic: YAML and TOML are both first-class citizens.
- Profile-aware: different environments (dev, staging, prod) are handled with profile overlay files, not conditional logic in code.
- Environment-variable friendly: every config key can be overridden by an env var, making deployments in containers and CI/CD pipelines straightforward.
- Type-safe binding: use
@config_propertiesto bind config sections to typed Python dataclasses.
The Config class is the central configuration holder in PyFly. It wraps a nested
dictionary and provides dot-notation access with environment variable overrides.
from pyfly.core import Configclass Config:
def __init__(self, data: dict[str, Any] | None = None) -> None:Creates a Config from a pre-built dictionary. Most users will use from_file() instead.
config = Config({"pyfly": {"app": {"name": "my-app"}}})
assert config.get("pyfly.app.name") == "my-app"@classmethod
def from_file(
cls,
path: str | Path,
active_profiles: list[str] | None = None,
load_defaults: bool = True,
) -> Config:Loads configuration from a YAML or TOML file, merging framework defaults and profile
overlays. This is the recommended way to create a Config instance.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
path |
str | Path |
(required) | Path to the base configuration file (.yaml or .toml). |
active_profiles |
list[str] | None |
None |
Profiles whose overlay files should be merged. |
load_defaults |
bool |
True |
Whether to load the bundled pyfly-defaults.yaml as the base layer. |
Merge order (later wins):
- Framework defaults (
pyfly-defaults.yaml) - Base config file (the file at
path) - Profile overlay files (
{stem}-{profile}{suffix}for each active profile, in order) - Environment variables (checked at read time in
get())
config = Config.from_file("pyfly.yaml", active_profiles=["dev"])def get(self, key: str, default: Any = None) -> Any:Retrieves a value by dot-notation key. Environment variables are checked first, then the nested dictionary is walked.
Dot-notation to env var mapping:
Config Key --> Environment Variable
pyfly.app.name --> PYFLY_APP_NAME
pyfly.web.port --> PYFLY_WEB_PORT
pyfly.data.pool-size --> PYFLY_DATA_POOL_SIZE
database.host --> PYFLY_DATABASE_HOST
The transformation:
- If the key starts with
pyfly., strip that prefix. - Replace dots (
.) and hyphens (-) with underscores (_). - Uppercase the result.
- Prefix with
PYFLY_.
If no env var is set, the method walks the nested dictionary using the dot-separated parts.
Returns default if the key is not found.
port = config.get("pyfly.web.port", 8080) # int 8080 from YAML, or str from env varRelaxed segment matching: the dictionary walk in get() / _raw_get() is relaxed
(Spring Boot style): each path segment is matched with kebab/snake-case treated as
interchangeable. So config.get("pyfly.data.pool-size") finds a value stored under
pool_size, and config.get("my_prop.sub_key") finds one stored under my-prop.sub-key.
An exact match is tried first (hot path); only when absent does it fall back to comparing
the _relaxed() form (-/whitespace -> _, lower-cased) of each key. This is implemented
by _dict_get_relaxed() in core/config.py.
def get_section(self, prefix: str) -> dict[str, Any]:Returns all values under a dot-notation prefix as a dictionary subtree.
web_config = config.get_section("pyfly.web")
# {"port": 8080, "host": "0.0.0.0", "debug": False, "docs": {"enabled": True}, ...}If the prefix does not exist, returns an empty dict {}.
def bind(self, config_cls: type[T]) -> T:Binds a config section to a @config_properties dataclass, producing a typed object.
See the @config_properties section for details.
@config_properties(prefix="pyfly.web")
@dataclass
class WebConfig:
port: int = 8080
host: str = "0.0.0.0"
web = config.bind(WebConfig)
print(web.port) # 8080Raises ValueError if the class is not decorated with @config_properties.
def reload_from_sources(self) -> bool:Re-reads the original configuration sources and atomically swaps in the freshly
merged result, so a running application picks up edits to the config files and profile
overlays without a restart (Spring Cloud config refresh). It replays the exact merge
recorded by from_sources() — framework defaults, starter defaults,
config/ files, project-root files, and profile overlays — under an internal lock, then
rebinds _data in a single assignment. Because get() reads that single attribute,
concurrent readers always see a consistent snapshot (the old tree or the new one, never a
half-merged mix).
Returns:
| Return | Meaning |
|---|---|
True |
The sources were re-read and the merged config was swapped in. |
False |
The instance was not built via from_sources() (e.g. a dict-constructed Config), so there is nothing to reload — a no-op. |
config = Config.from_sources(".", active_profiles=["prod"])
# ... edit pyfly.yaml / pyfly-prod.yaml on disk ...
config.reload_from_sources() # True — files re-read, get() now returns new values
Config({"a": 1}).reload_from_sources() # False — dict-constructed, nothing to reloadNote: environment-variable overrides and
${...}placeholders are always resolved at read time inget(), so they reflect the current process environment regardless of reloading.reload_from_sources()is specifically about re-reading the files.
This method is invoked automatically by POST /actuator/refresh — see
Runtime Configuration Refresh below.
PyFly supports Spring Cloud-style runtime configuration refresh: you can change config files (or profile overlays) on disk and have a running application pick up the changes — no restart — by issuing a single management request.
curl -X POST http://localhost:8080/actuator/refresh
# {"refreshed": ["FeatureFlags-singleton", "PricingProperties-singleton"]}The endpoint resolves the injectable ContextRefresher and calls its refresh(), which
performs the following steps in order (src/pyfly/context/refresh.py):
- Re-reads the config sources. It calls
Config.reload_from_sources(), which replays the original multi-source merge and atomically swaps in the new tree. This is the step that lets edits topyfly.yaml/pyfly-{profile}.yamltake effect at runtime. (For a dict-constructedConfigthis is a no-op.) - Evicts all refresh-scoped beans (
@refresh_scope). Each cached instance is dropped so the next resolution rebuilds it — re-running constructor/field injection and re-reading@Valueplaceholders against the now-refreshedConfig. - Resets
@config_propertiessingletons. Their backing instances are cleared so they re-bind()from the liveConfig(which now reflects the re-read files, env-var overrides, and resolved${...}placeholders) on next resolution. - Publishes a
RefreshScopeRefreshedEventon the application event bus.
The response is {"refreshed": [...]}, listing the cache keys of the evicted
refresh-scoped beans (an empty list when none are registered).
Because step 1 now re-reads the files before steps 2–3 rebuild the affected beans, both
@refresh_scope and @config_properties beans see the new file values after a
refresh:
from dataclasses import dataclass
from pyfly.container import component
from pyfly.core import config_properties
from pyfly.container.refresh_scope import refresh_scope
from pyfly.core.value import Value
@config_properties(prefix="pyfly.pricing")
@dataclass
class PricingProperties:
base_rate: float = 1.0 # edit pyfly.yaml + POST /actuator/refresh -> re-bound
@component
@refresh_scope
class FeatureFlags:
new_checkout: bool = Value("${features.new-checkout:false}")
# next resolution after refresh re-reads ${features.new-checkout} from the live ConfigLike Spring Boot, the actuator is secure-by-default: only health and info are
reachable over HTTP. The refresh endpoint is registered whenever the actuator is enabled
with a context, but it is not mounted until you add it to the exposure include list:
pyfly:
management:
endpoints:
web:
exposure:
include: "health,info,refresh" # or "*" to expose every enabled endpointWith the default health,info, POST /actuator/refresh returns 404. See the
Actuator guide for the full endpoint reference.
YAML is the default configuration format. PyFly uses PyYAML (yaml.safe_load) for
parsing.
# pyfly.yaml
pyfly:
app:
name: "inventory-service"
version: "2.0.0"
profiles:
active: "dev"
web:
port: 8080
host: "0.0.0.0"
debug: true
data:
enabled: true
url: "postgresql+asyncpg://localhost:5432/inventory"
pool-size: 10
logging:
level:
root: "DEBUG"
format: "console"Nested keys map directly to dot-notation access:
config.get("pyfly.data.url") # "postgresql+asyncpg://localhost:5432/inventory"
config.get("pyfly.data.pool-size") # 10TOML is an alternative configuration format, parsed with Python's built-in tomllib
(Python 3.11+). Use .toml for projects that prefer INI-like syntax with strict typing.
# pyfly.toml
[pyfly.app]
name = "inventory-service"
version = "2.0.0"
[pyfly.profiles]
active = "dev"
[pyfly.web]
port = 8080
host = "0.0.0.0"
debug = true
[pyfly.data]
enabled = true
url = "postgresql+asyncpg://localhost:5432/inventory"
pool-size = 10
[pyfly.logging.level]
root = "DEBUG"
[pyfly.logging]
format = "console"Both YAML and TOML produce identical nested dictionary structures. The format is
determined by the file extension (.yaml vs .toml). All features -- layering, profiles,
env var overrides, @config_properties binding -- work identically with both formats.
Profiles let you maintain separate configuration for different environments (development, staging, production, testing) without conditional logic in your code.
Profiles are activated in priority order:
-
Environment variable (highest priority):
PYFLY_PROFILES_ACTIVE=prod,metrics python main.py
-
Config file (fallback):
pyfly: profiles: active: "dev"
-
Programmatically (in
Config.from_file()):config = Config.from_file("pyfly.yaml", active_profiles=["prod", "metrics"])
Multiple profiles are comma-separated. They are applied in order, so the last profile's values win on conflicts.
For each active profile, PyFly looks for a file named {stem}-{profile}{suffix} in the
same directory as the base config file.
| Base File | Profile | Overlay File |
|---|---|---|
pyfly.yaml |
dev |
pyfly-dev.yaml |
pyfly.yaml |
prod |
pyfly-prod.yaml |
pyfly.toml |
staging |
pyfly-staging.toml |
config/pyfly.yaml |
test |
config/pyfly-test.yaml |
Profile overlay files only need to contain the keys that differ from the base:
# pyfly-prod.yaml
pyfly:
web:
port: 443
debug: false
logging:
level:
root: "WARNING"
data:
url: "postgresql+asyncpg://prod-host:5432/inventory"
pool-size: 20Stereotype decorators (@service, @component, @repository, …) and @bean accept a
profile parameter that controls when a bean is active — the equivalent of Spring's
@Profile. The expression is stored as __pyfly_profile__ and evaluated by
Environment.accepts_profiles() during ApplicationContext startup
(_filter_by_profile()); beans whose expression does not match the active profiles are
dropped before instantiation.
| Expression | Meaning |
|---|---|
"dev" |
Active when the dev profile is active. |
"!production" |
Active when production is not active. |
"dev,test" |
Active when dev or test is active (legacy comma-OR). |
@service(profile="dev")
class DevOnlyService:
"""Only loaded when 'dev' profile is active."""
...
@service(profile="!test")
class ProductionService:
"""Loaded in all profiles except 'test'."""
...Since v26.06.39, profile expressions support the full Spring Boot 2.4+ grammar:
the & (and), | (or), and ! (not) operators combined with () grouping. This is
evaluated by Environment.accepts_profiles() (in pyfly.context.environment). The legacy
comma-OR form still works for backward compatibility.
| Expression | Active when… |
|---|---|
"prod & cloud" |
both prod and cloud are active. |
"prod | qa" |
either prod or qa is active. |
"(prod & cloud) | qa" |
prod and cloud are both active, or qa is active. |
"!(dev | test)" |
neither dev nor test is active. |
@service(profile="prod & cloud")
class CloudMetricsExporter:
"""Only loaded when BOTH 'prod' and 'cloud' profiles are active."""
...
@service(profile="!(dev | test)")
class RealPaymentGateway:
"""Loaded in any profile that is not 'dev' and not 'test'."""
...You can also evaluate expressions directly against the Environment:
from pyfly.core import Config
from pyfly.context.environment import Environment
env = Environment(Config({"pyfly": {"profiles": {"active": "prod,cloud"}}}))
env.accepts_profiles("prod & cloud") # True
env.accepts_profiles("(prod & cloud) | qa") # True
env.accepts_profiles("!(dev | test)") # True
env.accepts_profiles("dev,test") # False (legacy comma-OR still supported)The evaluator is safe by construction: each profile token is substituted with True/False
and the resulting boolean expression is parsed with ast.parse(..., mode="eval") and walked
node-by-node (only and/or/not and grouping are honored), never via Python eval. A
malformed expression evaluates to False rather than raising.
Profiles must be resolved before Config.from_file() runs, because the method
needs to know which overlay files to merge. This is handled by
PyFlyApplication._resolve_profiles_early(), which:
- Checks the
PYFLY_PROFILES_ACTIVEenvironment variable. - If not set, reads the base config file (YAML only) and extracts
pyfly.profiles.active. - Returns the list of active profiles for use in
Config.from_file().
This means profile activation via the config file works even before the full configuration is loaded.
PyFly's four-layer configuration system is the core of its flexibility. Each layer deeply merges into the previous, with later layers taking precedence.
Priority (highest to lowest):
4. Environment Variables PYFLY_WEB_PORT=9090
3. Profile Overlay Files pyfly-prod.yaml
2. User Configuration File pyfly.yaml
1. Framework Defaults pyfly-defaults.yaml (bundled)
The bundled pyfly-defaults.yaml inside pyfly.resources provides sensible defaults for
every configuration key the framework reads. You never edit this file. It is loaded using
importlib.resources so it works correctly in packaged distributions. Its full contents
are listed in the Framework Defaults Reference.
Your pyfly.yaml or pyfly.toml. When no explicit path is given to PyFlyApplication,
it auto-discovers by checking these candidates in order:
pyfly.yamlpyfly.tomlconfig/pyfly.yamlconfig/pyfly.toml
For each active profile, the corresponding overlay file is loaded and merged. If multiple profiles are active, they are applied in order:
PYFLY_PROFILES_ACTIVE=dev,metricsMerge order: defaults -> base -> pyfly-dev.yaml -> pyfly-metrics.yaml.
Checked at read time in Config.get(). This means they always win, even if set after
the config file is loaded. This layer enables runtime overrides without touching any
config files -- ideal for container deployments and CI/CD.
Layers are combined using a recursive deep merge (Config._deep_merge()). For nested
dictionaries, keys from the override layer are merged into the base; for non-dict values,
the override replaces the base entirely.
Example:
# Base (pyfly.yaml)
pyfly:
web:
port: 8080
host: "0.0.0.0"
docs:
enabled: true
# Overlay (pyfly-prod.yaml)
pyfly:
web:
port: 443Result after merge:
pyfly:
web:
port: 443 # overridden
host: "0.0.0.0" # preserved from base
docs:
enabled: true # preserved from basePyFly can import configuration from a remote config server at
bootstrap. When pyfly.cloud.config.uri (or the alias pyfly.config.import) is set
and pyfly.cloud.config.enabled is not false, PyFlyApplication._import_remote_config()
fetches the remote bundle during construction and deep-merges it as a high-precedence
source on top of the locally loaded config.
pyfly:
cloud:
config:
uri: "http://config:8888" # remote config server base URL
enabled: true # default true; set false to disable import
label: "main" # optional, defaults to "main"
fail-fast: false # default false; see below
username: "configuser" # optional HTTP basic auth
password: "s3cret" # optional HTTP basic authBehavior:
- The application name (
pyfly.app.name, falling back to the app's own name) and the comma-joined active profiles are sent to the server's/{application}/{profile}/{label}endpoint viaConfigClient. - The returned
propertySourcesare flattened and merged into the liveConfig, and aconfig-server (<uri>)entry is appended toloaded_sources. - The import is non-fatal by default: an unreachable server, a missing
httpxdependency, or a non-200 response logs a warning and the app falls back to local config only. Setpyfly.cloud.config.fail-fast: trueto make any import failure abort startup. - Because the import runs in the synchronous
__init__, it is skipped (with a warning) if an event loop is already running.
See the Config Server guide for the server side.
Every dot-notation config key maps to an environment variable:
- Strip the
pyfly.prefix (if present). - Replace
.and-with_. - Uppercase.
- Prefix with
PYFLY_.
| Config Key | Environment Variable |
|---|---|
pyfly.app.name |
PYFLY_APP_NAME |
pyfly.web.port |
PYFLY_WEB_PORT |
pyfly.web.debug |
PYFLY_WEB_DEBUG |
pyfly.data.pool-size |
PYFLY_DATA_POOL_SIZE |
pyfly.cache.redis.url |
PYFLY_CACHE_REDIS_URL |
pyfly.client.retry.max-attempts |
PYFLY_CLIENT_RETRY_MAX_ATTEMPTS |
pyfly.logging.level.root |
PYFLY_LOGGING_LEVEL_ROOT |
Environment variables are always strings. When read via Config.get(), they are returned
as strings. Type coercion happens in Config.bind() when binding to a @config_properties
dataclass:
| Target Type | Coercion |
|---|---|
int |
int(value) |
float |
float(value) |
bool |
value.lower() in ("true", "1", "yes") |
str |
No coercion needed. |
# Override the web server port
PYFLY_WEB_PORT=9090
# Enable debug mode
PYFLY_WEB_DEBUG=true
# Set the database URL
PYFLY_DATA_URL="postgresql+asyncpg://prod:5432/mydb"
# Activate profiles
PYFLY_PROFILES_ACTIVE=prod,metrics
# Set cache TTL
PYFLY_CACHE_TTL=600
# Set retry attempts
PYFLY_CLIENT_RETRY_MAX_ATTEMPTS=5@config_properties creates typed configuration classes that bind to specific config
prefixes. This eliminates string-based config access and gives you IDE autocompletion,
type checking, and default values.
from pyfly.core import config_propertiesDecorate a @dataclass with @config_properties(prefix="..."):
from dataclasses import dataclass
from pyfly.core import config_properties
@config_properties(prefix="pyfly.data")
@dataclass
class DataConfig:
enabled: bool = False
url: str = "sqlite+aiosqlite:///pyfly.db"
echo: bool = False
pool_size: int = 5The prefix determines which config section is read. Field names must match the keys
in that section. The decorator sets __pyfly_config_prefix__ on the class.
Call config.bind(ConfigClass) to produce a populated instance:
config = Config.from_file("pyfly.yaml")
data_config = config.bind(DataConfig)
print(data_config.url) # From pyfly.yaml or env var
print(data_config.pool_size) # 5 (default) or overriddenIf the class is not decorated with @config_properties, bind() raises a ValueError.
- Read the
__pyfly_config_prefix__attribute from the class. - Call
effective_section(prefix)— a resolved copy of the subtree with${...}placeholders expanded, environment-variable overrides applied, and env-only keys injected (values that exist only asPYFLY_*env vars but have no file entry). - For Pydantic
BaseModelsubclasses: pass the normalized section tomodel_validate()for fail-fast validation and rich type coercion. - For dataclasses: get type hints via
get_type_hints(), match fields using relaxed (kebab/snake interchangeable) key lookup, apply type coercion as needed. - Construct the dataclass with the gathered kwargs. Fields not present in config use their dataclass default values.
When values come from a YAML file, they are already correctly typed (YAML parsers
handle int, float, bool natively). When values come from config sections that contain
string data (e.g., from environment variable injection), bind() coerces:
| Target Type | String Coercion Rule |
|---|---|
int |
int(value) |
float |
float(value) |
bool |
value.lower() in ("true", "1", "yes") |
Fields not present in the config section use the dataclass default values.
bind() resolves its section via effective_section(), which not only resolves
${...} placeholders and overlays env-var overrides, but also injects env-only
keys that have no corresponding leaf in any config file. A PYFLY_<PREFIX>_*
variable whose key does not exist in the loaded YAML/TOML is added to the bound section
so that bind() sees the same value get() would.
For example, with no pyfly.data.replica-url key anywhere in your files:
PYFLY_DATA_REPLICA_URL="postgresql+asyncpg://replica:5432/orders"binds to a replica_url field on a @config_properties(prefix="pyfly.data") class.
The env suffix is split on _ (treated Spring-style as path separators relative to the
prefix); only absent leaves are added, and existing file/overlay values are never
overwritten. This is implemented by _inject_env_only() in core/config.py.
While @config_properties binds an entire configuration section to a dataclass, @Value injects individual configuration values directly into bean fields. It works as a Python descriptor that resolves expressions at bean creation time.
from pyfly.core.value import Value@Value supports four expression forms:
| Expression | Behaviour | Example |
|---|---|---|
${key} |
Resolve from Config; raise KeyError if missing |
Value("${pyfly.app.name}") |
${key:default} |
Resolve from Config; use default if missing | Value("${pyfly.timeout:30}") |
#{ ... } |
Evaluate a SpEL-lite expression (arithmetic/boolean/ternary, ${key} substitution, env) |
Value("#{${pyfly.workers:1} * 2}") |
literal |
Return the string as-is (no ${}/#{} wrapper) |
Value("hello") |
The #{ ... } form is the SpEL-lite expression language described below.
The key uses dot-notation to navigate the Config hierarchy (e.g., pyfly.data.mongodb.uri resolves to config["pyfly"]["data"]["mongodb"]["uri"]).
Placeholder config-references use the same relaxed segment matching as get():
kebab/snake-case segments are interchangeable. So ${my-prop.sub-key} resolves a value
stored under my_prop.sub_key (and vice versa). Each ${...} reference is also checked
against environment variables first — both its literal dotted name and the PYFLY_*
relaxed mapping (so ${app.name} honors PYFLY_APP_NAME) — before falling back to the
config tree and finally the inline :default.
Declare Value descriptors as class-level fields on any bean:
from pyfly.container import service
from pyfly.core.value import Value
@service
class NotificationService:
app_name: str = Value("${pyfly.app.name}")
max_retries: int = Value("${notifications.max-retries:3}")
sender_email: str = Value("${notifications.sender:noreply@example.com}")
async def send(self, to: str, message: str) -> None:
# self.app_name, self.max_retries, self.sender_email
# are resolved from Config when the bean is created
...The DI container resolves Value descriptors during bean initialization, before @post_construct hooks run.
| Feature | @Value |
@config_properties |
|---|---|---|
| Granularity | Individual fields | Entire config section |
| Location | Any bean class | Dedicated config dataclass |
| Default values | Inline ${key:default} |
Dataclass field defaults |
| Type coercion | Manual (values returned as strings) | Automatic via bind() |
| Use case | A few scattered config values | Structured config with many related fields |
Rule of thumb: Use @Value for 1-3 config values in a bean. Use @config_properties when a component needs a whole section of related configuration.
Source file: src/pyfly/core/value.py
PyFly ships a small, safe expression evaluator — its subset of Spring's SpEL. It
backs the #{ ... } form used by @Value and
@conditional_on_expression, letting you compute config
values and toggle beans from arithmetic and config-placeholder substitution without
writing any Python at the call site.
from pyfly.core.expression import evaluate, is_expressionAn expression is any string wrapped in #{ ... }. evaluate(text, config=None) parses
it with Python's ast module and evaluates it against a whitelist of node types; the
result is the computed value (any Python type).
Supported constructs:
| Category | Operators / Forms | Example | Result |
|---|---|---|---|
| Arithmetic | +, -, *, /, //, %, **, unary +/- |
#{2 * 5 + 1} |
11 |
| Comparison | ==, !=, <, <=, >, >= (incl. chained) |
#{3 > 2} |
True |
| Boolean | and, or, not |
#{not false} |
True |
| Ternary | a if cond else b |
#{100 if 2 > 1 else 200} |
100 |
| Literals | numbers, strings, true/false/null, lists, tuples |
#{[1, 2, 3]} |
[1, 2, 3] |
| Subscript | mapping[key] |
#{env['HOME']} |
env value |
true/false/null (and their Python True/False/None spellings) are recognized as
literals.
${key:default} substitution. Before evaluation, every ${key} /
${key:default} placeholder in the expression is resolved against the Config passed to
evaluate() and inlined as a literal. A bare ${key} raises ExpressionError if the key
is missing; the :default form falls back to the default.
from pyfly.core import Config
from pyfly.core.expression import evaluate
cfg = Config({"pyfly": {"workers": 4}})
evaluate("#{${pyfly.workers} * 2}", cfg) # 8
evaluate("#{${pyfly.missing:3} + 1}", cfg) # 4 (default used)The env mapping. A read-only env mapping exposes the process environment
(os.environ) for subscripting:
import os
os.environ["FEATURE_FLAG"] = "on"
evaluate("#{env['FEATURE_FLAG'] == 'on'}") # TrueIn a bean, the same forms work through @Value:
from pyfly.container import service
from pyfly.core.value import Value
@service
class PoolService:
# double the configured worker count, defaulting to 1
pool_size: int = Value("#{${pyfly.workers:1} * 2}")
# enable batching only when more than one worker is configured
batching: bool = Value("#{${pyfly.workers:1} > 1}")is_expression(text) returns whether a string is a #{ ... } expression — @Value
uses it to decide between SpEL-lite evaluation and plain ${...} placeholder resolution.
@conditional_on_expression registers a bean only when a SpEL-lite #{ ... } expression
is truthy — the equivalent of Spring Boot's @ConditionalOnExpression. The expression is
evaluated against the active config at ApplicationContext startup.
from pyfly.container import service
from pyfly.context import conditional_on_expression
@conditional_on_expression("#{${pyfly.workers:1} > 1}")
@service
class ParallelScheduler:
"""Only registered when pyfly.workers is greater than 1."""
...Because the expression supports ${key:default} substitution and the env mapping, it
covers numeric thresholds and environment-driven toggles that @conditional_on_property
(string equality only) cannot express. Combine it with the other @conditional_on_*
decorators (@conditional_on_property, @conditional_on_class, @conditional_on_bean,
@conditional_on_missing_bean) for auto-configuration-style wiring.
The evaluator is intentionally not full SpEL — it is designed so an expression can never execute arbitrary code:
- No
eval. Expressions are parsed withast.parse(..., mode="eval")and walked node-by-node; Python'seval/execare never called. - Whitelisted node types only. Only the operators and literal forms listed above are
evaluated. Any other node raises
ExpressionError. - No attribute access.
#{(1).__class__}is rejected — attribute navigation is not a whitelisted node. - No function or method calls.
#{__import__('os')}is rejected — call nodes are not whitelisted. - No assignment, no name resolution beyond the safe builtins. The only names available
are the literals (
true/false/null) and theenvmapping; an unknown name raisesExpressionError.
A malformed expression raises ExpressionError (a subclass of PyFlyException).
from pyfly.core.expression import ExpressionError, evaluate
for unsafe in ("#{__import__('os')}", "#{(1).__class__}", "#{unknown_name}"):
try:
evaluate(unsafe)
except ExpressionError:
pass # all three are rejectedSource file: src/pyfly/core/expression.py (decorator in src/pyfly/context/conditions.py)
The following are all default values from pyfly-defaults.yaml, organized by section.
Every key can be overridden in your config file or via environment variables.
| Key | Default | Description |
|---|---|---|
pyfly.app.name |
"pyfly-app" |
Application name used in logs and the banner. |
pyfly.app.version |
"0.1.0" |
Application version string. |
pyfly.app.description |
"" |
Human-readable application description. |
| Key | Default | Description |
|---|---|---|
pyfly.profiles.active |
"" |
Comma-separated list of active profiles. |
| Key | Default | Description |
|---|---|---|
pyfly.banner.mode |
"TEXT" |
Banner mode: TEXT, MINIMAL, or OFF. |
pyfly.banner.location |
"" |
Path to a custom banner file. Empty = use default ASCII art. |
| Key | Default | Description |
|---|---|---|
pyfly.logging.level.root |
"INFO" |
Root log level. |
pyfly.logging.format |
"console" |
Log output format. |
| Key | Default | Description |
|---|---|---|
pyfly.web.port |
8080 |
HTTP server port. |
pyfly.web.host |
"0.0.0.0" |
HTTP server bind address. |
pyfly.web.debug |
false |
Enable debug mode. |
pyfly.web.docs.enabled |
true |
Enable API documentation endpoints. |
pyfly.web.actuator.enabled |
false |
Enable actuator management endpoints. |
| Key | Default | Description |
|---|---|---|
pyfly.data.enabled |
false |
Enable the data layer. |
pyfly.data.url |
"sqlite+aiosqlite:///pyfly.db" |
Database connection URL. |
pyfly.data.echo |
false |
Echo SQL statements (for debugging). |
pyfly.data.pool-size |
5 |
Connection pool size. |
| Key | Default | Description |
|---|---|---|
pyfly.cache.enabled |
false |
Enable caching. |
pyfly.cache.provider |
"memory" |
Cache provider: redis or memory. |
pyfly.cache.redis.url |
"redis://localhost:6379/0" |
Redis connection URL. |
pyfly.cache.ttl |
300 |
Default cache TTL in seconds. |
| Key | Default | Description |
|---|---|---|
pyfly.messaging.provider |
"memory" |
Messaging provider: kafka, rabbitmq, or memory. |
pyfly.messaging.kafka.bootstrap-servers |
"localhost:9092" |
Kafka bootstrap servers. |
pyfly.messaging.rabbitmq.url |
"amqp://guest:guest@localhost/" |
RabbitMQ connection URL. |
| Key | Default | Description |
|---|---|---|
pyfly.client.timeout |
30 |
HTTP client timeout in seconds. |
pyfly.client.retry.max-attempts |
3 |
Maximum retry attempts. |
pyfly.client.retry.base-delay |
1.0 |
Base delay between retries (seconds). |
pyfly.client.circuit-breaker.failure-threshold |
5 |
Failures before the circuit opens. |
pyfly.client.circuit-breaker.recovery-timeout |
30 |
Seconds before attempting recovery. |
| Key | Default | Description |
|---|---|---|
pyfly.server.type |
"auto" |
Server type: auto, granian, uvicorn, or hypercorn. |
pyfly.server.event-loop |
"auto" |
Event loop: auto, uvloop, winloop, or asyncio. |
pyfly.server.workers |
0 |
Number of worker processes (0 = CPU count). |
pyfly.server.backlog |
1024 |
TCP connection backlog. |
pyfly.server.graceful-timeout |
30 |
Graceful shutdown timeout in seconds. |
pyfly.server.http |
"auto" |
HTTP implementation: auto, h11, httptools. |
pyfly.server.keep-alive-timeout |
5 |
Keep-alive timeout in seconds. |
| Key | Default | Description |
|---|---|---|
pyfly.admin.enabled |
true |
Enable the admin dashboard. |
pyfly.admin.path |
"/admin" |
URL path for the admin dashboard. |
pyfly.admin.title |
"PyFly Admin" |
Dashboard title. |
pyfly.admin.theme |
"auto" |
Theme: auto, light, or dark. |
pyfly.admin.require-auth |
false |
Require authentication for admin access. |
pyfly.admin.refresh-interval |
5000 |
SSE refresh interval in milliseconds. |
| Key | Default | Description |
|---|---|---|
pyfly.security.enabled |
false |
Enable the security module. |
pyfly.security.jwt.secret |
"change-me-in-production" |
JWT signing secret. Must be changed in production. |
pyfly.security.jwt.algorithm |
"HS256" |
JWT signing algorithm. |
pyfly.security.password.bcrypt-rounds |
12 |
Bcrypt hashing rounds. |
| Key | Default | Description |
|---|---|---|
pyfly.observability.metrics.enabled |
true |
Enable metrics collection. |
pyfly.observability.tracing.enabled |
true |
Enable distributed tracing. |
pyfly.observability.tracing.service-name |
"${pyfly.app.name}" |
OpenTelemetry service name (defaults to app name). |
pyfly:
app:
name: "pyfly-app"
version: "0.1.0"
description: ""
profiles:
active: ""
banner:
mode: "TEXT"
location: ""
logging:
level:
root: "INFO"
format: "console"
web:
port: 8080
host: "0.0.0.0"
debug: false
docs:
enabled: true
actuator:
enabled: false
server:
type: "auto"
event-loop: "auto"
workers: 0
backlog: 1024
graceful-timeout: 30
http: "auto"
keep-alive-timeout: 5
granian:
runtime-threads: 1
runtime-mode: "auto"
respawn-failed-workers: true
data:
enabled: false
url: "sqlite+aiosqlite:///pyfly.db"
echo: false
pool-size: 5
relational:
ddl-auto: "create"
cache:
enabled: false
provider: "memory"
ttl: 300
messaging:
provider: "memory"
client:
timeout: 30
retry:
max-attempts: 3
base-delay: 1.0
circuit-breaker:
failure-threshold: 5
recovery-timeout: 30
admin:
enabled: true
path: "/admin"
title: "PyFly Admin"
theme: "auto"
require-auth: false
refresh-interval: 5000
security:
enabled: false
jwt:
secret: "change-me-in-production"
algorithm: "HS256"
password:
bcrypt-rounds: 12
observability:
metrics:
enabled: true
tracing:
enabled: true
service-name: "${pyfly.app.name}"
transactional:
enabled: false
saga:
compensation_policy: STRICT_SEQUENTIAL
default_timeout_ms: 300000
tcc:
default_timeout_ms: 30000
retry_enabled: true
max_retries: 3This example demonstrates a realistic multi-environment configuration setup for a service that uses a database, cache, and messaging.
order-service/
pyfly.yaml # Base config (shared)
pyfly-dev.yaml # Dev overrides
pyfly-staging.yaml # Staging overrides
pyfly-prod.yaml # Production overrides
order_service/
__init__.py
app.py
config.py
...
main.py
pyfly:
app:
name: "order-service"
version: "3.2.0"
web:
port: 8080
docs:
enabled: true
data:
enabled: true
url: "sqlite+aiosqlite:///orders.db"
pool-size: 5
cache:
enabled: true
provider: "memory"
ttl: 300
messaging:
provider: "memory"
logging:
level:
root: "INFO"
format: "console"pyfly:
web:
debug: true
data:
echo: true
logging:
level:
root: "DEBUG"pyfly:
web:
port: 8080
data:
url: "postgresql+asyncpg://staging-db:5432/orders"
pool-size: 10
cache:
redis:
url: "redis://staging-redis:6379/0"pyfly:
web:
port: 443
debug: false
docs:
enabled: false
data:
url: "postgresql+asyncpg://prod-db:5432/orders"
pool-size: 25
cache:
redis:
url: "redis://prod-redis:6379/0"
ttl: 600
messaging:
kafka:
bootstrap-servers: "kafka-1:9092,kafka-2:9092,kafka-3:9092"
logging:
level:
root: "WARNING"
format: "json"
banner:
mode: "OFF"from dataclasses import dataclass
from pyfly.core import config_properties
@config_properties(prefix="pyfly.data")
@dataclass
class DataConfig:
enabled: bool = False
url: str = "sqlite+aiosqlite:///orders.db"
echo: bool = False
pool_size: int = 5
@config_properties(prefix="pyfly.cache")
@dataclass
class CacheConfig:
enabled: bool = False
provider: str = "memory"
ttl: int = 300from pyfly.core import pyfly_application
@pyfly_application(
name="order-service",
version="3.2.0",
scan_packages=["order_service"],
)
class OrderServiceApp:
passimport asyncio
from pyfly.core import PyFlyApplication
from order_service.app import OrderServiceApp
from order_service.config import DataConfig, CacheConfig
async def main():
app = PyFlyApplication(OrderServiceApp)
# Access typed config
data_cfg = app.config.bind(DataConfig)
cache_cfg = app.config.bind(CacheConfig)
print(f"Database: {data_cfg.url} (pool={data_cfg.pool_size})")
print(f"Cache TTL: {cache_cfg.ttl}s")
await app.startup()
# ... serve requests ...
await app.shutdown()
if __name__ == "__main__":
asyncio.run(main())# Development (local SQLite, debug logging)
PYFLY_PROFILES_ACTIVE=dev python main.py
# Staging (PostgreSQL, Redis cache)
PYFLY_PROFILES_ACTIVE=staging python main.py
# Production (full infrastructure, JSON logging, no docs)
PYFLY_PROFILES_ACTIVE=prod python main.py
# Production with env var overrides (e.g., in a container)
PYFLY_PROFILES_ACTIVE=prod \
PYFLY_DATA_URL="postgresql+asyncpg://rds-prod:5432/orders" \
PYFLY_WEB_PORT=8080 \
python main.pyFor the production container example above, the effective configuration is built as:
- Framework defaults (from
pyfly-defaults.yaml) - Base config (
pyfly.yaml: pool-size=5, port=8080, cache TTL=300) - Profile overlay (
pyfly-prod.yaml: pool-size=25, port=443, cache TTL=600) - Env vars (
PYFLY_DATA_URLoverrides the prod DB URL,PYFLY_WEB_PORT=8080overrides the prod port)
Final effective values:
| Key | Value | Source |
|---|---|---|
pyfly.web.port |
"8080" |
Env var (overrides prod overlay's 443) |
pyfly.web.debug |
false |
Prod overlay |
pyfly.web.docs.enabled |
false |
Prod overlay |
pyfly.data.url |
"postgresql+asyncpg://rds-prod:5432/orders" |
Env var |
pyfly.data.pool-size |
25 |
Prod overlay |
pyfly.cache.ttl |
600 |
Prod overlay |
pyfly.logging.format |
"json" |
Prod overlay |
pyfly.logging.level.root |
"WARNING" |
Prod overlay |
pyfly.banner.mode |
"OFF" |
Prod overlay |