crypticorn-utils 0.1.0rc1__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.
@@ -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
+ """