core-framework 0.3.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 (222) hide show
  1. core_framework/__init__.py +0 -0
  2. core_framework/alembic/comment/alembic/README +1 -0
  3. core_framework/alembic/comment/alembic/env.py +72 -0
  4. core_framework/alembic/comment/alembic/script.py.mako +28 -0
  5. core_framework/alembic/comment/alembic/versions/30334fd1347b_init.py +59 -0
  6. core_framework/alembic/comment/alembic/versions/a2b3c4d5e6f7_improve_comment_indexes.py +54 -0
  7. core_framework/alembic/comment/alembic/versions/bcc8e00cfc8b_add_extra_tables.py +64 -0
  8. core_framework/alembic/comment/alembic/versions/d1e2f3a4b5c6_add_comment_stats_dirty_table.py +29 -0
  9. core_framework/alembic/comment/alembic/versions/e3f4a5b6c7d8_cascade_delete_comment_descendants.py +49 -0
  10. core_framework/alembic/comment/alembic/versions/f7e6d5c4b3a2_comments_path_to_ltree.py +60 -0
  11. core_framework/alembic/comment/alembic.ini +52 -0
  12. core_framework/alembic/extension/alembic/README +1 -0
  13. core_framework/alembic/extension/alembic/env.py +98 -0
  14. core_framework/alembic/extension/alembic/script.py.mako +28 -0
  15. core_framework/alembic/extension/alembic/versions/0389226049cb_add_pg_trgm_extension.py +25 -0
  16. core_framework/alembic/extension/alembic/versions/5dc58b016cf5_add_citext_extension.py +25 -0
  17. core_framework/alembic/extension/alembic/versions/b0ba0d8a284e_add_pg_stat_statements_extension.py +25 -0
  18. core_framework/alembic/extension/alembic/versions/c9d0e1f2a3b4_add_ltree_extension.py +25 -0
  19. core_framework/alembic/extension/alembic.ini +147 -0
  20. core_framework/alembic/moderation/alembic/README +1 -0
  21. core_framework/alembic/moderation/alembic/env.py +98 -0
  22. core_framework/alembic/moderation/alembic/script.py.mako +28 -0
  23. core_framework/alembic/moderation/alembic/versions/085ba9021850_add_category_to_user_restrictions.py +93 -0
  24. core_framework/alembic/moderation/alembic/versions/5f9e4fc14a41_create_moderation_appeals_table.py +69 -0
  25. core_framework/alembic/moderation/alembic/versions/63e37381e73b_add_user_reports_table.py +33 -0
  26. core_framework/alembic/moderation/alembic/versions/6a2ae31b7ac6_add_moderation_actions_table.py +34 -0
  27. core_framework/alembic/moderation/alembic/versions/716aa1735c03_improve_indexes.py +36 -0
  28. core_framework/alembic/moderation/alembic/versions/7d243ddbfde1_add_post_reports_table.py +35 -0
  29. core_framework/alembic/moderation/alembic/versions/8fba1f72dd46_add_indexes.py +64 -0
  30. core_framework/alembic/moderation/alembic/versions/95cc35a51984_update_restriction_history.py +91 -0
  31. core_framework/alembic/moderation/alembic/versions/9ad79d0af730_add_unique_constraint_user_reports_.py +28 -0
  32. core_framework/alembic/moderation/alembic/versions/a5e569f5df1a_create_user_restrictions_table.py +38 -0
  33. core_framework/alembic/moderation/alembic/versions/b2c3d4e5f6a7_add_indexes.py +42 -0
  34. core_framework/alembic/moderation/alembic/versions/c3d4e5f6a7b8_improve_report_indexes.py +48 -0
  35. core_framework/alembic/moderation/alembic/versions/d4af74643ff5_add_internal_notes_table.py +38 -0
  36. core_framework/alembic/moderation/alembic/versions/db20f2fb7390_add_comment_reports_table.py +35 -0
  37. core_framework/alembic/moderation/alembic/versions/e66226952ea6_add_report_category_to_user_reports_.py +54 -0
  38. core_framework/alembic/moderation/alembic/versions/f5e8cb275c30_enforce_1_pending_appeal.py +29 -0
  39. core_framework/alembic/moderation/alembic/versions/fe1faad2832d_create_restriction_history_table.py +69 -0
  40. core_framework/alembic/moderation/alembic.ini +147 -0
  41. core_framework/alembic/post/alembic/README +1 -0
  42. core_framework/alembic/post/alembic/env.py +97 -0
  43. core_framework/alembic/post/alembic/script.py.mako +28 -0
  44. core_framework/alembic/post/alembic/versions/51542673f5c8_add_tables_for_muted_banned_users.py +41 -0
  45. core_framework/alembic/post/alembic/versions/5beeeae40a4a_add_post_views_table.py +45 -0
  46. core_framework/alembic/post/alembic/versions/620971509a8b_init.py +55 -0
  47. core_framework/alembic/post/alembic/versions/a1b2c3d4e5f6_add_indexes.py +44 -0
  48. core_framework/alembic/post/alembic/versions/c1d2e3f4a5b6_add_post_hashtags_table.py +36 -0
  49. core_framework/alembic/post/alembic/versions/e56723f2afff_add_post_stats_table.py +39 -0
  50. core_framework/alembic/post/alembic/versions/fbc723ac58cc_add_post_likes_table.py +32 -0
  51. core_framework/alembic/post/alembic.ini +149 -0
  52. core_framework/alembic/user/alembic/README +1 -0
  53. core_framework/alembic/user/alembic/env.py +98 -0
  54. core_framework/alembic/user/alembic/script.py.mako +28 -0
  55. core_framework/alembic/user/alembic/versions/1a8bb99726ed_remove_avatar_id_from_users.py +81 -0
  56. core_framework/alembic/user/alembic/versions/2ccacf455941_improve_indexes.py +34 -0
  57. core_framework/alembic/user/alembic/versions/47f47ce2110e_create_user_deletions_table.py +31 -0
  58. core_framework/alembic/user/alembic/versions/5976db3f0175_drop_user_states.py +26 -0
  59. core_framework/alembic/user/alembic/versions/62417002cf32_add_indexes.py +46 -0
  60. core_framework/alembic/user/alembic/versions/6f7ccf3c226b_refactor_user_login_events.py +66 -0
  61. core_framework/alembic/user/alembic/versions/73432817015b_add_user_preferences_table.py +33 -0
  62. core_framework/alembic/user/alembic/versions/765bc01a7a59_create_user_blocks_table.py +33 -0
  63. core_framework/alembic/user/alembic/versions/7a56631f9927_create_user_login_events_table.py +49 -0
  64. core_framework/alembic/user/alembic/versions/831611e589bc_create_user_state.py +31 -0
  65. core_framework/alembic/user/alembic/versions/83c98ab2a779_add_user_profiles_table.py +88 -0
  66. core_framework/alembic/user/alembic/versions/8a94362cad6d_create_user_role.py +31 -0
  67. core_framework/alembic/user/alembic/versions/94b973923895_add_user_change_history_table.py +97 -0
  68. core_framework/alembic/user/alembic/versions/cbc0f4efe84f_add_avatar_id_column_to_users_table.py +31 -0
  69. core_framework/alembic/user/alembic/versions/d8b98ac6b073_add_index_for_get_admin_user_ids_query.py +29 -0
  70. core_framework/alembic/user/alembic/versions/ddb70cc09d16_create_user_states_table.py +34 -0
  71. core_framework/alembic/user/alembic/versions/f9ba10815ecd_add_users_table.py +33 -0
  72. core_framework/alembic/user/alembic.ini +147 -0
  73. core_framework/api/__init__.py +0 -0
  74. core_framework/api/admin/__init__.py +0 -0
  75. core_framework/api/admin/comments/router.py +69 -0
  76. core_framework/api/admin/comments/schemas.py +53 -0
  77. core_framework/api/admin/moderation/__init__.py +0 -0
  78. core_framework/api/admin/moderation/router.py +205 -0
  79. core_framework/api/admin/moderation/schemas.py +110 -0
  80. core_framework/api/admin/posts/router.py +62 -0
  81. core_framework/api/admin/posts/schemas.py +29 -0
  82. core_framework/api/admin/router.py +17 -0
  83. core_framework/api/admin/users/__init__.py +0 -0
  84. core_framework/api/admin/users/router.py +181 -0
  85. core_framework/api/admin/users/schemas.py +137 -0
  86. core_framework/api/auth/__init__.py +0 -0
  87. core_framework/api/auth/router.py +21 -0
  88. core_framework/api/auth/schemas.py +28 -0
  89. core_framework/api/comments/authenticated/router.py +126 -0
  90. core_framework/api/comments/authenticated/schemas.py +27 -0
  91. core_framework/api/comments/public/router.py +103 -0
  92. core_framework/api/comments/public/schemas.py +36 -0
  93. core_framework/api/comments/router.py +9 -0
  94. core_framework/api/comments/schemas.py +17 -0
  95. core_framework/api/dependencies.py +168 -0
  96. core_framework/api/events/router.py +39 -0
  97. core_framework/api/events/schemas.py +20 -0
  98. core_framework/api/posts/authenticated/router.py +83 -0
  99. core_framework/api/posts/authenticated/schemas.py +37 -0
  100. core_framework/api/posts/public/router.py +100 -0
  101. core_framework/api/posts/public/schemas.py +39 -0
  102. core_framework/api/posts/router.py +9 -0
  103. core_framework/api/posts/schemas.py +39 -0
  104. core_framework/api/router.py +19 -0
  105. core_framework/api/schemas.py +9 -0
  106. core_framework/api/system/__init__.py +0 -0
  107. core_framework/api/system/router.py +108 -0
  108. core_framework/api/users/__init__.py +0 -0
  109. core_framework/api/users/authenticated/__init__.py +0 -0
  110. core_framework/api/users/authenticated/router.py +244 -0
  111. core_framework/api/users/authenticated/schemas.py +81 -0
  112. core_framework/api/users/public/__init__.py +0 -0
  113. core_framework/api/users/public/router.py +25 -0
  114. core_framework/api/users/public/schemas.py +7 -0
  115. core_framework/api/users/router.py +9 -0
  116. core_framework/api/users/shared/schemas.py +174 -0
  117. core_framework/application/__init__.py +0 -0
  118. core_framework/application/auth/__init__.py +0 -0
  119. core_framework/application/auth/access_service.py +26 -0
  120. core_framework/application/auth/auth_service.py +10 -0
  121. core_framework/application/auth/models.py +10 -0
  122. core_framework/application/bootstrap.py +19 -0
  123. core_framework/application/comments/admin_service.py +236 -0
  124. core_framework/application/comments/aggregation_service.py +28 -0
  125. core_framework/application/comments/authenticated_service.py +89 -0
  126. core_framework/application/comments/public_service.py +218 -0
  127. core_framework/application/events/README.md +26 -0
  128. core_framework/application/events/event_service.py +51 -0
  129. core_framework/application/events/event_token.py +46 -0
  130. core_framework/application/events/models.py +9 -0
  131. core_framework/application/moderation/__init__.py +0 -0
  132. core_framework/application/moderation/appeal_service.py +98 -0
  133. core_framework/application/moderation/moderator_service.py +46 -0
  134. core_framework/application/moderation/report_service.py +180 -0
  135. core_framework/application/moderation/scheduled_service.py +5 -0
  136. core_framework/application/moderation/user_service.py +180 -0
  137. core_framework/application/posts/admin_service.py +104 -0
  138. core_framework/application/posts/aggregation_service.py +28 -0
  139. core_framework/application/posts/authenticated_service.py +72 -0
  140. core_framework/application/posts/public_service.py +197 -0
  141. core_framework/application/shared/__init__.py +0 -0
  142. core_framework/application/shared/enums.py +16 -0
  143. core_framework/application/shared/exceptions.py +16 -0
  144. core_framework/application/shared/user_agent.py +24 -0
  145. core_framework/application/users/__init__.py +0 -0
  146. core_framework/application/users/admin_service.py +298 -0
  147. core_framework/application/users/authenticated_service.py +179 -0
  148. core_framework/application/users/public_service.py +7 -0
  149. core_framework/application/users/scheduled_service.py +5 -0
  150. core_framework/bundled_alembic.py +57 -0
  151. core_framework/core/__init__.py +37 -0
  152. core_framework/core/cache.py +234 -0
  153. core_framework/core/context.py +14 -0
  154. core_framework/core/database.py +111 -0
  155. core_framework/core/exception_handlers/__init__.py +3 -0
  156. core_framework/core/exception_handlers/comment.py +99 -0
  157. core_framework/core/exception_handlers/common.py +5 -0
  158. core_framework/core/exception_handlers/moderation.py +104 -0
  159. core_framework/core/exception_handlers/post.py +54 -0
  160. core_framework/core/exception_handlers/setup.py +80 -0
  161. core_framework/core/exception_handlers/user.py +72 -0
  162. core_framework/core/http_client.py +64 -0
  163. core_framework/core/logging.py +99 -0
  164. core_framework/core/middleware.py +64 -0
  165. core_framework/core/observability.py +36 -0
  166. core_framework/core/pagination.py +203 -0
  167. core_framework/core/redis.py +135 -0
  168. core_framework/core/runtime.py +66 -0
  169. core_framework/core/settings.py +189 -0
  170. core_framework/domains/__init__.py +0 -0
  171. core_framework/domains/comment/README.md +243 -0
  172. core_framework/domains/comment/__init__.py +25 -0
  173. core_framework/domains/comment/constants.py +3 -0
  174. core_framework/domains/comment/dependencies.py +29 -0
  175. core_framework/domains/comment/enums.py +11 -0
  176. core_framework/domains/comment/exceptions.py +31 -0
  177. core_framework/domains/comment/models.py +54 -0
  178. core_framework/domains/comment/repository.py +947 -0
  179. core_framework/domains/comment/service.py +259 -0
  180. core_framework/domains/moderation/README.md +138 -0
  181. core_framework/domains/moderation/__init__.py +47 -0
  182. core_framework/domains/moderation/dependencies.py +29 -0
  183. core_framework/domains/moderation/enums.py +62 -0
  184. core_framework/domains/moderation/exceptions.py +31 -0
  185. core_framework/domains/moderation/models.py +94 -0
  186. core_framework/domains/moderation/repository.py +828 -0
  187. core_framework/domains/moderation/service.py +334 -0
  188. core_framework/domains/post/README.md +182 -0
  189. core_framework/domains/post/__init__.py +22 -0
  190. core_framework/domains/post/constants.py +3 -0
  191. core_framework/domains/post/dependencies.py +29 -0
  192. core_framework/domains/post/enums.py +18 -0
  193. core_framework/domains/post/exceptions.py +21 -0
  194. core_framework/domains/post/models.py +53 -0
  195. core_framework/domains/post/repository.py +791 -0
  196. core_framework/domains/post/service.py +204 -0
  197. core_framework/domains/user/README.md +74 -0
  198. core_framework/domains/user/__init__.py +39 -0
  199. core_framework/domains/user/constants.py +8 -0
  200. core_framework/domains/user/dependencies.py +29 -0
  201. core_framework/domains/user/enums.py +19 -0
  202. core_framework/domains/user/exceptions.py +31 -0
  203. core_framework/domains/user/models.py +124 -0
  204. core_framework/domains/user/repository.py +612 -0
  205. core_framework/domains/user/service.py +257 -0
  206. core_framework/domains/user/utils.py +182 -0
  207. core_framework/main.py +104 -0
  208. core_framework/worker/__init__.py +0 -0
  209. core_framework/worker/main.py +56 -0
  210. core_framework/worker/schedules/__init__.py +35 -0
  211. core_framework/worker/schedules/schedule_aggregate_comment_stats.py +32 -0
  212. core_framework/worker/schedules/schedule_aggregate_post_view_counts.py +28 -0
  213. core_framework/worker/schedules/schedule_expired_account_deletions.py +24 -0
  214. core_framework/worker/schedules/schedule_expired_mute_lifts.py +24 -0
  215. core_framework/worker/tasks/__init__.py +11 -0
  216. core_framework/worker/tasks/process_account_deletion.py +13 -0
  217. core_framework/worker/tasks/process_aggregate_comment_stats.py +19 -0
  218. core_framework/worker/tasks/process_aggregate_post_stats.py +12 -0
  219. core_framework/worker/tasks/process_mute_lift.py +13 -0
  220. core_framework-0.3.0.dist-info/METADATA +22 -0
  221. core_framework-0.3.0.dist-info/RECORD +222 -0
  222. core_framework-0.3.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,203 @@
