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,567 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, get_args, get_origin
5
+
6
+ from swiftpy.database.orm.model import Model
7
+ from swiftpy.database.orm.relationships import (
8
+ BelongsTo,
9
+ BelongsToMany,
10
+ HasMany,
11
+ HasOne,
12
+ Relation,
13
+ )
14
+ from swiftpy.support.strings import pluralize, snake_case
15
+
16
+ if TYPE_CHECKING:
17
+ from swiftpy.admin.auth import AdminUser
18
+ from swiftpy.core.application import Application
19
+ from swiftpy.database.orm.model import ModelQueryBuilder
20
+ from swiftpy.http.request import Request
21
+ from swiftpy.http.router import Router
22
+
23
+ T = TypeVar("T", bound=Model)
24
+ A = TypeVar("A", bound="ModelAdmin")
25
+
26
+
27
+ def humanize_name(name: str) -> str:
28
+ """Convert snake_case column or identifier to title case label."""
29
+ if name.lower() == "id":
30
+ return "ID"
31
+ clean = name.removesuffix("_id") if name.endswith("_id") and len(name) > 3 else name
32
+ return clean.replace("_", " ").title()
33
+
34
+
35
+ _humanize_name = humanize_name
36
+
37
+
38
+ def _infer_type_name(hint: Any) -> str:
39
+ """Infer primitive string type name from a Python type annotation."""
40
+ if hint is None or hint is type(None):
41
+ return "str"
42
+
43
+ if isinstance(hint, str):
44
+ norm = hint.strip().lower()
45
+ if "bool" in norm:
46
+ return "bool"
47
+ if "int" in norm:
48
+ return "int"
49
+ if "float" in norm or "decimal" in norm:
50
+ return "float"
51
+ if "datetime" in norm or "date" in norm:
52
+ return "datetime"
53
+ if "dict" in norm or "mapping" in norm:
54
+ return "dict"
55
+ if "list" in norm or "sequence" in norm or "set" in norm or "tuple" in norm:
56
+ return "list"
57
+ return "str"
58
+
59
+ origin = get_origin(hint)
60
+ if origin is not None:
61
+ args = get_args(hint)
62
+ # Handle Optional[X] or Union[X, None]
63
+ non_none_args = [a for a in args if a is not type(None)]
64
+ if non_none_args:
65
+ return _infer_type_name(non_none_args[0])
66
+ return "str"
67
+
68
+ if isinstance(hint, type):
69
+ name = hint.__name__.lower()
70
+ if name in ("int", "integer"):
71
+ return "int"
72
+ if name in ("float", "decimal"):
73
+ return "float"
74
+ if name in ("bool", "boolean"):
75
+ return "bool"
76
+ if name in ("datetime", "date", "time"):
77
+ return "datetime"
78
+ if name in ("dict", "mapping"):
79
+ return "dict"
80
+ if name in ("list", "sequence", "set", "tuple"):
81
+ return "list"
82
+ return "str"
83
+
84
+ return "str"
85
+
86
+
87
+ class FieldMetadata:
88
+ """Metadata describing a declared field on an Active Record Model."""
89
+
90
+ def __init__(
91
+ self,
92
+ name: str,
93
+ type_hint: Any,
94
+ nullable: bool = True,
95
+ default: Any = None,
96
+ is_primary_key: bool = False,
97
+ is_foreign_key: bool = False,
98
+ foreign_relation_name: str | None = None,
99
+ foreign_model_name: str | None = None,
100
+ ) -> None:
101
+ self.name = name
102
+ self.type_hint = type_hint
103
+ self.type_name = _infer_type_name(type_hint)
104
+ self.nullable = nullable
105
+ self.default = default
106
+ self.is_primary_key = is_primary_key
107
+ self.is_foreign_key = is_foreign_key
108
+ self.foreign_relation_name = foreign_relation_name
109
+ self.foreign_model_name = foreign_model_name
110
+ self.label = humanize_name(name)
111
+
112
+ @property
113
+ def input_type(self) -> str:
114
+ if self.is_foreign_key:
115
+ return "select"
116
+ if self.type_name == "int":
117
+ return "number"
118
+ if self.type_name == "float":
119
+ return "number"
120
+ if self.type_name == "bool":
121
+ return "checkbox"
122
+ if self.type_name == "datetime":
123
+ return "datetime-local"
124
+ if self.name in ("content", "description", "body", "bio", "notes", "text"):
125
+ return "textarea"
126
+ return "text"
127
+
128
+ def coerce_value(self, raw_value: Any) -> Any:
129
+ """Coerce raw HTTP form/JSON input to the declared field type."""
130
+ if self.type_name == "bool":
131
+ if isinstance(raw_value, bool):
132
+ return raw_value
133
+ if raw_value is None or raw_value == "":
134
+ return False
135
+ norm = str(raw_value).strip().lower()
136
+ return norm in ("true", "1", "yes", "on")
137
+
138
+ if raw_value is None or raw_value == "":
139
+ if self.default is not None:
140
+ return self.default
141
+ if self.nullable:
142
+ return None
143
+ if self.type_name == "str":
144
+ return ""
145
+ return None
146
+
147
+ if self.type_name == "int":
148
+ try:
149
+ return int(raw_value)
150
+ except (ValueError, TypeError) as exc:
151
+ raise ValueError(f"Field '{self.name}' must be a valid integer.") from exc
152
+
153
+ if self.type_name == "float":
154
+ try:
155
+ return float(raw_value)
156
+ except (ValueError, TypeError) as exc:
157
+ raise ValueError(f"Field '{self.name}' must be a valid number.") from exc
158
+
159
+ return str(raw_value)
160
+
161
+
162
+ class RelationshipMetadata:
163
+ """Metadata describing a declared relationship on an Active Record Model."""
164
+
165
+ def __init__(
166
+ self,
167
+ name: str,
168
+ relation_obj: Relation[Any],
169
+ owner_cls: type[Model],
170
+ ) -> None:
171
+ self.name = name
172
+ self.relation_obj = relation_obj
173
+ self.owner_cls = owner_cls
174
+ self.foreign_key = relation_obj.foreign_key or f"{snake_case(owner_cls.__name__)}_id"
175
+ if isinstance(relation_obj, BelongsTo):
176
+ target = self.get_target_model() if relation_obj.local_key is None else None
177
+ self.local_key = (
178
+ relation_obj.local_key
179
+ or getattr(target, "__primary_key__", None)
180
+ or getattr(owner_cls, "__primary_key__", "id")
181
+ )
182
+ else:
183
+ self.local_key = relation_obj.local_key or getattr(owner_cls, "__primary_key__", "id")
184
+
185
+ if isinstance(relation_obj, BelongsTo):
186
+ self.relation_type = "BelongsTo"
187
+ elif isinstance(relation_obj, HasMany):
188
+ self.relation_type = "HasMany"
189
+ elif isinstance(relation_obj, HasOne):
190
+ self.relation_type = "HasOne"
191
+ elif isinstance(relation_obj, BelongsToMany):
192
+ self.relation_type = "BelongsToMany"
193
+ else:
194
+ self.relation_type = "Relation"
195
+
196
+ if isinstance(relation_obj, BelongsToMany):
197
+ self.pivot_table = getattr(relation_obj, "pivot_table", None)
198
+ else:
199
+ self.pivot_table = None
200
+
201
+ self.target_model_name = relation_obj.related_model_name
202
+ self.label = humanize_name(name)
203
+
204
+ def get_target_model(self) -> type[Model] | None:
205
+ """Resolve the target Model class, or return None if unresolved."""
206
+ try:
207
+ return self.relation_obj.get_target_model()
208
+ except (KeyError, ValueError, AttributeError, LookupError, RuntimeError):
209
+ return None
210
+
211
+
212
+ class ModelAdmin:
213
+ """
214
+ Administrative configuration for a registered Model.
215
+ Controls list display columns, search fields, ordering, RBAC permissions, and CRUD form behaviors.
216
+ """
217
+
218
+ model: type[Model]
219
+ list_display: ClassVar[list[str] | tuple[str, ...] | None] = None
220
+ search_fields: ClassVar[list[str]] = []
221
+ ordering: ClassVar[str | None] = None
222
+ readonly_fields: ClassVar[list[str]] = []
223
+ exclude: ClassVar[list[str]] = []
224
+ per_page: ClassVar[int] = 25
225
+ can_create: ClassVar[bool] = True
226
+ can_edit: ClassVar[bool] = True
227
+ can_delete: ClassVar[bool] = True
228
+ required_view_permission: ClassVar[str | None] = None
229
+ required_create_permission: ClassVar[str | None] = None
230
+ required_edit_permission: ClassVar[str | None] = None
231
+ required_delete_permission: ClassVar[str | None] = None
232
+
233
+ def __init__(self, model: type[Model], site: AdminSite | None = None) -> None:
234
+ self.model = model
235
+ self.site = site
236
+ self._fields_cache: list[FieldMetadata] | None = None
237
+ self._relations_cache: dict[str, RelationshipMetadata] | None = None
238
+
239
+ def __call__(self, admin_cls: type[ModelAdmin]) -> type[ModelAdmin]:
240
+ """Support decorator usage: @admin.register(Model) class MyAdmin(ModelAdmin): ..."""
241
+ if (
242
+ isinstance(admin_cls, type)
243
+ and issubclass(admin_cls, ModelAdmin)
244
+ and self.site is not None
245
+ ):
246
+ self.site.unregister(self.model)
247
+ instance = admin_cls(self.model, site=self.site)
248
+ self.site._register_model_admin(self.model, instance)
249
+ return admin_cls
250
+
251
+ @property
252
+ def primary_key(self) -> str:
253
+ """Declared primary key column name on the underlying model."""
254
+ return str(getattr(self.model, "__primary_key__", "id"))
255
+
256
+ @property
257
+ def slug(self) -> str:
258
+ """URL slug for this model in the admin panel."""
259
+ raw_table = getattr(self.model, "__table__", None)
260
+ if raw_table and isinstance(raw_table, str):
261
+ return raw_table.lower()
262
+ return pluralize(snake_case(self.model.__name__)).lower()
263
+
264
+ @property
265
+ def title(self) -> str:
266
+ """Singular humanized display name."""
267
+ return humanize_name(snake_case(self.model.__name__))
268
+
269
+ @property
270
+ def plural_title(self) -> str:
271
+ """Plural humanized display name."""
272
+ return pluralize(self.title)
273
+
274
+ def get_relationships(self) -> dict[str, RelationshipMetadata]:
275
+ """Inspect and return declared relationships on the model."""
276
+ if self._relations_cache is not None:
277
+ return self._relations_cache
278
+
279
+ relations_config = getattr(self.model, "__relations_config__", {})
280
+ result: dict[str, RelationshipMetadata] = {}
281
+ for rel_name, rel_obj in relations_config.items():
282
+ result[rel_name] = RelationshipMetadata(
283
+ name=rel_name,
284
+ relation_obj=rel_obj,
285
+ owner_cls=self.model,
286
+ )
287
+ self._relations_cache = result
288
+ return result
289
+
290
+ def get_fields(self) -> list[FieldMetadata]:
291
+ """Inspect and return declared fields on the model with relationship mapping."""
292
+ if self._fields_cache is not None:
293
+ return self._fields_cache
294
+
295
+ declared_fields = getattr(self.model, "__fields__", {})
296
+ defaults = getattr(self.model, "__field_defaults__", {})
297
+ pk = getattr(self.model, "__primary_key__", "id")
298
+ relationships = self.get_relationships()
299
+
300
+ # Build reverse lookup of foreign_key -> (relation_name, target_model_name)
301
+ fk_map: dict[str, tuple[str, str]] = {}
302
+ for rel_name, rel_meta in relationships.items():
303
+ if rel_meta.relation_type == "BelongsTo" and rel_meta.foreign_key:
304
+ fk_map[rel_meta.foreign_key] = (rel_name, rel_meta.target_model_name)
305
+
306
+ fields: list[FieldMetadata] = []
307
+ for field_name, type_hint in declared_fields.items():
308
+ is_pk = field_name == pk
309
+ is_fk = field_name in fk_map
310
+ rel_info = fk_map.get(field_name)
311
+ foreign_rel_name: str | None = rel_info[0] if rel_info else None
312
+ foreign_target_name: str | None = rel_info[1] if rel_info else None
313
+ def_val = defaults.get(field_name)
314
+
315
+ fields.append(
316
+ FieldMetadata(
317
+ name=field_name,
318
+ type_hint=type_hint,
319
+ nullable=is_pk or field_name in defaults,
320
+ default=def_val,
321
+ is_primary_key=is_pk,
322
+ is_foreign_key=is_fk,
323
+ foreign_relation_name=foreign_rel_name,
324
+ foreign_model_name=foreign_target_name,
325
+ )
326
+ )
327
+
328
+ self._fields_cache = fields
329
+ return fields
330
+
331
+ def get_field(self, name: str) -> FieldMetadata | None:
332
+ for f in self.get_fields():
333
+ if f.name == name:
334
+ return f
335
+ return None
336
+
337
+ def get_list_display(self) -> list[str]:
338
+ """Columns to display in the list view table."""
339
+ if self.list_display:
340
+ return list(self.list_display)
341
+ all_field_names = [f.name for f in self.get_fields()]
342
+ # Show primary key and first few fields
343
+ pk = getattr(self.model, "__primary_key__", "id")
344
+ cols = [pk] if pk in all_field_names else []
345
+ for f in all_field_names:
346
+ if f != pk and f not in cols:
347
+ cols.append(f)
348
+ if len(cols) >= 6:
349
+ break
350
+ return cols
351
+
352
+ def get_form_fields(self) -> list[FieldMetadata]:
353
+ """Fields to include in Create and Edit forms."""
354
+ pk = getattr(self.model, "__primary_key__", "id")
355
+ excluded = set(self.exclude)
356
+ return [
357
+ f
358
+ for f in self.get_fields()
359
+ if not f.is_primary_key and f.name != pk and f.name not in excluded
360
+ ]
361
+
362
+ def _has_action_permission(
363
+ self,
364
+ user: AdminUser,
365
+ explicit_perm: str | None,
366
+ default_perms: tuple[str, ...],
367
+ *,
368
+ action_enabled: bool = True,
369
+ ) -> bool:
370
+ if not action_enabled or user.is_admin is not True:
371
+ return False
372
+ if explicit_perm is not None:
373
+ return user.has_permission(explicit_perm)
374
+ return any(user.has_permission(p) for p in default_perms)
375
+
376
+ def has_view_permission(self, request: Request, user: AdminUser) -> bool:
377
+ """Return whether `user` is permitted to view records for this model."""
378
+ return self._has_action_permission(
379
+ user,
380
+ self.required_view_permission,
381
+ ("all", "models:read", f"{self.slug}:read"),
382
+ )
383
+
384
+ def has_create_permission(self, request: Request, user: AdminUser) -> bool:
385
+ """Return whether `user` is permitted to create records for this model."""
386
+ return self._has_action_permission(
387
+ user,
388
+ self.required_create_permission,
389
+ ("all", "models:write", f"{self.slug}:create"),
390
+ action_enabled=self.can_create,
391
+ )
392
+
393
+ def has_edit_permission(self, request: Request, user: AdminUser) -> bool:
394
+ """Return whether `user` is permitted to edit records for this model."""
395
+ return self._has_action_permission(
396
+ user,
397
+ self.required_edit_permission,
398
+ ("all", "models:write", f"{self.slug}:edit"),
399
+ action_enabled=self.can_edit,
400
+ )
401
+
402
+ def has_delete_permission(self, request: Request, user: AdminUser) -> bool:
403
+ """Return whether `user` is permitted to delete records for this model."""
404
+ return self._has_action_permission(
405
+ user,
406
+ self.required_delete_permission,
407
+ ("all", "models:write", "models:delete", f"{self.slug}:delete"),
408
+ action_enabled=self.can_delete,
409
+ )
410
+
411
+ def get_queryset(self) -> ModelQueryBuilder[Any]:
412
+ """
413
+ Produce a base ModelQueryBuilder for list views.
414
+ Automatically eager-loads BelongsTo relationships to eliminate N+1 queries.
415
+ """
416
+ builder = self.model.query()
417
+ relationships = self.get_relationships()
418
+ eager_rels = [
419
+ name
420
+ for name, meta in relationships.items()
421
+ if meta.relation_type == "BelongsTo"
422
+ ]
423
+ if eager_rels:
424
+ builder.with_(*eager_rels)
425
+ if self.ordering:
426
+ raw_order = self.ordering.strip()
427
+ if raw_order.startswith("-") and len(raw_order) > 1:
428
+ builder.order_by(raw_order[1:].strip(), "DESC")
429
+ else:
430
+ parts = raw_order.split()
431
+ if len(parts) == 2 and parts[1].upper() in ("ASC", "DESC"):
432
+ builder.order_by(parts[0], parts[1].upper())
433
+ else:
434
+ builder.order_by(raw_order, "ASC")
435
+ return builder
436
+
437
+
438
+ _ROUTE_COMPILER: Callable[[AdminSite, Application, str], None] | None = None
439
+ _ROUTER_FACTORY: Callable[[AdminSite, str], Router] | None = None
440
+
441
+
442
+ def set_admin_route_compiler(
443
+ compiler: Callable[[AdminSite, Application, str], None],
444
+ factory: Callable[[AdminSite, str], Router],
445
+ ) -> None:
446
+ """Register views compiler callbacks to avoid circular or inline imports."""
447
+ global _ROUTE_COMPILER, _ROUTER_FACTORY
448
+ _ROUTE_COMPILER = compiler
449
+ _ROUTER_FACTORY = factory
450
+
451
+
452
+ class AdminSite:
453
+ """
454
+ Central administrative registry managing registered Models and generating CRUD screens.
455
+ """
456
+
457
+ def __init__(self, title: str = "SwiftPY Admin") -> None:
458
+ self.title = title
459
+ self._registry: dict[type[Model], ModelAdmin] = {}
460
+ self._slug_registry: dict[str, tuple[type[Model], ModelAdmin]] = {}
461
+
462
+ def register(
463
+ self,
464
+ model_or_admin: type[Model | ModelAdmin] | None = None,
465
+ admin_class: type[ModelAdmin] | None = None,
466
+ ) -> Any:
467
+ """
468
+ Register a Model with the AdminSite.
469
+ Supports:
470
+ 1. Direct invocation: admin.register(Model, ModelAdmin)
471
+ 2. Direct invocation: admin.register(Model)
472
+ 3. Decorator on Model: @admin.register class MyModel(Model): ...
473
+ 4. Decorator on ModelAdmin: @admin.register(MyModel) class MyAdmin(ModelAdmin): ...
474
+ """
475
+ # Check non-Model parameter
476
+ if model_or_admin is not None and not (isinstance(model_or_admin, type) and issubclass(model_or_admin, Model)):
477
+ raise TypeError(f"AdminSite.register() expects a Model subclass, got {model_or_admin!r}.")
478
+
479
+ # Case 1: admin.register(MyModel, MyAdmin)
480
+ if (
481
+ isinstance(model_or_admin, type)
482
+ and issubclass(model_or_admin, Model)
483
+ and admin_class is not None
484
+ ):
485
+ instance = admin_class(model_or_admin, site=self)
486
+ self._register_model_admin(model_or_admin, instance)
487
+ return instance
488
+
489
+ # Case 2: @admin.register(MyModel) or admin.register(MyModel)
490
+ if (
491
+ isinstance(model_or_admin, type)
492
+ and issubclass(model_or_admin, Model)
493
+ and admin_class is None
494
+ ):
495
+ instance = ModelAdmin(model_or_admin, site=self)
496
+ self._register_model_admin(model_or_admin, instance)
497
+ return instance
498
+
499
+ # Case 3: parameterless decorator: @admin.register class MyModel(Model): ...
500
+ def parameterless_decorator(cls: type[Model]) -> type[Model]:
501
+ if not isinstance(cls, type) or not issubclass(cls, Model):
502
+ raise TypeError(f"AdminSite.register() expects a Model subclass, got {cls!r}.")
503
+ instance = ModelAdmin(cls, site=self)
504
+ self._register_model_admin(cls, instance)
505
+ return cls
506
+
507
+ return parameterless_decorator
508
+
509
+ def _register_model_admin(self, model: type[Model], admin_instance: ModelAdmin) -> None:
510
+ if model in self._registry:
511
+ raise ValueError(f"Model '{model.__name__}' is already registered with AdminSite '{self.title}'.")
512
+ if admin_instance.slug in self._slug_registry:
513
+ existing_model = self._slug_registry[admin_instance.slug][0]
514
+ raise ValueError(
515
+ f"Admin slug collision: '{admin_instance.slug}' is already registered for model '{existing_model.__name__}'."
516
+ )
517
+ self._registry[model] = admin_instance
518
+ self._slug_registry[admin_instance.slug] = (model, admin_instance)
519
+
520
+ def unregister(self, model: type[Model]) -> None:
521
+ """Remove a model from the admin registry."""
522
+ admin_instance = self._registry.pop(model, None)
523
+ if admin_instance:
524
+ self._slug_registry.pop(admin_instance.slug, None)
525
+
526
+ def is_registered(self, model: type[Model]) -> bool:
527
+ """Check if a Model class is registered in the admin site."""
528
+ return model in self._registry
529
+
530
+ def get_model_admin(self, model: type[Model]) -> ModelAdmin:
531
+ """Retrieve the ModelAdmin instance for a registered model."""
532
+ if model not in self._registry:
533
+ raise KeyError(f"Model '{model.__name__}' is not registered with the admin site.")
534
+ return self._registry[model]
535
+
536
+ def get_by_slug(self, slug: str) -> tuple[type[Model], ModelAdmin] | None:
537
+ """Retrieve (Model, ModelAdmin) pair by its URL slug."""
538
+ return self._slug_registry.get(slug.lower().strip())
539
+
540
+ def all_registered(self) -> list[ModelAdmin]:
541
+ """Return all registered ModelAdmin instances."""
542
+ return list(self._registry.values())
543
+
544
+ def mount(self, app: Application, prefix: str = "/admin") -> None:
545
+ """Mount auto-generated CRUD routes onto a SwiftPY Application."""
546
+ if _ROUTE_COMPILER is None:
547
+ raise RuntimeError("Admin views route compiler is not initialized.")
548
+ _ROUTE_COMPILER(self, app, prefix)
549
+
550
+ def get_router(self, prefix: str = "/admin") -> Router:
551
+ """Return a configured Router containing all admin CRUD routes."""
552
+ if _ROUTER_FACTORY is None:
553
+ raise RuntimeError("Admin views router factory is not initialized.")
554
+ return _ROUTER_FACTORY(self, prefix)
555
+
556
+
557
+ # Global singleton instance
558
+ admin = AdminSite()
559
+
560
+ __all__ = [
561
+ "AdminSite",
562
+ "FieldMetadata",
563
+ "ModelAdmin",
564
+ "RelationshipMetadata",
565
+ "admin",
566
+ "humanize_name",
567
+ ]