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,183 @@
|
|
1
|
+
import json
|
2
|
+
import logging
|
3
|
+
from typing import Any, Optional
|
4
|
+
|
5
|
+
from crypticorn_utils.errors import (
|
6
|
+
ApiError,
|
7
|
+
ApiErrorIdentifier,
|
8
|
+
ApiErrorLevel,
|
9
|
+
ApiErrorType,
|
10
|
+
)
|
11
|
+
from fastapi import FastAPI
|
12
|
+
from fastapi import HTTPException as FastAPIHTTPException
|
13
|
+
from fastapi import Request
|
14
|
+
from fastapi.exceptions import RequestValidationError, ResponseValidationError
|
15
|
+
from fastapi.responses import JSONResponse
|
16
|
+
from pydantic import BaseModel, Field
|
17
|
+
|
18
|
+
try:
|
19
|
+
from enum import StrEnum
|
20
|
+
except ImportError:
|
21
|
+
from strenum import StrEnum
|
22
|
+
|
23
|
+
_logger = logging.getLogger("crypticorn")
|
24
|
+
|
25
|
+
|
26
|
+
class _ExceptionType(StrEnum):
|
27
|
+
"""The protocol the exception is called from"""
|
28
|
+
|
29
|
+
HTTP = "http"
|
30
|
+
WEBSOCKET = "websocket"
|
31
|
+
|
32
|
+
|
33
|
+
class ExceptionDetail(BaseModel):
|
34
|
+
"""Exception details returned to the client."""
|
35
|
+
|
36
|
+
message: Optional[str] = Field(None, description="An additional error message")
|
37
|
+
code: ApiErrorIdentifier = Field(..., description="The unique error code")
|
38
|
+
type: ApiErrorType = Field(..., description="The type of error")
|
39
|
+
level: ApiErrorLevel = Field(..., description="The level of the error")
|
40
|
+
status_code: int = Field(..., description="The HTTP status code")
|
41
|
+
details: Any = Field(None, description="Additional details about the error")
|
42
|
+
|
43
|
+
|
44
|
+
class ExceptionContent(BaseModel):
|
45
|
+
"""Exception content used when raising an exception."""
|
46
|
+
|
47
|
+
error: ApiError = Field(..., description="The unique error code")
|
48
|
+
message: Optional[str] = Field(None, description="An additional error message")
|
49
|
+
details: Any = Field(None, description="Additional details about the error")
|
50
|
+
|
51
|
+
def enrich(
|
52
|
+
self, _type: Optional[_ExceptionType] = _ExceptionType.HTTP
|
53
|
+
) -> ExceptionDetail:
|
54
|
+
return ExceptionDetail(
|
55
|
+
message=self.message,
|
56
|
+
code=self.error.identifier,
|
57
|
+
type=self.error.type,
|
58
|
+
level=self.error.level,
|
59
|
+
status_code=(
|
60
|
+
self.error.http_code
|
61
|
+
if _type == _ExceptionType.HTTP
|
62
|
+
else self.error.websocket_code
|
63
|
+
),
|
64
|
+
details=self.details,
|
65
|
+
)
|
66
|
+
|
67
|
+
|
68
|
+
class HTTPException(FastAPIHTTPException):
|
69
|
+
"""A custom HTTP exception wrapper around FastAPI's HTTPException.
|
70
|
+
It allows for a more structured way to handle errors, with a message and an error code. The status code is being derived from the detail's error.
|
71
|
+
The ApiError class is the source of truth. If the error is not yet implemented, there are fallbacks in place.
|
72
|
+
"""
|
73
|
+
|
74
|
+
def __init__(
|
75
|
+
self,
|
76
|
+
content: ExceptionContent,
|
77
|
+
headers: Optional[dict[str, str]] = None,
|
78
|
+
_type: Optional[_ExceptionType] = _ExceptionType.HTTP,
|
79
|
+
):
|
80
|
+
self.content = content
|
81
|
+
self.headers = headers
|
82
|
+
assert isinstance(content, ExceptionContent)
|
83
|
+
body = content.enrich(_type)
|
84
|
+
super().__init__(
|
85
|
+
status_code=body.status_code,
|
86
|
+
detail=body.model_dump(mode="json"),
|
87
|
+
headers=headers,
|
88
|
+
)
|
89
|
+
|
90
|
+
|
91
|
+
class WebSocketException(HTTPException):
|
92
|
+
"""A WebSocketException is to be used for WebSocket connections. It is a wrapper around the HTTPException class to maintain the same structure, but using a different status code.
|
93
|
+
To be used in the same way as the HTTPException.
|
94
|
+
"""
|
95
|
+
|
96
|
+
def __init__(
|
97
|
+
self, content: ExceptionContent, headers: Optional[dict[str, str]] = None
|
98
|
+
):
|
99
|
+
super().__init__(content, headers, _type=_ExceptionType.WEBSOCKET)
|
100
|
+
|
101
|
+
@classmethod
|
102
|
+
def from_http_exception(cls, http_exception: HTTPException):
|
103
|
+
"""Helper method to convert an HTTPException to a WebSocketException."""
|
104
|
+
return WebSocketException(
|
105
|
+
content=http_exception.content,
|
106
|
+
headers=http_exception.headers,
|
107
|
+
)
|
108
|
+
|
109
|
+
|
110
|
+
async def general_handler(request: Request, exc: Exception) -> JSONResponse:
|
111
|
+
"""Default exception handler for all exceptions."""
|
112
|
+
body = ExceptionContent(message=str(exc), error=ApiError.UNKNOWN_ERROR)
|
113
|
+
http_exc = HTTPException(content=body)
|
114
|
+
res = JSONResponse(
|
115
|
+
status_code=http_exc.status_code,
|
116
|
+
content=http_exc.detail,
|
117
|
+
headers=http_exc.headers,
|
118
|
+
)
|
119
|
+
_logger.error(f"General error: {json.loads(res.__dict__.get('body'))}")
|
120
|
+
return res
|
121
|
+
|
122
|
+
|
123
|
+
async def request_validation_handler(
|
124
|
+
request: Request, exc: RequestValidationError
|
125
|
+
) -> JSONResponse:
|
126
|
+
"""Exception handler for all request validation errors."""
|
127
|
+
body = ExceptionContent(message=str(exc), error=ApiError.INVALID_DATA_REQUEST)
|
128
|
+
http_exc = HTTPException(content=body)
|
129
|
+
res = JSONResponse(
|
130
|
+
status_code=http_exc.status_code,
|
131
|
+
content=http_exc.detail,
|
132
|
+
headers=http_exc.headers,
|
133
|
+
)
|
134
|
+
_logger.error(f"Request validation error: {json.loads(res.__dict__.get('body'))}")
|
135
|
+
return res
|
136
|
+
|
137
|
+
|
138
|
+
async def response_validation_handler(
|
139
|
+
request: Request, exc: ResponseValidationError
|
140
|
+
) -> JSONResponse:
|
141
|
+
"""Exception handler for all response validation errors."""
|
142
|
+
body = ExceptionContent(message=str(exc), error=ApiError.INVALID_DATA_RESPONSE)
|
143
|
+
http_exc = HTTPException(content=body)
|
144
|
+
res = JSONResponse(
|
145
|
+
status_code=http_exc.status_code,
|
146
|
+
content=http_exc.detail,
|
147
|
+
headers=http_exc.headers,
|
148
|
+
)
|
149
|
+
_logger.error(f"Response validation error: {json.loads(res.__dict__.get('body'))}")
|
150
|
+
return res
|
151
|
+
|
152
|
+
|
153
|
+
async def http_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
154
|
+
"""Exception handler for HTTPExceptions. It unwraps the HTTPException and returns the detail in a flat JSON response."""
|
155
|
+
res = JSONResponse(
|
156
|
+
status_code=exc.status_code, content=exc.detail, headers=exc.headers
|
157
|
+
)
|
158
|
+
_logger.error(f"HTTP error: {json.loads(res.__dict__.get('body'))}")
|
159
|
+
return res
|
160
|
+
|
161
|
+
|
162
|
+
def register_exception_handlers(app: FastAPI):
|
163
|
+
"""Utility to register serveral exception handlers in one go. Catches Exception, HTTPException and Data Validation errors, logs them and responds with a unified json body."""
|
164
|
+
app.add_exception_handler(Exception, general_handler)
|
165
|
+
app.add_exception_handler(FastAPIHTTPException, http_handler)
|
166
|
+
app.add_exception_handler(RequestValidationError, request_validation_handler)
|
167
|
+
app.add_exception_handler(ResponseValidationError, response_validation_handler)
|
168
|
+
|
169
|
+
|
170
|
+
exception_response = {
|
171
|
+
"default": {"model": ExceptionDetail, "description": "Error response"}
|
172
|
+
}
|
173
|
+
|
174
|
+
|
175
|
+
class CrypticornException(Exception):
|
176
|
+
"""A custom exception class for Crypticorn."""
|
177
|
+
|
178
|
+
def __init__(self, error: ApiError, message: str = None):
|
179
|
+
self.message = message
|
180
|
+
self.error = error
|
181
|
+
|
182
|
+
def __str__(self):
|
183
|
+
return f"{self.error.identifier}: {self.message}"
|
@@ -0,0 +1,130 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import logging
|
4
|
+
import os
|
5
|
+
import sys
|
6
|
+
from datetime import datetime
|
7
|
+
from logging.handlers import RotatingFileHandler
|
8
|
+
|
9
|
+
from crypticorn_utils.ansi_colors import AnsiColors as C
|
10
|
+
from crypticorn_utils.mixins import ValidateEnumMixin
|
11
|
+
|
12
|
+
try:
|
13
|
+
from enum import StrEnum
|
14
|
+
except ImportError:
|
15
|
+
from strenum import StrEnum
|
16
|
+
|
17
|
+
|
18
|
+
class LogLevel(ValidateEnumMixin, StrEnum):
|
19
|
+
DEBUG = "DEBUG"
|
20
|
+
INFO = "INFO"
|
21
|
+
WARNING = "WARNING"
|
22
|
+
ERROR = "ERROR"
|
23
|
+
CRITICAL = "CRITICAL"
|
24
|
+
|
25
|
+
@classmethod
|
26
|
+
def get_color(cls, level: str) -> str:
|
27
|
+
"""Get the ansi color based on the log level."""
|
28
|
+
if level == cls.DEBUG:
|
29
|
+
return C.GREEN_BRIGHT
|
30
|
+
elif level == cls.INFO:
|
31
|
+
return C.BLUE_BRIGHT
|
32
|
+
elif level == cls.WARNING:
|
33
|
+
return C.YELLOW_BRIGHT
|
34
|
+
elif level == cls.ERROR:
|
35
|
+
return C.RED_BRIGHT
|
36
|
+
elif level == cls.CRITICAL:
|
37
|
+
return C.RED_BOLD
|
38
|
+
else:
|
39
|
+
return C.RESET
|
40
|
+
|
41
|
+
@staticmethod
|
42
|
+
def get_level(level: "LogLevel") -> int:
|
43
|
+
"""Get the integer value from a log level name."""
|
44
|
+
return logging._nameToLevel.get(level, logging.INFO)
|
45
|
+
|
46
|
+
@staticmethod
|
47
|
+
def get_name(level: int) -> "LogLevel":
|
48
|
+
"""Get the level name from the integer value of a log level."""
|
49
|
+
return LogLevel(logging._levelToName.get(level, "INFO"))
|
50
|
+
|
51
|
+
|
52
|
+
_LOGFORMAT = (
|
53
|
+
f"{C.CYAN_BOLD}%(asctime)s{C.RESET} - "
|
54
|
+
f"{C.GREEN_BOLD}%(name)s{C.RESET} - "
|
55
|
+
f"%(levelcolor)s%(levelname)s{C.RESET} - "
|
56
|
+
f"%(message)s"
|
57
|
+
)
|
58
|
+
_DATEFMT = "%Y-%m-%d %H:%M:%S.%f:"
|
59
|
+
|
60
|
+
|
61
|
+
class _CustomFormatter(logging.Formatter):
|
62
|
+
def __init__(self, *args, **kwargs):
|
63
|
+
super().__init__(*args, **kwargs)
|
64
|
+
|
65
|
+
def format(self, record):
|
66
|
+
color = LogLevel.get_color(record.levelname)
|
67
|
+
record.levelcolor = color
|
68
|
+
return super().format(record)
|
69
|
+
|
70
|
+
def formatTime(self, record, datefmt=_DATEFMT):
|
71
|
+
dt = datetime.fromtimestamp(record.created)
|
72
|
+
s = dt.strftime(datefmt)
|
73
|
+
return s[:-3] # Trim last 3 digits to get milliseconds
|
74
|
+
|
75
|
+
|
76
|
+
def configure_logging(
|
77
|
+
name: str = None,
|
78
|
+
fmt: str = _LOGFORMAT,
|
79
|
+
datefmt: str = _DATEFMT,
|
80
|
+
stdout_level: int = logging.INFO,
|
81
|
+
file_level: int = logging.INFO,
|
82
|
+
log_file: str = None,
|
83
|
+
filters: list[logging.Filter] = [],
|
84
|
+
) -> None:
|
85
|
+
"""Configures the logging for the application.
|
86
|
+
Run this function as early as possible in the application (for example using the `lifespan` parameter in FastAPI).
|
87
|
+
Then use can use the default `logging.getLogger(__name__)` method to get the logger (or <name> if you set the name parameter).
|
88
|
+
:param name: The name of the logger. If not provided, the root logger will be used. Use a name if you use multiple loggers in the same application.
|
89
|
+
:param fmt: The format of the log message.
|
90
|
+
:param datefmt: The date format of the log message.
|
91
|
+
:param stdout_level: The level of the log message to be printed to the console.
|
92
|
+
:param file_level: The level of the log message to be written to the file. Only used if `log_file` is provided.
|
93
|
+
:param log_file: The file to write the log messages to.
|
94
|
+
:param filters: A list of filters to apply to the log handlers.
|
95
|
+
"""
|
96
|
+
logger = logging.getLogger(name) if name else logging.getLogger()
|
97
|
+
|
98
|
+
if logger.handlers: # clear existing handlers to avoid duplicates
|
99
|
+
logger.handlers.clear()
|
100
|
+
|
101
|
+
logger.setLevel(min(stdout_level, file_level)) # set to most verbose level
|
102
|
+
|
103
|
+
# Configure stdout handler
|
104
|
+
stdout_handler = logging.StreamHandler(sys.stdout)
|
105
|
+
stdout_handler.setLevel(stdout_level)
|
106
|
+
stdout_handler.setFormatter(_CustomFormatter(fmt=fmt, datefmt=datefmt))
|
107
|
+
for filter in filters:
|
108
|
+
stdout_handler.addFilter(filter)
|
109
|
+
logger.addHandler(stdout_handler)
|
110
|
+
|
111
|
+
# Configure file handler
|
112
|
+
if log_file:
|
113
|
+
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
114
|
+
file_handler = RotatingFileHandler(
|
115
|
+
log_file, maxBytes=10 * 1024 * 1024, backupCount=5
|
116
|
+
)
|
117
|
+
file_handler.setLevel(file_level)
|
118
|
+
file_handler.setFormatter(_CustomFormatter(fmt=fmt, datefmt=datefmt))
|
119
|
+
for filter in filters:
|
120
|
+
file_handler.addFilter(filter)
|
121
|
+
logger.addHandler(file_handler)
|
122
|
+
|
123
|
+
if name:
|
124
|
+
logger.propagate = False
|
125
|
+
|
126
|
+
|
127
|
+
def disable_logging():
|
128
|
+
"""Disable logging for the crypticorn logger."""
|
129
|
+
logger = logging.getLogger("crypticorn")
|
130
|
+
logger.disabled = True
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# metrics/registry.py
|
2
|
+
from prometheus_client import CollectorRegistry, Counter, Histogram
|
3
|
+
from crypticorn_utils._migration import has_migrated
|
4
|
+
|
5
|
+
registry = CollectorRegistry()
|
6
|
+
|
7
|
+
if has_migrated:
|
8
|
+
HTTP_REQUESTS_COUNT = Counter(
|
9
|
+
"http_requests_total",
|
10
|
+
"Total HTTP requests",
|
11
|
+
["method", "endpoint", "status_code", "auth_type"],
|
12
|
+
registry=registry,
|
13
|
+
)
|
14
|
+
|
15
|
+
HTTP_REQUEST_DURATION = Histogram(
|
16
|
+
"http_request_duration_seconds",
|
17
|
+
"HTTP request duration in seconds",
|
18
|
+
["endpoint", "method"],
|
19
|
+
registry=registry,
|
20
|
+
)
|
21
|
+
|
22
|
+
REQUEST_SIZE = Histogram(
|
23
|
+
"http_request_size_bytes", "Size of HTTP request bodies", ["method", "endpoint"]
|
24
|
+
)
|
25
|
+
|
26
|
+
|
27
|
+
RESPONSE_SIZE = Histogram(
|
28
|
+
"http_response_size_bytes",
|
29
|
+
"Size of HTTP responses",
|
30
|
+
["method", "endpoint"],
|
31
|
+
registry=registry,
|
32
|
+
)
|
@@ -0,0 +1,125 @@
|
|
1
|
+
import time
|
2
|
+
import warnings
|
3
|
+
from contextlib import asynccontextmanager
|
4
|
+
|
5
|
+
from crypticorn_utils.logging import configure_logging
|
6
|
+
from crypticorn_utils.warnings import CrypticornDeprecatedSince217
|
7
|
+
from fastapi import FastAPI, Request
|
8
|
+
from fastapi.middleware.cors import CORSMiddleware
|
9
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
10
|
+
from typing_extensions import deprecated
|
11
|
+
from crypticorn_utils.metrics import has_migrated
|
12
|
+
|
13
|
+
if has_migrated:
|
14
|
+
from crypticorn_utils.metrics import (
|
15
|
+
HTTP_REQUEST_DURATION,
|
16
|
+
HTTP_REQUESTS_COUNT,
|
17
|
+
REQUEST_SIZE,
|
18
|
+
RESPONSE_SIZE,
|
19
|
+
)
|
20
|
+
# otherwise prometheus reqisters metrics twice, resulting in an exception
|
21
|
+
class PrometheusMiddleware(BaseHTTPMiddleware):
|
22
|
+
async def dispatch(self, request: Request, call_next):
|
23
|
+
|
24
|
+
if "authorization" in request.headers:
|
25
|
+
auth_type = (
|
26
|
+
request.headers["authorization"].split()[0]
|
27
|
+
if " " in request.headers["authorization"]
|
28
|
+
else "none"
|
29
|
+
)
|
30
|
+
elif "x-api-key" in request.headers:
|
31
|
+
auth_type = "X-API-KEY"
|
32
|
+
else:
|
33
|
+
auth_type = "none"
|
34
|
+
|
35
|
+
try:
|
36
|
+
endpoint = request.get(
|
37
|
+
"route"
|
38
|
+
).path # use /time/{type} instead of dynamic route to avoid high cardinality
|
39
|
+
except Exception:
|
40
|
+
endpoint = request.url.path
|
41
|
+
|
42
|
+
start = time.perf_counter()
|
43
|
+
response = await call_next(request)
|
44
|
+
duration = time.perf_counter() - start
|
45
|
+
|
46
|
+
HTTP_REQUESTS_COUNT.labels(
|
47
|
+
method=request.method,
|
48
|
+
endpoint=endpoint,
|
49
|
+
status_code=response.status_code,
|
50
|
+
auth_type=auth_type,
|
51
|
+
).inc()
|
52
|
+
|
53
|
+
try:
|
54
|
+
body = await request.body()
|
55
|
+
size = len(body)
|
56
|
+
except Exception:
|
57
|
+
size = 0
|
58
|
+
|
59
|
+
REQUEST_SIZE.labels(
|
60
|
+
method=request.method,
|
61
|
+
endpoint=endpoint,
|
62
|
+
).observe(size)
|
63
|
+
|
64
|
+
try:
|
65
|
+
body = await response.body()
|
66
|
+
size = len(body)
|
67
|
+
except Exception:
|
68
|
+
size = 0
|
69
|
+
|
70
|
+
RESPONSE_SIZE.labels(
|
71
|
+
method=request.method,
|
72
|
+
endpoint=endpoint,
|
73
|
+
).observe(size)
|
74
|
+
|
75
|
+
HTTP_REQUEST_DURATION.labels(
|
76
|
+
endpoint=endpoint,
|
77
|
+
method=request.method,
|
78
|
+
).observe(duration)
|
79
|
+
|
80
|
+
return response
|
81
|
+
|
82
|
+
|
83
|
+
@deprecated("Use add_middleware instead", category=None)
|
84
|
+
def add_cors_middleware(app: "FastAPI"):
|
85
|
+
warnings.warn(
|
86
|
+
"add_cors_middleware is deprecated. Use add_middleware instead.",
|
87
|
+
CrypticornDeprecatedSince217,
|
88
|
+
)
|
89
|
+
app.add_middleware(
|
90
|
+
CORSMiddleware,
|
91
|
+
allow_origins=[
|
92
|
+
"http://localhost:5173", # vite dev server
|
93
|
+
"http://localhost:4173", # vite preview server
|
94
|
+
],
|
95
|
+
allow_origin_regex="^https://([a-zA-Z0-9-]+.)*crypticorn.(dev|com)/?$", # matches (multiple or no) subdomains of crypticorn.dev and crypticorn.com
|
96
|
+
allow_credentials=True,
|
97
|
+
allow_methods=["*"],
|
98
|
+
allow_headers=["*"],
|
99
|
+
)
|
100
|
+
|
101
|
+
|
102
|
+
def add_middleware(app: "FastAPI"):
|
103
|
+
app.add_middleware(
|
104
|
+
CORSMiddleware,
|
105
|
+
allow_origins=[
|
106
|
+
"http://localhost:5173", # vite dev server
|
107
|
+
"http://localhost:4173", # vite preview server
|
108
|
+
],
|
109
|
+
allow_origin_regex="^https://([a-zA-Z0-9-]+.)*crypticorn.(dev|com)/?$", # matches (multiple or no) subdomains of crypticorn.dev and crypticorn.com
|
110
|
+
allow_credentials=True,
|
111
|
+
allow_methods=["*"],
|
112
|
+
allow_headers=["*"],
|
113
|
+
)
|
114
|
+
if has_migrated:
|
115
|
+
app.add_middleware(PrometheusMiddleware)
|
116
|
+
|
117
|
+
|
118
|
+
@asynccontextmanager
|
119
|
+
async def default_lifespan(app: FastAPI):
|
120
|
+
"""Default lifespan for the applications.
|
121
|
+
This is used to configure the logging for the application.
|
122
|
+
To override this, pass a different lifespan to the FastAPI constructor or call this lifespan within a custom lifespan.
|
123
|
+
"""
|
124
|
+
configure_logging()
|
125
|
+
yield
|
@@ -0,0 +1,68 @@
|
|
1
|
+
import logging
|
2
|
+
import warnings
|
3
|
+
from enum import EnumMeta
|
4
|
+
|
5
|
+
from crypticorn_utils.warnings import CrypticornDeprecatedSince28
|
6
|
+
|
7
|
+
_logger = logging.getLogger("crypticorn")
|
8
|
+
|
9
|
+
|
10
|
+
class ValidateEnumMixin:
|
11
|
+
"""
|
12
|
+
Mixin for validating enum values manually.
|
13
|
+
|
14
|
+
⚠️ Note:
|
15
|
+
This does NOT enforce validation automatically on enum creation.
|
16
|
+
It's up to the developer to call `Class.validate(value)` where needed.
|
17
|
+
|
18
|
+
Usage:
|
19
|
+
>>> class Color(ValidateEnumMixin, StrEnum):
|
20
|
+
>>> RED = "red"
|
21
|
+
>>> GREEN = "green"
|
22
|
+
|
23
|
+
>>> Color.validate("red") # True
|
24
|
+
>>> Color.validate("yellow") # False
|
25
|
+
|
26
|
+
Order of inheritance matters — the mixin must come first.
|
27
|
+
"""
|
28
|
+
|
29
|
+
@classmethod
|
30
|
+
def validate(cls, value) -> bool:
|
31
|
+
"""Validate if a value is in the enum. True if so, False otherwise."""
|
32
|
+
try:
|
33
|
+
cls(value)
|
34
|
+
return True
|
35
|
+
except ValueError:
|
36
|
+
return False
|
37
|
+
|
38
|
+
|
39
|
+
# This Mixin will be removed in a future version. And has no effect from now on
|
40
|
+
class ExcludeEnumMixin:
|
41
|
+
"""(deprecated) Mixin to exclude enum from OpenAPI schema. We use this to avoid duplicating enums when generating client code from the openapi spec."""
|
42
|
+
|
43
|
+
def __init_subclass__(cls, **kwargs):
|
44
|
+
super().__init_subclass__(**kwargs)
|
45
|
+
if cls.__name__.startswith("ExcludeEnumMixin"):
|
46
|
+
warnings.warn(
|
47
|
+
"The `ExcludeEnumMixin` class is deprecated. Should be removed from enums inheriting this class.",
|
48
|
+
category=CrypticornDeprecatedSince28,
|
49
|
+
)
|
50
|
+
|
51
|
+
@classmethod
|
52
|
+
def __get_pydantic_json_schema__(cls, core_schema, handler):
|
53
|
+
schema = handler(core_schema)
|
54
|
+
# schema.pop("enum", None)
|
55
|
+
return schema
|
56
|
+
|
57
|
+
|
58
|
+
class ApiErrorFallback(EnumMeta):
|
59
|
+
"""Fallback for enum members that are not yet published to PyPI."""
|
60
|
+
|
61
|
+
def __getattr__(cls, name):
|
62
|
+
# Let Pydantic/internal stuff pass silently ! fragile
|
63
|
+
if name.startswith("__") or name.startswith("_pytest"):
|
64
|
+
raise AttributeError(name)
|
65
|
+
_logger.warning(
|
66
|
+
f"Unknown enum member '{name}' - update crypticorn package or check for typos"
|
67
|
+
)
|
68
|
+
return cls.UNKNOWN_ERROR
|