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,16 @@
1
+ from __future__ import annotations
2
+
3
+ from swiftpy.database.pagination.contracts import (
4
+ PaginatorContract,
5
+ SimplePaginatorContract,
6
+ )
7
+ from swiftpy.database.pagination.paginator import Paginator
8
+ from swiftpy.database.pagination.simple_paginator import SimplePaginator
9
+
10
+ __all__ = [
11
+ "Paginator",
12
+ "PaginatorContract",
13
+ "SimplePaginator",
14
+ "SimplePaginatorContract",
15
+ ]
16
+
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from typing import Any, Protocol, TypeVar
5
+
6
+ T_co = TypeVar("T_co", covariant=True)
7
+
8
+
9
+ class PaginatorContract[T_co](Protocol):
10
+ items: list[T_co]
11
+ total: int
12
+ per_page: int
13
+ current_page: int
14
+ last_page: int
15
+
16
+ @property
17
+ def has_more_pages(self) -> bool: ...
18
+
19
+ @property
20
+ def has_previous_page(self) -> bool: ...
21
+
22
+ @property
23
+ def first_item(self) -> int | None: ...
24
+
25
+ @property
26
+ def last_item(self) -> int | None: ...
27
+
28
+ def to_dict(self) -> dict[str, Any]: ...
29
+
30
+ def __iter__(self) -> Iterator[T_co]: ...
31
+
32
+ def __len__(self) -> int: ...
33
+
34
+ def __getitem__(self, index: int) -> T_co: ...
35
+
36
+
37
+ class SimplePaginatorContract[T_co](Protocol):
38
+ items: list[T_co]
39
+ per_page: int
40
+ current_page: int
41
+
42
+ @property
43
+ def has_more_pages(self) -> bool: ...
44
+
45
+ @property
46
+ def has_previous_page(self) -> bool: ...
47
+
48
+ @property
49
+ def first_item(self) -> int | None: ...
50
+
51
+ @property
52
+ def last_item(self) -> int | None: ...
53
+
54
+ def to_dict(self) -> dict[str, Any]: ...
55
+
56
+ def __iter__(self) -> Iterator[T_co]: ...
57
+
58
+ def __len__(self) -> int: ...
59
+
60
+ def __getitem__(self, index: int) -> T_co: ...
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections.abc import Iterator
5
+ from typing import Any
6
+
7
+
8
+ class Paginator[T]:
9
+ """
10
+ Length-aware paginator presenting a paginated slice of records along with
11
+ navigation metadata, total counts, and serialization helpers.
12
+ """
13
+
14
+ def __init__(
15
+ self,
16
+ items: list[T],
17
+ total: int,
18
+ per_page: int,
19
+ current_page: int,
20
+ last_page: int | None = None,
21
+ ) -> None:
22
+ if isinstance(total, bool) or not isinstance(total, int):
23
+ raise TypeError("total must be an integer.")
24
+ if total < 0:
25
+ raise ValueError("total cannot be negative.")
26
+
27
+ if isinstance(per_page, bool) or not isinstance(per_page, int):
28
+ raise TypeError("per_page must be an integer.")
29
+ if per_page < 1:
30
+ raise ValueError("per_page must be >= 1.")
31
+
32
+ if isinstance(current_page, bool) or not isinstance(current_page, int):
33
+ raise TypeError("current_page must be an integer.")
34
+ if current_page < 1:
35
+ raise ValueError("current_page must be >= 1.")
36
+
37
+ self.items: list[T] = items
38
+ self.total: int = total
39
+ self.per_page: int = per_page
40
+ self.current_page: int = current_page
41
+
42
+ if last_page is None:
43
+ self.last_page = max(1, math.ceil(total / per_page)) if per_page > 0 else 1
44
+ else:
45
+ if isinstance(last_page, bool) or not isinstance(last_page, int):
46
+ raise TypeError("last_page must be an integer.")
47
+ if last_page < 1:
48
+ raise ValueError("last_page must be >= 1.")
49
+ self.last_page = last_page
50
+
51
+ @property
52
+ def has_more_pages(self) -> bool:
53
+ """Return True if there are pages beyond the current page."""
54
+ return self.current_page < self.last_page
55
+
56
+ @property
57
+ def has_previous_page(self) -> bool:
58
+ """Return True if the current page is greater than page 1."""
59
+ return self.current_page > 1
60
+
61
+ @property
62
+ def first_item(self) -> int | None:
63
+ """Return the 1-based index of the first record in the current page, or None if empty."""
64
+ if not self.items:
65
+ return None
66
+ return (self.current_page - 1) * self.per_page + 1
67
+
68
+ @property
69
+ def last_item(self) -> int | None:
70
+ """Return the 1-based index of the last record in the current page, or None if empty."""
71
+ first = self.first_item
72
+ if first is None:
73
+ return None
74
+ return first + len(self.items) - 1
75
+
76
+ def to_dict(self) -> dict[str, Any]:
77
+ """Serialize paginator data and metadata into a standard dictionary payload."""
78
+ serialized_items: list[Any] = []
79
+ for item in self.items:
80
+ if hasattr(item, "to_dict") and callable(item.to_dict):
81
+ serialized_items.append(item.to_dict())
82
+ elif isinstance(item, dict):
83
+ serialized_items.append(dict(item))
84
+ else:
85
+ serialized_items.append(item)
86
+
87
+ return {
88
+ "data": serialized_items,
89
+ "current_page": self.current_page,
90
+ "per_page": self.per_page,
91
+ "total": self.total,
92
+ "last_page": self.last_page,
93
+ "from": self.first_item,
94
+ "to": self.last_item,
95
+ "has_more_pages": self.has_more_pages,
96
+ "has_previous_page": self.has_previous_page,
97
+ }
98
+
99
+ def __iter__(self) -> Iterator[T]:
100
+ return iter(self.items)
101
+
102
+ def __len__(self) -> int:
103
+ return len(self.items)
104
+
105
+ def __getitem__(self, index: int) -> T:
106
+ return self.items[index]
107
+
108
+ def __repr__(self) -> str:
109
+ return (
110
+ f"<{self.__class__.__name__} page={self.current_page}/{self.last_page} "
111
+ f"count={len(self.items)} total={self.total}>"
112
+ )
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from typing import Any
5
+
6
+
7
+ class SimplePaginator[T]:
8
+ """
9
+ Lightweight paginator that skips expensive COUNT(*) operations by querying
10
+ for an extra record (per_page + 1) to determine if additional pages exist.
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ items: list[T],
16
+ per_page: int,
17
+ current_page: int,
18
+ has_more: bool,
19
+ ) -> None:
20
+ if isinstance(per_page, bool) or not isinstance(per_page, int):
21
+ raise TypeError("per_page must be an integer.")
22
+ if per_page < 1:
23
+ raise ValueError("per_page must be >= 1.")
24
+
25
+ if isinstance(current_page, bool) or not isinstance(current_page, int):
26
+ raise TypeError("current_page must be an integer.")
27
+ if current_page < 1:
28
+ raise ValueError("current_page must be >= 1.")
29
+
30
+ if not isinstance(has_more, bool):
31
+ raise TypeError("has_more must be a boolean.")
32
+
33
+ self.items: list[T] = items
34
+ self.per_page: int = per_page
35
+ self.current_page: int = current_page
36
+ self._has_more: bool = has_more
37
+
38
+ @property
39
+ def has_more_pages(self) -> bool:
40
+ """Return True if additional pages exist beyond the current page."""
41
+ return self._has_more
42
+
43
+ @property
44
+ def has_previous_page(self) -> bool:
45
+ """Return True if the current page is greater than page 1."""
46
+ return self.current_page > 1
47
+
48
+ @property
49
+ def first_item(self) -> int | None:
50
+ """Return the 1-based index of the first record in the current page, or None if empty."""
51
+ if not self.items:
52
+ return None
53
+ return (self.current_page - 1) * self.per_page + 1
54
+
55
+ @property
56
+ def last_item(self) -> int | None:
57
+ """Return the 1-based index of the last record in the current page, or None if empty."""
58
+ first = self.first_item
59
+ if first is None:
60
+ return None
61
+ return first + len(self.items) - 1
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ """Serialize simple paginator data and metadata into a standard dictionary payload."""
65
+ serialized_items: list[Any] = []
66
+ for item in self.items:
67
+ if hasattr(item, "to_dict") and callable(item.to_dict):
68
+ serialized_items.append(item.to_dict())
69
+ elif isinstance(item, dict):
70
+ serialized_items.append(dict(item))
71
+ else:
72
+ serialized_items.append(item)
73
+
74
+ return {
75
+ "data": serialized_items,
76
+ "current_page": self.current_page,
77
+ "per_page": self.per_page,
78
+ "from": self.first_item,
79
+ "to": self.last_item,
80
+ "has_more_pages": self.has_more_pages,
81
+ "has_previous_page": self.has_previous_page,
82
+ }
83
+
84
+ def __iter__(self) -> Iterator[T]:
85
+ return iter(self.items)
86
+
87
+ def __len__(self) -> int:
88
+ return len(self.items)
89
+
90
+ def __getitem__(self, index: int) -> T:
91
+ return self.items[index]
92
+
93
+ def __repr__(self) -> str:
94
+ return (
95
+ f"<{self.__class__.__name__} page={self.current_page} "
96
+ f"count={len(self.items)} has_more={self._has_more}>"
97
+ )
@@ -0,0 +1,218 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import ssl
6
+ from typing import Any
7
+
8
+ import asyncpg
9
+
10
+ from swiftpy.core.config import Config, ConfigurationError
11
+
12
+ logger = logging.getLogger("swiftpy.database.pool")
13
+
14
+
15
+ def _create_ssl_context(
16
+ cafile: str | None,
17
+ *,
18
+ verify_mode: ssl.VerifyMode,
19
+ check_hostname: bool,
20
+ ) -> ssl.SSLContext:
21
+ """Create an SSLContext while translating file/SSL errors into ConfigurationError."""
22
+ try:
23
+ ctx = ssl.create_default_context(cafile=cafile)
24
+ except (OSError, ssl.SSLError, ValueError) as exc:
25
+ raise ConfigurationError(
26
+ f"Failed to load database SSL CA configuration (cafile={cafile!r}): {exc}"
27
+ ) from exc
28
+ ctx.check_hostname = check_hostname
29
+ ctx.verify_mode = verify_mode
30
+ return ctx
31
+
32
+
33
+ def _build_ssl_context(
34
+ ssl_opt: Any,
35
+ ssl_ca_file: str | None = None,
36
+ ) -> ssl.SSLContext | bool:
37
+ """
38
+ Build an ssl.SSLContext or boolean flag for asyncpg from configuration.
39
+ Supported modes: disable, require, verify-ca, verify-full.
40
+ Boolean True (or string 'true') maps to 'require' with a warning for backward compatibility.
41
+ """
42
+ if ssl_opt is False or ssl_opt is None:
43
+ return False
44
+
45
+ cafile = ssl_ca_file if ssl_ca_file else None
46
+
47
+ if ssl_opt is True:
48
+ logger.warning(
49
+ "Database SSL configured with boolean True; mapping to 'require' "
50
+ "(encrypted connection without certificate verification). "
51
+ "Prefer explicit 'verify-full' or 'verify-ca' in production."
52
+ )
53
+ return _create_ssl_context(
54
+ cafile, verify_mode=ssl.CERT_NONE, check_hostname=False
55
+ )
56
+
57
+ if isinstance(ssl_opt, ssl.SSLContext):
58
+ return ssl_opt
59
+
60
+ if isinstance(ssl_opt, str):
61
+ mode = ssl_opt.strip().lower()
62
+ if mode in ("", "disable", "false", "0", "off", "no"):
63
+ return False
64
+ if mode in ("true", "1", "yes", "on"):
65
+ logger.warning(
66
+ "Database SSL configured with boolean string %r; mapping to 'require' "
67
+ "(encrypted connection without certificate verification). "
68
+ "Prefer explicit 'verify-full' or 'verify-ca' in production.",
69
+ ssl_opt,
70
+ )
71
+ return _create_ssl_context(
72
+ cafile, verify_mode=ssl.CERT_NONE, check_hostname=False
73
+ )
74
+ if mode == "require":
75
+ return _create_ssl_context(
76
+ cafile, verify_mode=ssl.CERT_NONE, check_hostname=False
77
+ )
78
+ if mode == "verify-ca":
79
+ return _create_ssl_context(
80
+ cafile, verify_mode=ssl.CERT_REQUIRED, check_hostname=False
81
+ )
82
+ if mode == "verify-full":
83
+ return _create_ssl_context(
84
+ cafile, verify_mode=ssl.CERT_REQUIRED, check_hostname=True
85
+ )
86
+
87
+ raise ConfigurationError(
88
+ f"Unsupported database SSL mode: {ssl_opt!r}. "
89
+ "Expected one of: disable, require, verify-ca, verify-full."
90
+ )
91
+
92
+
93
+ class DatabasePool:
94
+ """Manages the application-wide asyncpg connection pool."""
95
+
96
+ def __init__(self, config: Config) -> None:
97
+ self.config = config
98
+ self._pool: asyncpg.Pool | None = None
99
+ self._init_lock = asyncio.Lock()
100
+
101
+ @property
102
+ def is_initialized(self) -> bool:
103
+ """Return whether the underlying asyncpg pool has been initialized."""
104
+ return self._pool is not None
105
+
106
+ async def init(self) -> asyncpg.Pool:
107
+ """
108
+ Initialize the asyncpg connection pool utilizing the injected Config repository.
109
+ Idempotent and safe under concurrent calls.
110
+ """
111
+ if self._pool is not None:
112
+ return self._pool
113
+
114
+ async with self._init_lock:
115
+ if self._pool is not None:
116
+ return self._pool
117
+
118
+ default_conn = str(self.config.get("database.default", "postgresql"))
119
+ conn_path = f"database.connections.{default_conn}"
120
+
121
+ user = str(self.config.get(f"{conn_path}.user", "postgres"))
122
+ raw_password = self.config.get(f"{conn_path}.password", "")
123
+ password = "" if raw_password is None else str(raw_password)
124
+
125
+ app_env = str(
126
+ self.config.get("app.env") or self.config.get("env") or "development"
127
+ ).lower()
128
+ debug_val = (
129
+ self.config.get("app.debug")
130
+ if self.config.get("app.debug") is not None
131
+ else self.config.get("debug")
132
+ )
133
+ is_production = app_env in ("production", "prod")
134
+ is_debug = bool(debug_val) if debug_val is not None else not is_production
135
+
136
+ if not password:
137
+ if is_production or not is_debug:
138
+ raise ConfigurationError(
139
+ f"Database connection '{default_conn}' has an empty password "
140
+ "while running in non-debug/production mode. Set DB_PASSWORD explicitly."
141
+ )
142
+ logger.warning(
143
+ "Database connection '%s' (user=%r) is using an empty password in debug mode.",
144
+ default_conn,
145
+ user,
146
+ )
147
+
148
+ ca_file_raw = self.config.get(
149
+ f"{conn_path}.ssl_ca_file"
150
+ ) or self.config.get("database.ssl_ca_file")
151
+ ssl_ca_file = str(ca_file_raw).strip() if ca_file_raw else None
152
+ ssl_arg = _build_ssl_context(
153
+ self.config.get(f"{conn_path}.ssl", "disable"),
154
+ ssl_ca_file=ssl_ca_file,
155
+ )
156
+
157
+ statement_timeout = float(
158
+ self.config.get("database.timeouts.statement", 30.0)
159
+ )
160
+ tx_timeout_raw = self.config.get("database.timeouts.transaction", None)
161
+ settings: dict[str, str] = {}
162
+ if statement_timeout > 0:
163
+ settings["statement_timeout"] = str(int(statement_timeout * 1000))
164
+ if tx_timeout_raw is not None:
165
+ tx_timeout = float(tx_timeout_raw)
166
+ if tx_timeout > 0:
167
+ settings["idle_in_transaction_session_timeout"] = str(
168
+ int(tx_timeout * 1000)
169
+ )
170
+ server_settings: dict[str, str] | None = settings or None
171
+
172
+ self._pool = await asyncpg.create_pool(
173
+ host=str(self.config.get(f"{conn_path}.host", "127.0.0.1")),
174
+ port=int(self.config.get(f"{conn_path}.port", 5432)),
175
+ database=str(self.config.get(f"{conn_path}.database", "swiftpy")),
176
+ user=user,
177
+ password=password,
178
+ ssl=ssl_arg,
179
+ min_size=int(self.config.get("database.pool.min_size", 5)),
180
+ max_size=int(self.config.get("database.pool.max_size", 20)),
181
+ max_queries=int(self.config.get("database.pool.max_queries", 50000)),
182
+ max_inactive_connection_lifetime=float(
183
+ self.config.get(
184
+ "database.pool.max_inactive_connection_lifetime", 300.0
185
+ )
186
+ ),
187
+ command_timeout=float(
188
+ self.config.get("database.pool.command_timeout", 60.0)
189
+ ),
190
+ timeout=float(self.config.get("database.timeouts.connect", 10.0)),
191
+ server_settings=server_settings,
192
+ )
193
+ return self._pool
194
+
195
+ def get_pool(self) -> asyncpg.Pool:
196
+ """Return the active connection pool instance."""
197
+ if self._pool is None:
198
+ raise RuntimeError(
199
+ "Database pool is not initialized. Ensure it is initialized during application startup hooks."
200
+ )
201
+ return self._pool
202
+
203
+ async def close(self, timeout: float = 5.0) -> None:
204
+ """Gracefully shutdown the connection pool with a timeout fallback to terminate()."""
205
+ if self._pool is not None:
206
+ pool = self._pool
207
+ self._pool = None
208
+ try:
209
+ await asyncio.wait_for(pool.close(), timeout=timeout)
210
+ except TimeoutError:
211
+ logger.warning(
212
+ "Database pool close timed out after %.2fs; terminating active connections.",
213
+ timeout,
214
+ )
215
+ pool.terminate()
216
+
217
+
218
+ __all__ = ["DatabasePool", "_build_ssl_context"]