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,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."""