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,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
|
+
)
|
swiftpy/database/pool.py
ADDED
|
@@ -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"]
|