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,223 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized Redis connection configuration resolver.
|
|
3
|
+
|
|
4
|
+
Guarantees single-source-of-truth configuration resolution through Config,
|
|
5
|
+
with proper URL-encoding of user credentials and consistent defaults.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import quote
|
|
12
|
+
|
|
13
|
+
from swiftpy.core.config import Config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _first_not_none(*values: Any) -> Any:
|
|
17
|
+
"""Return the first value that is not None."""
|
|
18
|
+
for val in values:
|
|
19
|
+
if val is not None:
|
|
20
|
+
return val
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resolve_section_dict(cfg: Config, section: str) -> dict[str, Any]:
|
|
25
|
+
"""Retrieve dictionary for the given section or known subsystem fallback."""
|
|
26
|
+
# Subsystem-specific structures
|
|
27
|
+
if section in ("cache", "cache.redis", "cache.stores.redis"):
|
|
28
|
+
stores = cfg.get("cache.stores")
|
|
29
|
+
if isinstance(stores, dict):
|
|
30
|
+
redis_store = stores.get("redis")
|
|
31
|
+
if isinstance(redis_store, dict):
|
|
32
|
+
return {str(k): v for k, v in redis_store.items()}
|
|
33
|
+
cache_redis = cfg.get("cache.redis")
|
|
34
|
+
if isinstance(cache_redis, dict):
|
|
35
|
+
return {str(k): v for k, v in cache_redis.items()}
|
|
36
|
+
|
|
37
|
+
if section in ("queue", "queue.redis", "queue.connections.redis"):
|
|
38
|
+
connections = cfg.get("queue.connections")
|
|
39
|
+
if isinstance(connections, dict):
|
|
40
|
+
redis_conn = connections.get("redis")
|
|
41
|
+
if isinstance(redis_conn, dict):
|
|
42
|
+
return {str(k): v for k, v in redis_conn.items()}
|
|
43
|
+
queue_redis = cfg.get("queue.redis")
|
|
44
|
+
if isinstance(queue_redis, dict):
|
|
45
|
+
return {str(k): v for k, v in queue_redis.items()}
|
|
46
|
+
|
|
47
|
+
if section in ("events", "events.redis"):
|
|
48
|
+
events_redis = cfg.get("events.redis")
|
|
49
|
+
if isinstance(events_redis, dict):
|
|
50
|
+
return {str(k): v for k, v in events_redis.items()}
|
|
51
|
+
|
|
52
|
+
val = cfg.get(section)
|
|
53
|
+
if isinstance(val, dict):
|
|
54
|
+
val_redis = val.get("redis")
|
|
55
|
+
if isinstance(val_redis, dict):
|
|
56
|
+
return {str(k): v for k, v in val_redis.items()}
|
|
57
|
+
return {str(k): v for k, v in val.items()}
|
|
58
|
+
|
|
59
|
+
return {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_redis_params(
|
|
63
|
+
config: Config | None = None,
|
|
64
|
+
section: str = "redis",
|
|
65
|
+
*,
|
|
66
|
+
host: str | None = None,
|
|
67
|
+
port: int | None = None,
|
|
68
|
+
password: str | None = None,
|
|
69
|
+
database: int | None = None,
|
|
70
|
+
username: str | None = None,
|
|
71
|
+
ssl: bool | None = None,
|
|
72
|
+
) -> dict[str, Any]:
|
|
73
|
+
"""
|
|
74
|
+
Resolve Redis connection parameters from Config and keyword overrides.
|
|
75
|
+
|
|
76
|
+
Reads only through Config (never raw os.getenv in runtime callers),
|
|
77
|
+
ensuring deterministic precedence and environment variable integration.
|
|
78
|
+
"""
|
|
79
|
+
if config is None:
|
|
80
|
+
try:
|
|
81
|
+
cfg = Config.get_instance()
|
|
82
|
+
except (RuntimeError, ValueError):
|
|
83
|
+
cfg = Config()
|
|
84
|
+
else:
|
|
85
|
+
cfg = config
|
|
86
|
+
|
|
87
|
+
# Check for direct URL in section or sub-path
|
|
88
|
+
section_dict = _resolve_section_dict(cfg, section)
|
|
89
|
+
explicit_url = _first_not_none(
|
|
90
|
+
section_dict.get("url"),
|
|
91
|
+
cfg.get(f"{section}.url"),
|
|
92
|
+
cfg.get(f"{section}.redis_url"),
|
|
93
|
+
cfg.get("redis.url"),
|
|
94
|
+
cfg.get("redis_url"),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Resolve individual components
|
|
98
|
+
cfg_host = _first_not_none(
|
|
99
|
+
section_dict.get("host"),
|
|
100
|
+
cfg.get(f"{section}.host"),
|
|
101
|
+
cfg.get("redis.host"),
|
|
102
|
+
cfg.get("redis_host"),
|
|
103
|
+
)
|
|
104
|
+
cfg_port = _first_not_none(
|
|
105
|
+
section_dict.get("port"),
|
|
106
|
+
cfg.get(f"{section}.port"),
|
|
107
|
+
cfg.get("redis.port"),
|
|
108
|
+
cfg.get("redis_port"),
|
|
109
|
+
)
|
|
110
|
+
cfg_password = (
|
|
111
|
+
section_dict.get("password")
|
|
112
|
+
if "password" in section_dict
|
|
113
|
+
else _first_not_none(
|
|
114
|
+
cfg.get(f"{section}.password"),
|
|
115
|
+
cfg.get("redis.password"),
|
|
116
|
+
cfg.get("redis_password"),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Per-subsystem database fallback
|
|
121
|
+
subsystem_db = None
|
|
122
|
+
if section in ("queue", "queue.redis", "queue.connections.redis"):
|
|
123
|
+
subsystem_db = _first_not_none(cfg.get("redis_queue_db"), cfg.get("redis.queue_db"))
|
|
124
|
+
elif section in ("cache", "cache.redis", "cache.stores.redis"):
|
|
125
|
+
subsystem_db = _first_not_none(cfg.get("redis_cache_db"), cfg.get("redis.cache_db"))
|
|
126
|
+
elif section in ("events", "events.redis"):
|
|
127
|
+
subsystem_db = _first_not_none(cfg.get("redis_events_db"), cfg.get("redis.events_db"))
|
|
128
|
+
|
|
129
|
+
cfg_database = (
|
|
130
|
+
section_dict.get("database")
|
|
131
|
+
if "database" in section_dict
|
|
132
|
+
else _first_not_none(
|
|
133
|
+
subsystem_db,
|
|
134
|
+
cfg.get(f"{section}.database"),
|
|
135
|
+
cfg.get("redis.database"),
|
|
136
|
+
cfg.get("redis_db"),
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
cfg_username = _first_not_none(
|
|
141
|
+
section_dict.get("username"),
|
|
142
|
+
cfg.get(f"{section}.username"),
|
|
143
|
+
cfg.get("redis.username"),
|
|
144
|
+
)
|
|
145
|
+
cfg_ssl = (
|
|
146
|
+
section_dict.get("ssl")
|
|
147
|
+
if "ssl" in section_dict
|
|
148
|
+
else _first_not_none(cfg.get(f"{section}.ssl"), cfg.get("redis.ssl"))
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Apply overrides over config
|
|
152
|
+
final_host = host if host is not None else (str(cfg_host) if cfg_host is not None else "127.0.0.1")
|
|
153
|
+
final_port = port if port is not None else (int(cfg_port) if cfg_port is not None else 6379)
|
|
154
|
+
final_password = password if password is not None else (str(cfg_password) if cfg_password is not None else None)
|
|
155
|
+
final_database = database if database is not None else (int(cfg_database) if cfg_database is not None else 0)
|
|
156
|
+
final_username = username if username is not None else (str(cfg_username) if cfg_username is not None else None)
|
|
157
|
+
final_ssl = ssl if ssl is not None else (bool(cfg_ssl) if cfg_ssl is not None else False)
|
|
158
|
+
|
|
159
|
+
# If an explicit URL was configured and no individual component override was provided, use it
|
|
160
|
+
has_overrides = any(
|
|
161
|
+
arg is not None
|
|
162
|
+
for arg in (host, port, password, database, username, ssl)
|
|
163
|
+
)
|
|
164
|
+
if explicit_url and not has_overrides:
|
|
165
|
+
return {
|
|
166
|
+
"host": final_host,
|
|
167
|
+
"port": final_port,
|
|
168
|
+
"password": final_password,
|
|
169
|
+
"database": final_database,
|
|
170
|
+
"username": final_username,
|
|
171
|
+
"ssl": final_ssl,
|
|
172
|
+
"url": str(explicit_url),
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Construct the percent-encoded URL
|
|
176
|
+
scheme = "rediss" if final_ssl else "redis"
|
|
177
|
+
userinfo = ""
|
|
178
|
+
if final_username is not None and final_password is not None:
|
|
179
|
+
userinfo = f"{quote(final_username, safe='')}:{quote(final_password, safe='')}@"
|
|
180
|
+
elif final_password is not None:
|
|
181
|
+
userinfo = f":{quote(final_password, safe='')}@"
|
|
182
|
+
elif final_username is not None:
|
|
183
|
+
userinfo = f"{quote(final_username, safe='')}@"
|
|
184
|
+
|
|
185
|
+
url = f"{scheme}://{userinfo}{final_host}:{final_port}/{final_database}"
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"host": final_host,
|
|
189
|
+
"port": final_port,
|
|
190
|
+
"password": final_password,
|
|
191
|
+
"database": final_database,
|
|
192
|
+
"username": final_username,
|
|
193
|
+
"ssl": final_ssl,
|
|
194
|
+
"url": url,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_redis_url(
|
|
199
|
+
config: Config | None = None,
|
|
200
|
+
section: str = "redis",
|
|
201
|
+
*,
|
|
202
|
+
host: str | None = None,
|
|
203
|
+
port: int | None = None,
|
|
204
|
+
password: str | None = None,
|
|
205
|
+
database: int | None = None,
|
|
206
|
+
username: str | None = None,
|
|
207
|
+
ssl: bool | None = None,
|
|
208
|
+
) -> str:
|
|
209
|
+
"""Build a sanitized and credential-encoded Redis connection URL."""
|
|
210
|
+
params = resolve_redis_params(
|
|
211
|
+
config=config,
|
|
212
|
+
section=section,
|
|
213
|
+
host=host,
|
|
214
|
+
port=port,
|
|
215
|
+
password=password,
|
|
216
|
+
database=database,
|
|
217
|
+
username=username,
|
|
218
|
+
ssl=ssl,
|
|
219
|
+
)
|
|
220
|
+
return str(params["url"])
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
__all__ = ["build_redis_url", "resolve_redis_params"]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_UNCOUNTABLES = {
|
|
6
|
+
"aircraft",
|
|
7
|
+
"deer",
|
|
8
|
+
"equipment",
|
|
9
|
+
"fish",
|
|
10
|
+
"information",
|
|
11
|
+
"money",
|
|
12
|
+
"police",
|
|
13
|
+
"rice",
|
|
14
|
+
"series",
|
|
15
|
+
"sheep",
|
|
16
|
+
"species",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
_IRREGULARS = {
|
|
20
|
+
"child": "children",
|
|
21
|
+
"foot": "feet",
|
|
22
|
+
"goose": "geese",
|
|
23
|
+
"man": "men",
|
|
24
|
+
"mouse": "mice",
|
|
25
|
+
"ox": "oxen",
|
|
26
|
+
"person": "people",
|
|
27
|
+
"quiz": "quizzes",
|
|
28
|
+
"tooth": "teeth",
|
|
29
|
+
"woman": "women",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_PLURAL_RULES: list[tuple[re.Pattern[str], str]] = [
|
|
33
|
+
(re.compile(r"(quiz)$", re.IGNORECASE), r"\1zes"),
|
|
34
|
+
(re.compile(r"^(ox)$", re.IGNORECASE), r"\1en"),
|
|
35
|
+
(re.compile(r"([ml])ouse$", re.IGNORECASE), r"\1ice"),
|
|
36
|
+
(re.compile(r"(matr|vert|ind)(?:ix|ex)$", re.IGNORECASE), r"\1ices"),
|
|
37
|
+
(re.compile(r"(ax|test)is$", re.IGNORECASE), r"\1es"),
|
|
38
|
+
(re.compile(r"sis$", re.IGNORECASE), "ses"),
|
|
39
|
+
(re.compile(r"(x|ch|ss|sh)$", re.IGNORECASE), r"\1es"),
|
|
40
|
+
(re.compile(r"([^aeiouy]|qu)y$", re.IGNORECASE), r"\1ies"),
|
|
41
|
+
(re.compile(r"(?:([^f])fe|([lr])f)$", re.IGNORECASE), r"\1\2ves"),
|
|
42
|
+
(re.compile(r"([ti])um$", re.IGNORECASE), r"\1a"),
|
|
43
|
+
(re.compile(r"(buffal|tomat)o$", re.IGNORECASE), r"\1oes"),
|
|
44
|
+
(re.compile(r"(bu)s$", re.IGNORECASE), r"\1ses"),
|
|
45
|
+
(re.compile(r"(alias|status)$", re.IGNORECASE), r"\1es"),
|
|
46
|
+
(re.compile(r"(octop|vir)us$", re.IGNORECASE), r"\1uses"),
|
|
47
|
+
(re.compile(r"s$", re.IGNORECASE), "s"),
|
|
48
|
+
(re.compile(r"$"), "s"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def snake_case(name: str) -> str:
|
|
53
|
+
"""
|
|
54
|
+
Convert PascalCase, camelCase, or mixed acronyms to snake_case.
|
|
55
|
+
|
|
56
|
+
Examples:
|
|
57
|
+
snake_case("UserProfile") -> "user_profile"
|
|
58
|
+
snake_case("APIKey") -> "api_key"
|
|
59
|
+
snake_case("HTTPServer") -> "http_server"
|
|
60
|
+
snake_case("HTTPRequest") -> "http_request"
|
|
61
|
+
snake_case("UserAPIKey") -> "user_api_key"
|
|
62
|
+
snake_case("Oauth2Client") -> "oauth2_client"
|
|
63
|
+
"""
|
|
64
|
+
if not name:
|
|
65
|
+
return name
|
|
66
|
+
|
|
67
|
+
# 1. Separate consecutive uppercase runs followed by titlecase/lowercase
|
|
68
|
+
# e.g., 'APIKey' -> 'API_Key', 'HTTPRequest' -> 'HTTP_Request'
|
|
69
|
+
s1 = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
|
70
|
+
|
|
71
|
+
# 2. Separate lowercase/digit followed by uppercase
|
|
72
|
+
# e.g., 'UserAPI_Key' -> 'User_API_Key', 'oauth2Client' -> 'oauth2_Client'
|
|
73
|
+
s2 = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1)
|
|
74
|
+
|
|
75
|
+
return s2.lower()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def pluralize(word: str) -> str:
|
|
79
|
+
"""Pluralize an English word or snake_cased identifier, preserving capitalization."""
|
|
80
|
+
if not word:
|
|
81
|
+
return word
|
|
82
|
+
|
|
83
|
+
parts = word.split("_")
|
|
84
|
+
last = parts[-1]
|
|
85
|
+
last_lower = last.lower()
|
|
86
|
+
|
|
87
|
+
if last_lower in _UNCOUNTABLES:
|
|
88
|
+
plural_last = last
|
|
89
|
+
elif last_lower in _IRREGULARS:
|
|
90
|
+
replacement = _IRREGULARS[last_lower]
|
|
91
|
+
if last.isupper():
|
|
92
|
+
plural_last = replacement.upper()
|
|
93
|
+
elif last.istitle():
|
|
94
|
+
plural_last = replacement.title()
|
|
95
|
+
else:
|
|
96
|
+
plural_last = replacement
|
|
97
|
+
else:
|
|
98
|
+
for pattern, replacement in _PLURAL_RULES:
|
|
99
|
+
if pattern.search(last):
|
|
100
|
+
plural_last = pattern.sub(replacement, last)
|
|
101
|
+
break
|
|
102
|
+
else:
|
|
103
|
+
plural_last = last + "s"
|
|
104
|
+
|
|
105
|
+
if last.isupper():
|
|
106
|
+
plural_last = plural_last.upper()
|
|
107
|
+
elif last.istitle():
|
|
108
|
+
plural_last = plural_last[0].upper() + plural_last[1:]
|
|
109
|
+
|
|
110
|
+
parts[-1] = plural_last
|
|
111
|
+
return "_".join(parts)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
__all__ = ["pluralize", "snake_case"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.testing.fakes.ai_fake import AIFake, FakeCall
|
|
4
|
+
from swiftpy.testing.fakes.vectorstore_fake import VectorStoreCall, VectorStoreFake
|
|
5
|
+
from swiftpy.testing.fixtures import db_transaction, transactional_test
|
|
6
|
+
from swiftpy.testing.http_client import TestClient, TestResponse, WebSocketTestSession
|
|
7
|
+
from swiftpy.testing.test_case import TestCase
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AIFake",
|
|
11
|
+
"FakeCall",
|
|
12
|
+
"TestCase",
|
|
13
|
+
"TestClient",
|
|
14
|
+
"TestResponse",
|
|
15
|
+
"VectorStoreCall",
|
|
16
|
+
"VectorStoreFake",
|
|
17
|
+
"WebSocketTestSession",
|
|
18
|
+
"db_transaction",
|
|
19
|
+
"transactional_test",
|
|
20
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.testing.fakes.ai_fake import AIFake, FakeCall
|
|
4
|
+
from swiftpy.testing.fakes.vectorstore_fake import VectorStoreCall, VectorStoreFake
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AIFake",
|
|
8
|
+
"FakeCall",
|
|
9
|
+
"VectorStoreCall",
|
|
10
|
+
"VectorStoreFake",
|
|
11
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from swiftpy.ai._deterministic import (
|
|
4
|
+
generate_deterministic_embedding as _deterministic_fake_vector,
|
|
5
|
+
)
|
|
6
|
+
from swiftpy.ai._fake import (
|
|
7
|
+
AIFake,
|
|
8
|
+
FakeCall,
|
|
9
|
+
FakeEmbeddingCall,
|
|
10
|
+
FixtureType,
|
|
11
|
+
_task_fake_stack,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AIFake",
|
|
16
|
+
"FakeCall",
|
|
17
|
+
"FakeEmbeddingCall",
|
|
18
|
+
"FixtureType",
|
|
19
|
+
"_deterministic_fake_vector",
|
|
20
|
+
"_task_fake_stack",
|
|
21
|
+
]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from swiftpy.ai.vectorstore.base import SearchResult, VectorRecord
|
|
8
|
+
from swiftpy.ai.vectorstore.in_memory_adapter import InMemoryVectorStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class VectorStoreCall:
|
|
13
|
+
"""Recorded invocation made against a VectorStoreFake."""
|
|
14
|
+
|
|
15
|
+
method: str
|
|
16
|
+
collection: str
|
|
17
|
+
args: dict[str, Any] = field(default_factory=dict)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class VectorStoreFake(InMemoryVectorStore):
|
|
21
|
+
"""
|
|
22
|
+
In-memory vector store test fake that records operations for assertions.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
collection_name: str = "default",
|
|
28
|
+
dimensions: int | None = None,
|
|
29
|
+
metric: str = "cosine",
|
|
30
|
+
*,
|
|
31
|
+
max_records: int | None = 100_000,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(
|
|
34
|
+
collection_name=collection_name,
|
|
35
|
+
dimensions=dimensions,
|
|
36
|
+
metric=metric,
|
|
37
|
+
max_records=max_records,
|
|
38
|
+
)
|
|
39
|
+
self.calls: list[VectorStoreCall] = []
|
|
40
|
+
|
|
41
|
+
def reset(self) -> None:
|
|
42
|
+
"""Clear all stored vectors and recorded calls."""
|
|
43
|
+
self.calls.clear()
|
|
44
|
+
self._collections.clear()
|
|
45
|
+
|
|
46
|
+
def recorded(self, method: str | None = None) -> list[VectorStoreCall]:
|
|
47
|
+
"""Return recorded invocations, optionally filtered by method name."""
|
|
48
|
+
if method is None:
|
|
49
|
+
return list(self.calls)
|
|
50
|
+
return [call for call in self.calls if call.method == method]
|
|
51
|
+
|
|
52
|
+
def assert_called(self, method: str, *, times: int | None = None) -> None:
|
|
53
|
+
"""Assert that `method` was invoked (and optionally how many times)."""
|
|
54
|
+
matched = self.recorded(method)
|
|
55
|
+
if not matched and (times is None or times > 0):
|
|
56
|
+
raise AssertionError(f"Expected VectorStore method '{method}' to be called.")
|
|
57
|
+
if times is not None and len(matched) != times:
|
|
58
|
+
raise AssertionError(
|
|
59
|
+
f"Expected VectorStore method '{method}' to be called {times} time(s), got {len(matched)}."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def assert_upserted(self, record_id: str | None = None) -> None:
|
|
63
|
+
"""Assert that an upsert/upsert_batch occurred (optionally for a specific id)."""
|
|
64
|
+
upserts = [
|
|
65
|
+
call for call in self.calls if call.method in ("upsert", "upsert_batch")
|
|
66
|
+
]
|
|
67
|
+
if not upserts:
|
|
68
|
+
raise AssertionError("Expected at least one vectorstore upsert call.")
|
|
69
|
+
if record_id is not None:
|
|
70
|
+
found = any(
|
|
71
|
+
call.args.get("id") == record_id
|
|
72
|
+
or record_id in call.args.get("ids", ())
|
|
73
|
+
for call in upserts
|
|
74
|
+
)
|
|
75
|
+
if not found:
|
|
76
|
+
raise AssertionError(
|
|
77
|
+
f"Expected record id '{record_id}' to be upserted, got {upserts!r}."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def assert_searched(self, *, times: int | None = None) -> None:
|
|
81
|
+
"""Assert that search() was invoked."""
|
|
82
|
+
self.assert_called("search", times=times)
|
|
83
|
+
|
|
84
|
+
async def create_collection(
|
|
85
|
+
self,
|
|
86
|
+
name: str,
|
|
87
|
+
dimensions: int,
|
|
88
|
+
metric: str = "cosine",
|
|
89
|
+
) -> None:
|
|
90
|
+
await super().create_collection(name, dimensions, metric=metric)
|
|
91
|
+
self.calls.append(
|
|
92
|
+
VectorStoreCall(
|
|
93
|
+
method="create_collection",
|
|
94
|
+
collection=name,
|
|
95
|
+
args={"name": name, "dimensions": dimensions, "metric": metric},
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
async def upsert(
|
|
100
|
+
self,
|
|
101
|
+
id: str,
|
|
102
|
+
vector: list[float],
|
|
103
|
+
metadata: dict[str, Any] | None = None,
|
|
104
|
+
content: str = "",
|
|
105
|
+
) -> None:
|
|
106
|
+
await super().upsert(id, vector, metadata=metadata, content=content)
|
|
107
|
+
self.calls.append(
|
|
108
|
+
VectorStoreCall(
|
|
109
|
+
method="upsert",
|
|
110
|
+
collection=self._current_collection,
|
|
111
|
+
args={"id": id, "dimensions": len(vector), "metadata": metadata},
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async def upsert_batch(
|
|
116
|
+
self,
|
|
117
|
+
records: Sequence[
|
|
118
|
+
VectorRecord
|
|
119
|
+
| dict[str, Any]
|
|
120
|
+
| tuple[str, list[float], dict[str, Any] | None]
|
|
121
|
+
| tuple[str, list[float], dict[str, Any] | None, str]
|
|
122
|
+
],
|
|
123
|
+
) -> None:
|
|
124
|
+
ids: list[str] = []
|
|
125
|
+
for rec in records:
|
|
126
|
+
if isinstance(rec, VectorRecord):
|
|
127
|
+
ids.append(rec.id)
|
|
128
|
+
elif isinstance(rec, dict):
|
|
129
|
+
ids.append(str(rec.get("id", "")))
|
|
130
|
+
elif isinstance(rec, tuple) and rec:
|
|
131
|
+
ids.append(str(rec[0]))
|
|
132
|
+
await super().upsert_batch(records)
|
|
133
|
+
self.calls.append(
|
|
134
|
+
VectorStoreCall(
|
|
135
|
+
method="upsert_batch",
|
|
136
|
+
collection=self._current_collection,
|
|
137
|
+
args={"count": len(records), "ids": tuple(ids)},
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def search(
|
|
142
|
+
self,
|
|
143
|
+
vector: list[float],
|
|
144
|
+
limit: int = 10,
|
|
145
|
+
filter: dict[str, Any] | None = None,
|
|
146
|
+
) -> list[SearchResult]:
|
|
147
|
+
results = await super().search(vector, limit=limit, filter=filter)
|
|
148
|
+
self.calls.append(
|
|
149
|
+
VectorStoreCall(
|
|
150
|
+
method="search",
|
|
151
|
+
collection=self._current_collection,
|
|
152
|
+
args={
|
|
153
|
+
"limit": limit,
|
|
154
|
+
"filter": filter,
|
|
155
|
+
"result_count": len(results),
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
return results
|
|
160
|
+
|
|
161
|
+
async def delete(self, id: str) -> bool:
|
|
162
|
+
deleted = await super().delete(id)
|
|
163
|
+
self.calls.append(
|
|
164
|
+
VectorStoreCall(
|
|
165
|
+
method="delete",
|
|
166
|
+
collection=self._current_collection,
|
|
167
|
+
args={"id": id, "deleted": deleted},
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
return deleted
|
|
171
|
+
|
|
172
|
+
async def delete_batch(self, ids: Sequence[str]) -> int:
|
|
173
|
+
count = await super().delete_batch(ids)
|
|
174
|
+
self.calls.append(
|
|
175
|
+
VectorStoreCall(
|
|
176
|
+
method="delete_batch",
|
|
177
|
+
collection=self._current_collection,
|
|
178
|
+
args={"ids": tuple(ids), "deleted_count": count},
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
return count
|
|
182
|
+
|
|
183
|
+
async def drop_collection(self, name: str) -> None:
|
|
184
|
+
await super().drop_collection(name)
|
|
185
|
+
self.calls.append(
|
|
186
|
+
VectorStoreCall(
|
|
187
|
+
method="drop_collection",
|
|
188
|
+
collection=name,
|
|
189
|
+
args={"name": name},
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
__all__ = ["VectorStoreCall", "VectorStoreFake"]
|