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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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"]
|