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.
- 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.0rc1.dist-info/METADATA +95 -0
- crypticorn_utils-0.1.0rc1.dist-info/RECORD +30 -0
- crypticorn_utils-0.1.0rc1.dist-info/WHEEL +5 -0
- crypticorn_utils-0.1.0rc1.dist-info/entry_points.txt +2 -0
- crypticorn_utils-0.1.0rc1.dist-info/licenses/LICENSE +15 -0
- crypticorn_utils-0.1.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,16 @@
|
|
1
|
+
from crypticorn_utils.ansi_colors import *
|
2
|
+
from crypticorn_utils.auth import *
|
3
|
+
from crypticorn_utils.decorators import *
|
4
|
+
from crypticorn_utils.enums import *
|
5
|
+
from crypticorn_utils.errors import *
|
6
|
+
from crypticorn_utils.exceptions import *
|
7
|
+
from crypticorn_utils.logging import *
|
8
|
+
from crypticorn_utils.metrics import *
|
9
|
+
from crypticorn_utils.middleware import *
|
10
|
+
from crypticorn_utils.mixins import *
|
11
|
+
from crypticorn_utils.openapi import *
|
12
|
+
from crypticorn_utils.pagination import *
|
13
|
+
from crypticorn_utils.router.admin_router import router as admin_router
|
14
|
+
from crypticorn_utils.router.status_router import router as status_router
|
15
|
+
from crypticorn_utils.utils import *
|
16
|
+
from crypticorn_utils.warnings import *
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# This file is used to check if the crypticorn version is greater than 2.18
|
2
|
+
# This is to be compatible with this new utils library
|
3
|
+
|
4
|
+
import importlib.metadata
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
6
|
+
|
7
|
+
try:
|
8
|
+
crypticorn_version = importlib.metadata.distribution("crypticorn").version
|
9
|
+
parts = crypticorn_version.split(".")
|
10
|
+
has_migrated = parts[0] >= "2" and parts[1] > "18"
|
11
|
+
except PackageNotFoundError:
|
12
|
+
has_migrated = False
|
@@ -0,0 +1,41 @@
|
|
1
|
+
try:
|
2
|
+
from enum import StrEnum
|
3
|
+
except ImportError:
|
4
|
+
from strenum import StrEnum
|
5
|
+
|
6
|
+
|
7
|
+
class AnsiColors(StrEnum):
|
8
|
+
"""Provides a collection of ANSI color codes for terminal text formatting, including regular, bright, and bold text colors. Useful for creating colorful and readable console output."""
|
9
|
+
|
10
|
+
# Regular Text Colors
|
11
|
+
BLACK = "\033[30m" # black
|
12
|
+
RED = "\033[31m" # red
|
13
|
+
GREEN = "\033[32m" # green
|
14
|
+
YELLOW = "\033[33m" # yellow
|
15
|
+
BLUE = "\033[34m" # blue
|
16
|
+
MAGENTA = "\033[35m" # magenta
|
17
|
+
CYAN = "\033[36m" # cyan
|
18
|
+
WHITE = "\033[37m" # white
|
19
|
+
|
20
|
+
# Bright Text Colors
|
21
|
+
BLACK_BRIGHT = "\033[90m" # black_bright
|
22
|
+
RED_BRIGHT = "\033[91m" # red_bright
|
23
|
+
GREEN_BRIGHT = "\033[92m" # green_bright
|
24
|
+
YELLOW_BRIGHT = "\033[93m" # yellow_bright
|
25
|
+
BLUE_BRIGHT = "\033[94m" # blue_bright
|
26
|
+
MAGENTA_BRIGHT = "\033[95m" # magenta_bright
|
27
|
+
CYAN_BRIGHT = "\033[96m" # cyan_bright
|
28
|
+
WHITE_BRIGHT = "\033[97m" # white_bright
|
29
|
+
|
30
|
+
# Bold Text Colors
|
31
|
+
BLACK_BOLD = "\033[1;30m" # black_bold
|
32
|
+
RED_BOLD = "\033[1;31m" # red_bold
|
33
|
+
GREEN_BOLD = "\033[1;32m" # green_bold
|
34
|
+
YELLOW_BOLD = "\033[1;33m" # yellow_bold
|
35
|
+
BLUE_BOLD = "\033[1;34m" # blue_bold
|
36
|
+
MAGENTA_BOLD = "\033[1;35m" # magenta_bold
|
37
|
+
CYAN_BOLD = "\033[1;36m" # cyan_bold
|
38
|
+
WHITE_BOLD = "\033[1;37m" # white_bold
|
39
|
+
|
40
|
+
# Reset Color
|
41
|
+
RESET = "\033[0m"
|
crypticorn_utils/auth.py
ADDED
@@ -0,0 +1,345 @@
|
|
1
|
+
import json
|
2
|
+
from typing import Union
|
3
|
+
|
4
|
+
from crypticorn.auth import AuthClient, Configuration, Verify200Response
|
5
|
+
from crypticorn.auth.client.exceptions import ApiException
|
6
|
+
from crypticorn_utils.enums import Scope, BaseUrl
|
7
|
+
from crypticorn_utils.exceptions import ApiError, ExceptionContent, HTTPException
|
8
|
+
from fastapi import Depends, Query
|
9
|
+
from fastapi.security import (
|
10
|
+
APIKeyHeader,
|
11
|
+
HTTPAuthorizationCredentials,
|
12
|
+
HTTPBasic,
|
13
|
+
HTTPBasicCredentials,
|
14
|
+
HTTPBearer,
|
15
|
+
SecurityScopes,
|
16
|
+
)
|
17
|
+
from typing_extensions import Annotated
|
18
|
+
|
19
|
+
AUTHENTICATE_HEADER = "WWW-Authenticate"
|
20
|
+
BEARER_AUTH_SCHEME = "Bearer"
|
21
|
+
APIKEY_AUTH_SCHEME = "X-API-Key"
|
22
|
+
BASIC_AUTH_SCHEME = "Basic"
|
23
|
+
|
24
|
+
# Auth Schemes
|
25
|
+
http_bearer = HTTPBearer(
|
26
|
+
bearerFormat="JWT",
|
27
|
+
auto_error=False,
|
28
|
+
description="The JWT to use for authentication.",
|
29
|
+
)
|
30
|
+
|
31
|
+
apikey_header = APIKeyHeader(
|
32
|
+
name=APIKEY_AUTH_SCHEME,
|
33
|
+
auto_error=False,
|
34
|
+
description="The API key to use for authentication.",
|
35
|
+
)
|
36
|
+
|
37
|
+
http_basic = HTTPBasic(
|
38
|
+
scheme_name=BASIC_AUTH_SCHEME,
|
39
|
+
auto_error=False,
|
40
|
+
description="The username and password to use for authentication.",
|
41
|
+
)
|
42
|
+
|
43
|
+
|
44
|
+
# Auth Handler
|
45
|
+
class AuthHandler:
|
46
|
+
"""
|
47
|
+
Middleware for verifying API requests. Verifies the validity of the authentication token, scopes, etc.
|
48
|
+
|
49
|
+
:param base_url: The base URL of the API.
|
50
|
+
:param api_version: The version of the API.
|
51
|
+
"""
|
52
|
+
|
53
|
+
def __init__(
|
54
|
+
self,
|
55
|
+
base_url: BaseUrl = BaseUrl.PROD,
|
56
|
+
):
|
57
|
+
self.url = f"{base_url}/v1/auth"
|
58
|
+
self.client = AuthClient(Configuration(host=self.url), is_sync=False)
|
59
|
+
|
60
|
+
async def _verify_api_key(self, api_key: str) -> Verify200Response:
|
61
|
+
"""
|
62
|
+
Verifies the API key.
|
63
|
+
"""
|
64
|
+
# self.client.config.api_key = {apikey_header.scheme_name: api_key}
|
65
|
+
return await self.client.login.verify_api_key(api_key)
|
66
|
+
|
67
|
+
async def _verify_bearer(
|
68
|
+
self, bearer: HTTPAuthorizationCredentials
|
69
|
+
) -> Verify200Response:
|
70
|
+
"""
|
71
|
+
Verifies the bearer token.
|
72
|
+
"""
|
73
|
+
self.client.config.access_token = bearer.credentials
|
74
|
+
return await self.client.login.verify()
|
75
|
+
|
76
|
+
async def _verify_basic(self, basic: HTTPBasicCredentials) -> Verify200Response:
|
77
|
+
"""
|
78
|
+
Verifies the basic authentication credentials.
|
79
|
+
"""
|
80
|
+
return await self.client.login.verify_basic_auth(basic.username, basic.password)
|
81
|
+
|
82
|
+
async def _validate_scopes(
|
83
|
+
self, api_scopes: list[Scope], user_scopes: list[Scope]
|
84
|
+
) -> bool:
|
85
|
+
"""
|
86
|
+
Checks if the required scopes are a subset of the user scopes.
|
87
|
+
"""
|
88
|
+
if not set(api_scopes).issubset(user_scopes):
|
89
|
+
raise HTTPException(
|
90
|
+
content=ExceptionContent(
|
91
|
+
error=ApiError.INSUFFICIENT_SCOPES,
|
92
|
+
message="Insufficient scopes to access this resource (required: "
|
93
|
+
+ ", ".join(api_scopes)
|
94
|
+
+ ")",
|
95
|
+
),
|
96
|
+
)
|
97
|
+
|
98
|
+
async def _extract_message(self, e: ApiException) -> str:
|
99
|
+
"""
|
100
|
+
Tries to extract the message from the body of the exception.
|
101
|
+
"""
|
102
|
+
try:
|
103
|
+
load = json.loads(e.body)
|
104
|
+
except (json.JSONDecodeError, TypeError):
|
105
|
+
return e.body
|
106
|
+
else:
|
107
|
+
common_keys = ["message"]
|
108
|
+
for key in common_keys:
|
109
|
+
if key in load:
|
110
|
+
return load[key]
|
111
|
+
return load
|
112
|
+
|
113
|
+
async def _handle_exception(self, e: Exception) -> HTTPException:
|
114
|
+
"""
|
115
|
+
Handles exceptions and returns a HTTPException with the appropriate status code and detail.
|
116
|
+
"""
|
117
|
+
if isinstance(e, ApiException):
|
118
|
+
# handle the TRPC Zod errors from auth-service
|
119
|
+
# Unfortunately, we cannot share the error messages defined in python/crypticorn/common/errors.py with the typescript client
|
120
|
+
message = await self._extract_message(e)
|
121
|
+
if message == "Invalid API key":
|
122
|
+
error = ApiError.INVALID_API_KEY
|
123
|
+
elif message == "API key expired":
|
124
|
+
error = ApiError.EXPIRED_API_KEY
|
125
|
+
elif message == "jwt expired":
|
126
|
+
error = ApiError.EXPIRED_BEARER
|
127
|
+
elif message == "Invalid basic authentication credentials":
|
128
|
+
error = ApiError.INVALID_BASIC_AUTH
|
129
|
+
else:
|
130
|
+
message = "Invalid bearer token"
|
131
|
+
error = (
|
132
|
+
ApiError.INVALID_BEARER
|
133
|
+
) # jwt malformed, jwt not active (https://www.npmjs.com/package/jsonwebtoken#errors--codes)
|
134
|
+
return HTTPException(
|
135
|
+
content=ExceptionContent(
|
136
|
+
error=error,
|
137
|
+
message=message,
|
138
|
+
),
|
139
|
+
)
|
140
|
+
elif isinstance(e, HTTPException):
|
141
|
+
return e
|
142
|
+
else:
|
143
|
+
return HTTPException(
|
144
|
+
content=ExceptionContent(
|
145
|
+
error=ApiError.UNKNOWN_ERROR,
|
146
|
+
message=str(e),
|
147
|
+
),
|
148
|
+
)
|
149
|
+
|
150
|
+
async def api_key_auth(
|
151
|
+
self,
|
152
|
+
api_key: Annotated[Union[str, None], Depends(apikey_header)] = None,
|
153
|
+
sec: SecurityScopes = SecurityScopes(),
|
154
|
+
) -> Verify200Response:
|
155
|
+
"""
|
156
|
+
Verifies the API key and checks the scopes.
|
157
|
+
Use this function if you only want to allow access via the API key.
|
158
|
+
This function is used for HTTP connections.
|
159
|
+
"""
|
160
|
+
try:
|
161
|
+
return await self.full_auth(
|
162
|
+
bearer=None, api_key=api_key, basic=None, sec=sec
|
163
|
+
)
|
164
|
+
except HTTPException as e:
|
165
|
+
raise HTTPException(
|
166
|
+
content=ExceptionContent(
|
167
|
+
error=ApiError.from_json(e.detail),
|
168
|
+
message=e.detail.get("message"),
|
169
|
+
),
|
170
|
+
headers={AUTHENTICATE_HEADER: APIKEY_AUTH_SCHEME},
|
171
|
+
)
|
172
|
+
|
173
|
+
async def bearer_auth(
|
174
|
+
self,
|
175
|
+
bearer: Annotated[
|
176
|
+
Union[HTTPAuthorizationCredentials, None],
|
177
|
+
Depends(http_bearer),
|
178
|
+
] = None,
|
179
|
+
sec: SecurityScopes = SecurityScopes(),
|
180
|
+
) -> Verify200Response:
|
181
|
+
"""
|
182
|
+
Verifies the bearer token and checks the scopes.
|
183
|
+
Use this function if you only want to allow access via the bearer token.
|
184
|
+
This function is used for HTTP connections.
|
185
|
+
"""
|
186
|
+
try:
|
187
|
+
return await self.full_auth(
|
188
|
+
bearer=bearer, api_key=None, basic=None, sec=sec
|
189
|
+
)
|
190
|
+
except HTTPException as e:
|
191
|
+
raise HTTPException(
|
192
|
+
content=ExceptionContent(
|
193
|
+
error=ApiError.from_json(e.detail),
|
194
|
+
message=e.detail.get("message"),
|
195
|
+
),
|
196
|
+
headers={AUTHENTICATE_HEADER: BEARER_AUTH_SCHEME},
|
197
|
+
)
|
198
|
+
|
199
|
+
async def basic_auth(
|
200
|
+
self,
|
201
|
+
credentials: Annotated[Union[HTTPBasicCredentials, None], Depends(http_basic)],
|
202
|
+
) -> Verify200Response:
|
203
|
+
"""
|
204
|
+
Verifies the basic authentication credentials. This authentication method should just be used for special cases like /admin/metrics, where JWT and API key authentication are not desired or not possible.
|
205
|
+
"""
|
206
|
+
try:
|
207
|
+
return await self.full_auth(
|
208
|
+
basic=credentials, bearer=None, api_key=None, sec=None
|
209
|
+
)
|
210
|
+
except HTTPException as e:
|
211
|
+
raise HTTPException(
|
212
|
+
content=ExceptionContent(
|
213
|
+
error=ApiError.from_json(e.detail),
|
214
|
+
message=e.detail.get("message"),
|
215
|
+
),
|
216
|
+
headers={AUTHENTICATE_HEADER: BASIC_AUTH_SCHEME},
|
217
|
+
)
|
218
|
+
|
219
|
+
async def combined_auth(
|
220
|
+
self,
|
221
|
+
bearer: Annotated[
|
222
|
+
Union[HTTPAuthorizationCredentials, None], Depends(http_bearer)
|
223
|
+
] = None,
|
224
|
+
api_key: Annotated[Union[str, None], Depends(apikey_header)] = None,
|
225
|
+
sec: SecurityScopes = SecurityScopes(),
|
226
|
+
) -> Verify200Response:
|
227
|
+
"""
|
228
|
+
Verifies the bearer token and/or API key and checks the scopes.
|
229
|
+
Returns early on the first successful verification, otherwise tries all available tokens.
|
230
|
+
Use this function if you want to allow access via either the bearer token or the API key.
|
231
|
+
This function is used for HTTP connections.
|
232
|
+
"""
|
233
|
+
try:
|
234
|
+
return await self.full_auth(
|
235
|
+
basic=None, bearer=bearer, api_key=api_key, sec=sec
|
236
|
+
)
|
237
|
+
except HTTPException as e:
|
238
|
+
raise HTTPException(
|
239
|
+
content=ExceptionContent(
|
240
|
+
error=ApiError.from_json(e.detail),
|
241
|
+
message=e.detail.get("message"),
|
242
|
+
),
|
243
|
+
headers={
|
244
|
+
AUTHENTICATE_HEADER: f"{BEARER_AUTH_SCHEME}, {APIKEY_AUTH_SCHEME}"
|
245
|
+
},
|
246
|
+
)
|
247
|
+
|
248
|
+
async def full_auth(
|
249
|
+
self,
|
250
|
+
basic: Annotated[Union[HTTPBasicCredentials, None], Depends(http_basic)] = None,
|
251
|
+
bearer: Annotated[
|
252
|
+
Union[HTTPAuthorizationCredentials, None], Depends(http_bearer)
|
253
|
+
] = None,
|
254
|
+
api_key: Annotated[Union[str, None], Depends(apikey_header)] = None,
|
255
|
+
sec: SecurityScopes = SecurityScopes(),
|
256
|
+
) -> Verify200Response:
|
257
|
+
"""
|
258
|
+
IMPORTANT: combined_auth is sufficient for most use cases. This function adds basic auth to the mix, which is needed for external services like prometheus, but is not recommended for internal use.
|
259
|
+
Verifies the bearer token, API key and basic authentication credentials and checks the scopes.
|
260
|
+
Returns early on the first successful verification, otherwise tries all available tokens.
|
261
|
+
Use this function if you want to allow access via either the bearer token, the API key or the basic authentication credentials.
|
262
|
+
This function is used for HTTP connections.
|
263
|
+
"""
|
264
|
+
tokens = [bearer, api_key, basic]
|
265
|
+
last_error = None
|
266
|
+
for token in tokens:
|
267
|
+
try:
|
268
|
+
if token is None:
|
269
|
+
continue
|
270
|
+
res = None
|
271
|
+
if isinstance(token, str):
|
272
|
+
res = await self._verify_api_key(token)
|
273
|
+
elif isinstance(token, HTTPAuthorizationCredentials):
|
274
|
+
res = await self._verify_bearer(token)
|
275
|
+
elif isinstance(token, HTTPBasicCredentials):
|
276
|
+
res = await self._verify_basic(token)
|
277
|
+
if res is None:
|
278
|
+
continue
|
279
|
+
if sec:
|
280
|
+
await self._validate_scopes(sec.scopes, res.scopes)
|
281
|
+
return res
|
282
|
+
|
283
|
+
except Exception as e:
|
284
|
+
last_error = await self._handle_exception(e)
|
285
|
+
continue
|
286
|
+
|
287
|
+
if last_error:
|
288
|
+
raise last_error
|
289
|
+
else:
|
290
|
+
raise HTTPException(
|
291
|
+
content=ExceptionContent(
|
292
|
+
error=ApiError.NO_CREDENTIALS,
|
293
|
+
message="No credentials provided. Check the WWW-Authenticate header for the available authentication methods.",
|
294
|
+
),
|
295
|
+
headers={
|
296
|
+
AUTHENTICATE_HEADER: f"{BEARER_AUTH_SCHEME}, {APIKEY_AUTH_SCHEME}, {BASIC_AUTH_SCHEME}"
|
297
|
+
},
|
298
|
+
)
|
299
|
+
|
300
|
+
async def ws_api_key_auth(
|
301
|
+
self,
|
302
|
+
api_key: Annotated[Union[str, None], Query()] = None,
|
303
|
+
sec: SecurityScopes = SecurityScopes(),
|
304
|
+
) -> Verify200Response:
|
305
|
+
"""
|
306
|
+
Verifies the API key and checks the scopes.
|
307
|
+
Use this function if you only want to allow access via the API key.
|
308
|
+
This function is used for WebSocket connections.
|
309
|
+
"""
|
310
|
+
return await self.api_key_auth(api_key=api_key, sec=sec)
|
311
|
+
|
312
|
+
async def ws_bearer_auth(
|
313
|
+
self,
|
314
|
+
bearer: Annotated[Union[str, None], Query()] = None,
|
315
|
+
sec: SecurityScopes = SecurityScopes(),
|
316
|
+
) -> Verify200Response:
|
317
|
+
"""
|
318
|
+
Verifies the bearer token and checks the scopes.
|
319
|
+
Use this function if you only want to allow access via the bearer token.
|
320
|
+
This function is used for WebSocket connections.
|
321
|
+
"""
|
322
|
+
credentials = (
|
323
|
+
HTTPAuthorizationCredentials(scheme="Bearer", credentials=bearer)
|
324
|
+
if bearer
|
325
|
+
else None
|
326
|
+
)
|
327
|
+
return await self.bearer_auth(bearer=credentials, sec=sec)
|
328
|
+
|
329
|
+
async def ws_combined_auth(
|
330
|
+
self,
|
331
|
+
bearer: Annotated[Union[str, None], Query()] = None,
|
332
|
+
api_key: Annotated[Union[str, None], Query()] = None,
|
333
|
+
sec: SecurityScopes = SecurityScopes(),
|
334
|
+
) -> Verify200Response:
|
335
|
+
"""
|
336
|
+
Verifies the bearer token and/or API key and checks the scopes.
|
337
|
+
Use this function if you want to allow access via either the bearer token or the API key.
|
338
|
+
This function is used for WebSocket connections.
|
339
|
+
"""
|
340
|
+
credentials = (
|
341
|
+
HTTPAuthorizationCredentials(scheme="Bearer", credentials=bearer)
|
342
|
+
if bearer
|
343
|
+
else None
|
344
|
+
)
|
345
|
+
return await self.combined_auth(bearer=credentials, api_key=api_key, sec=sec)
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# crypticorn/cli.py
|
2
|
+
|
3
|
+
import click
|
4
|
+
from crypticorn.cli import init_group, version
|
5
|
+
|
6
|
+
|
7
|
+
@click.group()
|
8
|
+
def cli():
|
9
|
+
"""🧙 Crypticorn CLI — magic for our microservices."""
|
10
|
+
pass
|
11
|
+
|
12
|
+
|
13
|
+
cli.add_command(init_group, name="init")
|
14
|
+
cli.add_command(version, name="version")
|
15
|
+
|
16
|
+
if __name__ == "__main__":
|
17
|
+
cli()
|
@@ -0,0 +1,127 @@
|
|
1
|
+
import click
|
2
|
+
from pathlib import Path
|
3
|
+
import subprocess
|
4
|
+
import importlib.resources
|
5
|
+
import crypticorn.cli.templates as templates
|
6
|
+
|
7
|
+
|
8
|
+
def get_git_root() -> Path:
|
9
|
+
"""Get the root directory of the git repository."""
|
10
|
+
try:
|
11
|
+
return Path(
|
12
|
+
subprocess.check_output(
|
13
|
+
["git", "rev-parse", "--show-toplevel"], text=True
|
14
|
+
).strip()
|
15
|
+
)
|
16
|
+
except Exception:
|
17
|
+
return Path.cwd()
|
18
|
+
|
19
|
+
|
20
|
+
def copy_template(template_name: str, target_path: Path):
|
21
|
+
"""Copy a template file to the target path."""
|
22
|
+
with importlib.resources.files(templates).joinpath(template_name).open(
|
23
|
+
"r"
|
24
|
+
) as template_file:
|
25
|
+
content = template_file.read()
|
26
|
+
|
27
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
28
|
+
with target_path.open("w") as f:
|
29
|
+
f.write(content)
|
30
|
+
|
31
|
+
click.secho(f"✅ Created: {target_path}", fg="green")
|
32
|
+
|
33
|
+
|
34
|
+
def check_file_exists(path: Path, force: bool):
|
35
|
+
if path.exists() and not force:
|
36
|
+
click.secho("File already exists, use --force / -f to overwrite", fg="red")
|
37
|
+
return False
|
38
|
+
return True
|
39
|
+
|
40
|
+
|
41
|
+
@click.group()
|
42
|
+
def init_group():
|
43
|
+
"""Initialize files like CI configs, linters, etc."""
|
44
|
+
pass
|
45
|
+
|
46
|
+
|
47
|
+
@init_group.command("ruff")
|
48
|
+
@click.option("-f", "--force", is_flag=True, help="Force overwrite the ruff.yml")
|
49
|
+
def init_ruff(force):
|
50
|
+
"""Add .github/workflows/ruff.yml"""
|
51
|
+
root = get_git_root()
|
52
|
+
target = root / ".github/workflows/ruff.yml"
|
53
|
+
if target.exists() and not force:
|
54
|
+
click.secho("File already exists, use --force / -f to overwrite", fg="red")
|
55
|
+
return
|
56
|
+
copy_template("ruff.yml", target)
|
57
|
+
|
58
|
+
|
59
|
+
@init_group.command("docker")
|
60
|
+
@click.option(
|
61
|
+
"-o", "--output", type=click.Path(), help="Custom output path for the Dockerfile"
|
62
|
+
)
|
63
|
+
@click.option("-f", "--force", is_flag=True, help="Force overwrite the Dockerfile")
|
64
|
+
def init_docker(output, force):
|
65
|
+
"""Add Dockerfile"""
|
66
|
+
root = get_git_root()
|
67
|
+
if output and Path(output).is_file():
|
68
|
+
click.secho("Output path is a file, please provide a directory path", fg="red")
|
69
|
+
return
|
70
|
+
target = (Path(output) if output else root) / "Dockerfile"
|
71
|
+
if not check_file_exists(target, force):
|
72
|
+
return
|
73
|
+
copy_template("Dockerfile", target)
|
74
|
+
click.secho("Make sure to update the Dockerfile", fg="yellow")
|
75
|
+
|
76
|
+
|
77
|
+
@init_group.command("auth")
|
78
|
+
@click.option(
|
79
|
+
"-o", "--output", type=click.Path(), help="Custom output path for the auth handler"
|
80
|
+
)
|
81
|
+
@click.option("-f", "--force", is_flag=True, help="Force overwrite the auth handler")
|
82
|
+
def init_auth(output, force):
|
83
|
+
"""Add auth.py with auth handler. Everything you need to start using the auth service."""
|
84
|
+
root = get_git_root()
|
85
|
+
if output and Path(output).is_file():
|
86
|
+
click.secho("Output path is a file, please provide a directory path", fg="red")
|
87
|
+
return
|
88
|
+
target = (Path(output) if output else root) / "auth.py"
|
89
|
+
if not check_file_exists(target, force):
|
90
|
+
return
|
91
|
+
copy_template("auth.py", target)
|
92
|
+
click.secho(
|
93
|
+
"""
|
94
|
+
Make sure to update the .env and .env.example files with:
|
95
|
+
IS_DOCKER=0
|
96
|
+
API_ENV=local
|
97
|
+
and the docker-compose.yml file with:
|
98
|
+
environment:
|
99
|
+
- IS_DOCKER=1
|
100
|
+
and the .github/workflows/main.yaml file with:
|
101
|
+
env:
|
102
|
+
API_ENV: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }}
|
103
|
+
""",
|
104
|
+
fg="yellow",
|
105
|
+
)
|
106
|
+
|
107
|
+
|
108
|
+
@init_group.command("dependabot")
|
109
|
+
@click.option("-f", "--force", is_flag=True, help="Force overwrite the dependabot.yml")
|
110
|
+
def init_dependabot(force):
|
111
|
+
"""Add dependabot.yml"""
|
112
|
+
root = get_git_root()
|
113
|
+
target = root / ".github/dependabot.yml"
|
114
|
+
if not check_file_exists(target, force):
|
115
|
+
return
|
116
|
+
copy_template("dependabot.yml", target)
|
117
|
+
|
118
|
+
|
119
|
+
@init_group.command("merge-env")
|
120
|
+
@click.option("-f", "--force", is_flag=True, help="Force overwrite the .env file")
|
121
|
+
def init_merge_env(force):
|
122
|
+
"""Add script to merge environment and file variables into .env"""
|
123
|
+
root = get_git_root()
|
124
|
+
target = root / "scripts/merge-env.sh"
|
125
|
+
if not check_file_exists(target, force):
|
126
|
+
return
|
127
|
+
copy_template("merge-env.sh", target)
|
File without changes
|
@@ -0,0 +1,33 @@
|
|
1
|
+
from crypticorn.common import (
|
2
|
+
AuthHandler as AuthHandler,
|
3
|
+
Scope as Scope,
|
4
|
+
Verify200Response as Verify200Response,
|
5
|
+
BaseUrl as BaseUrl,
|
6
|
+
ApiEnv as ApiEnv,
|
7
|
+
)
|
8
|
+
from fastapi import Security as Security
|
9
|
+
import os
|
10
|
+
import dotenv
|
11
|
+
import logging
|
12
|
+
|
13
|
+
dotenv.load_dotenv()
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
DOCKER_ENV = os.getenv("IS_DOCKER", "0")
|
18
|
+
API_ENV = os.getenv("API_ENV")
|
19
|
+
|
20
|
+
if not API_ENV:
|
21
|
+
raise ValueError(
|
22
|
+
"API_ENV is not set. Please set it to 'prod', 'dev' or 'local' in .env (of type ApiEnv)."
|
23
|
+
)
|
24
|
+
|
25
|
+
if DOCKER_ENV == "0":
|
26
|
+
logger.info(f"Using {API_ENV} environment")
|
27
|
+
base_url = BaseUrl.from_env(ApiEnv(API_ENV))
|
28
|
+
else:
|
29
|
+
base_url = BaseUrl.DOCKER
|
30
|
+
logger.info("Using docker environment")
|
31
|
+
|
32
|
+
auth_handler = AuthHandler(base_url=base_url)
|
33
|
+
logger.info(f"Auth URL: {auth_handler.client.config.host}")
|
@@ -0,0 +1,38 @@
|
|
1
|
+
from copy import deepcopy
|
2
|
+
from typing import Any, Optional, Tuple, Type
|
3
|
+
|
4
|
+
from pydantic import BaseModel, create_model
|
5
|
+
from pydantic.fields import FieldInfo
|
6
|
+
|
7
|
+
|
8
|
+
def partial_model(model: Type[BaseModel]) -> Type[BaseModel]:
|
9
|
+
"""Marks all fields of a model as optional. Useful for updating models.
|
10
|
+
Inherits all fields, docstrings, and the model name.
|
11
|
+
|
12
|
+
>>> @partial_model
|
13
|
+
>>> class Model(BaseModel):
|
14
|
+
>>> i: int
|
15
|
+
>>> f: float
|
16
|
+
>>> s: str
|
17
|
+
|
18
|
+
>>> Model(i=1)
|
19
|
+
"""
|
20
|
+
|
21
|
+
def make_field_optional(
|
22
|
+
field: FieldInfo, default: Any = None
|
23
|
+
) -> Tuple[Any, FieldInfo]:
|
24
|
+
new = deepcopy(field)
|
25
|
+
new.default = default
|
26
|
+
new.annotation = Optional[field.annotation] # type: ignore
|
27
|
+
return new.annotation, new
|
28
|
+
|
29
|
+
return create_model(
|
30
|
+
model.__name__,
|
31
|
+
__base__=model,
|
32
|
+
__module__=model.__module__,
|
33
|
+
__doc__=model.__doc__,
|
34
|
+
**{
|
35
|
+
field_name: make_field_optional(field_info)
|
36
|
+
for field_name, field_info in model.model_fields.items()
|
37
|
+
},
|
38
|
+
)
|