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,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"MAX_IDENTIFIER_LENGTH",
|
|
7
|
+
"quote_identifier",
|
|
8
|
+
"validate_column_identifier",
|
|
9
|
+
"validate_identifier",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
MAX_IDENTIFIER_LENGTH: int = 63
|
|
13
|
+
|
|
14
|
+
_IDENTIFIER_RE: re.Pattern[str] = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
15
|
+
_COLUMN_RE: re.Pattern[str] = re.compile(
|
|
16
|
+
r"^(?:"
|
|
17
|
+
r"\*"
|
|
18
|
+
r"|"
|
|
19
|
+
r"[a-zA-Z_][a-zA-Z0-9_]*"
|
|
20
|
+
r"(?:\.(?:[a-zA-Z_][a-zA-Z0-9_]*|\*))?"
|
|
21
|
+
r")$"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate_identifier(
|
|
26
|
+
identifier: str,
|
|
27
|
+
*,
|
|
28
|
+
allow_dotted: bool = False,
|
|
29
|
+
allow_wildcard: bool = False,
|
|
30
|
+
param_name: str | None = None,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""
|
|
33
|
+
Validate that an SQL identifier is secure, strictly alphanumeric/underscores,
|
|
34
|
+
within the 63-character PostgreSQL identifier limit, and contains no newlines or SQL injection.
|
|
35
|
+
"""
|
|
36
|
+
if not isinstance(identifier, str):
|
|
37
|
+
msg = (
|
|
38
|
+
f"Invalid {param_name} identifier '{identifier}'"
|
|
39
|
+
if param_name
|
|
40
|
+
else f"Invalid SQL identifier detected: {identifier}"
|
|
41
|
+
)
|
|
42
|
+
raise TypeError(msg)
|
|
43
|
+
|
|
44
|
+
if allow_wildcard and identifier == "*":
|
|
45
|
+
return identifier
|
|
46
|
+
|
|
47
|
+
if not identifier:
|
|
48
|
+
msg = (
|
|
49
|
+
f"Invalid {param_name} identifier '{identifier}'"
|
|
50
|
+
if param_name
|
|
51
|
+
else f"Invalid SQL identifier detected: {identifier}"
|
|
52
|
+
)
|
|
53
|
+
raise ValueError(msg)
|
|
54
|
+
|
|
55
|
+
segments = identifier.split(".") if allow_dotted else [identifier]
|
|
56
|
+
for seg in segments:
|
|
57
|
+
if allow_wildcard and seg == "*":
|
|
58
|
+
continue
|
|
59
|
+
if len(seg) > MAX_IDENTIFIER_LENGTH or not _IDENTIFIER_RE.fullmatch(seg):
|
|
60
|
+
msg = (
|
|
61
|
+
f"Invalid {param_name} identifier '{identifier}'"
|
|
62
|
+
if param_name
|
|
63
|
+
else f"Invalid SQL identifier detected: {identifier}"
|
|
64
|
+
)
|
|
65
|
+
raise ValueError(msg)
|
|
66
|
+
|
|
67
|
+
return identifier
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_column_identifier(column: str) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Validate a column identifier used in projections, supporting '*' or 'table.*' or 'table.column'.
|
|
73
|
+
"""
|
|
74
|
+
if not isinstance(column, str):
|
|
75
|
+
raise TypeError(
|
|
76
|
+
f"Invalid SQL column identifier type: expected str, got {type(column).__name__}"
|
|
77
|
+
)
|
|
78
|
+
if not _COLUMN_RE.fullmatch(column):
|
|
79
|
+
raise ValueError(f"Invalid SQL column identifier detected: {column}")
|
|
80
|
+
|
|
81
|
+
segments = column.split(".")
|
|
82
|
+
for seg in segments:
|
|
83
|
+
if seg != "*" and len(seg) > MAX_IDENTIFIER_LENGTH:
|
|
84
|
+
raise ValueError(f"Invalid SQL column identifier detected: {column}")
|
|
85
|
+
|
|
86
|
+
return column
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def quote_identifier(
|
|
90
|
+
identifier: str,
|
|
91
|
+
*,
|
|
92
|
+
allow_dotted: bool = True,
|
|
93
|
+
allow_wildcard: bool = True,
|
|
94
|
+
) -> str:
|
|
95
|
+
"""Validate, then quote each dotted segment: t.col becomes "t"."col"."""
|
|
96
|
+
validate_identifier(
|
|
97
|
+
identifier,
|
|
98
|
+
allow_dotted=allow_dotted,
|
|
99
|
+
allow_wildcard=allow_wildcard,
|
|
100
|
+
)
|
|
101
|
+
if identifier == "*":
|
|
102
|
+
return "*"
|
|
103
|
+
return ".".join(
|
|
104
|
+
f'"{part}"' if part != "*" else "*" for part in identifier.split(".")
|
|
105
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.database.migrations.blueprint import (
|
|
4
|
+
Blueprint,
|
|
5
|
+
ColumnDefinition,
|
|
6
|
+
DefaultRaw,
|
|
7
|
+
ForeignKeyDefinition,
|
|
8
|
+
IndexDefinition,
|
|
9
|
+
raw,
|
|
10
|
+
)
|
|
11
|
+
from swiftpy.database.migrations.lock import MigrationLock, MigrationLockError
|
|
12
|
+
from swiftpy.database.migrations.migration import Migration, MigrationProtocol
|
|
13
|
+
from swiftpy.database.migrations.migrator import (
|
|
14
|
+
MigrationFile,
|
|
15
|
+
MigrationRecord,
|
|
16
|
+
Migrator,
|
|
17
|
+
)
|
|
18
|
+
from swiftpy.database.migrations.schema import Schema, SchemaGrammar
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Blueprint",
|
|
22
|
+
"ColumnDefinition",
|
|
23
|
+
"DefaultRaw",
|
|
24
|
+
"ForeignKeyDefinition",
|
|
25
|
+
"IndexDefinition",
|
|
26
|
+
"Migration",
|
|
27
|
+
"MigrationFile",
|
|
28
|
+
"MigrationLock",
|
|
29
|
+
"MigrationLockError",
|
|
30
|
+
"MigrationProtocol",
|
|
31
|
+
"MigrationRecord",
|
|
32
|
+
"Migrator",
|
|
33
|
+
"Schema",
|
|
34
|
+
"SchemaGrammar",
|
|
35
|
+
"raw",
|
|
36
|
+
]
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Self
|
|
4
|
+
|
|
5
|
+
from swiftpy.database.identifiers import validate_identifier
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ALLOWED_COLUMN_TYPES",
|
|
9
|
+
"Blueprint",
|
|
10
|
+
"ColumnDefinition",
|
|
11
|
+
"DefaultRaw",
|
|
12
|
+
"ForeignKeyDefinition",
|
|
13
|
+
"IndexDefinition",
|
|
14
|
+
"raw",
|
|
15
|
+
"validate_identifier",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
_UNSET = object()
|
|
19
|
+
|
|
20
|
+
ALLOWED_COLUMN_TYPES: frozenset[str] = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"id",
|
|
23
|
+
"increments",
|
|
24
|
+
"big_increments",
|
|
25
|
+
"uuid",
|
|
26
|
+
"string",
|
|
27
|
+
"text",
|
|
28
|
+
"integer",
|
|
29
|
+
"big_integer",
|
|
30
|
+
"small_integer",
|
|
31
|
+
"boolean",
|
|
32
|
+
"decimal",
|
|
33
|
+
"float",
|
|
34
|
+
"json",
|
|
35
|
+
"jsonb",
|
|
36
|
+
"date",
|
|
37
|
+
"time",
|
|
38
|
+
"timestamp",
|
|
39
|
+
"timestamptz",
|
|
40
|
+
"vector",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_column_length(length: int | None) -> int | None:
|
|
46
|
+
if length is None:
|
|
47
|
+
return None
|
|
48
|
+
if (
|
|
49
|
+
isinstance(length, bool)
|
|
50
|
+
or not isinstance(length, int)
|
|
51
|
+
or length < 1
|
|
52
|
+
or length > 10_485_760
|
|
53
|
+
):
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"Column length must be an integer between 1 and 10485760, got {length!r}"
|
|
56
|
+
)
|
|
57
|
+
return length
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate_decimal_params(precision: int, scale: int) -> tuple[int, int]:
|
|
61
|
+
if (
|
|
62
|
+
isinstance(precision, bool)
|
|
63
|
+
or not isinstance(precision, int)
|
|
64
|
+
or precision < 1
|
|
65
|
+
or precision > 1000
|
|
66
|
+
):
|
|
67
|
+
raise ValueError(
|
|
68
|
+
f"Decimal precision must be an integer between 1 and 1000, got {precision!r}"
|
|
69
|
+
)
|
|
70
|
+
if (
|
|
71
|
+
isinstance(scale, bool)
|
|
72
|
+
or not isinstance(scale, int)
|
|
73
|
+
or scale < 0
|
|
74
|
+
or scale > precision
|
|
75
|
+
):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"Decimal scale must be an integer between 0 and {precision}, got {scale!r}"
|
|
78
|
+
)
|
|
79
|
+
return precision, scale
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DefaultRaw:
|
|
83
|
+
"""Explicit marker for raw SQL default expressions in migrations (e.g. raw('now()'))."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, sql: str) -> None:
|
|
86
|
+
if not isinstance(sql, str) or not sql.strip():
|
|
87
|
+
raise ValueError("Raw SQL default expression must be a non-empty string.")
|
|
88
|
+
self.sql: str = sql.strip()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def raw(sql: str) -> DefaultRaw:
|
|
92
|
+
"""Wrap a trusted SQL expression for use in ColumnDefinition.default()."""
|
|
93
|
+
return DefaultRaw(sql)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ColumnDefinition:
|
|
97
|
+
"""Represents a fluent column definition inside a table blueprint."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, name: str, type_name: str) -> None:
|
|
100
|
+
self.name: str = validate_identifier(name)
|
|
101
|
+
normalized_type = (
|
|
102
|
+
type_name.strip().lower() if isinstance(type_name, str) else ""
|
|
103
|
+
)
|
|
104
|
+
if normalized_type not in ALLOWED_COLUMN_TYPES:
|
|
105
|
+
raise ValueError(f"Unsupported column type: {type_name!r}")
|
|
106
|
+
self.type_name: str = normalized_type
|
|
107
|
+
self.length: int | None = None
|
|
108
|
+
self.precision: int | None = None
|
|
109
|
+
self.scale: int | None = None
|
|
110
|
+
self.is_nullable: bool = False
|
|
111
|
+
self.is_unique: bool = False
|
|
112
|
+
self.is_primary: bool = False
|
|
113
|
+
self.default_val: Any = _UNSET
|
|
114
|
+
self.is_index: bool = False
|
|
115
|
+
self.index_name: str | None = None
|
|
116
|
+
self.dimensions: int | None = None
|
|
117
|
+
self.comment_text: str | None = None
|
|
118
|
+
self.is_unsigned: bool = False
|
|
119
|
+
|
|
120
|
+
def nullable(self, value: bool = True) -> Self:
|
|
121
|
+
"""Specify whether the column may accept NULL values."""
|
|
122
|
+
self.is_nullable = value
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def default(self, value: Any) -> Self:
|
|
126
|
+
"""Specify the default value for the column."""
|
|
127
|
+
self.default_val = value
|
|
128
|
+
return self
|
|
129
|
+
|
|
130
|
+
def unique(self, name: str | None = None) -> Self:
|
|
131
|
+
"""Add a unique constraint or index for this column."""
|
|
132
|
+
self.is_unique = True
|
|
133
|
+
if name:
|
|
134
|
+
self.index_name = validate_identifier(name)
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
def index(self, name: str | None = None) -> Self:
|
|
138
|
+
"""Add an index for this column."""
|
|
139
|
+
self.is_index = True
|
|
140
|
+
if name:
|
|
141
|
+
self.index_name = validate_identifier(name)
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def primary(self) -> Self:
|
|
145
|
+
"""Mark this column as the primary key."""
|
|
146
|
+
self.is_primary = True
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def comment(self, text: str) -> Self:
|
|
150
|
+
"""Set a comment on this column."""
|
|
151
|
+
self.comment_text = text
|
|
152
|
+
return self
|
|
153
|
+
|
|
154
|
+
def unsigned(self) -> Self:
|
|
155
|
+
"""Mark an integer column as unsigned."""
|
|
156
|
+
self.is_unsigned = True
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ForeignKeyDefinition:
|
|
161
|
+
"""Represents a fluent foreign key constraint definition."""
|
|
162
|
+
|
|
163
|
+
def __init__(self, column: str, name: str | None = None) -> None:
|
|
164
|
+
self.column: str = validate_identifier(column)
|
|
165
|
+
self.name: str | None = validate_identifier(name) if name else None
|
|
166
|
+
self.ref_column: str = "id"
|
|
167
|
+
self.ref_table: str | None = None
|
|
168
|
+
self.on_delete_action: str = "NO ACTION"
|
|
169
|
+
self.on_update_action: str = "NO ACTION"
|
|
170
|
+
|
|
171
|
+
def references(self, column: str) -> Self:
|
|
172
|
+
"""Specify the referenced column on the foreign table."""
|
|
173
|
+
self.ref_column = validate_identifier(column)
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def on(self, table: str) -> Self:
|
|
177
|
+
"""Specify the referenced foreign table."""
|
|
178
|
+
self.ref_table = validate_identifier(table)
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def on_delete(self, action: str) -> Self:
|
|
182
|
+
"""Set ON DELETE referential action."""
|
|
183
|
+
action_clean = action.strip().upper()
|
|
184
|
+
allowed = {"CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"}
|
|
185
|
+
if action_clean not in allowed:
|
|
186
|
+
raise ValueError(f"Invalid ON DELETE action: {action!r}")
|
|
187
|
+
self.on_delete_action = action_clean
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def on_update(self, action: str) -> Self:
|
|
191
|
+
"""Set ON UPDATE referential action."""
|
|
192
|
+
action_clean = action.strip().upper()
|
|
193
|
+
allowed = {"CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"}
|
|
194
|
+
if action_clean not in allowed:
|
|
195
|
+
raise ValueError(f"Invalid ON UPDATE action: {action!r}")
|
|
196
|
+
self.on_update_action = action_clean
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
def cascade_on_delete(self) -> Self:
|
|
200
|
+
"""Convenience method for ON DELETE CASCADE."""
|
|
201
|
+
return self.on_delete("CASCADE")
|
|
202
|
+
|
|
203
|
+
def cascade_on_update(self) -> Self:
|
|
204
|
+
"""Convenience method for ON UPDATE CASCADE."""
|
|
205
|
+
return self.on_update("CASCADE")
|
|
206
|
+
|
|
207
|
+
def null_on_delete(self) -> Self:
|
|
208
|
+
"""Convenience method for ON DELETE SET NULL."""
|
|
209
|
+
return self.on_delete("SET NULL")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class IndexDefinition:
|
|
213
|
+
"""Represents a table-level index or composite unique constraint."""
|
|
214
|
+
|
|
215
|
+
def __init__(
|
|
216
|
+
self,
|
|
217
|
+
columns: list[str],
|
|
218
|
+
name: str | None = None,
|
|
219
|
+
*,
|
|
220
|
+
is_unique: bool = False,
|
|
221
|
+
) -> None:
|
|
222
|
+
self.columns: list[str] = [validate_identifier(c) for c in columns]
|
|
223
|
+
self.name: str | None = validate_identifier(name) if name else None
|
|
224
|
+
self.is_unique: bool = is_unique
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Blueprint:
|
|
228
|
+
"""Table definition Domain-Specific Language (DSL) for schema creation and modification."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, table: str, *, is_create: bool = True) -> None:
|
|
231
|
+
self.table: str = validate_identifier(table)
|
|
232
|
+
self.is_create: bool = is_create
|
|
233
|
+
self.columns: list[ColumnDefinition] = []
|
|
234
|
+
self.foreign_keys: list[ForeignKeyDefinition] = []
|
|
235
|
+
self.indexes: list[IndexDefinition] = []
|
|
236
|
+
self.primary_columns: list[str] = []
|
|
237
|
+
self.primary_key_name: str | None = None
|
|
238
|
+
self.drop_columns_list: list[str] = []
|
|
239
|
+
self.rename_columns_map: dict[str, str] = {}
|
|
240
|
+
self.drop_indexes_list: list[str] = []
|
|
241
|
+
self.drop_unique_list: list[str] = []
|
|
242
|
+
self.drop_foreign_list: list[str] = []
|
|
243
|
+
|
|
244
|
+
def _add_column(self, name: str, type_name: str) -> ColumnDefinition:
|
|
245
|
+
col = ColumnDefinition(name, type_name)
|
|
246
|
+
self.columns.append(col)
|
|
247
|
+
return col
|
|
248
|
+
|
|
249
|
+
# --- Identity & Primary Keys ---
|
|
250
|
+
|
|
251
|
+
def id(self, name: str = "id") -> ColumnDefinition:
|
|
252
|
+
"""Create a Bigint auto-incrementing identity primary key."""
|
|
253
|
+
col = self._add_column(name, "id")
|
|
254
|
+
col.is_primary = True
|
|
255
|
+
return col
|
|
256
|
+
|
|
257
|
+
def increments(self, name: str = "id") -> ColumnDefinition:
|
|
258
|
+
"""Create a 32-bit serial auto-incrementing primary key."""
|
|
259
|
+
col = self._add_column(name, "increments")
|
|
260
|
+
col.is_primary = True
|
|
261
|
+
return col
|
|
262
|
+
|
|
263
|
+
def big_increments(self, name: str = "id") -> ColumnDefinition:
|
|
264
|
+
"""Create a 64-bit bigserial auto-incrementing primary key."""
|
|
265
|
+
col = self._add_column(name, "big_increments")
|
|
266
|
+
col.is_primary = True
|
|
267
|
+
return col
|
|
268
|
+
|
|
269
|
+
# --- Data Types ---
|
|
270
|
+
|
|
271
|
+
def uuid(self, name: str) -> ColumnDefinition:
|
|
272
|
+
"""Create a UUID column."""
|
|
273
|
+
return self._add_column(name, "uuid")
|
|
274
|
+
|
|
275
|
+
def string(self, name: str, length: int | None = 255) -> ColumnDefinition:
|
|
276
|
+
"""Create a VARCHAR string column with optional max length."""
|
|
277
|
+
validated_length = _validate_column_length(length)
|
|
278
|
+
col = self._add_column(name, "string")
|
|
279
|
+
col.length = validated_length
|
|
280
|
+
return col
|
|
281
|
+
|
|
282
|
+
def text(self, name: str) -> ColumnDefinition:
|
|
283
|
+
"""Create a TEXT column."""
|
|
284
|
+
return self._add_column(name, "text")
|
|
285
|
+
|
|
286
|
+
def integer(self, name: str) -> ColumnDefinition:
|
|
287
|
+
"""Create a standard 32-bit INTEGER column."""
|
|
288
|
+
return self._add_column(name, "integer")
|
|
289
|
+
|
|
290
|
+
def big_integer(self, name: str) -> ColumnDefinition:
|
|
291
|
+
"""Create a 64-bit BIGINT column."""
|
|
292
|
+
return self._add_column(name, "big_integer")
|
|
293
|
+
|
|
294
|
+
def small_integer(self, name: str) -> ColumnDefinition:
|
|
295
|
+
"""Create a 16-bit SMALLINT column."""
|
|
296
|
+
return self._add_column(name, "small_integer")
|
|
297
|
+
|
|
298
|
+
def boolean(self, name: str) -> ColumnDefinition:
|
|
299
|
+
"""Create a BOOLEAN column."""
|
|
300
|
+
return self._add_column(name, "boolean")
|
|
301
|
+
|
|
302
|
+
def decimal(
|
|
303
|
+
self, name: str, precision: int = 8, scale: int = 2
|
|
304
|
+
) -> ColumnDefinition:
|
|
305
|
+
"""Create a fixed-point NUMERIC/DECIMAL column."""
|
|
306
|
+
validated_precision, validated_scale = _validate_decimal_params(
|
|
307
|
+
precision, scale
|
|
308
|
+
)
|
|
309
|
+
col = self._add_column(name, "decimal")
|
|
310
|
+
col.precision = validated_precision
|
|
311
|
+
col.scale = validated_scale
|
|
312
|
+
return col
|
|
313
|
+
|
|
314
|
+
def float(self, name: str) -> ColumnDefinition:
|
|
315
|
+
"""Create a floating-point DOUBLE PRECISION column."""
|
|
316
|
+
return self._add_column(name, "float")
|
|
317
|
+
|
|
318
|
+
def json(self, name: str) -> ColumnDefinition:
|
|
319
|
+
"""Create a JSON column."""
|
|
320
|
+
return self._add_column(name, "json")
|
|
321
|
+
|
|
322
|
+
def jsonb(self, name: str) -> ColumnDefinition:
|
|
323
|
+
"""Create a binary JSONB column."""
|
|
324
|
+
return self._add_column(name, "jsonb")
|
|
325
|
+
|
|
326
|
+
def date(self, name: str) -> ColumnDefinition:
|
|
327
|
+
"""Create a DATE column."""
|
|
328
|
+
return self._add_column(name, "date")
|
|
329
|
+
|
|
330
|
+
def time(self, name: str) -> ColumnDefinition:
|
|
331
|
+
"""Create a TIME column."""
|
|
332
|
+
return self._add_column(name, "time")
|
|
333
|
+
|
|
334
|
+
def timestamp(self, name: str, tz: bool = True) -> ColumnDefinition:
|
|
335
|
+
"""Create a TIMESTAMP column (with or without time zone)."""
|
|
336
|
+
type_name = "timestamptz" if tz else "timestamp"
|
|
337
|
+
return self._add_column(name, type_name)
|
|
338
|
+
|
|
339
|
+
def timestamps(self, tz: bool = True) -> tuple[ColumnDefinition, ColumnDefinition]:
|
|
340
|
+
"""Create created_at and updated_at timestamp columns with default CURRENT_TIMESTAMP."""
|
|
341
|
+
created = self.timestamp("created_at", tz=tz).default(raw("CURRENT_TIMESTAMP"))
|
|
342
|
+
updated = self.timestamp("updated_at", tz=tz).default(raw("CURRENT_TIMESTAMP"))
|
|
343
|
+
return created, updated
|
|
344
|
+
|
|
345
|
+
def soft_deletes(
|
|
346
|
+
self, name: str = "deleted_at", tz: bool = True
|
|
347
|
+
) -> ColumnDefinition:
|
|
348
|
+
"""Create a nullable deleted_at timestamp column for soft deletion."""
|
|
349
|
+
return self.timestamp(name, tz=tz).nullable()
|
|
350
|
+
|
|
351
|
+
def vector(self, name: str, dimensions: int = 1536) -> ColumnDefinition:
|
|
352
|
+
"""Create a pgvector vector column with specified dimensions."""
|
|
353
|
+
if isinstance(dimensions, bool) or not isinstance(dimensions, int) or dimensions <= 0:
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"Vector dimensions must be a positive integer, got {dimensions}"
|
|
356
|
+
)
|
|
357
|
+
col = self._add_column(name, "vector")
|
|
358
|
+
col.dimensions = dimensions
|
|
359
|
+
return col
|
|
360
|
+
|
|
361
|
+
# --- Constraints & Indexes ---
|
|
362
|
+
|
|
363
|
+
def unique(self, columns: str | list[str], name: str | None = None) -> Self:
|
|
364
|
+
"""Add a table-level or composite UNIQUE constraint."""
|
|
365
|
+
cols = [columns] if isinstance(columns, str) else columns
|
|
366
|
+
self.indexes.append(IndexDefinition(cols, name, is_unique=True))
|
|
367
|
+
return self
|
|
368
|
+
|
|
369
|
+
def index(self, columns: str | list[str], name: str | None = None) -> Self:
|
|
370
|
+
"""Add a table-level or composite index."""
|
|
371
|
+
cols = [columns] if isinstance(columns, str) else columns
|
|
372
|
+
self.indexes.append(IndexDefinition(cols, name, is_unique=False))
|
|
373
|
+
return self
|
|
374
|
+
|
|
375
|
+
def primary(self, columns: str | list[str], name: str | None = None) -> Self:
|
|
376
|
+
"""Define a table-level or composite primary key constraint."""
|
|
377
|
+
cols = [columns] if isinstance(columns, str) else columns
|
|
378
|
+
self.primary_columns = [validate_identifier(c) for c in cols]
|
|
379
|
+
if name:
|
|
380
|
+
self.primary_key_name = validate_identifier(name)
|
|
381
|
+
return self
|
|
382
|
+
|
|
383
|
+
def foreign(self, column: str, name: str | None = None) -> ForeignKeyDefinition:
|
|
384
|
+
"""Define a fluent foreign key constraint on the given column."""
|
|
385
|
+
fk = ForeignKeyDefinition(column, name)
|
|
386
|
+
self.foreign_keys.append(fk)
|
|
387
|
+
return fk
|
|
388
|
+
|
|
389
|
+
def foreign_key(
|
|
390
|
+
self,
|
|
391
|
+
column: str,
|
|
392
|
+
references: str,
|
|
393
|
+
on: str,
|
|
394
|
+
on_delete: str = "NO ACTION",
|
|
395
|
+
on_update: str = "NO ACTION",
|
|
396
|
+
name: str | None = None,
|
|
397
|
+
) -> Self:
|
|
398
|
+
"""Direct helper to define a complete foreign key constraint."""
|
|
399
|
+
fk = self.foreign(column, name)
|
|
400
|
+
fk.references(references).on(on).on_delete(on_delete).on_update(on_update)
|
|
401
|
+
return self
|
|
402
|
+
|
|
403
|
+
# --- Alter Operations ---
|
|
404
|
+
|
|
405
|
+
def drop_column(self, *names: str) -> Self:
|
|
406
|
+
"""Drop one or more columns from an existing table."""
|
|
407
|
+
for n in names:
|
|
408
|
+
self.drop_columns_list.append(validate_identifier(n))
|
|
409
|
+
return self
|
|
410
|
+
|
|
411
|
+
def rename_column(self, from_name: str, to_name: str) -> Self:
|
|
412
|
+
"""Rename an existing column."""
|
|
413
|
+
self.rename_columns_map[validate_identifier(from_name)] = validate_identifier(
|
|
414
|
+
to_name
|
|
415
|
+
)
|
|
416
|
+
return self
|
|
417
|
+
|
|
418
|
+
def drop_index(self, name: str) -> Self:
|
|
419
|
+
"""Drop an index by name."""
|
|
420
|
+
self.drop_indexes_list.append(validate_identifier(name))
|
|
421
|
+
return self
|
|
422
|
+
|
|
423
|
+
def drop_unique(self, name: str) -> Self:
|
|
424
|
+
"""Drop a unique constraint by name."""
|
|
425
|
+
self.drop_unique_list.append(validate_identifier(name))
|
|
426
|
+
return self
|
|
427
|
+
|
|
428
|
+
def drop_foreign(self, name: str) -> Self:
|
|
429
|
+
"""Drop a foreign key constraint by name."""
|
|
430
|
+
self.drop_foreign_list.append(validate_identifier(name))
|
|
431
|
+
return self
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
import asyncpg
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
# Deterministic 64-bit signed integer key: 'SWIFTPY' in hex -> 0x53574946545059
|
|
13
|
+
DEFAULT_MIGRATION_LOCK_KEY: int = 0x53574946545059
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MigrationLockError(RuntimeError):
|
|
17
|
+
"""Raised when an advisory lock cannot be acquired or timed out."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MigrationLock:
|
|
21
|
+
"""
|
|
22
|
+
PostgreSQL advisory lock manager.
|
|
23
|
+
|
|
24
|
+
Prevents concurrent migration runs across distributed workers, processes,
|
|
25
|
+
or containers using native pg_try_advisory_lock / pg_advisory_unlock.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
connection: asyncpg.Connection,
|
|
31
|
+
key: int = DEFAULT_MIGRATION_LOCK_KEY,
|
|
32
|
+
timeout: float = 30.0,
|
|
33
|
+
poll_interval: float = 0.2,
|
|
34
|
+
) -> None:
|
|
35
|
+
self.connection: asyncpg.Connection = connection
|
|
36
|
+
self.key: int = key
|
|
37
|
+
self.timeout: float = timeout
|
|
38
|
+
self.poll_interval: float = poll_interval
|
|
39
|
+
self.is_locked: bool = False
|
|
40
|
+
|
|
41
|
+
async def acquire(self) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
Attempt to acquire the advisory lock, polling up to the specified timeout.
|
|
44
|
+
Raises MigrationLockError if the lock cannot be acquired within the timeout window.
|
|
45
|
+
"""
|
|
46
|
+
start = time.monotonic()
|
|
47
|
+
|
|
48
|
+
while True:
|
|
49
|
+
# pg_try_advisory_lock returns boolean immediately without blocking PostgreSQL backend
|
|
50
|
+
locked: bool = await self.connection.fetchval(
|
|
51
|
+
"SELECT pg_try_advisory_lock($1);", self.key
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if locked:
|
|
55
|
+
self.is_locked = True
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
elapsed = time.monotonic() - start
|
|
59
|
+
if elapsed >= self.timeout:
|
|
60
|
+
raise MigrationLockError(
|
|
61
|
+
f"Failed to acquire migration advisory lock (key={hex(self.key)}) "
|
|
62
|
+
f"after {self.timeout:.1f}s. Another migration process is currently active."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
await asyncio.sleep(self.poll_interval)
|
|
66
|
+
|
|
67
|
+
async def release(self) -> bool:
|
|
68
|
+
"""Release the PostgreSQL advisory lock."""
|
|
69
|
+
if not self.is_locked:
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
is_closed = getattr(self.connection, "is_closed", None)
|
|
73
|
+
if callable(is_closed) and is_closed():
|
|
74
|
+
self.is_locked = False
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
unlocked: bool = await self.connection.fetchval(
|
|
78
|
+
"SELECT pg_advisory_unlock($1);", self.key
|
|
79
|
+
)
|
|
80
|
+
self.is_locked = False
|
|
81
|
+
return unlocked
|
|
82
|
+
|
|
83
|
+
async def __aenter__(self) -> Self:
|
|
84
|
+
await self.acquire()
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
async def __aexit__(
|
|
88
|
+
self,
|
|
89
|
+
exc_type: type[BaseException] | None,
|
|
90
|
+
exc_val: BaseException | None,
|
|
91
|
+
exc_tb: object,
|
|
92
|
+
) -> None:
|
|
93
|
+
try:
|
|
94
|
+
await self.release()
|
|
95
|
+
except Exception as release_exc:
|
|
96
|
+
if exc_type is not None:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"Failed to release migration lock (key=%s) during exception unwind (%s): %s",
|
|
99
|
+
hex(self.key),
|
|
100
|
+
exc_type.__name__,
|
|
101
|
+
release_exc,
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
raise
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@runtime_checkable
|
|
7
|
+
class MigrationProtocol(Protocol):
|
|
8
|
+
"""Protocol satisfied by any migration class or instance providing up/down methods."""
|
|
9
|
+
|
|
10
|
+
async def up(self) -> None: ...
|
|
11
|
+
|
|
12
|
+
async def down(self) -> None: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Migration:
|
|
16
|
+
"""Base class for all SwiftPY migrations."""
|
|
17
|
+
|
|
18
|
+
async def up(self) -> None:
|
|
19
|
+
"""Run the forward migration changes."""
|
|
20
|
+
|
|
21
|
+
async def down(self) -> None:
|
|
22
|
+
"""Reverse the migration changes."""
|