provide-foundation 0.0.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- provide/__init__.py +15 -0
- provide/foundation/__init__.py +155 -0
- provide/foundation/_version.py +58 -0
- provide/foundation/cli/__init__.py +67 -0
- provide/foundation/cli/commands/__init__.py +3 -0
- provide/foundation/cli/commands/deps.py +71 -0
- provide/foundation/cli/commands/logs/__init__.py +63 -0
- provide/foundation/cli/commands/logs/generate.py +357 -0
- provide/foundation/cli/commands/logs/generate_old.py +569 -0
- provide/foundation/cli/commands/logs/query.py +174 -0
- provide/foundation/cli/commands/logs/send.py +166 -0
- provide/foundation/cli/commands/logs/tail.py +112 -0
- provide/foundation/cli/decorators.py +262 -0
- provide/foundation/cli/main.py +65 -0
- provide/foundation/cli/testing.py +220 -0
- provide/foundation/cli/utils.py +210 -0
- provide/foundation/config/__init__.py +106 -0
- provide/foundation/config/base.py +295 -0
- provide/foundation/config/env.py +369 -0
- provide/foundation/config/loader.py +311 -0
- provide/foundation/config/manager.py +387 -0
- provide/foundation/config/schema.py +284 -0
- provide/foundation/config/sync.py +281 -0
- provide/foundation/config/types.py +78 -0
- provide/foundation/config/validators.py +80 -0
- provide/foundation/console/__init__.py +29 -0
- provide/foundation/console/input.py +364 -0
- provide/foundation/console/output.py +178 -0
- provide/foundation/context/__init__.py +12 -0
- provide/foundation/context/core.py +356 -0
- provide/foundation/core.py +20 -0
- provide/foundation/crypto/__init__.py +182 -0
- provide/foundation/crypto/algorithms.py +111 -0
- provide/foundation/crypto/certificates.py +896 -0
- provide/foundation/crypto/checksums.py +301 -0
- provide/foundation/crypto/constants.py +57 -0
- provide/foundation/crypto/hashing.py +265 -0
- provide/foundation/crypto/keys.py +188 -0
- provide/foundation/crypto/signatures.py +144 -0
- provide/foundation/crypto/utils.py +164 -0
- provide/foundation/errors/__init__.py +96 -0
- provide/foundation/errors/auth.py +73 -0
- provide/foundation/errors/base.py +81 -0
- provide/foundation/errors/config.py +103 -0
- provide/foundation/errors/context.py +299 -0
- provide/foundation/errors/decorators.py +484 -0
- provide/foundation/errors/handlers.py +360 -0
- provide/foundation/errors/integration.py +105 -0
- provide/foundation/errors/platform.py +37 -0
- provide/foundation/errors/process.py +140 -0
- provide/foundation/errors/resources.py +133 -0
- provide/foundation/errors/runtime.py +160 -0
- provide/foundation/errors/safe_decorators.py +133 -0
- provide/foundation/errors/types.py +276 -0
- provide/foundation/file/__init__.py +79 -0
- provide/foundation/file/atomic.py +157 -0
- provide/foundation/file/directory.py +134 -0
- provide/foundation/file/formats.py +236 -0
- provide/foundation/file/lock.py +175 -0
- provide/foundation/file/safe.py +179 -0
- provide/foundation/file/utils.py +170 -0
- provide/foundation/hub/__init__.py +88 -0
- provide/foundation/hub/click_builder.py +310 -0
- provide/foundation/hub/commands.py +42 -0
- provide/foundation/hub/components.py +640 -0
- provide/foundation/hub/decorators.py +244 -0
- provide/foundation/hub/info.py +32 -0
- provide/foundation/hub/manager.py +446 -0
- provide/foundation/hub/registry.py +279 -0
- provide/foundation/hub/type_mapping.py +54 -0
- provide/foundation/hub/types.py +28 -0
- provide/foundation/logger/__init__.py +41 -0
- provide/foundation/logger/base.py +22 -0
- provide/foundation/logger/config/__init__.py +16 -0
- provide/foundation/logger/config/base.py +40 -0
- provide/foundation/logger/config/logging.py +394 -0
- provide/foundation/logger/config/telemetry.py +188 -0
- provide/foundation/logger/core.py +239 -0
- provide/foundation/logger/custom_processors.py +172 -0
- provide/foundation/logger/emoji/__init__.py +44 -0
- provide/foundation/logger/emoji/matrix.py +209 -0
- provide/foundation/logger/emoji/sets.py +458 -0
- provide/foundation/logger/emoji/types.py +56 -0
- provide/foundation/logger/factories.py +56 -0
- provide/foundation/logger/processors/__init__.py +13 -0
- provide/foundation/logger/processors/main.py +254 -0
- provide/foundation/logger/processors/trace.py +113 -0
- provide/foundation/logger/ratelimit/__init__.py +31 -0
- provide/foundation/logger/ratelimit/limiters.py +294 -0
- provide/foundation/logger/ratelimit/processor.py +203 -0
- provide/foundation/logger/ratelimit/queue_limiter.py +305 -0
- provide/foundation/logger/setup/__init__.py +29 -0
- provide/foundation/logger/setup/coordinator.py +138 -0
- provide/foundation/logger/setup/emoji_resolver.py +64 -0
- provide/foundation/logger/setup/processors.py +85 -0
- provide/foundation/logger/setup/testing.py +39 -0
- provide/foundation/logger/trace.py +38 -0
- provide/foundation/metrics/__init__.py +119 -0
- provide/foundation/metrics/otel.py +122 -0
- provide/foundation/metrics/simple.py +165 -0
- provide/foundation/observability/__init__.py +53 -0
- provide/foundation/observability/openobserve/__init__.py +79 -0
- provide/foundation/observability/openobserve/auth.py +72 -0
- provide/foundation/observability/openobserve/client.py +307 -0
- provide/foundation/observability/openobserve/commands.py +357 -0
- provide/foundation/observability/openobserve/exceptions.py +41 -0
- provide/foundation/observability/openobserve/formatters.py +298 -0
- provide/foundation/observability/openobserve/models.py +134 -0
- provide/foundation/observability/openobserve/otlp.py +320 -0
- provide/foundation/observability/openobserve/search.py +222 -0
- provide/foundation/observability/openobserve/streaming.py +235 -0
- provide/foundation/platform/__init__.py +44 -0
- provide/foundation/platform/detection.py +193 -0
- provide/foundation/platform/info.py +157 -0
- provide/foundation/process/__init__.py +39 -0
- provide/foundation/process/async_runner.py +373 -0
- provide/foundation/process/lifecycle.py +406 -0
- provide/foundation/process/runner.py +390 -0
- provide/foundation/setup/__init__.py +101 -0
- provide/foundation/streams/__init__.py +44 -0
- provide/foundation/streams/console.py +57 -0
- provide/foundation/streams/core.py +65 -0
- provide/foundation/streams/file.py +104 -0
- provide/foundation/testing/__init__.py +166 -0
- provide/foundation/testing/cli.py +227 -0
- provide/foundation/testing/crypto.py +163 -0
- provide/foundation/testing/fixtures.py +49 -0
- provide/foundation/testing/hub.py +23 -0
- provide/foundation/testing/logger.py +106 -0
- provide/foundation/testing/streams.py +54 -0
- provide/foundation/tracer/__init__.py +49 -0
- provide/foundation/tracer/context.py +115 -0
- provide/foundation/tracer/otel.py +135 -0
- provide/foundation/tracer/spans.py +174 -0
- provide/foundation/types.py +32 -0
- provide/foundation/utils/__init__.py +97 -0
- provide/foundation/utils/deps.py +195 -0
- provide/foundation/utils/env.py +491 -0
- provide/foundation/utils/formatting.py +483 -0
- provide/foundation/utils/parsing.py +235 -0
- provide/foundation/utils/rate_limiting.py +112 -0
- provide/foundation/utils/streams.py +67 -0
- provide/foundation/utils/timing.py +93 -0
- provide_foundation-0.0.0.dev0.dist-info/METADATA +469 -0
- provide_foundation-0.0.0.dev0.dist-info/RECORD +149 -0
- provide_foundation-0.0.0.dev0.dist-info/WHEEL +5 -0
- provide_foundation-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- provide_foundation-0.0.0.dev0.dist-info/licenses/LICENSE +201 -0
- provide_foundation-0.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,295 @@
|
|
1
|
+
"""
|
2
|
+
Base configuration classes and utilities.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from __future__ import annotations
|
6
|
+
|
7
|
+
from collections.abc import Callable
|
8
|
+
import copy
|
9
|
+
from typing import Any, TypeVar
|
10
|
+
|
11
|
+
from attrs import NOTHING, Attribute, define, field as attrs_field, fields
|
12
|
+
|
13
|
+
from provide.foundation.config.types import ConfigDict, ConfigSource
|
14
|
+
|
15
|
+
T = TypeVar("T", bound="BaseConfig")
|
16
|
+
|
17
|
+
|
18
|
+
def field(
|
19
|
+
*,
|
20
|
+
default: Any = NOTHING,
|
21
|
+
factory: Callable[[], Any] | None = None,
|
22
|
+
validator: Callable[[Any, Attribute, Any], None] | None = None,
|
23
|
+
converter: Callable[[Any], Any] | None = None,
|
24
|
+
metadata: dict[str, Any] | None = None,
|
25
|
+
description: str | None = None,
|
26
|
+
env_var: str | None = None,
|
27
|
+
env_prefix: str | None = None,
|
28
|
+
sensitive: bool = False,
|
29
|
+
**kwargs: Any,
|
30
|
+
) -> Any:
|
31
|
+
"""
|
32
|
+
Enhanced attrs field with configuration-specific metadata.
|
33
|
+
|
34
|
+
Args:
|
35
|
+
default: Default value for the field
|
36
|
+
factory: Factory function to generate default value
|
37
|
+
validator: Validation function
|
38
|
+
converter: Conversion function
|
39
|
+
metadata: Additional metadata
|
40
|
+
description: Human-readable description
|
41
|
+
env_var: Environment variable name override
|
42
|
+
env_prefix: Prefix for environment variable
|
43
|
+
sensitive: Whether this field contains sensitive data
|
44
|
+
**kwargs: Additional attrs field arguments
|
45
|
+
"""
|
46
|
+
config_metadata = metadata or {}
|
47
|
+
|
48
|
+
# Add configuration-specific metadata
|
49
|
+
if description:
|
50
|
+
config_metadata["description"] = description
|
51
|
+
if env_var:
|
52
|
+
config_metadata["env_var"] = env_var
|
53
|
+
if env_prefix:
|
54
|
+
config_metadata["env_prefix"] = env_prefix
|
55
|
+
if sensitive:
|
56
|
+
config_metadata["sensitive"] = sensitive
|
57
|
+
|
58
|
+
# Handle factory vs default
|
59
|
+
if factory is not None:
|
60
|
+
return attrs_field(
|
61
|
+
factory=factory,
|
62
|
+
validator=validator,
|
63
|
+
converter=converter,
|
64
|
+
metadata=config_metadata,
|
65
|
+
**kwargs,
|
66
|
+
)
|
67
|
+
else:
|
68
|
+
return attrs_field(
|
69
|
+
default=default,
|
70
|
+
validator=validator,
|
71
|
+
converter=converter,
|
72
|
+
metadata=config_metadata,
|
73
|
+
**kwargs,
|
74
|
+
)
|
75
|
+
|
76
|
+
|
77
|
+
@define(slots=True, repr=False)
|
78
|
+
class BaseConfig:
|
79
|
+
"""
|
80
|
+
Base configuration class with common functionality.
|
81
|
+
|
82
|
+
All configuration classes should inherit from this.
|
83
|
+
All methods are async to support async validation and I/O operations.
|
84
|
+
"""
|
85
|
+
|
86
|
+
# These are instance attributes that need to be defined outside of slots
|
87
|
+
_source_map: dict[str, ConfigSource] = attrs_field(init=False, factory=lambda: {})
|
88
|
+
_original_values: dict[str, Any] = attrs_field(init=False, factory=lambda: {})
|
89
|
+
|
90
|
+
def __attrs_post_init__(self):
|
91
|
+
"""Post-initialization hook for subclasses."""
|
92
|
+
# The _source_map and _original_values are now handled by attrs with factory
|
93
|
+
# Note: validate() is now async, so we can't call it here
|
94
|
+
# Users must explicitly call await config.validate() after creation
|
95
|
+
pass
|
96
|
+
|
97
|
+
async def validate(self) -> None:
|
98
|
+
"""
|
99
|
+
Validate the configuration.
|
100
|
+
|
101
|
+
Override this method to add custom validation logic.
|
102
|
+
Can perform async operations like checking database connections.
|
103
|
+
"""
|
104
|
+
pass
|
105
|
+
|
106
|
+
def to_dict(self, include_sensitive: bool = False) -> ConfigDict:
|
107
|
+
"""
|
108
|
+
Convert configuration to dictionary.
|
109
|
+
|
110
|
+
Args:
|
111
|
+
include_sensitive: Whether to include sensitive fields
|
112
|
+
|
113
|
+
Returns:
|
114
|
+
Dictionary representation of the configuration
|
115
|
+
"""
|
116
|
+
result = {}
|
117
|
+
|
118
|
+
for attr in fields(self.__class__):
|
119
|
+
value = getattr(self, attr.name)
|
120
|
+
|
121
|
+
# Skip sensitive fields if requested
|
122
|
+
if not include_sensitive and attr.metadata.get("sensitive", False):
|
123
|
+
continue
|
124
|
+
|
125
|
+
# Convert nested configs recursively
|
126
|
+
if isinstance(value, BaseConfig):
|
127
|
+
value = value.to_dict(include_sensitive)
|
128
|
+
elif isinstance(value, dict):
|
129
|
+
value = self._convert_dict_values(value, include_sensitive)
|
130
|
+
elif isinstance(value, list):
|
131
|
+
value = self._convert_list_values(value, include_sensitive)
|
132
|
+
|
133
|
+
result[attr.name] = value
|
134
|
+
|
135
|
+
return result
|
136
|
+
|
137
|
+
def _convert_dict_values(self, d: dict, include_sensitive: bool) -> dict:
|
138
|
+
"""Convert dictionary values recursively."""
|
139
|
+
result = {}
|
140
|
+
for key, value in d.items():
|
141
|
+
if isinstance(value, BaseConfig):
|
142
|
+
value = value.to_dict(include_sensitive)
|
143
|
+
elif isinstance(value, dict):
|
144
|
+
value = self._convert_dict_values(value, include_sensitive)
|
145
|
+
elif isinstance(value, list):
|
146
|
+
value = self._convert_list_values(value, include_sensitive)
|
147
|
+
result[key] = value
|
148
|
+
return result
|
149
|
+
|
150
|
+
def _convert_list_values(self, lst: list, include_sensitive: bool) -> list:
|
151
|
+
"""Convert list values recursively."""
|
152
|
+
result = []
|
153
|
+
for value in lst:
|
154
|
+
if isinstance(value, BaseConfig):
|
155
|
+
value = value.to_dict(include_sensitive)
|
156
|
+
elif isinstance(value, dict):
|
157
|
+
value = self._convert_dict_values(value, include_sensitive)
|
158
|
+
elif isinstance(value, list):
|
159
|
+
value = self._convert_list_values(value, include_sensitive)
|
160
|
+
result.append(value)
|
161
|
+
return result
|
162
|
+
|
163
|
+
@classmethod
|
164
|
+
def from_dict(
|
165
|
+
cls: type[T], data: ConfigDict, source: ConfigSource = ConfigSource.RUNTIME
|
166
|
+
) -> T:
|
167
|
+
"""
|
168
|
+
Create configuration from dictionary.
|
169
|
+
|
170
|
+
Args:
|
171
|
+
data: Configuration data
|
172
|
+
source: Source of the configuration
|
173
|
+
|
174
|
+
Returns:
|
175
|
+
Configuration instance
|
176
|
+
"""
|
177
|
+
# Filter data to only include fields defined in the class
|
178
|
+
field_names = {f.name for f in fields(cls)}
|
179
|
+
filtered_data = {k: v for k, v in data.items() if k in field_names}
|
180
|
+
|
181
|
+
# Create instance
|
182
|
+
instance = cls(**filtered_data)
|
183
|
+
|
184
|
+
# Track sources
|
185
|
+
for key in filtered_data:
|
186
|
+
instance._source_map[key] = source
|
187
|
+
instance._original_values[key] = filtered_data[key]
|
188
|
+
|
189
|
+
return instance
|
190
|
+
|
191
|
+
def update(
|
192
|
+
self, updates: ConfigDict, source: ConfigSource = ConfigSource.RUNTIME
|
193
|
+
) -> None:
|
194
|
+
"""
|
195
|
+
Update configuration with new values.
|
196
|
+
|
197
|
+
Args:
|
198
|
+
updates: Dictionary of updates
|
199
|
+
source: Source of the updates
|
200
|
+
"""
|
201
|
+
for key, value in updates.items():
|
202
|
+
if hasattr(self, key):
|
203
|
+
# Only update if new source has higher precedence
|
204
|
+
current_source = self._source_map.get(key, ConfigSource.DEFAULT)
|
205
|
+
if source >= current_source:
|
206
|
+
setattr(self, key, value)
|
207
|
+
self._source_map[key] = source
|
208
|
+
self._original_values[key] = value
|
209
|
+
|
210
|
+
# Note: validate() is async, must be called separately if needed
|
211
|
+
|
212
|
+
def get_source(self, field_name: str) -> ConfigSource | None:
|
213
|
+
"""
|
214
|
+
Get the source of a configuration field.
|
215
|
+
|
216
|
+
Args:
|
217
|
+
field_name: Name of the field
|
218
|
+
|
219
|
+
Returns:
|
220
|
+
Source of the field value or None
|
221
|
+
"""
|
222
|
+
return self._source_map.get(field_name)
|
223
|
+
|
224
|
+
def reset_to_defaults(self) -> None:
|
225
|
+
"""Reset all fields to their default values."""
|
226
|
+
for attr in fields(self.__class__):
|
227
|
+
# Skip internal fields
|
228
|
+
if attr.name.startswith("_"):
|
229
|
+
continue
|
230
|
+
|
231
|
+
if attr.default != NOTHING:
|
232
|
+
setattr(self, attr.name, attr.default)
|
233
|
+
elif attr.factory != NOTHING:
|
234
|
+
# attrs factory is always callable
|
235
|
+
setattr(self, attr.name, attr.factory())
|
236
|
+
|
237
|
+
self._source_map.clear()
|
238
|
+
self._original_values.clear()
|
239
|
+
|
240
|
+
# Note: validate() is async, must be called separately if needed
|
241
|
+
|
242
|
+
def clone(self: T) -> T:
|
243
|
+
"""Create a deep copy of the configuration."""
|
244
|
+
cloned = copy.deepcopy(self)
|
245
|
+
# Note: validate() is async, must be called separately if needed
|
246
|
+
return cloned
|
247
|
+
|
248
|
+
def diff(self, other: BaseConfig) -> dict[str, tuple[Any, Any]]:
|
249
|
+
"""
|
250
|
+
Compare with another configuration.
|
251
|
+
|
252
|
+
Args:
|
253
|
+
other: Configuration to compare with
|
254
|
+
|
255
|
+
Returns:
|
256
|
+
Dictionary of differences (field_name: (self_value, other_value))
|
257
|
+
"""
|
258
|
+
if not isinstance(other, self.__class__):
|
259
|
+
raise TypeError(
|
260
|
+
f"Cannot compare {self.__class__.__name__} with {other.__class__.__name__}"
|
261
|
+
)
|
262
|
+
|
263
|
+
differences = {}
|
264
|
+
|
265
|
+
for attr in fields(self.__class__):
|
266
|
+
self_value = getattr(self, attr.name)
|
267
|
+
other_value = getattr(other, attr.name)
|
268
|
+
|
269
|
+
if self_value != other_value:
|
270
|
+
differences[attr.name] = (self_value, other_value)
|
271
|
+
|
272
|
+
return differences
|
273
|
+
|
274
|
+
def __repr__(self) -> str:
|
275
|
+
"""String representation hiding sensitive fields."""
|
276
|
+
# Get the actual attrs fields
|
277
|
+
import attrs
|
278
|
+
|
279
|
+
attr_fields = attrs.fields(self.__class__)
|
280
|
+
|
281
|
+
parts = []
|
282
|
+
for attr in attr_fields:
|
283
|
+
# Skip internal fields
|
284
|
+
if attr.name.startswith("_"):
|
285
|
+
continue
|
286
|
+
|
287
|
+
value = getattr(self, attr.name)
|
288
|
+
|
289
|
+
# Hide sensitive values
|
290
|
+
if attr.metadata.get("sensitive", False):
|
291
|
+
value = "***SENSITIVE***"
|
292
|
+
|
293
|
+
parts.append(f"{attr.name}={value!r}")
|
294
|
+
|
295
|
+
return f"{self.__class__.__name__}({', '.join(parts)})"
|
@@ -0,0 +1,369 @@
|
|
1
|
+
"""
|
2
|
+
Environment variable configuration utilities.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from __future__ import annotations
|
6
|
+
|
7
|
+
import asyncio
|
8
|
+
from collections.abc import Callable
|
9
|
+
import os
|
10
|
+
from typing import Any, TypeVar
|
11
|
+
|
12
|
+
try:
|
13
|
+
import aiofiles
|
14
|
+
except ImportError:
|
15
|
+
aiofiles = None
|
16
|
+
from attrs import fields
|
17
|
+
|
18
|
+
from provide.foundation.config.base import BaseConfig, field
|
19
|
+
from provide.foundation.config.types import ConfigSource
|
20
|
+
from provide.foundation.utils.parsing import (
|
21
|
+
auto_parse,
|
22
|
+
)
|
23
|
+
|
24
|
+
T = TypeVar("T")
|
25
|
+
|
26
|
+
|
27
|
+
async def get_env_async(
|
28
|
+
var_name: str,
|
29
|
+
default: str | None = None,
|
30
|
+
required: bool = False,
|
31
|
+
secret_file: bool = True,
|
32
|
+
) -> str | None:
|
33
|
+
"""
|
34
|
+
Get environment variable value with optional file-based secret support (async).
|
35
|
+
|
36
|
+
Args:
|
37
|
+
var_name: Environment variable name
|
38
|
+
default: Default value if not found
|
39
|
+
required: Whether the variable is required
|
40
|
+
secret_file: Whether to support file:// prefix for secrets
|
41
|
+
|
42
|
+
Returns:
|
43
|
+
Environment variable value or default
|
44
|
+
|
45
|
+
Raises:
|
46
|
+
ValueError: If required and not found
|
47
|
+
"""
|
48
|
+
value = os.environ.get(var_name)
|
49
|
+
|
50
|
+
if value is None:
|
51
|
+
if required:
|
52
|
+
raise ValueError(f"Required environment variable '{var_name}' not found")
|
53
|
+
return default
|
54
|
+
|
55
|
+
# Handle file-based secrets asynchronously
|
56
|
+
if secret_file and value.startswith("file://"):
|
57
|
+
file_path = value[7:] # Remove "file://" prefix
|
58
|
+
try:
|
59
|
+
async with aiofiles.open(file_path) as f:
|
60
|
+
value = await f.read()
|
61
|
+
value = value.strip()
|
62
|
+
except Exception as e:
|
63
|
+
raise ValueError(f"Failed to read secret from file '{file_path}': {e}")
|
64
|
+
|
65
|
+
return value
|
66
|
+
|
67
|
+
|
68
|
+
def get_env(
|
69
|
+
var_name: str,
|
70
|
+
default: str | None = None,
|
71
|
+
required: bool = False,
|
72
|
+
secret_file: bool = True,
|
73
|
+
) -> str | None:
|
74
|
+
"""
|
75
|
+
Get environment variable value with optional file-based secret support (sync).
|
76
|
+
|
77
|
+
This is a compatibility function that uses sync I/O.
|
78
|
+
Prefer get_env_async for new code.
|
79
|
+
|
80
|
+
Args:
|
81
|
+
var_name: Environment variable name
|
82
|
+
default: Default value if not found
|
83
|
+
required: Whether the variable is required
|
84
|
+
secret_file: Whether to support file:// prefix for secrets
|
85
|
+
|
86
|
+
Returns:
|
87
|
+
Environment variable value or default
|
88
|
+
|
89
|
+
Raises:
|
90
|
+
ValueError: If required and not found
|
91
|
+
"""
|
92
|
+
value = os.environ.get(var_name)
|
93
|
+
|
94
|
+
if value is None:
|
95
|
+
if required:
|
96
|
+
raise ValueError(f"Required environment variable '{var_name}' not found")
|
97
|
+
return default
|
98
|
+
|
99
|
+
# Handle file-based secrets synchronously
|
100
|
+
if secret_file and value.startswith("file://"):
|
101
|
+
file_path = value[7:] # Remove "file://" prefix
|
102
|
+
try:
|
103
|
+
with open(file_path) as f:
|
104
|
+
value = f.read().strip()
|
105
|
+
except Exception as e:
|
106
|
+
raise ValueError(f"Failed to read secret from file '{file_path}': {e}")
|
107
|
+
|
108
|
+
return value
|
109
|
+
|
110
|
+
|
111
|
+
def env_field(
|
112
|
+
env_var: str | None = None,
|
113
|
+
env_prefix: str | None = None,
|
114
|
+
parser: Callable[[str], Any] | None = None,
|
115
|
+
**kwargs,
|
116
|
+
) -> Any:
|
117
|
+
"""
|
118
|
+
Create a field that can be loaded from environment variables.
|
119
|
+
|
120
|
+
Args:
|
121
|
+
env_var: Explicit environment variable name
|
122
|
+
env_prefix: Prefix for environment variable
|
123
|
+
parser: Custom parser function
|
124
|
+
**kwargs: Additional field arguments
|
125
|
+
|
126
|
+
Returns:
|
127
|
+
Field descriptor
|
128
|
+
"""
|
129
|
+
metadata = kwargs.pop("metadata", {})
|
130
|
+
|
131
|
+
if env_var:
|
132
|
+
metadata["env_var"] = env_var
|
133
|
+
if env_prefix:
|
134
|
+
metadata["env_prefix"] = env_prefix
|
135
|
+
if parser:
|
136
|
+
metadata["env_parser"] = parser
|
137
|
+
|
138
|
+
return field(metadata=metadata, **kwargs)
|
139
|
+
|
140
|
+
|
141
|
+
class RuntimeConfig(BaseConfig):
|
142
|
+
"""
|
143
|
+
Configuration that can be loaded from environment variables.
|
144
|
+
All methods are async to support async secret fetching and validation.
|
145
|
+
"""
|
146
|
+
|
147
|
+
@classmethod
|
148
|
+
def from_env(
|
149
|
+
cls: type[T],
|
150
|
+
prefix: str = "",
|
151
|
+
delimiter: str = "_",
|
152
|
+
case_sensitive: bool = False,
|
153
|
+
) -> T:
|
154
|
+
"""
|
155
|
+
Load configuration from environment variables synchronously.
|
156
|
+
|
157
|
+
Args:
|
158
|
+
prefix: Prefix for all environment variables
|
159
|
+
delimiter: Delimiter between prefix and field name
|
160
|
+
case_sensitive: Whether variable names are case-sensitive
|
161
|
+
|
162
|
+
Returns:
|
163
|
+
Configuration instance
|
164
|
+
"""
|
165
|
+
data = {}
|
166
|
+
|
167
|
+
for attr in fields(cls):
|
168
|
+
# Determine environment variable name
|
169
|
+
env_var = attr.metadata.get("env_var")
|
170
|
+
|
171
|
+
if not env_var:
|
172
|
+
# Build from prefix and field name
|
173
|
+
field_prefix = attr.metadata.get("env_prefix", prefix)
|
174
|
+
field_name = attr.name.upper() if not case_sensitive else attr.name
|
175
|
+
|
176
|
+
if field_prefix:
|
177
|
+
env_var = f"{field_prefix}{delimiter}{field_name}"
|
178
|
+
else:
|
179
|
+
env_var = field_name
|
180
|
+
|
181
|
+
# Get value from environment
|
182
|
+
raw_value = os.environ.get(env_var)
|
183
|
+
|
184
|
+
if raw_value is not None:
|
185
|
+
value = raw_value
|
186
|
+
# Check if it's a file-based secret
|
187
|
+
if value.startswith("file://"):
|
188
|
+
# Read synchronously
|
189
|
+
file_path = value[7:]
|
190
|
+
try:
|
191
|
+
with open(file_path) as f:
|
192
|
+
value = f.read().strip()
|
193
|
+
except Exception as e:
|
194
|
+
raise ValueError(
|
195
|
+
f"Failed to read secret from file '{file_path}': {e}"
|
196
|
+
)
|
197
|
+
|
198
|
+
# Apply parser if specified
|
199
|
+
parser = attr.metadata.get("env_parser")
|
200
|
+
|
201
|
+
if parser:
|
202
|
+
try:
|
203
|
+
value = parser(value)
|
204
|
+
except Exception as e:
|
205
|
+
raise ValueError(f"Failed to parse {env_var}: {e}")
|
206
|
+
else:
|
207
|
+
# Try to infer parser from type
|
208
|
+
value = RuntimeConfig._auto_parse(attr, value)
|
209
|
+
|
210
|
+
data[attr.name] = value
|
211
|
+
|
212
|
+
return cls.from_dict(data, source=ConfigSource.ENV)
|
213
|
+
|
214
|
+
@classmethod
|
215
|
+
async def from_env_async(
|
216
|
+
cls: type[T],
|
217
|
+
prefix: str = "",
|
218
|
+
delimiter: str = "_",
|
219
|
+
case_sensitive: bool = False,
|
220
|
+
use_async_secrets: bool = True,
|
221
|
+
) -> T:
|
222
|
+
"""
|
223
|
+
Load configuration from environment variables asynchronously.
|
224
|
+
|
225
|
+
Args:
|
226
|
+
prefix: Prefix for all environment variables
|
227
|
+
delimiter: Delimiter between prefix and field name
|
228
|
+
case_sensitive: Whether variable names are case-sensitive
|
229
|
+
use_async_secrets: Whether to use async I/O for file-based secrets
|
230
|
+
|
231
|
+
Returns:
|
232
|
+
Configuration instance
|
233
|
+
"""
|
234
|
+
data = {}
|
235
|
+
|
236
|
+
# Collect all async operations
|
237
|
+
async_tasks = {}
|
238
|
+
sync_values = {}
|
239
|
+
|
240
|
+
for attr in fields(cls):
|
241
|
+
# Determine environment variable name
|
242
|
+
env_var = attr.metadata.get("env_var")
|
243
|
+
|
244
|
+
if not env_var:
|
245
|
+
# Build from prefix and field name
|
246
|
+
field_prefix = attr.metadata.get("env_prefix", prefix)
|
247
|
+
field_name = attr.name.upper() if not case_sensitive else attr.name
|
248
|
+
|
249
|
+
if field_prefix:
|
250
|
+
env_var = f"{field_prefix}{delimiter}{field_name}"
|
251
|
+
else:
|
252
|
+
env_var = field_name
|
253
|
+
|
254
|
+
# Get value from environment
|
255
|
+
raw_value = os.environ.get(env_var)
|
256
|
+
|
257
|
+
if raw_value is not None:
|
258
|
+
# Check if it's a file-based secret
|
259
|
+
if use_async_secrets and raw_value.startswith("file://"):
|
260
|
+
# Schedule async read
|
261
|
+
async_tasks[attr.name] = cls._read_secret_async(raw_value[7:])
|
262
|
+
else:
|
263
|
+
# Store for sync processing
|
264
|
+
sync_values[attr.name] = (attr, raw_value)
|
265
|
+
|
266
|
+
# Execute all async reads in parallel
|
267
|
+
if async_tasks:
|
268
|
+
async_results = await asyncio.gather(*async_tasks.values())
|
269
|
+
for field_name, value in zip(
|
270
|
+
async_tasks.keys(), async_results, strict=False
|
271
|
+
):
|
272
|
+
# Find the attribute
|
273
|
+
attr = next(a for a in fields(cls) if a.name == field_name)
|
274
|
+
sync_values[field_name] = (attr, value)
|
275
|
+
|
276
|
+
# Process all values
|
277
|
+
for field_name, (attr, value) in sync_values.items():
|
278
|
+
# Apply parser if specified
|
279
|
+
parser = attr.metadata.get("env_parser")
|
280
|
+
|
281
|
+
if parser:
|
282
|
+
try:
|
283
|
+
value = parser(value)
|
284
|
+
except Exception as e:
|
285
|
+
raise ValueError(f"Failed to parse {env_var}: {e}")
|
286
|
+
else:
|
287
|
+
# Try to infer parser from type
|
288
|
+
value = RuntimeConfig._auto_parse(attr, value)
|
289
|
+
|
290
|
+
data[field_name] = value
|
291
|
+
|
292
|
+
return cls.from_dict(data, source=ConfigSource.ENV)
|
293
|
+
|
294
|
+
@staticmethod
|
295
|
+
async def _read_secret_async(file_path: str) -> str:
|
296
|
+
"""Read secret from file asynchronously."""
|
297
|
+
try:
|
298
|
+
if aiofiles:
|
299
|
+
async with aiofiles.open(file_path) as f:
|
300
|
+
content = await f.read()
|
301
|
+
return content.strip()
|
302
|
+
else:
|
303
|
+
# Fallback to synchronous read
|
304
|
+
with open(file_path) as f:
|
305
|
+
content = f.read()
|
306
|
+
return content.strip()
|
307
|
+
except Exception as e:
|
308
|
+
raise ValueError(f"Failed to read secret from file '{file_path}': {e}")
|
309
|
+
|
310
|
+
@staticmethod
|
311
|
+
def _auto_parse(attr: Any, value: str) -> Any:
|
312
|
+
"""
|
313
|
+
Automatically parse value based on field type.
|
314
|
+
|
315
|
+
Args:
|
316
|
+
attr: Field attribute
|
317
|
+
value: String value to parse
|
318
|
+
|
319
|
+
Returns:
|
320
|
+
Parsed value
|
321
|
+
"""
|
322
|
+
# Use the utility function from utils.parsing
|
323
|
+
return auto_parse(attr, value)
|
324
|
+
|
325
|
+
def to_env_dict(self, prefix: str = "", delimiter: str = "_") -> dict[str, str]:
|
326
|
+
"""
|
327
|
+
Convert configuration to environment variable dictionary.
|
328
|
+
|
329
|
+
Args:
|
330
|
+
prefix: Prefix for all environment variables
|
331
|
+
delimiter: Delimiter between prefix and field name
|
332
|
+
|
333
|
+
Returns:
|
334
|
+
Dictionary of environment variables
|
335
|
+
"""
|
336
|
+
env_dict = {}
|
337
|
+
|
338
|
+
for attr in fields(self.__class__):
|
339
|
+
value = getattr(self, attr.name)
|
340
|
+
|
341
|
+
# Skip None values
|
342
|
+
if value is None:
|
343
|
+
continue
|
344
|
+
|
345
|
+
# Determine environment variable name
|
346
|
+
env_var = attr.metadata.get("env_var")
|
347
|
+
|
348
|
+
if not env_var:
|
349
|
+
field_prefix = attr.metadata.get("env_prefix", prefix)
|
350
|
+
field_name = attr.name.upper()
|
351
|
+
|
352
|
+
if field_prefix:
|
353
|
+
env_var = f"{field_prefix}{delimiter}{field_name}"
|
354
|
+
else:
|
355
|
+
env_var = field_name
|
356
|
+
|
357
|
+
# Convert value to string
|
358
|
+
if isinstance(value, bool):
|
359
|
+
str_value = "true" if value else "false"
|
360
|
+
elif isinstance(value, list):
|
361
|
+
str_value = ",".join(str(item) for item in value)
|
362
|
+
elif isinstance(value, dict):
|
363
|
+
str_value = ",".join(f"{k}={v}" for k, v in value.items())
|
364
|
+
else:
|
365
|
+
str_value = str(value)
|
366
|
+
|
367
|
+
env_dict[env_var] = str_value
|
368
|
+
|
369
|
+
return env_dict
|