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.
@@ -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"
@@ -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,4 @@
1
+ from crypticorn.cli.init import init_group
2
+ from crypticorn.cli.version import version
3
+
4
+ __all__ = ["init_group", "version"]
@@ -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,8 @@
1
+ import importlib.metadata
2
+ import click
3
+
4
+
5
+ @click.command("version")
6
+ def version():
7
+ """Print the version of the crypticorn package"""
8
+ click.echo(importlib.metadata.distribution("crypticorn").version)
@@ -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
+ )