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,290 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from collections.abc import Sequence
5
+ from typing import Any
6
+
7
+ from swiftpy.core.config import ConfigurationError
8
+ from swiftpy.validation.protocols import ValidatorRule
9
+ from swiftpy.validation.rules.builtin import (
10
+ BailRule,
11
+ BetweenRule,
12
+ BoolRule,
13
+ EmailRule,
14
+ FloatRule,
15
+ InRule,
16
+ IntRule,
17
+ MaxRule,
18
+ MinRule,
19
+ NotInRule,
20
+ RegexRule,
21
+ StrRule,
22
+ UrlRule,
23
+ )
24
+ from swiftpy.validation.rules.exists import ExistsRule
25
+ from swiftpy.validation.rules.required import NullableRule, RequiredRule
26
+ from swiftpy.validation.rules.unique import UniqueRule
27
+
28
+
29
+ def _split_unescaped(text: str, delimiter: str) -> list[str]:
30
+ """Split text by delimiter, ignoring escaped delimiters (e.g. \\| or \\:) while preserving backslash pairs."""
31
+ tokens: list[str] = []
32
+ current: list[str] = []
33
+ i = 0
34
+ n = len(text)
35
+ while i < n:
36
+ char = text[i]
37
+ if char == "\\":
38
+ if i + 1 < n and text[i + 1] == delimiter:
39
+ current.append(delimiter)
40
+ i += 2
41
+ continue
42
+ if i + 1 < n and text[i + 1] == "\\":
43
+ current.append("\\\\")
44
+ i += 2
45
+ continue
46
+ current.append("\\")
47
+ i += 1
48
+ continue
49
+ if char == delimiter:
50
+ tokens.append("".join(current))
51
+ current = []
52
+ i += 1
53
+ continue
54
+ current.append(char)
55
+ i += 1
56
+ tokens.append("".join(current))
57
+ return tokens
58
+
59
+
60
+ def _parse_num(val: str) -> int | float:
61
+ """Parse string into finite int or float, raising ConfigurationError on invalid input."""
62
+ clean = val.strip()
63
+ if not clean:
64
+ raise ConfigurationError("Numeric validation rule argument cannot be empty")
65
+ try:
66
+ if any(ch in clean for ch in (".", "e", "E")):
67
+ num = float(clean)
68
+ if not math.isfinite(num):
69
+ raise ConfigurationError(
70
+ f"Validation rule numeric argument must be finite, got '{val}'"
71
+ )
72
+ return num
73
+ return int(clean)
74
+ except ValueError as exc:
75
+ raise ConfigurationError(
76
+ f"Expected numeric validation rule argument, got '{val}'"
77
+ ) from exc
78
+
79
+
80
+ def _parse_between(v: str) -> BetweenRule:
81
+ parts = [p.strip() for p in v.split(",")]
82
+ if len(parts) != 2 or not parts[0] or not parts[1]:
83
+ raise ConfigurationError(
84
+ f"between rule requires min and max arguments, e.g. 'between:1,10' (got '{v}')"
85
+ )
86
+ min_val = _parse_num(parts[0])
87
+ max_val = _parse_num(parts[1])
88
+ if min_val > max_val:
89
+ raise ConfigurationError(
90
+ f"between rule min ({min_val}) cannot be greater than max ({max_val})"
91
+ )
92
+ return BetweenRule(min_val, max_val)
93
+
94
+
95
+ def _parse_unique(v: str) -> UniqueRule:
96
+ parts = [p.strip() for p in v.split(",")]
97
+ if len(parts) < 2 or not parts[0] or not parts[1]:
98
+ raise ConfigurationError(
99
+ "unique rule requires table and column arguments, e.g. 'unique:users,email'"
100
+ )
101
+ table = parts[0]
102
+ column = parts[1]
103
+ ignore_id: Any = None
104
+ id_column: str = "id"
105
+ case_insensitive = False
106
+ if len(parts) > 2:
107
+ for extra in parts[2:]:
108
+ if extra.lower() == "ci":
109
+ case_insensitive = True
110
+ elif ignore_id is None:
111
+ ignore_id = extra if extra != "" else None
112
+ elif id_column == "id":
113
+ id_column = extra
114
+ return UniqueRule(
115
+ table=table,
116
+ column=column,
117
+ ignore_id=ignore_id,
118
+ id_column=id_column,
119
+ case_insensitive=case_insensitive,
120
+ )
121
+
122
+
123
+ def _parse_exists(v: str) -> ExistsRule:
124
+ parts = [p.strip() for p in v.split(",")]
125
+ if len(parts) != 2 or not parts[0] or not parts[1]:
126
+ raise ConfigurationError(
127
+ f"exists rule requires table and column arguments, e.g. 'exists:users,id' (got '{v}')"
128
+ )
129
+ return ExistsRule(table=parts[0], column=parts[1])
130
+
131
+
132
+ def _parse_in_values(v: str) -> list[str]:
133
+ values = [x.strip() for x in v.split(",") if x.strip()]
134
+ if not values:
135
+ raise ConfigurationError(
136
+ f"in/not_in rule requires at least one allowed/disallowed value (got '{v}')"
137
+ )
138
+ return values
139
+
140
+
141
+ def _parse_regex(v: str) -> RegexRule:
142
+ pattern = v.strip()
143
+ if not pattern:
144
+ raise ConfigurationError("regex rule requires a non-empty pattern argument")
145
+ return RegexRule(pattern)
146
+
147
+
148
+ def _parse_email(v: str = "") -> EmailRule:
149
+ if v:
150
+ return EmailRule(lowercase=(v.strip().lower() in ("lowercase", "ci", "lc")))
151
+ return EmailRule()
152
+
153
+
154
+ _REQUIRED_ARG_RULES: frozenset[str] = frozenset(
155
+ {"min", "max", "between", "in", "in_", "not_in", "regex", "unique", "exists"}
156
+ )
157
+
158
+
159
+ class RuleRegistry:
160
+ """
161
+ Registry for resolving validation rules from names, string expressions,
162
+ or factory functions.
163
+ """
164
+
165
+ def __init__(self) -> None:
166
+ self._rules: dict[str, Any] = {}
167
+ self._register_builtins()
168
+
169
+ def _register_builtins(self) -> None:
170
+ self.register("required", RequiredRule)
171
+ self.register("nullable", NullableRule)
172
+ self.register("bail", BailRule)
173
+ self.register("str", StrRule)
174
+ self.register("string", StrRule)
175
+ self.register("int", IntRule)
176
+ self.register("integer", IntRule)
177
+ self.register("float", FloatRule)
178
+ self.register("bool", BoolRule)
179
+ self.register("boolean", BoolRule)
180
+ self.register("email", _parse_email)
181
+ self.register("url", UrlRule)
182
+ self.register("min", lambda v: MinRule(_parse_num(v)))
183
+ self.register("max", lambda v: MaxRule(_parse_num(v)))
184
+ self.register("between", _parse_between)
185
+ self.register("in", lambda v: InRule(_parse_in_values(v)))
186
+ self.register("in_", lambda v: InRule(_parse_in_values(v)))
187
+ self.register("not_in", lambda v: NotInRule(_parse_in_values(v)))
188
+ self.register("regex", _parse_regex)
189
+ self.register("unique", _parse_unique)
190
+ self.register("exists", _parse_exists)
191
+
192
+ def register(self, name: str, rule_target: Any) -> None:
193
+ """Register a custom rule by name."""
194
+ self._rules[name.lower()] = rule_target
195
+
196
+ def get(self, name: str) -> Any:
197
+ return self._rules.get(name.lower())
198
+
199
+ def parse(self, rule_item: Any) -> Any:
200
+ """Parse a single rule definition into a rule instance."""
201
+ # Built-in Python types mapping
202
+ if rule_item is str:
203
+ return StrRule()
204
+ if rule_item is int:
205
+ return IntRule()
206
+ if rule_item is float:
207
+ return FloatRule()
208
+ if rule_item is bool:
209
+ return BoolRule()
210
+
211
+ # Already an instance of a rule
212
+ if isinstance(rule_item, (ValidatorRule, RequiredRule, NullableRule)) or (
213
+ hasattr(rule_item, "validate") or hasattr(rule_item, "passes")
214
+ ):
215
+ if isinstance(rule_item, type):
216
+ return rule_item()
217
+ return rule_item
218
+
219
+ # Callable factory with no args
220
+ if callable(rule_item) and not isinstance(rule_item, type):
221
+ try:
222
+ res = rule_item()
223
+ if hasattr(res, "validate") or hasattr(res, "passes"):
224
+ return res
225
+ except TypeError:
226
+ pass
227
+
228
+ if isinstance(rule_item, str):
229
+ rule_str = rule_item.strip()
230
+ if not rule_str:
231
+ return None
232
+
233
+ parts = _split_unescaped(rule_str, ":")
234
+ name = parts[0].strip().lower()
235
+ arg = ":".join(parts[1:]).strip() if len(parts) > 1 else None
236
+
237
+ factory = self.get(name)
238
+ if factory is None:
239
+ raise ValueError(f"Unknown validation rule '{name}'")
240
+
241
+ if arg is not None:
242
+ if name in _REQUIRED_ARG_RULES and not arg:
243
+ raise ConfigurationError(
244
+ f"Validation rule '{name}' requires a non-empty argument"
245
+ )
246
+ return factory(arg)
247
+ else:
248
+ if name in _REQUIRED_ARG_RULES:
249
+ raise ConfigurationError(
250
+ f"Validation rule '{name}' requires an argument"
251
+ )
252
+ try:
253
+ return factory() if callable(factory) else factory
254
+ except TypeError as exc:
255
+ raise ConfigurationError(
256
+ f"Validation rule '{name}' requires valid arguments: {exc}"
257
+ ) from exc
258
+
259
+ raise TypeError(f"Unsupported validation rule type: {type(rule_item)}")
260
+
261
+ def parse_rules(self, rules_input: str | Sequence[Any]) -> list[Any]:
262
+ """Parse a pipe-separated string or ordered sequence of rules into a list of rule instances."""
263
+ if isinstance(rules_input, (set, frozenset)):
264
+ raise TypeError(
265
+ "Validation rules cannot be an unordered set; use a pipe-delimited string or list/tuple."
266
+ )
267
+ if isinstance(rules_input, str):
268
+ raw_list = [
269
+ r.strip() for r in _split_unescaped(rules_input, "|") if r.strip()
270
+ ]
271
+ else:
272
+ raw_list = list(rules_input)
273
+
274
+ parsed: list[Any] = []
275
+ for item in raw_list:
276
+ rule = self.parse(item)
277
+ if rule is not None:
278
+ parsed.append(rule)
279
+ return parsed
280
+
281
+
282
+ default_registry = RuleRegistry()
283
+
284
+
285
+ def register_rule(name: str, rule_target: Any) -> None:
286
+ """Public helper to register a custom rule with default registry."""
287
+ default_registry.register(name, rule_target)
288
+
289
+
290
+ __all__ = ["RuleRegistry", "default_registry", "register_rule"]
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class RequiredRule:
7
+ """
8
+ Validates that a field is present, not None, not an empty string,
9
+ not whitespace-only, and not an empty collection.
10
+ """
11
+
12
+ def validate(self, field: str, value: Any, data: dict[str, Any]) -> bool:
13
+ if field not in data:
14
+ return False
15
+ if value is None:
16
+ return False
17
+ if isinstance(value, str) and not value.strip():
18
+ return False
19
+ return not (isinstance(value, (list, dict, set, tuple)) and len(value) == 0)
20
+
21
+ def message(self, field: str) -> str:
22
+ return f"The {field} field is required."
23
+
24
+
25
+ class NullableRule:
26
+ """
27
+ Allows a field to be null or empty. When present and the field value
28
+ is None or empty string, subsequent validation rules for that field are skipped.
29
+ """
30
+
31
+ is_nullable_rule: bool = True
32
+
33
+ def validate(self, field: str, value: Any, data: dict[str, Any]) -> bool:
34
+ return True
35
+
36
+ def message(self, field: str) -> str:
37
+ return f"The {field} field may be null."
38
+
39
+
40
+ def required() -> RequiredRule:
41
+ """Helper to instantiate RequiredRule."""
42
+ return RequiredRule()
43
+
44
+
45
+ def nullable() -> NullableRule:
46
+ """Helper to instantiate NullableRule."""
47
+ return NullableRule()
48
+
49
+
50
+ __all__ = [
51
+ "NullableRule",
52
+ "RequiredRule",
53
+ "nullable",
54
+ "required",
55
+ ]
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ import asyncpg
7
+
8
+ from swiftpy.core.container import get_container
9
+ from swiftpy.database.identifiers import validate_identifier
10
+ from swiftpy.database.pool import DatabasePool
11
+ from swiftpy.database.transaction import get_active_connection
12
+
13
+ _INT_LITERAL_RE = re.compile(r"^[+-]?[0-9]+$")
14
+
15
+
16
+ class UniqueRule:
17
+ """
18
+ Validates that a field value is unique in a given database table and column.
19
+ Non-blocking async query executed against the active transaction or DatabasePool.
20
+ """
21
+
22
+ requires_db: bool = True
23
+
24
+ def __init__(
25
+ self,
26
+ table: str,
27
+ column: str,
28
+ ignore_id: Any = None,
29
+ id_column: str = "id",
30
+ pool: DatabasePool | None = None,
31
+ case_insensitive: bool = False,
32
+ ) -> None:
33
+ self.table = validate_identifier(table, param_name="table")
34
+ self.column = validate_identifier(column, param_name="column")
35
+ self.id_column = validate_identifier(id_column, param_name="column")
36
+ self.ignore_id = ignore_id
37
+ self.pool = pool
38
+ self.case_insensitive = case_insensitive
39
+
40
+ async def _fetchval_unique(
41
+ self,
42
+ conn: Any,
43
+ col_expr: str,
44
+ val_param: str,
45
+ value: Any,
46
+ *,
47
+ use_savepoint: bool,
48
+ ) -> Any:
49
+ async def _run_one(query: str, *args: Any) -> Any:
50
+ if use_savepoint and hasattr(conn, "transaction"):
51
+ async with conn.transaction():
52
+ return await conn.fetchval(query, *args)
53
+ return await conn.fetchval(query, *args)
54
+
55
+ if self.ignore_id is None:
56
+ query = (
57
+ f'SELECT 1 FROM "{self.table}" WHERE {col_expr} = {val_param} LIMIT 1'
58
+ )
59
+ return await _run_one(query, value)
60
+
61
+ query = (
62
+ f'SELECT 1 FROM "{self.table}" '
63
+ f'WHERE {col_expr} = {val_param} AND "{self.id_column}" != $2 LIMIT 1'
64
+ )
65
+ try:
66
+ return await _run_one(query, value, self.ignore_id)
67
+ except asyncpg.DataError:
68
+ if isinstance(self.ignore_id, str) and _INT_LITERAL_RE.fullmatch(
69
+ self.ignore_id.strip()
70
+ ):
71
+ return await _run_one(query, value, int(self.ignore_id.strip()))
72
+ raise
73
+
74
+ async def validate(self, field: str, value: Any, data: dict[str, Any]) -> bool:
75
+ if value is None or value == "":
76
+ return True
77
+
78
+ col_expr = (
79
+ f'LOWER("{self.column}")' if self.case_insensitive else f'"{self.column}"'
80
+ )
81
+ val_param = "LOWER($1)" if self.case_insensitive else "$1"
82
+
83
+ try:
84
+ active_conn = get_active_connection()
85
+ if active_conn is not None:
86
+ row = await self._fetchval_unique(
87
+ active_conn, col_expr, val_param, value, use_savepoint=True
88
+ )
89
+ return row is None
90
+
91
+ active_pool = self.pool
92
+ if active_pool is None:
93
+ try:
94
+ container = get_container()
95
+ if container.has(DatabasePool):
96
+ active_pool = container.resolve(DatabasePool)
97
+ except RuntimeError:
98
+ pass
99
+
100
+ if active_pool is None:
101
+ raise RuntimeError(
102
+ "DatabasePool is required for UniqueRule validation. Ensure it is registered in the container or passed explicitly."
103
+ )
104
+
105
+ db = active_pool.get_pool()
106
+ async with db.acquire() as conn:
107
+ row = await self._fetchval_unique(
108
+ conn, col_expr, val_param, value, use_savepoint=False
109
+ )
110
+
111
+ return row is None
112
+ except (asyncpg.DataError, ValueError, TypeError):
113
+ return False
114
+
115
+ def message(self, field: str) -> str:
116
+ return f"The {field} has already been taken."
117
+
118
+
119
+ def unique(
120
+ table: str,
121
+ column: str,
122
+ ignore_id: Any = None,
123
+ id_column: str = "id",
124
+ pool: DatabasePool | None = None,
125
+ case_insensitive: bool = False,
126
+ ) -> UniqueRule:
127
+ """Helper to instantiate UniqueRule."""
128
+ return UniqueRule(
129
+ table=table,
130
+ column=column,
131
+ ignore_id=ignore_id,
132
+ id_column=id_column,
133
+ pool=pool,
134
+ case_insensitive=case_insensitive,
135
+ )
136
+
137
+
138
+ __all__ = ["UniqueRule", "unique"]