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.
- core_framework/__init__.py +0 -0
- core_framework/alembic/comment/alembic/README +1 -0
- core_framework/alembic/comment/alembic/env.py +72 -0
- core_framework/alembic/comment/alembic/script.py.mako +28 -0
- core_framework/alembic/comment/alembic/versions/30334fd1347b_init.py +59 -0
- core_framework/alembic/comment/alembic/versions/a2b3c4d5e6f7_improve_comment_indexes.py +54 -0
- core_framework/alembic/comment/alembic/versions/bcc8e00cfc8b_add_extra_tables.py +64 -0
- core_framework/alembic/comment/alembic/versions/d1e2f3a4b5c6_add_comment_stats_dirty_table.py +29 -0
- core_framework/alembic/comment/alembic/versions/e3f4a5b6c7d8_cascade_delete_comment_descendants.py +49 -0
- core_framework/alembic/comment/alembic/versions/f7e6d5c4b3a2_comments_path_to_ltree.py +60 -0
- core_framework/alembic/comment/alembic.ini +52 -0
- core_framework/alembic/extension/alembic/README +1 -0
- core_framework/alembic/extension/alembic/env.py +98 -0
- core_framework/alembic/extension/alembic/script.py.mako +28 -0
- core_framework/alembic/extension/alembic/versions/0389226049cb_add_pg_trgm_extension.py +25 -0
- core_framework/alembic/extension/alembic/versions/5dc58b016cf5_add_citext_extension.py +25 -0
- core_framework/alembic/extension/alembic/versions/b0ba0d8a284e_add_pg_stat_statements_extension.py +25 -0
- core_framework/alembic/extension/alembic/versions/c9d0e1f2a3b4_add_ltree_extension.py +25 -0
- core_framework/alembic/extension/alembic.ini +147 -0
- core_framework/alembic/moderation/alembic/README +1 -0
- core_framework/alembic/moderation/alembic/env.py +98 -0
- core_framework/alembic/moderation/alembic/script.py.mako +28 -0
- core_framework/alembic/moderation/alembic/versions/085ba9021850_add_category_to_user_restrictions.py +93 -0
- core_framework/alembic/moderation/alembic/versions/5f9e4fc14a41_create_moderation_appeals_table.py +69 -0
- core_framework/alembic/moderation/alembic/versions/63e37381e73b_add_user_reports_table.py +33 -0
- core_framework/alembic/moderation/alembic/versions/6a2ae31b7ac6_add_moderation_actions_table.py +34 -0
- core_framework/alembic/moderation/alembic/versions/716aa1735c03_improve_indexes.py +36 -0
- core_framework/alembic/moderation/alembic/versions/7d243ddbfde1_add_post_reports_table.py +35 -0
- core_framework/alembic/moderation/alembic/versions/8fba1f72dd46_add_indexes.py +64 -0
- core_framework/alembic/moderation/alembic/versions/95cc35a51984_update_restriction_history.py +91 -0
- core_framework/alembic/moderation/alembic/versions/9ad79d0af730_add_unique_constraint_user_reports_.py +28 -0
- core_framework/alembic/moderation/alembic/versions/a5e569f5df1a_create_user_restrictions_table.py +38 -0
- core_framework/alembic/moderation/alembic/versions/b2c3d4e5f6a7_add_indexes.py +42 -0
- core_framework/alembic/moderation/alembic/versions/c3d4e5f6a7b8_improve_report_indexes.py +48 -0
- core_framework/alembic/moderation/alembic/versions/d4af74643ff5_add_internal_notes_table.py +38 -0
- core_framework/alembic/moderation/alembic/versions/db20f2fb7390_add_comment_reports_table.py +35 -0
- core_framework/alembic/moderation/alembic/versions/e66226952ea6_add_report_category_to_user_reports_.py +54 -0
- core_framework/alembic/moderation/alembic/versions/f5e8cb275c30_enforce_1_pending_appeal.py +29 -0
- core_framework/alembic/moderation/alembic/versions/fe1faad2832d_create_restriction_history_table.py +69 -0
- core_framework/alembic/moderation/alembic.ini +147 -0
- core_framework/alembic/post/alembic/README +1 -0
- core_framework/alembic/post/alembic/env.py +97 -0
- core_framework/alembic/post/alembic/script.py.mako +28 -0
- core_framework/alembic/post/alembic/versions/51542673f5c8_add_tables_for_muted_banned_users.py +41 -0
- core_framework/alembic/post/alembic/versions/5beeeae40a4a_add_post_views_table.py +45 -0
- core_framework/alembic/post/alembic/versions/620971509a8b_init.py +55 -0
- core_framework/alembic/post/alembic/versions/a1b2c3d4e5f6_add_indexes.py +44 -0
- core_framework/alembic/post/alembic/versions/c1d2e3f4a5b6_add_post_hashtags_table.py +36 -0
- core_framework/alembic/post/alembic/versions/e56723f2afff_add_post_stats_table.py +39 -0
- core_framework/alembic/post/alembic/versions/fbc723ac58cc_add_post_likes_table.py +32 -0
- core_framework/alembic/post/alembic.ini +149 -0
- core_framework/alembic/user/alembic/README +1 -0
- core_framework/alembic/user/alembic/env.py +98 -0
- core_framework/alembic/user/alembic/script.py.mako +28 -0
- core_framework/alembic/user/alembic/versions/1a8bb99726ed_remove_avatar_id_from_users.py +81 -0
- core_framework/alembic/user/alembic/versions/2ccacf455941_improve_indexes.py +34 -0
- core_framework/alembic/user/alembic/versions/47f47ce2110e_create_user_deletions_table.py +31 -0
- core_framework/alembic/user/alembic/versions/5976db3f0175_drop_user_states.py +26 -0
- core_framework/alembic/user/alembic/versions/62417002cf32_add_indexes.py +46 -0
- core_framework/alembic/user/alembic/versions/6f7ccf3c226b_refactor_user_login_events.py +66 -0
- core_framework/alembic/user/alembic/versions/73432817015b_add_user_preferences_table.py +33 -0
- core_framework/alembic/user/alembic/versions/765bc01a7a59_create_user_blocks_table.py +33 -0
- core_framework/alembic/user/alembic/versions/7a56631f9927_create_user_login_events_table.py +49 -0
- core_framework/alembic/user/alembic/versions/831611e589bc_create_user_state.py +31 -0
- core_framework/alembic/user/alembic/versions/83c98ab2a779_add_user_profiles_table.py +88 -0
- core_framework/alembic/user/alembic/versions/8a94362cad6d_create_user_role.py +31 -0
- core_framework/alembic/user/alembic/versions/94b973923895_add_user_change_history_table.py +97 -0
- core_framework/alembic/user/alembic/versions/cbc0f4efe84f_add_avatar_id_column_to_users_table.py +31 -0
- core_framework/alembic/user/alembic/versions/d8b98ac6b073_add_index_for_get_admin_user_ids_query.py +29 -0
- core_framework/alembic/user/alembic/versions/ddb70cc09d16_create_user_states_table.py +34 -0
- core_framework/alembic/user/alembic/versions/f9ba10815ecd_add_users_table.py +33 -0
- core_framework/alembic/user/alembic.ini +147 -0
- core_framework/api/__init__.py +0 -0
- core_framework/api/admin/__init__.py +0 -0
- core_framework/api/admin/comments/router.py +69 -0
- core_framework/api/admin/comments/schemas.py +53 -0
- core_framework/api/admin/moderation/__init__.py +0 -0
- core_framework/api/admin/moderation/router.py +205 -0
- core_framework/api/admin/moderation/schemas.py +110 -0
- core_framework/api/admin/posts/router.py +62 -0
- core_framework/api/admin/posts/schemas.py +29 -0
- core_framework/api/admin/router.py +17 -0
- core_framework/api/admin/users/__init__.py +0 -0
- core_framework/api/admin/users/router.py +181 -0
- core_framework/api/admin/users/schemas.py +137 -0
- core_framework/api/auth/__init__.py +0 -0
- core_framework/api/auth/router.py +21 -0
- core_framework/api/auth/schemas.py +28 -0
- core_framework/api/comments/authenticated/router.py +126 -0
- core_framework/api/comments/authenticated/schemas.py +27 -0
- core_framework/api/comments/public/router.py +103 -0
- core_framework/api/comments/public/schemas.py +36 -0
- core_framework/api/comments/router.py +9 -0
- core_framework/api/comments/schemas.py +17 -0
- core_framework/api/dependencies.py +168 -0
- core_framework/api/events/router.py +39 -0
- core_framework/api/events/schemas.py +20 -0
- core_framework/api/posts/authenticated/router.py +83 -0
- core_framework/api/posts/authenticated/schemas.py +37 -0
- core_framework/api/posts/public/router.py +100 -0
- core_framework/api/posts/public/schemas.py +39 -0
- core_framework/api/posts/router.py +9 -0
- core_framework/api/posts/schemas.py +39 -0
- core_framework/api/router.py +19 -0
- core_framework/api/schemas.py +9 -0
- core_framework/api/system/__init__.py +0 -0
- core_framework/api/system/router.py +108 -0
- core_framework/api/users/__init__.py +0 -0
- core_framework/api/users/authenticated/__init__.py +0 -0
- core_framework/api/users/authenticated/router.py +244 -0
- core_framework/api/users/authenticated/schemas.py +81 -0
- core_framework/api/users/public/__init__.py +0 -0
- core_framework/api/users/public/router.py +25 -0
- core_framework/api/users/public/schemas.py +7 -0
- core_framework/api/users/router.py +9 -0
- core_framework/api/users/shared/schemas.py +174 -0
- core_framework/application/__init__.py +0 -0
- core_framework/application/auth/__init__.py +0 -0
- core_framework/application/auth/access_service.py +26 -0
- core_framework/application/auth/auth_service.py +10 -0
- core_framework/application/auth/models.py +10 -0
- core_framework/application/bootstrap.py +19 -0
- core_framework/application/comments/admin_service.py +236 -0
- core_framework/application/comments/aggregation_service.py +28 -0
- core_framework/application/comments/authenticated_service.py +89 -0
- core_framework/application/comments/public_service.py +218 -0
- core_framework/application/events/README.md +26 -0
- core_framework/application/events/event_service.py +51 -0
- core_framework/application/events/event_token.py +46 -0
- core_framework/application/events/models.py +9 -0
- core_framework/application/moderation/__init__.py +0 -0
- core_framework/application/moderation/appeal_service.py +98 -0
- core_framework/application/moderation/moderator_service.py +46 -0
- core_framework/application/moderation/report_service.py +180 -0
- core_framework/application/moderation/scheduled_service.py +5 -0
- core_framework/application/moderation/user_service.py +180 -0
- core_framework/application/posts/admin_service.py +104 -0
- core_framework/application/posts/aggregation_service.py +28 -0
- core_framework/application/posts/authenticated_service.py +72 -0
- core_framework/application/posts/public_service.py +197 -0
- core_framework/application/shared/__init__.py +0 -0
- core_framework/application/shared/enums.py +16 -0
- core_framework/application/shared/exceptions.py +16 -0
- core_framework/application/shared/user_agent.py +24 -0
- core_framework/application/users/__init__.py +0 -0
- core_framework/application/users/admin_service.py +298 -0
- core_framework/application/users/authenticated_service.py +179 -0
- core_framework/application/users/public_service.py +7 -0
- core_framework/application/users/scheduled_service.py +5 -0
- core_framework/bundled_alembic.py +57 -0
- core_framework/core/__init__.py +37 -0
- core_framework/core/cache.py +234 -0
- core_framework/core/context.py +14 -0
- core_framework/core/database.py +111 -0
- core_framework/core/exception_handlers/__init__.py +3 -0
- core_framework/core/exception_handlers/comment.py +99 -0
- core_framework/core/exception_handlers/common.py +5 -0
- core_framework/core/exception_handlers/moderation.py +104 -0
- core_framework/core/exception_handlers/post.py +54 -0
- core_framework/core/exception_handlers/setup.py +80 -0
- core_framework/core/exception_handlers/user.py +72 -0
- core_framework/core/http_client.py +64 -0
- core_framework/core/logging.py +99 -0
- core_framework/core/middleware.py +64 -0
- core_framework/core/observability.py +36 -0
- core_framework/core/pagination.py +203 -0
- core_framework/core/redis.py +135 -0
- core_framework/core/runtime.py +66 -0
- core_framework/core/settings.py +189 -0
- core_framework/domains/__init__.py +0 -0
- core_framework/domains/comment/README.md +243 -0
- core_framework/domains/comment/__init__.py +25 -0
- core_framework/domains/comment/constants.py +3 -0
- core_framework/domains/comment/dependencies.py +29 -0
- core_framework/domains/comment/enums.py +11 -0
- core_framework/domains/comment/exceptions.py +31 -0
- core_framework/domains/comment/models.py +54 -0
- core_framework/domains/comment/repository.py +947 -0
- core_framework/domains/comment/service.py +259 -0
- core_framework/domains/moderation/README.md +138 -0
- core_framework/domains/moderation/__init__.py +47 -0
- core_framework/domains/moderation/dependencies.py +29 -0
- core_framework/domains/moderation/enums.py +62 -0
- core_framework/domains/moderation/exceptions.py +31 -0
- core_framework/domains/moderation/models.py +94 -0
- core_framework/domains/moderation/repository.py +828 -0
- core_framework/domains/moderation/service.py +334 -0
- core_framework/domains/post/README.md +182 -0
- core_framework/domains/post/__init__.py +22 -0
- core_framework/domains/post/constants.py +3 -0
- core_framework/domains/post/dependencies.py +29 -0
- core_framework/domains/post/enums.py +18 -0
- core_framework/domains/post/exceptions.py +21 -0
- core_framework/domains/post/models.py +53 -0
- core_framework/domains/post/repository.py +791 -0
- core_framework/domains/post/service.py +204 -0
- core_framework/domains/user/README.md +74 -0
- core_framework/domains/user/__init__.py +39 -0
- core_framework/domains/user/constants.py +8 -0
- core_framework/domains/user/dependencies.py +29 -0
- core_framework/domains/user/enums.py +19 -0
- core_framework/domains/user/exceptions.py +31 -0
- core_framework/domains/user/models.py +124 -0
- core_framework/domains/user/repository.py +612 -0
- core_framework/domains/user/service.py +257 -0
- core_framework/domains/user/utils.py +182 -0
- core_framework/main.py +104 -0
- core_framework/worker/__init__.py +0 -0
- core_framework/worker/main.py +56 -0
- core_framework/worker/schedules/__init__.py +35 -0
- core_framework/worker/schedules/schedule_aggregate_comment_stats.py +32 -0
- core_framework/worker/schedules/schedule_aggregate_post_view_counts.py +28 -0
- core_framework/worker/schedules/schedule_expired_account_deletions.py +24 -0
- core_framework/worker/schedules/schedule_expired_mute_lifts.py +24 -0
- core_framework/worker/tasks/__init__.py +11 -0
- core_framework/worker/tasks/process_account_deletion.py +13 -0
- core_framework/worker/tasks/process_aggregate_comment_stats.py +19 -0
- core_framework/worker/tasks/process_aggregate_post_stats.py +12 -0
- core_framework/worker/tasks/process_mute_lift.py +13 -0
- core_framework-0.3.0.dist-info/METADATA +22 -0
- core_framework-0.3.0.dist-info/RECORD +222 -0
- 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
|