1
+ from collections.abc import Mapping, Sequence
2
+ from datetime import datetime, timezone
3
+ from enum import StrEnum
4
+ from functools import cached_property
5
+ from typing import Annotated, Any, Final, Literal, TypedDict
6
+
7
+ from fastapi import HTTPException, Query, Request
8
+ from itsdangerous import BadSignature, URLSafeSerializer
9
+ from pydantic import BaseModel, ConfigDict, field_validator
10
+
11
+ DEFAULT_LIMIT: Final[int] = 10
12
+ MAX_LIMIT: Final[int] = 100
13
+ DEFAULT_CURSOR: Final[datetime] = datetime(3000, 1, 1, 0, 0)
14
+ DEFAULT_OFFSET: Final[int] = 0
15
+ DEFAULT_MAX_ITEMS: Final[int] = 10_000
16
+ _configured_secret_key: str | None = None
17
+
18
+
19
+ class PaginationCursorType(StrEnum):
20
+ CREATED_AT = "created_at"
21
+ UPDATED_AT = "updated_at"
22
+ SIGNUP_AT = "signup_at"
23
+
24
+
25
+ class PaginationResult[T](TypedDict):
26
+ items: Sequence[T]
27
+ next_page_href: str | None
28
+
29
+
30
+ class PaginationResponse[T](BaseModel):
31
+ items: list[T]
32
+ next_page_href: str | None
33
+
34
+
35
+ def configure_pagination(*, secret_key: str) -> None:
36
+ global _configured_secret_key
37
+ _configured_secret_key = secret_key
38
+
39
+
40
+ def _build_token_serializer(secret_key: str) -> URLSafeSerializer:
41
+ return URLSafeSerializer(secret_key, salt="pagination")
42
+
43
+
44
+ def _get_token_serializer() -> URLSafeSerializer:
45
+ if _configured_secret_key is None:
46
+ msg = "Pagination is not configured. Call configure_pagination() during application bootstrap."
47
+ raise RuntimeError(msg)
48
+ return _build_token_serializer(_configured_secret_key)
49
+
50
+
51
+ def _cursor_to_utc_iso(cursor: datetime) -> str:
52
+ """Serialize cursor as UTC ISO 8601 with full resolution (including microseconds)."""
53
+ if cursor.tzinfo is None:
54
+ utc = cursor.replace(tzinfo=timezone.utc)
55
+ else:
56
+ utc = cursor.astimezone(timezone.utc)
57
+ return utc.isoformat()
58
+
59
+
60
+ def _parse_cursor_payload(raw: object) -> datetime:
61
+ """Decode cursor from token payload (UTC ISO 8601 string)."""
62
+ if not isinstance(raw, str):
63
+ msg = f"Invalid cursor type in page token: {type(raw).__name__}"
64
+ raise TypeError(msg)
65
+ parsed = datetime.fromisoformat(raw)
66
+ if parsed.tzinfo is None:
67
+ return parsed.replace(tzinfo=timezone.utc)
68
+ return parsed.astimezone(timezone.utc)
69
+
70
+
71
+ def _decode_page_token(token: str | None) -> tuple[datetime, int]:
72
+ if token is None:
73
+ return DEFAULT_CURSOR, 0
74
+
75
+ try:
76
+ data = _get_token_serializer().loads(token)
77
+ cursor = _parse_cursor_payload(data["c"])
78
+ retrieved = data["r"]
79
+ return cursor, retrieved
80
+ except (BadSignature, KeyError, TypeError, ValueError) as e:
81
+ raise HTTPException(status_code=400, detail="Invalid page token") from e
82
+
83
+
84
+ def _encode_page_token(cursor: datetime, retrieved: int) -> str:
85
+ return _get_token_serializer().dumps({"c": _cursor_to_utc_iso(cursor), "r": retrieved})
86
+
87
+
88
+ class _BaseLimitParams(BaseModel):
89
+ model_config = ConfigDict(frozen=True)
90
+
91
+ limit: Annotated[int, Query(ge=DEFAULT_LIMIT, le=MAX_LIMIT)] = DEFAULT_LIMIT
92
+
93
+ @field_validator("limit", mode="after")
94
+ @classmethod
95
+ def increment_limit(cls, v: int) -> int:
96
+ return v + 1
97
+
98
+
99
+ class TokenCursorQueryParams(_BaseLimitParams):
100
+ page_token: Annotated[str | None, Query()] = None
101
+
102
+ @cached_property
103
+ def _decoded(self) -> tuple[datetime, int]:
104
+ return _decode_page_token(self.page_token)
105
+
106
+ @property
107
+ def cursor(self) -> datetime:
108
+ return self._decoded[0]
109
+
110
+ @property
111
+ def retrieved(self) -> int:
112
+ return self._decoded[1]
113
+
114
+
115
+ class OffsetQueryParams(_BaseLimitParams):
116
+ offset: Annotated[int, Query(ge=DEFAULT_OFFSET)] = DEFAULT_OFFSET
117
+
118
+
119
+ def paginate_cursor[T](
120
+ request: Request,
121
+ items: Sequence[T],
122
+ limit: int,
123
+ cursor_field: PaginationCursorType,
124
+ retrieved: int = 0,
125
+ max_items: int = DEFAULT_MAX_ITEMS,
126
+ ) -> PaginationResult[T]:
127
+ """Paginate items using cursor-based pagination with signed tokens."""
128
+ return _paginate(request, items, limit, "cursor", cursor_field, retrieved, max_items)
129
+
130
+
131
+ def paginate_offset[T](
132
+ request: Request,
133
+ items: Sequence[T],
134
+ limit: int,
135
+ retrieved: int = 0,
136
+ max_items: int | None = None,
137
+ ) -> PaginationResult[T]:
138
+ """Paginate items using offset-based pagination."""
139
+ return _paginate(request, items, limit, "offset", retrieved=retrieved, max_items=max_items)
140
+
141
+
142
+ def _get_field(obj: Any, field: str) -> Any:
143
+ if isinstance(obj, Mapping):
144
+ return obj.get(field)
145
+ return getattr(obj, field, None)
146
+
147
+
148
+ def _paginate[T](
149
+ request: Request,
150
+ items: Sequence[T],
151
+ limit: int,
152
+ pagination_type: Literal["offset", "cursor"],
153
+ cursor_field: PaginationCursorType | None = None,
154
+ retrieved: int = 0,
155
+ max_items: int | None = None,
156
+ ) -> PaginationResult[T]:
157
+ page_size = limit - 1
158
+
159
+ last_page = len(items) <= page_size
160
+ if last_page:
161
+ return {"items": items, "next_page_href": None}
162
+
163
+ # Check if we've reached max_items limit
164
+ new_retrieved = retrieved + page_size
165
+ if max_items is not None and new_retrieved >= max_items:
166
+ return {"items": items[:page_size], "next_page_href": None}
167
+
168
+ match pagination_type:
169
+ case "offset":
170
+ current_offset = int(request.query_params.get("offset", "0"))
171
+ next_offset = current_offset + page_size
172
+
173
+ next_url = request.url.include_query_params(
174
+ limit=page_size,
175
+ offset=next_offset,
176
+ )
177
+
178
+ case "cursor":
179
+ if cursor_field is None: # pragma: no cover
180
+ raise ValueError("cursor_field is required for cursor pagination")
181
+
182
+ cursor = _get_field(items[-1], cursor_field.value)
183
+
184
+ if cursor is None:
185
+ raise ValueError(f"No cursor found in field: {cursor_field.value}")
186
+
187
+ if not isinstance(cursor, datetime):
188
+ raise TypeError(f"Cursor field {cursor_field.value!r} must be datetime, got {type(cursor).__name__}")
189
+
190
+ # Generate signed token with cursor and retrieved count
191
+ token = _encode_page_token(cursor, new_retrieved)
192
+
193
+ # Build URL with token (remove old page_token param, add new token)
194
+ base_url = request.url.remove_query_params(["page_token"])
195
+ next_url = base_url.include_query_params(
196
+ limit=page_size,
197
+ page_token=token,
198
+ )
199
+
200
+ return {
201
+ "items": items[:page_size],
202
+ "next_page_href": f"{next_url.path}?{next_url.query}" if next_url.query else next_url.path,
203
+ }
@@ -0,0 +1,135 @@
1
+ from collections.abc import AsyncIterator
2
+ from types import TracebackType
3
+ from typing import Final, Self
4
+
5
+ from redis.asyncio import Redis
6
+ from redis.exceptions import RedisError
7
+ from saq.queue.redis import RedisQueue as BaseRedisQueue
8
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
9
+
10
+ REDIS_NOT_CONNECTED_MSG: Final[str] = "Redis is not connected"
11
+ REDIS_ALREADY_CONNECTED_MSG: Final[str] = "Redis is already connected"
12
+
13
+
14
+ class RedisQueue:
15
+ __slots__ = ("_queue", "_host", "_port", "_database")
16
+
17
+ def __init__(self, host: str, port: int, database: int) -> None:
18
+ self._queue: BaseRedisQueue | None = None
19
+ self._host = host
20
+ self._port = port
21
+ self._database = database
22
+
23
+ async def __aenter__(self) -> Self:
24
+ return await self.connect()
25
+
26
+ async def __aexit__(
27
+ self,
28
+ exc_type: type[BaseException] | None,
29
+ exc_value: BaseException | None,
30
+ traceback: TracebackType | None,
31
+ ) -> None:
32
+ await self.disconnect()
33
+
34
+ @retry(
35
+ stop=stop_after_attempt(10),
36
+ wait=wait_exponential(multiplier=2, min=2, max=30),
37
+ retry=retry_if_exception_type((RedisError, OSError)),
38
+ )
39
+ async def connect(self) -> Self:
40
+ if self._queue is not None:
41
+ raise RuntimeError(REDIS_ALREADY_CONNECTED_MSG)
42
+
43
+ self._queue = BaseRedisQueue.from_url(f"redis://{self._host}:{self._port}/{self._database}")
44
+ return self
45
+
46
+ async def disconnect(self) -> None:
47
+ if self._queue is None:
48
+ return
49
+
50
+ await self._queue.disconnect()
51
+ self._queue = None
52
+
53
+ @property
54
+ def redis_queue(self) -> BaseRedisQueue:
55
+ if self._queue is None:
56
+ raise RuntimeError(REDIS_NOT_CONNECTED_MSG)
57
+
58
+ return self._queue
59
+
60
+
61
+ class RedisCache:
62
+ __slots__ = ("_client", "_host", "_port", "_database")
63
+
64
+ def __init__(self, host: str, port: int, database: int) -> None:
65
+ self._client: Redis | None = None
66
+ self._host = host
67
+ self._port = port
68
+ self._database = database
69
+
70
+ async def __aenter__(self) -> Self:
71
+ return await self.connect()
72
+
73
+ async def __aexit__(
74
+ self,
75
+ exc_type: type[BaseException] | None,
76
+ exc_value: BaseException | None,
77
+ traceback: TracebackType | None,
78
+ ) -> None:
79
+ await self.disconnect()
80
+
81
+ @retry(
82
+ stop=stop_after_attempt(10),
83
+ wait=wait_exponential(multiplier=2, min=2, max=30),
84
+ retry=retry_if_exception_type((RedisError, OSError)),
85
+ )
86
+ async def connect(self) -> Self:
87
+ if self._client is not None:
88
+ raise RuntimeError(REDIS_ALREADY_CONNECTED_MSG)
89
+
90
+ self._client = Redis(
91
+ host=self._host,
92
+ port=self._port,
93
+ db=self._database,
94
+ )
95
+ await self._client.ping()
96
+ return self
97
+
98
+ async def disconnect(self) -> None:
99
+ if self._client is None:
100
+ return
101
+
102
+ try:
103
+ await self._client.aclose()
104
+ finally:
105
+ self._client = None
106
+
107
+ @property
108
+ def client(self) -> Redis:
109
+ if self._client is None:
110
+ raise RuntimeError(REDIS_NOT_CONNECTED_MSG)
111
+
112
+ return self._client
113
+
114
+ async def ping(self) -> bool:
115
+ return await self.client.ping()
116
+
117
+ async def get(self, key: bytes) -> bytes | None:
118
+ return await self.client.get(key)
119
+
120
+ async def set(self, key: bytes, value: bytes, ex: int) -> None:
121
+ await self.client.set(key, value, ex=ex)
122
+
123
+ async def scan_iter(self, match: bytes, count: int) -> AsyncIterator[bytes]:
124
+ async for key in self.client.scan_iter(match=match, count=count):
125
+ yield key
126
+
127
+ async def sadd(self, key: str, *values: str) -> int:
128
+ return await self.client.sadd(key, *values) # type: ignore[assignment]
129
+
130
+ async def sismember(self, key: str, value: str) -> bool:
131
+ result: int = await self.client.sismember(key, value) # type: ignore[assignment]
132
+ return bool(result)
133
+
134
+ async def unlink(self, *keys: bytes) -> None:
135
+ await self.client.unlink(*keys)
@@ -0,0 +1,66 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from core_framework.core.database import Postgres
5
+ from core_framework.core.http_client import HttpClient
6
+ from core_framework.core.redis import RedisCache, RedisQueue
7
+ from core_framework.core.settings import Settings
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class CoreRuntime:
12
+ settings: Settings
13
+ write_postgres: Postgres
14
+ read_postgres: Postgres
15
+ redis_queue: RedisQueue
16
+ redis_cache: RedisCache
17
+ general_http_client: HttpClient
18
+
19
+
20
+ class _UnconfiguredDependencyProxy:
21
+ __slots__ = ("dependency_name",)
22
+
23
+ def __init__(self, dependency_name: str) -> None:
24
+ self.dependency_name = dependency_name
25
+
26
+ def __getattr__(self, _: str) -> Any:
27
+ msg = (
28
+ f"{self.dependency_name} is not configured. "
29
+ "Build the app via init_app(), create_task_worker(), or call the relevant "
30
+ "configure_*() bootstrap helper before use."
31
+ )
32
+ raise RuntimeError(msg)
33
+
34
+
35
+ def unconfigured_dependency(dependency_name: str) -> Any:
36
+ return _UnconfiguredDependencyProxy(dependency_name)
37
+
38
+
39
+ def build_core_runtime(settings: Settings) -> CoreRuntime:
40
+ return CoreRuntime(
41
+ settings=settings,
42
+ write_postgres=Postgres(
43
+ database_url=settings.write_postgres.postgres_url,
44
+ min_connections=settings.write_postgres.min_connections,
45
+ max_connections=settings.write_postgres.max_connections,
46
+ ),
47
+ read_postgres=Postgres(
48
+ database_url=settings.read_postgres.postgres_url,
49
+ min_connections=settings.read_postgres.min_connections,
50
+ max_connections=settings.read_postgres.max_connections,
51
+ ),
52
+ redis_queue=RedisQueue(
53
+ host=settings.redis.host,
54
+ port=settings.redis.port,
55
+ database=settings.redis.queue_db,
56
+ ),
57
+ redis_cache=RedisCache(
58
+ host=settings.redis.host,
59
+ port=settings.redis.port,
60
+ database=settings.redis.cache_db,
61
+ ),
62
+ general_http_client=HttpClient(
63
+ timeout=settings.http_client.timeout_config,
64
+ limits=settings.http_client.limit_config,
65
+ ),
66
+ )
@@ -0,0 +1,189 @@
1
+ from functools import cache
2
+ from importlib.metadata import PackageNotFoundError, version
3
+ from pathlib import Path
4
+ from typing import Annotated, Any, Literal
5
+
6
+ from httpx import Limits, Timeout
7
+ from pydantic import BaseModel, Field, HttpUrl, field_validator, model_validator
8
+ from pydantic_core import MultiHostUrl
9
+ from pydantic_settings import (
10
+ BaseSettings,
11
+ PydanticBaseSettingsSource,
12
+ SettingsConfigDict,
13
+ TomlConfigSettingsSource,
14
+ )
15
+
16
+
17
+ def get_version() -> str:
18
+ try:
19
+ return version("core_framework")
20
+ except PackageNotFoundError:
21
+ return "dev"
22
+
23
+
24
+ class AvatarConfig(BaseModel):
25
+ base_url: HttpUrl
26
+ default_url: HttpUrl
27
+ extension: str = "webp"
28
+
29
+ @field_validator("base_url", mode="before")
30
+ @classmethod
31
+ def strip_trailing_slash(cls, v: str) -> str:
32
+ return v.rstrip("/")
33
+
34
+
35
+ class BannerConfig(BaseModel):
36
+ base_url: HttpUrl
37
+ default_url: HttpUrl
38
+ extension: str = "webp"
39
+
40
+ @field_validator("base_url", mode="before")
41
+ @classmethod
42
+ def strip_trailing_slash(cls, v: str) -> str:
43
+ return v.rstrip("/")
44
+
45
+
46
+ class AppConfig(BaseModel):
47
+ environment: Literal["local", "development", "production"]
48
+ debug: bool
49
+ openapi_url: str
50
+ allowed_hosts: list[str]
51
+ secret_key: str
52
+
53
+ @property
54
+ def fastapi_kwargs(self) -> dict[str, Any]:
55
+ return {
56
+ "title": "core-framework API",
57
+ "version": get_version(),
58
+ "debug": self.debug,
59
+ "openapi_url": self.openapi_url,
60
+ }
61
+
62
+
63
+ class LoggingConfig(BaseModel):
64
+ sink: str
65
+ level: Literal["trace", "debug", "info", "notice", "warn", "warning", "error", "fatal"]
66
+
67
+
68
+ class ObservabilityConfig(BaseModel):
69
+ enabled: bool
70
+ logfire_token: str
71
+
72
+
73
+ class HttpClientConfig(BaseModel):
74
+ timeout: float
75
+ max_connections: int
76
+ max_keepalive_connections: int
77
+
78
+ @property
79
+ def limit_config(self) -> Limits:
80
+ return Limits(
81
+ max_connections=self.max_connections,
82
+ max_keepalive_connections=self.max_keepalive_connections,
83
+ )
84
+
85
+ @property
86
+ def timeout_config(self) -> Timeout:
87
+ return Timeout(timeout=self.timeout)
88
+
89
+
90
+ class CorsConfig(BaseModel):
91
+ allow_credentials: bool
92
+ allowed_origins: list[str]
93
+ allowed_methods: list[str]
94
+ allowed_headers: list[str]
95
+
96
+
97
+ class PostgresConnectionConfig(BaseModel):
98
+ host: str
99
+ port: int
100
+ user: str
101
+ password: str
102
+ db_name: str
103
+ min_connections: int = 10
104
+ max_connections: int = 20
105
+
106
+ @model_validator(mode="after")
107
+ def validate_connection_limits(self) -> PostgresConnectionConfig:
108
+ if self.min_connections > self.max_connections:
109
+ raise ValueError(f"{self.min_connections=} must be less than {self.max_connections=}")
110
+ return self
111
+
112
+ @property
113
+ def alembic_postgres_url(self) -> str:
114
+ return MultiHostUrl.build(
115
+ scheme="postgresql+asyncpg",
116
+ host=self.host,
117
+ port=self.port,
118
+ username=self.user,
119
+ password=self.password,
120
+ path=self.db_name,
121
+ ).unicode_string()
122
+
123
+ @property
124
+ def postgres_url(self) -> str:
125
+ return MultiHostUrl.build(
126
+ scheme="postgresql",
127
+ host=self.host,
128
+ port=self.port,
129
+ username=self.user,
130
+ password=self.password,
131
+ path=self.db_name,
132
+ ).unicode_string()
133
+
134
+
135
+ class PostgresSchemasConfig(BaseModel):
136
+ schema_extension: str
137
+ schema_user: str
138
+ schema_moderation: str
139
+ schema_post: str
140
+ schema_comment: str
141
+
142
+
143
+ class RedisConfig(BaseModel):
144
+ host: str
145
+ port: int
146
+ cache_db: int
147
+ queue_db: int
148
+
149
+ @property
150
+ def redis_queue_url(self) -> str:
151
+ return f"redis://{self.host}:{self.port}/{self.queue_db}"
152
+
153
+ @property
154
+ def redis_cache_url(self) -> str:
155
+ return f"redis://{self.host}:{self.port}/{self.cache_db}"
156
+
157
+
158
+ class Settings(BaseSettings):
159
+ PROJECT_ROOT: Path = Path(__file__).parent.parent.parent
160
+
161
+ app: AppConfig
162
+ logging: LoggingConfig
163
+ observability: ObservabilityConfig
164
+ cors: CorsConfig
165
+ http_client: Annotated[HttpClientConfig, Field(alias="http-client")]
166
+ write_postgres: PostgresConnectionConfig
167
+ read_postgres: PostgresConnectionConfig
168
+ postgres_schemas: PostgresSchemasConfig
169
+ redis: RedisConfig
170
+ avatar: AvatarConfig
171
+ banner: BannerConfig
172
+
173
+ model_config = SettingsConfigDict(toml_file="config.toml")
174
+
175
+ @classmethod
176
+ def settings_customise_sources(
177
+ cls,
178
+ settings_cls: type[BaseSettings],
179
+ init_settings: PydanticBaseSettingsSource,
180
+ env_settings: PydanticBaseSettingsSource,
181
+ dotenv_settings: PydanticBaseSettingsSource,
182
+ file_secret_settings: PydanticBaseSettingsSource,
183
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
184
+ return (TomlConfigSettingsSource(settings_cls),)
185
+
186
+
187
+ @cache
188
+ def load_default_settings() -> Settings:
189
+ return Settings() # ty: ignore[missing-argument]
File without changes