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.
Files changed (149) hide show
  1. provide/__init__.py +15 -0
  2. provide/foundation/__init__.py +155 -0
  3. provide/foundation/_version.py +58 -0
  4. provide/foundation/cli/__init__.py +67 -0
  5. provide/foundation/cli/commands/__init__.py +3 -0
  6. provide/foundation/cli/commands/deps.py +71 -0
  7. provide/foundation/cli/commands/logs/__init__.py +63 -0
  8. provide/foundation/cli/commands/logs/generate.py +357 -0
  9. provide/foundation/cli/commands/logs/generate_old.py +569 -0
  10. provide/foundation/cli/commands/logs/query.py +174 -0
  11. provide/foundation/cli/commands/logs/send.py +166 -0
  12. provide/foundation/cli/commands/logs/tail.py +112 -0
  13. provide/foundation/cli/decorators.py +262 -0
  14. provide/foundation/cli/main.py +65 -0
  15. provide/foundation/cli/testing.py +220 -0
  16. provide/foundation/cli/utils.py +210 -0
  17. provide/foundation/config/__init__.py +106 -0
  18. provide/foundation/config/base.py +295 -0
  19. provide/foundation/config/env.py +369 -0
  20. provide/foundation/config/loader.py +311 -0
  21. provide/foundation/config/manager.py +387 -0
  22. provide/foundation/config/schema.py +284 -0
  23. provide/foundation/config/sync.py +281 -0
  24. provide/foundation/config/types.py +78 -0
  25. provide/foundation/config/validators.py +80 -0
  26. provide/foundation/console/__init__.py +29 -0
  27. provide/foundation/console/input.py +364 -0
  28. provide/foundation/console/output.py +178 -0
  29. provide/foundation/context/__init__.py +12 -0
  30. provide/foundation/context/core.py +356 -0
  31. provide/foundation/core.py +20 -0
  32. provide/foundation/crypto/__init__.py +182 -0
  33. provide/foundation/crypto/algorithms.py +111 -0
  34. provide/foundation/crypto/certificates.py +896 -0
  35. provide/foundation/crypto/checksums.py +301 -0
  36. provide/foundation/crypto/constants.py +57 -0
  37. provide/foundation/crypto/hashing.py +265 -0
  38. provide/foundation/crypto/keys.py +188 -0
  39. provide/foundation/crypto/signatures.py +144 -0
  40. provide/foundation/crypto/utils.py +164 -0
  41. provide/foundation/errors/__init__.py +96 -0
  42. provide/foundation/errors/auth.py +73 -0
  43. provide/foundation/errors/base.py +81 -0
  44. provide/foundation/errors/config.py +103 -0
  45. provide/foundation/errors/context.py +299 -0
  46. provide/foundation/errors/decorators.py +484 -0
  47. provide/foundation/errors/handlers.py +360 -0
  48. provide/foundation/errors/integration.py +105 -0
  49. provide/foundation/errors/platform.py +37 -0
  50. provide/foundation/errors/process.py +140 -0
  51. provide/foundation/errors/resources.py +133 -0
  52. provide/foundation/errors/runtime.py +160 -0
  53. provide/foundation/errors/safe_decorators.py +133 -0
  54. provide/foundation/errors/types.py +276 -0
  55. provide/foundation/file/__init__.py +79 -0
  56. provide/foundation/file/atomic.py +157 -0
  57. provide/foundation/file/directory.py +134 -0
  58. provide/foundation/file/formats.py +236 -0
  59. provide/foundation/file/lock.py +175 -0
  60. provide/foundation/file/safe.py +179 -0
  61. provide/foundation/file/utils.py +170 -0
  62. provide/foundation/hub/__init__.py +88 -0
  63. provide/foundation/hub/click_builder.py +310 -0
  64. provide/foundation/hub/commands.py +42 -0
  65. provide/foundation/hub/components.py +640 -0
  66. provide/foundation/hub/decorators.py +244 -0
  67. provide/foundation/hub/info.py +32 -0
  68. provide/foundation/hub/manager.py +446 -0
  69. provide/foundation/hub/registry.py +279 -0
  70. provide/foundation/hub/type_mapping.py +54 -0
  71. provide/foundation/hub/types.py +28 -0
  72. provide/foundation/logger/__init__.py +41 -0
  73. provide/foundation/logger/base.py +22 -0
  74. provide/foundation/logger/config/__init__.py +16 -0
  75. provide/foundation/logger/config/base.py +40 -0
  76. provide/foundation/logger/config/logging.py +394 -0
  77. provide/foundation/logger/config/telemetry.py +188 -0
  78. provide/foundation/logger/core.py +239 -0
  79. provide/foundation/logger/custom_processors.py +172 -0
  80. provide/foundation/logger/emoji/__init__.py +44 -0
  81. provide/foundation/logger/emoji/matrix.py +209 -0
  82. provide/foundation/logger/emoji/sets.py +458 -0
  83. provide/foundation/logger/emoji/types.py +56 -0
  84. provide/foundation/logger/factories.py +56 -0
  85. provide/foundation/logger/processors/__init__.py +13 -0
  86. provide/foundation/logger/processors/main.py +254 -0
  87. provide/foundation/logger/processors/trace.py +113 -0
  88. provide/foundation/logger/ratelimit/__init__.py +31 -0
  89. provide/foundation/logger/ratelimit/limiters.py +294 -0
  90. provide/foundation/logger/ratelimit/processor.py +203 -0
  91. provide/foundation/logger/ratelimit/queue_limiter.py +305 -0
  92. provide/foundation/logger/setup/__init__.py +29 -0
  93. provide/foundation/logger/setup/coordinator.py +138 -0
  94. provide/foundation/logger/setup/emoji_resolver.py +64 -0
  95. provide/foundation/logger/setup/processors.py +85 -0
  96. provide/foundation/logger/setup/testing.py +39 -0
  97. provide/foundation/logger/trace.py +38 -0
  98. provide/foundation/metrics/__init__.py +119 -0
  99. provide/foundation/metrics/otel.py +122 -0
  100. provide/foundation/metrics/simple.py +165 -0
  101. provide/foundation/observability/__init__.py +53 -0
  102. provide/foundation/observability/openobserve/__init__.py +79 -0
  103. provide/foundation/observability/openobserve/auth.py +72 -0
  104. provide/foundation/observability/openobserve/client.py +307 -0
  105. provide/foundation/observability/openobserve/commands.py +357 -0
  106. provide/foundation/observability/openobserve/exceptions.py +41 -0
  107. provide/foundation/observability/openobserve/formatters.py +298 -0
  108. provide/foundation/observability/openobserve/models.py +134 -0
  109. provide/foundation/observability/openobserve/otlp.py +320 -0
  110. provide/foundation/observability/openobserve/search.py +222 -0
  111. provide/foundation/observability/openobserve/streaming.py +235 -0
  112. provide/foundation/platform/__init__.py +44 -0
  113. provide/foundation/platform/detection.py +193 -0
  114. provide/foundation/platform/info.py +157 -0
  115. provide/foundation/process/__init__.py +39 -0
  116. provide/foundation/process/async_runner.py +373 -0
  117. provide/foundation/process/lifecycle.py +406 -0
  118. provide/foundation/process/runner.py +390 -0
  119. provide/foundation/setup/__init__.py +101 -0
  120. provide/foundation/streams/__init__.py +44 -0
  121. provide/foundation/streams/console.py +57 -0
  122. provide/foundation/streams/core.py +65 -0
  123. provide/foundation/streams/file.py +104 -0
  124. provide/foundation/testing/__init__.py +166 -0
  125. provide/foundation/testing/cli.py +227 -0
  126. provide/foundation/testing/crypto.py +163 -0
  127. provide/foundation/testing/fixtures.py +49 -0
  128. provide/foundation/testing/hub.py +23 -0
  129. provide/foundation/testing/logger.py +106 -0
  130. provide/foundation/testing/streams.py +54 -0
  131. provide/foundation/tracer/__init__.py +49 -0
  132. provide/foundation/tracer/context.py +115 -0
  133. provide/foundation/tracer/otel.py +135 -0
  134. provide/foundation/tracer/spans.py +174 -0
  135. provide/foundation/types.py +32 -0
  136. provide/foundation/utils/__init__.py +97 -0
  137. provide/foundation/utils/deps.py +195 -0
  138. provide/foundation/utils/env.py +491 -0
  139. provide/foundation/utils/formatting.py +483 -0
  140. provide/foundation/utils/parsing.py +235 -0
  141. provide/foundation/utils/rate_limiting.py +112 -0
  142. provide/foundation/utils/streams.py +67 -0
  143. provide/foundation/utils/timing.py +93 -0
  144. provide_foundation-0.0.0.dev0.dist-info/METADATA +469 -0
  145. provide_foundation-0.0.0.dev0.dist-info/RECORD +149 -0
  146. provide_foundation-0.0.0.dev0.dist-info/WHEEL +5 -0
  147. provide_foundation-0.0.0.dev0.dist-info/entry_points.txt +2 -0
  148. provide_foundation-0.0.0.dev0.dist-info/licenses/LICENSE +201 -0
  149. 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