crypticorn-utils 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.
- crypticorn_utils/__init__.py +16 -0
- crypticorn_utils/_migration.py +12 -0
- crypticorn_utils/ansi_colors.py +41 -0
- crypticorn_utils/auth.py +345 -0
- crypticorn_utils/cli/__init__.py +4 -0
- crypticorn_utils/cli/__main__.py +17 -0
- crypticorn_utils/cli/init.py +127 -0
- crypticorn_utils/cli/templates/__init__.py +0 -0
- crypticorn_utils/cli/templates/auth.py +33 -0
- crypticorn_utils/cli/version.py +8 -0
- crypticorn_utils/decorators.py +38 -0
- crypticorn_utils/enums.py +175 -0
- crypticorn_utils/errors.py +915 -0
- crypticorn_utils/exceptions.py +183 -0
- crypticorn_utils/logging.py +130 -0
- crypticorn_utils/metrics.py +32 -0
- crypticorn_utils/middleware.py +125 -0
- crypticorn_utils/mixins.py +68 -0
- crypticorn_utils/openapi.py +10 -0
- crypticorn_utils/pagination.py +286 -0
- crypticorn_utils/router/admin_router.py +117 -0
- crypticorn_utils/router/status_router.py +36 -0
- crypticorn_utils/utils.py +93 -0
- crypticorn_utils/warnings.py +79 -0
- crypticorn_utils-0.1.0.dist-info/METADATA +98 -0
- crypticorn_utils-0.1.0.dist-info/RECORD +30 -0
- crypticorn_utils-0.1.0.dist-info/WHEEL +5 -0
- crypticorn_utils-0.1.0.dist-info/entry_points.txt +2 -0
- crypticorn_utils-0.1.0.dist-info/licenses/LICENSE +15 -0
- crypticorn_utils-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,286 @@
|
|
1
|
+
"""Utilities for handling paginated API responses, cursor-based pagination, filtering, sorting, etc."""
|
2
|
+
|
3
|
+
import math
|
4
|
+
from typing import Annotated, Any, Generic, Literal, Optional, Type, TypeVar
|
5
|
+
|
6
|
+
from pydantic import BaseModel, Field, model_validator
|
7
|
+
|
8
|
+
T = TypeVar("T")
|
9
|
+
|
10
|
+
|
11
|
+
class PaginatedResponse(BaseModel, Generic[T]):
|
12
|
+
"""Pydantic model for paginated response
|
13
|
+
>>> @router.get("", operation_id="getOrders")
|
14
|
+
>>> async def get_orders(
|
15
|
+
>>> params: Annotated[PaginationParams, Query()],
|
16
|
+
>>> ) -> PaginatedResponse[Order]:
|
17
|
+
>>> ...
|
18
|
+
>>> return PaginatedResponse[Order](
|
19
|
+
data=orders,
|
20
|
+
total=count,
|
21
|
+
page=params.page,
|
22
|
+
page_size=params.page_size,
|
23
|
+
prev=PaginatedResponse.get_prev_page(params.page),
|
24
|
+
next=PaginatedResponse.get_next_page(count, params.page_size, params.page),
|
25
|
+
last=PaginatedResponse.get_last_page(count, params.page_size),
|
26
|
+
)
|
27
|
+
"""
|
28
|
+
|
29
|
+
data: list[T]
|
30
|
+
total: int = Field(description="The total number of items")
|
31
|
+
page: int = Field(description="The current page number")
|
32
|
+
page_size: int = Field(description="The number of items per page")
|
33
|
+
prev: Optional[int] = Field(None, description="The previous page number")
|
34
|
+
next: Optional[int] = Field(None, description="The next page number")
|
35
|
+
last: Optional[int] = Field(None, description="The last page number")
|
36
|
+
|
37
|
+
@staticmethod
|
38
|
+
def get_next_page(total: int, page_size: int, page: int) -> Optional[int]:
|
39
|
+
"""Get the next page number"""
|
40
|
+
if page < math.ceil(total / page_size):
|
41
|
+
return page + 1
|
42
|
+
return None
|
43
|
+
|
44
|
+
@staticmethod
|
45
|
+
def get_prev_page(page: int) -> Optional[int]:
|
46
|
+
"""Get the previous page number"""
|
47
|
+
if page > 1:
|
48
|
+
return page - 1
|
49
|
+
return None
|
50
|
+
|
51
|
+
@staticmethod
|
52
|
+
def get_last_page(total: int, page_size: int) -> int:
|
53
|
+
"""Get the last page number"""
|
54
|
+
return max(1, math.ceil(total / page_size))
|
55
|
+
|
56
|
+
|
57
|
+
class PaginationParams(BaseModel, Generic[T]):
|
58
|
+
"""Standard pagination parameters for usage in API endpoints. Check the [fastapi docs](https://fastapi.tiangolo.com/tutorial/query-param-models/?h=qu#query-parameters-with-a-pydantic-model) for usage examples.
|
59
|
+
You should inherit from this class when adding additional parameters. You should use this class in combination with `PaginatedResponse` to return the paginated response.
|
60
|
+
Usage:
|
61
|
+
>>> @router.get("", operation_id="getOrders")
|
62
|
+
>>> async def get_orders(
|
63
|
+
>>> params: Annotated[PaginationParams[Order], Query()],
|
64
|
+
>>> ) -> PaginatedResponse[Order]:
|
65
|
+
>>> ...
|
66
|
+
|
67
|
+
The default size is 10 items per page and there is a `HeavyPaginationParams` class with 100 items per page. You can override this default:
|
68
|
+
>>> class LightPaginationParams(PaginationParams):
|
69
|
+
>>> page_size: int = Field(default=5, description="The number of items per page")
|
70
|
+
"""
|
71
|
+
|
72
|
+
page: Optional[int] = Field(default=1, description="The current page number")
|
73
|
+
page_size: Annotated[int, Field(ge=1, le=100)] = Field(
|
74
|
+
10, description="The number of items per page. Default is 10, max is 100."
|
75
|
+
)
|
76
|
+
|
77
|
+
|
78
|
+
class HeavyPaginationParams(PaginationParams[T]):
|
79
|
+
"""Pagination parameters with a higher default size. Refer to `PaginationParams` for usage examples."""
|
80
|
+
|
81
|
+
page_size: Annotated[int, Field(ge=1, le=1000)] = Field(
|
82
|
+
100, description="The number of items per page. Default is 100, max is 1000."
|
83
|
+
)
|
84
|
+
|
85
|
+
|
86
|
+
class SortParams(BaseModel, Generic[T]):
|
87
|
+
"""Standard sort parameters for usage in API endpoints. Check the [fastapi docs](https://fastapi.tiangolo.com/tutorial/query-param-models/?h=qu#query-parameters-with-a-pydantic-model) for usage examples.
|
88
|
+
You should inherit from this class when adding additional parameters.
|
89
|
+
Usage:
|
90
|
+
>>> @router.get("", operation_id="getOrders")
|
91
|
+
>>> async def get_orders(
|
92
|
+
>>> params: Annotated[SortParams[Order], Query()],
|
93
|
+
>>> ) -> PaginatedResponse[Order]:
|
94
|
+
>>> ...
|
95
|
+
"""
|
96
|
+
|
97
|
+
sort_order: Optional[Literal["asc", "desc"]] = Field(
|
98
|
+
None, description="The order to sort by"
|
99
|
+
)
|
100
|
+
sort_by: Optional[str] = Field(None, description="The field to sort by")
|
101
|
+
|
102
|
+
@model_validator(mode="after")
|
103
|
+
def validate_sort(self):
|
104
|
+
# Extract the generic argument type
|
105
|
+
args: tuple = self.__pydantic_generic_metadata__.get("args")
|
106
|
+
if not args or not issubclass(args[0], BaseModel):
|
107
|
+
raise TypeError(
|
108
|
+
"SortParams must be used with a Pydantic BaseModel as a generic parameter"
|
109
|
+
)
|
110
|
+
if self.sort_by:
|
111
|
+
# check if the sort field is valid
|
112
|
+
model: Type[BaseModel] = args[0]
|
113
|
+
if self.sort_by not in model.model_fields:
|
114
|
+
raise ValueError(
|
115
|
+
f"Invalid field: '{self.sort_by}'. Must be one of: {list(model.model_fields)}"
|
116
|
+
)
|
117
|
+
if self.sort_order and self.sort_order not in ["asc", "desc"]:
|
118
|
+
raise ValueError(
|
119
|
+
f"Invalid order: '{self.sort_order}' — must be one of: ['asc', 'desc']"
|
120
|
+
)
|
121
|
+
if (
|
122
|
+
self.sort_order
|
123
|
+
and self.sort_by is None
|
124
|
+
or self.sort_by
|
125
|
+
and self.sort_order is None
|
126
|
+
):
|
127
|
+
raise ValueError("sort_order and sort_by must be provided together")
|
128
|
+
return self
|
129
|
+
|
130
|
+
|
131
|
+
class FilterParams(BaseModel, Generic[T]):
|
132
|
+
"""Standard filter parameters for usage in API endpoints. Check the [fastapi docs](https://fastapi.tiangolo.com/tutorial/query-param-models/?h=qu#query-parameters-with-a-pydantic-model) for usage examples.
|
133
|
+
You should inherit from this class when adding additional parameters.
|
134
|
+
Usage:
|
135
|
+
>>> @router.get("", operation_id="getOrders")
|
136
|
+
>>> async def get_orders(
|
137
|
+
>>> params: Annotated[FilterParams[Order], Query()],
|
138
|
+
>>> ) -> PaginatedResponse[Order]:
|
139
|
+
>>> ...
|
140
|
+
"""
|
141
|
+
|
142
|
+
filter_by: Optional[str] = Field(None, description="The field to filter by")
|
143
|
+
filter_value: Optional[str] = Field(None, description="The value to filter with")
|
144
|
+
# currently openapi-gen does not support typing.Any in combo with None, so we use str
|
145
|
+
# this is fine since the input is a string anyways from the request and the correct type is enforced by the model validator from the filter_by field
|
146
|
+
|
147
|
+
@model_validator(mode="after")
|
148
|
+
def validate_filter(self):
|
149
|
+
if self.filter_by and not self.filter_value:
|
150
|
+
raise ValueError("filter_by and filter_value must be provided together")
|
151
|
+
if self.filter_by:
|
152
|
+
# Extract the generic argument type
|
153
|
+
args: tuple = self.__pydantic_generic_metadata__.get("args")
|
154
|
+
if not args or not issubclass(args[0], BaseModel):
|
155
|
+
raise TypeError(
|
156
|
+
"FilterParams must be used with a Pydantic BaseModel as a generic parameter"
|
157
|
+
)
|
158
|
+
# check if the filter field is valid
|
159
|
+
model: Type[BaseModel] = args[0]
|
160
|
+
if self.filter_by not in model.model_fields:
|
161
|
+
raise ValueError(
|
162
|
+
f"Invalid field: '{self.filter_by}'. Must be one of: {list(model.model_fields)}"
|
163
|
+
)
|
164
|
+
self.filter_value = _enforce_field_type(
|
165
|
+
model, self.filter_by, self.filter_value
|
166
|
+
)
|
167
|
+
return self
|
168
|
+
|
169
|
+
|
170
|
+
class SortFilterParams(SortParams[T], FilterParams[T]):
|
171
|
+
"""Combines sort and filter parameters. Just a convenience class for when you need to combine sort and filter parameters.
|
172
|
+
You should inherit from this class when adding additional parameters.
|
173
|
+
Usage:
|
174
|
+
>>> @router.get("", operation_id="getOrders")
|
175
|
+
>>> async def get_orders(
|
176
|
+
>>> params: Annotated[SortFilterParams[Order], Query()],
|
177
|
+
>>> ) -> PaginatedResponse[Order]:
|
178
|
+
>>> ...
|
179
|
+
"""
|
180
|
+
|
181
|
+
@model_validator(mode="after")
|
182
|
+
def validate_sort_filter(self):
|
183
|
+
self.validate_sort()
|
184
|
+
self.validate_filter()
|
185
|
+
return self
|
186
|
+
|
187
|
+
|
188
|
+
class PageFilterParams(PaginationParams[T], FilterParams[T]):
|
189
|
+
"""Combines pagination and filter parameters. Just a convenience class for when you need to combine pagination and filter parameters.
|
190
|
+
You should inherit from this class when adding additional parameters.
|
191
|
+
Usage:
|
192
|
+
>>> @router.get("", operation_id="getOrders")
|
193
|
+
>>> async def get_orders(
|
194
|
+
>>> params: Annotated[PageFilterParams[Order], Query()],
|
195
|
+
>>> ) -> PaginatedResponse[Order]:
|
196
|
+
>>> ...
|
197
|
+
"""
|
198
|
+
|
199
|
+
@model_validator(mode="after")
|
200
|
+
def validate_page_filter(self):
|
201
|
+
self.validate_filter()
|
202
|
+
return self
|
203
|
+
|
204
|
+
|
205
|
+
class PageSortParams(PaginationParams[T], SortParams[T]):
|
206
|
+
"""Combines pagination and sort parameters. Just a convenience class for when you need to combine pagination and sort parameters.
|
207
|
+
You should inherit from this class when adding additional parameters.
|
208
|
+
Usage:
|
209
|
+
>>> @router.get("", operation_id="getOrders")
|
210
|
+
>>> async def get_orders(
|
211
|
+
>>> params: Annotated[PageSortParams[Order], Query()],
|
212
|
+
>>> ) -> PaginatedResponse[Order]:
|
213
|
+
>>> ...
|
214
|
+
"""
|
215
|
+
|
216
|
+
@model_validator(mode="after")
|
217
|
+
def validate_page_sort(self):
|
218
|
+
self.validate_sort()
|
219
|
+
return self
|
220
|
+
|
221
|
+
|
222
|
+
class PageSortFilterParams(
|
223
|
+
PaginationParams[T],
|
224
|
+
SortParams[T],
|
225
|
+
FilterParams[T],
|
226
|
+
):
|
227
|
+
"""Combines pagination, filter, and sort parameters. Just a convenience class for when you need to combine pagination, filter, and sort parameters.
|
228
|
+
You should inherit from this class when adding additional parameters.
|
229
|
+
Usage:
|
230
|
+
>>> @router.get("", operation_id="getOrders")
|
231
|
+
>>> async def get_orders(
|
232
|
+
>>> params: Annotated[PageSortFilterParams[Order], Query()],
|
233
|
+
>>> ) -> PaginatedResponse[Order]:
|
234
|
+
>>> ...
|
235
|
+
"""
|
236
|
+
|
237
|
+
@model_validator(mode="after")
|
238
|
+
def validate_filter_combo(self):
|
239
|
+
self.validate_filter()
|
240
|
+
self.validate_sort()
|
241
|
+
return self
|
242
|
+
|
243
|
+
|
244
|
+
class HeavyPageSortFilterParams(
|
245
|
+
HeavyPaginationParams[T], FilterParams[T], SortParams[T]
|
246
|
+
):
|
247
|
+
"""Combines heavy pagination, filter, and sort parameters. Just a convenience class for when you need to combine heavy pagination, filter, and sort parameters.
|
248
|
+
You should inherit from this class when adding additional parameters.
|
249
|
+
Usage:
|
250
|
+
>>> @router.get("", operation_id="getOrders")
|
251
|
+
>>> async def get_orders(
|
252
|
+
>>> params: Annotated[HeavyPageSortFilterParams[Order], Query()],
|
253
|
+
>>> ) -> PaginatedResponse[Order]:
|
254
|
+
>>> ...
|
255
|
+
"""
|
256
|
+
|
257
|
+
@model_validator(mode="after")
|
258
|
+
def validate_heavy_page_sort_filter(self):
|
259
|
+
self.validate_filter()
|
260
|
+
self.validate_sort()
|
261
|
+
return self
|
262
|
+
|
263
|
+
|
264
|
+
def _enforce_field_type(model: Type[BaseModel], field_name: str, value: Any) -> Any:
|
265
|
+
"""
|
266
|
+
Coerce or validate `value` to match the type of `field_name` on the given `model`. Should be used after checking that the field is valid.
|
267
|
+
|
268
|
+
:param model: The Pydantic model.
|
269
|
+
:param field_name: The name of the field to match.
|
270
|
+
:param value: The value to validate or coerce.
|
271
|
+
|
272
|
+
:return: The value cast to the expected type.
|
273
|
+
|
274
|
+
:raises: ValueError: If the field doesn't exist or coercion fails.
|
275
|
+
"""
|
276
|
+
expected_type = model.model_fields[field_name].annotation
|
277
|
+
|
278
|
+
if isinstance(value, expected_type):
|
279
|
+
return value
|
280
|
+
|
281
|
+
try:
|
282
|
+
return expected_type(value)
|
283
|
+
except Exception:
|
284
|
+
raise ValueError(
|
285
|
+
f"Expected {expected_type} for field {field_name}, got {type(value)}"
|
286
|
+
)
|
@@ -0,0 +1,117 @@
|
|
1
|
+
"""
|
2
|
+
This module contains the admin router for the API.
|
3
|
+
It provides endpoints for monitoring the server and getting information about the environment.
|
4
|
+
ONLY ALLOW ACCESS TO THIS ROUTER WITH ADMIN SCOPES.
|
5
|
+
>>> app.include_router(admin_router, dependencies=[Security(auth_handler.full_auth, scopes=[Scope.READ_ADMIN, Scope.WRITE_ADMIN])])
|
6
|
+
"""
|
7
|
+
|
8
|
+
import importlib.metadata
|
9
|
+
import logging
|
10
|
+
import os
|
11
|
+
import re
|
12
|
+
import threading
|
13
|
+
import time
|
14
|
+
from typing import Literal
|
15
|
+
|
16
|
+
import psutil
|
17
|
+
from crypticorn_utils.logging import LogLevel
|
18
|
+
from crypticorn_utils.metrics import registry
|
19
|
+
from fastapi import APIRouter, Query, Response
|
20
|
+
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
21
|
+
|
22
|
+
router = APIRouter(tags=["Admin"], prefix="/admin")
|
23
|
+
|
24
|
+
START_TIME = time.time()
|
25
|
+
|
26
|
+
|
27
|
+
@router.get("/log-level", status_code=200, operation_id="getLogLevel", deprecated=True)
|
28
|
+
async def get_logging_level() -> LogLevel:
|
29
|
+
"""
|
30
|
+
Get the log level of the server logger. Will be removed in a future release.
|
31
|
+
"""
|
32
|
+
return LogLevel.get_name(logging.getLogger().level)
|
33
|
+
|
34
|
+
|
35
|
+
@router.get("/uptime", operation_id="getUptime", status_code=200)
|
36
|
+
def get_uptime(type: Literal["seconds", "human"] = "seconds") -> str:
|
37
|
+
"""Return the server uptime in seconds or human-readable form."""
|
38
|
+
uptime_seconds = int(time.time() - START_TIME)
|
39
|
+
if type == "seconds":
|
40
|
+
return str(uptime_seconds)
|
41
|
+
elif type == "human":
|
42
|
+
return time.strftime("%H:%M:%S", time.gmtime(uptime_seconds))
|
43
|
+
|
44
|
+
|
45
|
+
@router.get("/memory", operation_id="getMemoryUsage", status_code=200)
|
46
|
+
def get_memory_usage() -> float:
|
47
|
+
"""
|
48
|
+
Resident Set Size (RSS) in MB — the actual memory used by the process in RAM.
|
49
|
+
Represents the physical memory footprint. Important for monitoring real usage.
|
50
|
+
"""
|
51
|
+
process = psutil.Process(os.getpid())
|
52
|
+
mem_info = process.memory_info()
|
53
|
+
return round(mem_info.rss / (1024 * 1024), 2)
|
54
|
+
|
55
|
+
|
56
|
+
@router.get("/threads", operation_id="getThreads", status_code=200)
|
57
|
+
def get_threads() -> dict:
|
58
|
+
"""Return count and names of active threads."""
|
59
|
+
threads = threading.enumerate()
|
60
|
+
return {
|
61
|
+
"count": len(threads),
|
62
|
+
"threads": [t.name for t in threads],
|
63
|
+
}
|
64
|
+
|
65
|
+
|
66
|
+
@router.get("/limits", operation_id="getContainerLimits", status_code=200)
|
67
|
+
def get_container_limits() -> dict:
|
68
|
+
"""Return container resource limits from cgroup."""
|
69
|
+
limits = {}
|
70
|
+
try:
|
71
|
+
with open("/sys/fs/cgroup/memory/memory.limit_in_bytes") as f:
|
72
|
+
limits["memory_limit_MB"] = int(f.read().strip()) / 1024 / 1024
|
73
|
+
except Exception:
|
74
|
+
limits["memory_limit_MB"] = "N/A"
|
75
|
+
|
76
|
+
try:
|
77
|
+
with open("/sys/fs/cgroup/cpu/cpu.cfs_quota_us") as f1, open(
|
78
|
+
"/sys/fs/cgroup/cpu/cpu.cfs_period_us"
|
79
|
+
) as f2:
|
80
|
+
quota = int(f1.read().strip())
|
81
|
+
period = int(f2.read().strip())
|
82
|
+
limits["cpu_limit_cores"] = quota / period if quota > 0 else "N/A"
|
83
|
+
except Exception:
|
84
|
+
limits["cpu_limit_cores"] = "N/A"
|
85
|
+
|
86
|
+
return limits
|
87
|
+
|
88
|
+
|
89
|
+
@router.get("/dependencies", operation_id="getDependencies", status_code=200)
|
90
|
+
def list_installed_packages(
|
91
|
+
include: list[str] = Query(
|
92
|
+
default=None,
|
93
|
+
description="List of regex patterns to match against package names. If not provided, all installed packages will be returned.",
|
94
|
+
)
|
95
|
+
) -> dict[str, str]:
|
96
|
+
"""Return a list of installed packages and versions.
|
97
|
+
|
98
|
+
The include parameter accepts regex patterns to match against package names.
|
99
|
+
For example:
|
100
|
+
- crypticorn.* will match all packages starting with 'crypticorn'
|
101
|
+
- .*tic.* will match all packages containing 'tic' in their name
|
102
|
+
"""
|
103
|
+
packages = {
|
104
|
+
dist.metadata["Name"]: dist.version
|
105
|
+
for dist in importlib.metadata.distributions()
|
106
|
+
if include is None
|
107
|
+
or any(re.match(pattern, dist.metadata["Name"]) for pattern in include)
|
108
|
+
}
|
109
|
+
return dict(sorted(packages.items()))
|
110
|
+
|
111
|
+
|
112
|
+
@router.get("/metrics", operation_id="getMetrics")
|
113
|
+
def metrics():
|
114
|
+
"""
|
115
|
+
Get Prometheus metrics for the application. Returns plain text.
|
116
|
+
"""
|
117
|
+
return Response(generate_latest(registry), media_type=CONTENT_TYPE_LATEST)
|
@@ -0,0 +1,36 @@
|
|
1
|
+
"""
|
2
|
+
This module contains the status router for the API.
|
3
|
+
It provides endpoints for checking the status of the API and get the server's time.
|
4
|
+
SHOULD ALLOW ACCESS TO THIS ROUTER WITHOUT AUTH.
|
5
|
+
To enable metrics, pass enable_metrics=True and the auth_handler to the router.
|
6
|
+
>>> status_router.enable_metrics = True
|
7
|
+
>>> status_router.auth_handler = auth_handler
|
8
|
+
Then include the router in the FastAPI app.
|
9
|
+
>>> app.include_router(status_router)
|
10
|
+
"""
|
11
|
+
|
12
|
+
from datetime import datetime
|
13
|
+
from typing import Literal
|
14
|
+
|
15
|
+
from fastapi import APIRouter, Request
|
16
|
+
|
17
|
+
router = APIRouter(tags=["Status"], prefix="")
|
18
|
+
|
19
|
+
|
20
|
+
@router.get("/", operation_id="ping")
|
21
|
+
async def ping(request: Request) -> str:
|
22
|
+
"""
|
23
|
+
Returns 'OK' if the API is running.
|
24
|
+
"""
|
25
|
+
return "OK"
|
26
|
+
|
27
|
+
|
28
|
+
@router.get("/time", operation_id="getTime")
|
29
|
+
async def time(type: Literal["iso", "unix"] = "iso") -> str:
|
30
|
+
"""
|
31
|
+
Returns the current time in either ISO or Unix timestamp (seconds) format.
|
32
|
+
"""
|
33
|
+
if type == "iso":
|
34
|
+
return datetime.now().isoformat()
|
35
|
+
elif type == "unix":
|
36
|
+
return str(int(datetime.now().timestamp()))
|
@@ -0,0 +1,93 @@
|
|
1
|
+
"""General utility functions and helper methods used across the codebase."""
|
2
|
+
|
3
|
+
import random
|
4
|
+
import string
|
5
|
+
import warnings
|
6
|
+
from datetime import datetime
|
7
|
+
from decimal import Decimal
|
8
|
+
from typing import Any, Union
|
9
|
+
|
10
|
+
import typing_extensions
|
11
|
+
from crypticorn_utils.exceptions import ApiError, ExceptionContent, HTTPException
|
12
|
+
from crypticorn_utils.warnings import CrypticornDeprecatedSince25
|
13
|
+
|
14
|
+
|
15
|
+
def throw_if_none(
|
16
|
+
value: Any,
|
17
|
+
message: str = "Object not found",
|
18
|
+
) -> None:
|
19
|
+
"""Throws an FastAPI HTTPException if the value is None. https://docs.python.org/3/library/stdtypes.html#truth-value-testing"""
|
20
|
+
if value is None:
|
21
|
+
raise HTTPException(content=ExceptionContent(error=ApiError.OBJECT_NOT_FOUND, message=message))
|
22
|
+
|
23
|
+
|
24
|
+
def throw_if_falsy(
|
25
|
+
value: Any,
|
26
|
+
message: str = "Object not found",
|
27
|
+
) -> None:
|
28
|
+
"""Throws an FastAPI HTTPException if the value is False. https://docs.python.org/3/library/stdtypes.html#truth-value-testing"""
|
29
|
+
if not value:
|
30
|
+
raise HTTPException(content=ExceptionContent(error=ApiError.OBJECT_NOT_FOUND, message=message))
|
31
|
+
|
32
|
+
|
33
|
+
def gen_random_id(length: int = 20) -> str:
|
34
|
+
"""Generate a random base62 string (a-zA-Z0-9) of specified length. The max possible combinations is 62^length.
|
35
|
+
Kucoin max 40, bingx max 40"""
|
36
|
+
charset = string.ascii_letters + string.digits
|
37
|
+
return "".join(random.choice(charset) for _ in range(length))
|
38
|
+
|
39
|
+
|
40
|
+
@typing_extensions.deprecated(
|
41
|
+
"The `is_equal` method is deprecated; use `math.is_close` instead.", category=None
|
42
|
+
)
|
43
|
+
def is_equal(
|
44
|
+
a: Union[float, Decimal],
|
45
|
+
b: Union[float, Decimal],
|
46
|
+
rel_tol: float = 1e-9,
|
47
|
+
abs_tol: float = 0.0,
|
48
|
+
) -> bool:
|
49
|
+
"""
|
50
|
+
Compare two Decimal numbers for approximate equality.
|
51
|
+
"""
|
52
|
+
warnings.warn(
|
53
|
+
"The `is_equal` method is deprecated; use `math.is_close` instead.",
|
54
|
+
category=CrypticornDeprecatedSince25,
|
55
|
+
)
|
56
|
+
if not isinstance(a, Decimal):
|
57
|
+
a = Decimal(str(a))
|
58
|
+
if not isinstance(b, Decimal):
|
59
|
+
b = Decimal(str(b))
|
60
|
+
|
61
|
+
# Convert tolerances to Decimal
|
62
|
+
return Decimal(abs(a - b)) <= max(
|
63
|
+
Decimal(str(rel_tol)) * max(abs(a), abs(b)), Decimal(str(abs_tol))
|
64
|
+
)
|
65
|
+
|
66
|
+
|
67
|
+
def optional_import(module_name: str, extra_name: str) -> Any:
|
68
|
+
"""
|
69
|
+
Tries to import a module. Raises `ImportError` if not found with a message to install the extra dependency.
|
70
|
+
"""
|
71
|
+
try:
|
72
|
+
return __import__(module_name)
|
73
|
+
except ImportError as e:
|
74
|
+
raise ImportError(
|
75
|
+
f"Optional dependency '{module_name}' is required for this feature. "
|
76
|
+
f"Install it with: pip install crypticorn[{extra_name}]"
|
77
|
+
) from e
|
78
|
+
|
79
|
+
|
80
|
+
def datetime_to_timestamp(v: Any):
|
81
|
+
"""Converts a datetime to a timestamp.
|
82
|
+
Can be used as a pydantic validator.
|
83
|
+
>>> from pydantic import BeforeValidator, BaseModel
|
84
|
+
>>> class MyModel(BaseModel):
|
85
|
+
... timestamp: Annotated[int, BeforeValidator(datetime_to_timestamp)]
|
86
|
+
"""
|
87
|
+
if isinstance(v, list):
|
88
|
+
return [
|
89
|
+
int(item.timestamp()) if isinstance(item, datetime) else item for item in v
|
90
|
+
]
|
91
|
+
elif isinstance(v, datetime):
|
92
|
+
return int(v.timestamp())
|
93
|
+
return v
|
@@ -0,0 +1,79 @@
|
|
1
|
+
"""Crypticorn-specific warnings."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
from typing import Union
|
6
|
+
|
7
|
+
|
8
|
+
class CrypticornDeprecationWarning(DeprecationWarning):
|
9
|
+
"""A Crypticorn specific deprecation warning.
|
10
|
+
|
11
|
+
This warning is raised when using deprecated functionality in Crypticorn. It provides information on when the
|
12
|
+
deprecation was introduced and the expected version in which the corresponding functionality will be removed.
|
13
|
+
|
14
|
+
Attributes:
|
15
|
+
message: Description of the warning.
|
16
|
+
since: Crypticorn version in what the deprecation was introduced.
|
17
|
+
expected_removal: Crypticorn version in what the corresponding functionality expected to be removed.
|
18
|
+
"""
|
19
|
+
|
20
|
+
message: str
|
21
|
+
since: tuple[int, int]
|
22
|
+
expected_removal: tuple[int, int]
|
23
|
+
|
24
|
+
def __init__(
|
25
|
+
self,
|
26
|
+
message: str,
|
27
|
+
*args: object,
|
28
|
+
since: tuple[int, int],
|
29
|
+
expected_removal: Union[tuple[int, int], None] = None,
|
30
|
+
) -> None:
|
31
|
+
super().__init__(message, *args)
|
32
|
+
self.message = message.rstrip(".")
|
33
|
+
self.since = since
|
34
|
+
self.expected_removal = (
|
35
|
+
expected_removal if expected_removal is not None else (since[0] + 1, 0)
|
36
|
+
)
|
37
|
+
|
38
|
+
def __str__(self) -> str:
|
39
|
+
message = (
|
40
|
+
f"{self.message}. Deprecated in Crypticorn v{self.since[0]}.{self.since[1]}"
|
41
|
+
f" to be removed in v{self.expected_removal[0]}.{self.expected_removal[1]}."
|
42
|
+
)
|
43
|
+
return message
|
44
|
+
|
45
|
+
|
46
|
+
class CrypticornDeprecatedSince25(CrypticornDeprecationWarning):
|
47
|
+
"""A specific `CrypticornDeprecationWarning` subclass defining functionality deprecated since Crypticorn 2.5."""
|
48
|
+
|
49
|
+
def __init__(self, message: str, *args: object) -> None:
|
50
|
+
super().__init__(message, *args, since=(2, 5), expected_removal=(3, 0))
|
51
|
+
|
52
|
+
|
53
|
+
class CrypticornDeprecatedSince28(CrypticornDeprecationWarning):
|
54
|
+
"""A specific `CrypticornDeprecationWarning` subclass defining functionality deprecated since Crypticorn 2.8."""
|
55
|
+
|
56
|
+
def __init__(self, message: str, *args: object) -> None:
|
57
|
+
super().__init__(message, *args, since=(2, 8), expected_removal=(3, 0))
|
58
|
+
|
59
|
+
|
60
|
+
class CrypticornDeprecatedSince215(CrypticornDeprecationWarning):
|
61
|
+
"""A specific `CrypticornDeprecationWarning` subclass defining functionality deprecated since Crypticorn 2.15."""
|
62
|
+
|
63
|
+
def __init__(self, message: str, *args: object) -> None:
|
64
|
+
super().__init__(message, *args, since=(2, 15), expected_removal=(3, 0))
|
65
|
+
|
66
|
+
|
67
|
+
class CrypticornDeprecatedSince217(CrypticornDeprecationWarning):
|
68
|
+
"""A specific `CrypticornDeprecationWarning` subclass defining functionality deprecated since Crypticorn 2.17."""
|
69
|
+
|
70
|
+
def __init__(self, message: str, *args: object) -> None:
|
71
|
+
super().__init__(message, *args, since=(2, 17), expected_removal=(3, 0))
|
72
|
+
|
73
|
+
|
74
|
+
class CrypticornExperimentalWarning(Warning):
|
75
|
+
"""A Crypticorn specific experimental functionality warning.
|
76
|
+
|
77
|
+
This warning is raised when using experimental functionality in Crypticorn.
|
78
|
+
It is raised to warn users that the functionality may change or be removed in future versions of Crypticorn.
|
79
|
+
"""
|