swiftpy-framework 0.1.0__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 (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Any
5
+
6
+ from swiftpy.core.application import Application
7
+ from swiftpy.core.container import Container, get_container, set_container
8
+ from swiftpy.core.providers.base import ServiceProvider
9
+
10
+ __all__ = [
11
+ "Container",
12
+ "create_app",
13
+ "get_container",
14
+ "set_container",
15
+ ]
16
+
17
+
18
+ def create_app(
19
+ providers: Sequence[type[ServiceProvider]] | None = None,
20
+ config: dict[str, Any] | None = None,
21
+ **kwargs: Any,
22
+ ) -> Application:
23
+ """
24
+ Bootstrap and initialize the SwiftPY application instance.
25
+
26
+ Convenience factory function wrapping Application instantiation and boot.
27
+ """
28
+ app = Application(providers=providers, config=config, **kwargs)
29
+ app.boot()
30
+ return app
swiftpy/core/config.py ADDED
@@ -0,0 +1,432 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import logging
6
+ import math
7
+ import os
8
+ from collections.abc import Mapping
9
+ from contextvars import ContextVar
10
+ from importlib.util import module_from_spec, spec_from_file_location
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from swiftpy.core.container import get_container
15
+
16
+ logger = logging.getLogger("swiftpy.core.config")
17
+
18
+ _MISSING = object()
19
+ _active_env_var: ContextVar[Mapping[str, str] | None] = ContextVar(
20
+ "_swiftpy_active_env", default=None
21
+ )
22
+
23
+
24
+ class ConfigurationError(ValueError):
25
+ """Raised when configuration values are malformed or invalid."""
26
+
27
+
28
+ def _parse_dotenv(dotenv_path: Path) -> dict[str, str]:
29
+ """Parse a `.env` file into key-value pairs without mutating `os.environ`."""
30
+ if not dotenv_path.is_file():
31
+ return {}
32
+
33
+ parsed: dict[str, str] = {}
34
+ try:
35
+ content = dotenv_path.read_text(encoding="utf-8")
36
+ except OSError as exc:
37
+ logger.warning("Failed to read .env file %s: %s", dotenv_path, exc)
38
+ return {}
39
+
40
+ for raw_line in content.splitlines():
41
+ line = raw_line.strip()
42
+ if not line or line.startswith("#"):
43
+ continue
44
+ if line.startswith("export "):
45
+ line = line[len("export ") :].strip()
46
+ if "=" not in line:
47
+ continue
48
+ key, val = line.split("=", 1)
49
+ key = key.strip()
50
+ if not key:
51
+ continue
52
+ val = val.strip()
53
+ if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
54
+ quote = val[0]
55
+ inner = val[1:-1]
56
+ if quote == '"':
57
+ inner = (
58
+ inner.replace('\\"', '"')
59
+ .replace("\\n", "\n")
60
+ .replace("\\r", "\r")
61
+ .replace("\\t", "\t")
62
+ )
63
+ val = inner
64
+ elif " #" in val:
65
+ val = val.split(" #", 1)[0].rstrip()
66
+ parsed[key] = val
67
+ return parsed
68
+
69
+
70
+ def env(key: str, default: Any = None) -> Any:
71
+ """Retrieve environment variables from active config load context or `os.environ`."""
72
+ active_env = _active_env_var.get()
73
+ if active_env is not None:
74
+ return active_env.get(key, default)
75
+ return os.getenv(key, default)
76
+
77
+
78
+ def env_bool(key: str, default: bool = False) -> bool:
79
+ """Retrieve an environment variable parsed strictly as a boolean."""
80
+ active_env = _active_env_var.get()
81
+ raw = active_env.get(key) if active_env is not None else os.getenv(key)
82
+ if raw is None:
83
+ return default
84
+ return raw.strip().lower() in ("true", "1", "yes", "on")
85
+
86
+
87
+ class Config:
88
+ """
89
+ Application configuration repository.
90
+ Supports dot-notation access for nested dictionaries and environment variable merging.
91
+ """
92
+
93
+ _instance: Config | None = None
94
+
95
+ def __init__(self, data: dict[str, Any] | None = None) -> None:
96
+ self._data: dict[str, Any] = copy.deepcopy(data) if data else {}
97
+
98
+ @classmethod
99
+ def get_instance(cls) -> Config:
100
+ """
101
+ Retrieve the active application's Config from the current container,
102
+ falling back to a default loaded Config when no application is active.
103
+ """
104
+ try:
105
+ container = get_container()
106
+ if container.has(cls):
107
+ return container.resolve(cls)
108
+ except RuntimeError:
109
+ pass
110
+
111
+ if cls._instance is None:
112
+ cls._instance = cls.load()
113
+ return cls._instance
114
+
115
+ @classmethod
116
+ def load(
117
+ cls,
118
+ config_dir: str | Path = "config",
119
+ env: dict[str, str] | None = None,
120
+ *,
121
+ strict: bool = True,
122
+ ignore_cache: bool = False,
123
+ ) -> Config:
124
+ """
125
+ Load configuration files from config_dir and merge with environment variables.
126
+ When `strict=True` (the default), broken or invalid config files raise `ConfigurationError`.
127
+ When `strict=False`, broken config files log a warning and are skipped.
128
+ """
129
+ config_path = Path(config_dir)
130
+ project_root = config_path.resolve().parent
131
+ cache_path = project_root / "bootstrap" / "cache" / "config.json"
132
+
133
+ if not ignore_cache and env is None and cache_path.is_file():
134
+ try:
135
+ with open(cache_path, "r", encoding="utf-8") as f:
136
+ cached_data = json.load(f)
137
+ if isinstance(cached_data, dict):
138
+ instance = cls(cached_data)
139
+ cls._instance = instance
140
+ return instance
141
+ except Exception as exc: # noqa: BLE001
142
+ logger.warning("Failed to load cached config %s: %s", cache_path, exc)
143
+
144
+ if env is None:
145
+ dotenv_vars = _parse_dotenv(project_root / ".env")
146
+ env_vars: Mapping[str, str] = {**dotenv_vars, **os.environ}
147
+ else:
148
+ env_vars = env
149
+
150
+ data: dict[str, Any] = {}
151
+ token = _active_env_var.set(env_vars)
152
+ try:
153
+ if config_path.is_dir():
154
+ for file in sorted(config_path.glob("*.py")):
155
+ if file.name.startswith(("_", ".")):
156
+ continue
157
+
158
+ spec = spec_from_file_location(file.stem, file)
159
+ if spec and spec.loader:
160
+ module = module_from_spec(spec)
161
+ try:
162
+ spec.loader.exec_module(module)
163
+ except Exception as exc:
164
+ if strict:
165
+ raise ConfigurationError(
166
+ f"Failed to load configuration file '{file}': {exc}"
167
+ ) from exc
168
+ logger.warning(
169
+ "Failed to load config file: %s",
170
+ file,
171
+ exc_info=True,
172
+ )
173
+ continue
174
+
175
+ if hasattr(module, "CONFIG"):
176
+ if isinstance(module.CONFIG, dict):
177
+ data[file.stem] = copy.deepcopy(module.CONFIG)
178
+ elif strict:
179
+ raise ConfigurationError(
180
+ f"Configuration file '{file}' must define CONFIG as a dict, "
181
+ f"got {type(module.CONFIG).__name__}."
182
+ )
183
+ finally:
184
+ _active_env_var.reset(token)
185
+
186
+ # Merge environment variables
187
+ cls._merge_env_variables(data, env_vars)
188
+
189
+ instance = cls(data)
190
+ cls._instance = instance
191
+ return instance
192
+
193
+ @classmethod
194
+ def _merge_env_variables(
195
+ cls,
196
+ data: dict[str, Any],
197
+ env_vars: Mapping[str, str],
198
+ ) -> None:
199
+ """
200
+ Merge environment variables into the configuration dictionary.
201
+ Supports deterministic precedence:
202
+ 1. Low tier: Standard top-level prefix and single-underscore normalized paths
203
+ 2. Medium tier: Double-underscore nested syntax (e.g. DATABASE__POOL__MAX_SIZE)
204
+ 3. High tier: Framework-specific SWIFTPY_ prefix (e.g. SWIFTPY_DATABASE__POOL__MAX_SIZE)
205
+ """
206
+ # First, build a map of existing normalized keys to their dotted paths
207
+ existing_paths: dict[str, str] = {}
208
+
209
+ def _index_paths(current: dict[str, Any], prefix: str = "") -> None:
210
+ for k, v in current.items():
211
+ if not k:
212
+ continue
213
+ path = f"{prefix}.{k}" if prefix else k
214
+ norm_key = path.upper().replace(".", "_")
215
+ existing_paths[norm_key] = path
216
+ if isinstance(v, dict):
217
+ _index_paths(v, path)
218
+
219
+ _index_paths(data)
220
+
221
+ # Precedence tiers: 1 (single-underscore/prefix) < 2 (double-underscore) < 3 (SWIFTPY_ prefix)
222
+ def _get_tier(raw_k: str) -> int:
223
+ if raw_k.startswith("SWIFTPY_"):
224
+ return 3
225
+ if "__" in raw_k:
226
+ return 2
227
+ return 1
228
+
229
+ sorted_env_items = sorted(env_vars.items(), key=lambda item: _get_tier(item[0]))
230
+
231
+ for raw_key, raw_val in sorted_env_items:
232
+ if not raw_key:
233
+ continue
234
+ key = raw_key.removeprefix("SWIFTPY_")
235
+ if not key or key.startswith("_") or key.endswith("_"):
236
+ continue
237
+
238
+ # 1. Double-underscore nested syntax
239
+ if "__" in key:
240
+ parts = [p.lower() for p in key.split("__")]
241
+ if any(not p or p.startswith("_") or p.endswith("_") for p in parts):
242
+ continue
243
+ dotted_key = ".".join(parts)
244
+ existing_val = cls._get_from_dict(data, dotted_key, _MISSING)
245
+ coerced = cls._coerce_value(raw_val, existing_val, key=dotted_key)
246
+ cls._set_in_dict(data, dotted_key, coerced)
247
+ continue
248
+
249
+ # 2. Match against existing normalized paths
250
+ norm_key = key.upper()
251
+ if norm_key in existing_paths:
252
+ dotted_key = existing_paths[norm_key]
253
+ existing_val = cls._get_from_dict(data, dotted_key, _MISSING)
254
+ coerced = cls._coerce_value(raw_val, existing_val, key=dotted_key)
255
+ cls._set_in_dict(data, dotted_key, coerced)
256
+ continue
257
+
258
+ # 3. Standard top-level prefix mapping (e.g. APP_DEBUG -> app.debug, APP_NAME -> app.name)
259
+ if "_" in key:
260
+ parts = key.split("_", 1)
261
+ top = parts[0].lower()
262
+ rest = parts[1].lower()
263
+ if not top or not rest:
264
+ continue
265
+ if top in data and isinstance(data[top], dict):
266
+ dotted_key = f"{top}.{rest}"
267
+ existing_val = cls._get_from_dict(data, dotted_key, _MISSING)
268
+ coerced = cls._coerce_value(raw_val, existing_val, key=dotted_key)
269
+ cls._set_in_dict(data, dotted_key, coerced)
270
+
271
+ @classmethod
272
+ def _coerce_value(
273
+ cls,
274
+ val: str,
275
+ existing: Any = _MISSING,
276
+ key: str = "",
277
+ ) -> Any:
278
+ """
279
+ Coerce raw string environment variable based on the declared type of `existing` (Decision D-8).
280
+ When no typed default exists, strings are preserved without blind numeric/boolean coercion.
281
+ """
282
+ if (existing is not _MISSING and isinstance(existing, bool)) or key in (
283
+ "app.debug",
284
+ "debug",
285
+ ):
286
+ return val.strip().lower() in ("true", "1", "yes", "on")
287
+ if existing is not _MISSING and existing is not None:
288
+ if isinstance(existing, int):
289
+ try:
290
+ return int(val)
291
+ except ValueError as exc:
292
+ raise ConfigurationError(
293
+ f"Configuration key '{key}' expects an integer, got '{val}'"
294
+ ) from exc
295
+ if isinstance(existing, float):
296
+ try:
297
+ res = float(val)
298
+ if math.isfinite(res):
299
+ return res
300
+ raise ConfigurationError(
301
+ f"Configuration key '{key}' expects a finite float, got '{val}'"
302
+ )
303
+ except ValueError as exc:
304
+ raise ConfigurationError(
305
+ f"Configuration key '{key}' expects a float, got '{val}'"
306
+ ) from exc
307
+ if isinstance(existing, list):
308
+ stripped = val.strip()
309
+ if not stripped:
310
+ return []
311
+ if stripped.startswith("[") and stripped.endswith("]"):
312
+ try:
313
+ parsed = json.loads(stripped)
314
+ except (json.JSONDecodeError, ValueError) as exc:
315
+ raise ConfigurationError(
316
+ f"Configuration key '{key}' contains malformed JSON: '{val}'"
317
+ ) from exc
318
+ if not isinstance(parsed, list):
319
+ raise ConfigurationError(
320
+ f"Configuration key '{key}' expects a list, got '{val}'"
321
+ )
322
+ return parsed
323
+ return [item.strip() for item in stripped.split(",") if item.strip()]
324
+
325
+ # JSON arrays / objects
326
+ if (val.startswith("{") and val.endswith("}")) or (
327
+ val.startswith("[") and val.endswith("]")
328
+ ):
329
+ try:
330
+ return json.loads(val)
331
+ except (json.JSONDecodeError, ValueError) as exc:
332
+ raise ConfigurationError(
333
+ f"Configuration key '{key}' contains malformed JSON: '{val}'"
334
+ ) from exc
335
+
336
+ return val
337
+
338
+ @classmethod
339
+ def _get_from_dict(
340
+ cls,
341
+ data: dict[str, Any],
342
+ key: str,
343
+ default: Any = _MISSING,
344
+ ) -> Any:
345
+ if not key:
346
+ return default
347
+ keys = key.split(".")
348
+ current: Any = data
349
+ for k in keys:
350
+ if not k:
351
+ return default
352
+ if isinstance(current, dict) and k in current:
353
+ current = current[k]
354
+ else:
355
+ return default
356
+ return current
357
+
358
+ @classmethod
359
+ def _set_in_dict(cls, data: dict[str, Any], key: str, value: Any) -> None:
360
+ if not key:
361
+ return
362
+ keys = key.split(".")
363
+ if any(not k for k in keys):
364
+ return
365
+ current = data
366
+ for k in keys[:-1]:
367
+ if k not in current or not isinstance(current[k], dict):
368
+ current[k] = {}
369
+ current = current[k]
370
+ current[keys[-1]] = value
371
+
372
+ def get(self, key: str, default: Any = None) -> Any:
373
+ """
374
+ Retrieve a configuration value using dot notation.
375
+ Distinguishes a missing key (returns `default`) from an explicit `None` value (returns `None`).
376
+ """
377
+ val = self._get_from_dict(self._data, key, _MISSING)
378
+ return default if val is _MISSING else val
379
+
380
+ def set(self, key: str, value: Any) -> None:
381
+ """Set a configuration value using dot notation."""
382
+ self._set_in_dict(self._data, key, value)
383
+
384
+ def has(self, key: str) -> bool:
385
+ """Check if a configuration key exists."""
386
+ return self._get_from_dict(self._data, key, _MISSING) is not _MISSING
387
+
388
+ def all(self) -> dict[str, Any]:
389
+ """Return a deep copy of the complete configuration dictionary."""
390
+ return copy.deepcopy(self._data)
391
+
392
+ def __getitem__(self, key: str) -> Any:
393
+ if not self.has(key):
394
+ raise KeyError(key)
395
+ return self.get(key)
396
+
397
+ def __setitem__(self, key: str, value: Any) -> None:
398
+ self.set(key, value)
399
+
400
+ def __contains__(self, key: str) -> bool:
401
+ return self.has(key)
402
+
403
+ def __call__(self, key: str | None = None, default: Any = None) -> Any:
404
+ if key is None:
405
+ return self
406
+ return self.get(key, default)
407
+
408
+ def dump_cache(
409
+ self,
410
+ cache_path: str | Path = "bootstrap/cache/config.json",
411
+ ) -> Path:
412
+ """Serialize configuration data to JSON cache for production performance."""
413
+ target = Path(cache_path)
414
+ target.parent.mkdir(parents=True, exist_ok=True)
415
+ with open(target, "w", encoding="utf-8") as f:
416
+ json.dump(self._data, f, indent=2, default=str)
417
+ return target
418
+
419
+ @classmethod
420
+ def clear_cache(
421
+ cls,
422
+ cache_path: str | Path = "bootstrap/cache/config.json",
423
+ ) -> bool:
424
+ """Delete serialized configuration cache file."""
425
+ target = Path(cache_path)
426
+ if target.is_file():
427
+ target.unlink()
428
+ return True
429
+ return False
430
+
431
+
432
+ __all__ = ["Config", "ConfigurationError", "env", "env_bool"]