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