hypern 0.1.0__cp310-cp310-manylinux_2_34_x86_64.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.
Files changed (65) hide show
  1. hypern/__init__.py +4 -0
  2. hypern/application.py +234 -0
  3. hypern/auth/__init__.py +0 -0
  4. hypern/auth/authorization.py +2 -0
  5. hypern/background.py +4 -0
  6. hypern/caching/__init__.py +0 -0
  7. hypern/caching/base/__init__.py +8 -0
  8. hypern/caching/base/backend.py +3 -0
  9. hypern/caching/base/key_maker.py +8 -0
  10. hypern/caching/cache_manager.py +56 -0
  11. hypern/caching/cache_tag.py +10 -0
  12. hypern/caching/custom_key_maker.py +11 -0
  13. hypern/caching/redis_backend.py +3 -0
  14. hypern/cli/__init__.py +0 -0
  15. hypern/cli/commands.py +0 -0
  16. hypern/config.py +149 -0
  17. hypern/datastructures.py +27 -0
  18. hypern/db/__init__.py +0 -0
  19. hypern/db/nosql/__init__.py +25 -0
  20. hypern/db/nosql/addons/__init__.py +4 -0
  21. hypern/db/nosql/addons/color.py +16 -0
  22. hypern/db/nosql/addons/daterange.py +30 -0
  23. hypern/db/nosql/addons/encrypted.py +53 -0
  24. hypern/db/nosql/addons/password.py +134 -0
  25. hypern/db/nosql/addons/unicode.py +10 -0
  26. hypern/db/sql/__init__.py +176 -0
  27. hypern/db/sql/addons/__init__.py +14 -0
  28. hypern/db/sql/addons/color.py +15 -0
  29. hypern/db/sql/addons/daterange.py +22 -0
  30. hypern/db/sql/addons/datetime.py +22 -0
  31. hypern/db/sql/addons/encrypted.py +58 -0
  32. hypern/db/sql/addons/password.py +170 -0
  33. hypern/db/sql/addons/ts_vector.py +46 -0
  34. hypern/db/sql/addons/unicode.py +15 -0
  35. hypern/db/sql/repository.py +289 -0
  36. hypern/enum.py +13 -0
  37. hypern/exceptions.py +93 -0
  38. hypern/hypern.cpython-310-x86_64-linux-gnu.so +0 -0
  39. hypern/hypern.pyi +172 -0
  40. hypern/i18n/__init__.py +0 -0
  41. hypern/logging/__init__.py +3 -0
  42. hypern/logging/logger.py +91 -0
  43. hypern/middleware/__init__.py +5 -0
  44. hypern/middleware/base.py +16 -0
  45. hypern/middleware/cors.py +38 -0
  46. hypern/middleware/i18n.py +1 -0
  47. hypern/middleware/limit.py +174 -0
  48. hypern/openapi/__init__.py +5 -0
  49. hypern/openapi/schemas.py +64 -0
  50. hypern/openapi/swagger.py +3 -0
  51. hypern/py.typed +0 -0
  52. hypern/response/__init__.py +3 -0
  53. hypern/response/response.py +134 -0
  54. hypern/routing/__init__.py +4 -0
  55. hypern/routing/dispatcher.py +65 -0
  56. hypern/routing/endpoint.py +27 -0
  57. hypern/routing/parser.py +101 -0
  58. hypern/routing/router.py +279 -0
  59. hypern/scheduler.py +5 -0
  60. hypern/security.py +44 -0
  61. hypern/worker.py +30 -0
  62. hypern-0.1.0.dist-info/METADATA +121 -0
  63. hypern-0.1.0.dist-info/RECORD +65 -0
  64. hypern-0.1.0.dist-info/WHEEL +4 -0
  65. hypern-0.1.0.dist-info/licenses/LICENSE +24 -0
hypern/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .application import Hypern
2
+ from robyn import Response, Request, jsonify
3
+
4
+ __all__ = ["Hypern", "Response", "Request", "jsonify"]
hypern/application.py ADDED
@@ -0,0 +1,234 @@
1
+ # -*- coding: utf-8 -*-
2
+ from typing import Any, List, TypeVar
3
+ from typing_extensions import Annotated, Doc
4
+ from robyn import Robyn
5
+ import orjson
6
+
7
+ from hypern.openapi import SwaggerUI, SchemaGenerator
8
+ from hypern.routing import Route
9
+ from hypern.response import JSONResponse
10
+ from hypern.logging import reset_logger
11
+ from hypern.datastructures import Contact, License, Info
12
+ from hypern.scheduler import Scheduler
13
+
14
+ reset_logger()
15
+
16
+ AppType = TypeVar("AppType", bound="Hypern")
17
+
18
+
19
+ class Hypern(Robyn):
20
+ def __init__(
21
+ self: AppType,
22
+ routes: Annotated[
23
+ List[Route] | None,
24
+ Doc(
25
+ """
26
+ A list of routes to serve incoming HTTP and WebSocket requests.
27
+ You can define routes using the `Route` class from `Hypern.routing`.
28
+ **Example**
29
+ ---
30
+ ```python
31
+ class DefaultRoute(HTTPEndpoint):
32
+ async def get(self, global_dependencies):
33
+ return PlainTextResponse("/hello")
34
+ Route("/test", DefaultRoute)
35
+
36
+ # Or you can define routes using the decorator
37
+ route = Route("/test)
38
+ @route.get("/route")
39
+ def def_get():
40
+ return PlainTextResponse("Hello")
41
+ ```
42
+ """
43
+ ),
44
+ ] = None,
45
+ title: Annotated[
46
+ str,
47
+ Doc(
48
+ """
49
+ The title of the API.
50
+
51
+ It will be added to the generated OpenAPI (e.g. visible at `/docs`).
52
+
53
+ Read more in the
54
+ """
55
+ ),
56
+ ] = "HyperN",
57
+ summary: Annotated[
58
+ str | None,
59
+ Doc(
60
+ """"
61
+ A short summary of the API.
62
+
63
+ It will be added to the generated OpenAPI (e.g. visible at `/docs`).
64
+ """
65
+ ),
66
+ ] = None,
67
+ description: Annotated[
68
+ str,
69
+ Doc(
70
+ """
71
+ A description of the API. Supports Markdown (using
72
+ [CommonMark syntax](https://commonmark.org/)).
73
+
74
+ It will be added to the generated OpenAPI (e.g. visible at `/docs`).
75
+ """
76
+ ),
77
+ ] = "",
78
+ version: Annotated[
79
+ str,
80
+ Doc(
81
+ """
82
+ The version of the API.
83
+
84
+ **Note** This is the version of your application, not the version of
85
+ the OpenAPI specification nor the version of Application being used.
86
+
87
+ It will be added to the generated OpenAPI (e.g. visible at `/docs`).
88
+ """
89
+ ),
90
+ ] = "0.0.1",
91
+ contact: Annotated[
92
+ Contact | None,
93
+ Doc(
94
+ """
95
+ A dictionary with the contact information for the exposed API.
96
+
97
+ It can contain several fields.
98
+
99
+ * `name`: (`str`) The name of the contact person/organization.
100
+ * `url`: (`str`) A URL pointing to the contact information. MUST be in
101
+ the format of a URL.
102
+ * `email`: (`str`) The email address of the contact person/organization.
103
+ MUST be in the format of an email address.
104
+ """
105
+ ),
106
+ ] = None,
107
+ openapi_url: Annotated[
108
+ str | None,
109
+ Doc(
110
+ """
111
+ The URL where the OpenAPI schema will be served from.
112
+
113
+ If you set it to `None`, no OpenAPI schema will be served publicly, and
114
+ the default automatic endpoints `/docs` and `/redoc` will also be
115
+ disabled.
116
+ """
117
+ ),
118
+ ] = "/openapi.json",
119
+ docs_url: Annotated[
120
+ str | None,
121
+ Doc(
122
+ """
123
+ The path to the automatic interactive API documentation.
124
+ It is handled in the browser by Swagger UI.
125
+
126
+ The default URL is `/docs`. You can disable it by setting it to `None`.
127
+
128
+ If `openapi_url` is set to `None`, this will be automatically disabled.
129
+ """
130
+ ),
131
+ ] = "/docs",
132
+ license_info: Annotated[
133
+ License | None,
134
+ Doc(
135
+ """
136
+ A dictionary with the license information for the exposed API.
137
+
138
+ It can contain several fields.
139
+
140
+ * `name`: (`str`) **REQUIRED** (if a `license_info` is set). The
141
+ license name used for the API.
142
+ * `identifier`: (`str`) An [SPDX](https://spdx.dev/) license expression
143
+ for the API. The `identifier` field is mutually exclusive of the `url`
144
+ field. Available since OpenAPI 3.1.0
145
+ * `url`: (`str`) A URL to the license used for the API. This MUST be
146
+ the format of a URL.
147
+
148
+ It will be added to the generated OpenAPI (e.g. visible at `/docs`).
149
+
150
+ **Example**
151
+
152
+ ```python
153
+ app = HyperN(
154
+ license_info={
155
+ "name": "Apache 2.0",
156
+ "url": "https://www.apache.org/licenses/LICENSE-2.0.html",
157
+ }
158
+ )
159
+ ```
160
+ """
161
+ ),
162
+ ] = None,
163
+ scheduler: Annotated[
164
+ Scheduler | None,
165
+ Doc(
166
+ """
167
+ A scheduler to run background tasks.
168
+ """
169
+ ),
170
+ ] = None,
171
+ *args: Any,
172
+ **kwargs: Any,
173
+ ) -> None:
174
+ super().__init__(__file__, *args, **kwargs)
175
+ self.scheduler = scheduler
176
+
177
+ for route in routes:
178
+ self.router.routes.extend(route(self).get_routes())
179
+
180
+ if openapi_url and docs_url:
181
+ self.add_openapi(
182
+ info=Info(
183
+ title=title,
184
+ summary=summary,
185
+ description=description,
186
+ version=version,
187
+ contact=contact,
188
+ license=license_info,
189
+ ),
190
+ openapi_url=openapi_url,
191
+ docs_url=docs_url,
192
+ )
193
+
194
+ def add_openapi(
195
+ self,
196
+ info: Info,
197
+ openapi_url: str,
198
+ docs_url: str,
199
+ ):
200
+ @self.get(openapi_url)
201
+ def schema():
202
+ schemas = SchemaGenerator(
203
+ {
204
+ "openapi": "3.0.0",
205
+ "info": info.model_dump(),
206
+ "components": {"securitySchemes": {}},
207
+ }
208
+ )
209
+ return JSONResponse(content=orjson.dumps(schemas.get_schema(self)))
210
+
211
+ @self.get(docs_url)
212
+ def template_render():
213
+ swagger = SwaggerUI(
214
+ title="Swagger",
215
+ openapi_url=openapi_url,
216
+ )
217
+ template = swagger.render_template()
218
+ return template
219
+
220
+ def add_middleware(self, middleware):
221
+ setattr(middleware, "app", self)
222
+ before_request = getattr(middleware, "before_request", None)
223
+ after_request = getattr(middleware, "after_request", None)
224
+ endpoint = getattr(middleware, "endpoint", None)
225
+ if before_request:
226
+ self.before_request(endpoint=endpoint)(before_request)
227
+ if after_request:
228
+ self.after_request(endpoint=endpoint)(after_request)
229
+ return self
230
+
231
+ def start(self, host: str = "127.0.0.1", port: int = 8080, _check_port: bool = True):
232
+ if self.scheduler:
233
+ self.scheduler.start()
234
+ return super().start(host, port, _check_port)
File without changes
@@ -0,0 +1,2 @@
1
+ class Authorization:
2
+ pass
hypern/background.py ADDED
@@ -0,0 +1,4 @@
1
+ from .hypern import BackgroundTask
2
+ from .hypern import BackgroundTasks
3
+
4
+ __all__ = ["BackgroundTask", "BackgroundTasks"]
File without changes
@@ -0,0 +1,8 @@
1
+ # -*- coding: utf-8 -*-
2
+ from .backend import BaseBackend
3
+ from .key_maker import BaseKeyMaker
4
+
5
+ __all__ = [
6
+ "BaseKeyMaker",
7
+ "BaseBackend",
8
+ ]
@@ -0,0 +1,3 @@
1
+ from hypern.hypern import BaseBackend
2
+
3
+ __all__ = ["BaseBackend"]
@@ -0,0 +1,8 @@
1
+ # -*- coding: utf-8 -*-
2
+ from abc import ABC, abstractmethod
3
+ from typing import Callable
4
+
5
+
6
+ class BaseKeyMaker(ABC):
7
+ @abstractmethod
8
+ async def make(self, function: Callable, prefix: str, identify_key: str) -> str: ...
@@ -0,0 +1,56 @@
1
+ # -*- coding: utf-8 -*-
2
+ from functools import wraps
3
+ from typing import Callable, Dict, Type
4
+
5
+ from .base import BaseBackend, BaseKeyMaker
6
+ from .cache_tag import CacheTag
7
+ import orjson
8
+
9
+
10
+ class CacheManager:
11
+ def __init__(self):
12
+ self.backend = None
13
+ self.key_maker = None
14
+
15
+ def init(self, backend: BaseBackend, key_maker: BaseKeyMaker) -> None:
16
+ self.backend = backend
17
+ self.key_maker = key_maker
18
+
19
+ def cached(self, tag: CacheTag, ttl: int = 60, identify: Dict = {}) -> Type[Callable]:
20
+ def _cached(function):
21
+ @wraps(function)
22
+ async def __cached(*args, **kwargs):
23
+ if not self.backend or not self.key_maker:
24
+ raise ValueError("Backend or KeyMaker not initialized")
25
+
26
+ _identify_key = []
27
+ for key, values in identify.items():
28
+ _obj = kwargs.get(key, None)
29
+ if not _obj:
30
+ raise ValueError(f"Caching: Identify key {key} not found in kwargs")
31
+ for attr in values:
32
+ _identify_key.append(f"{attr}={getattr(_obj, attr)}")
33
+ _identify_key = ":".join(_identify_key)
34
+
35
+ key = await self.key_maker.make(function=function, prefix=tag.value, identify_key=_identify_key)
36
+
37
+ cached_response = self.backend.get(key=key)
38
+ if cached_response:
39
+ return orjson.loads(cached_response)
40
+
41
+ response = await function(*args, **kwargs)
42
+ self.backend.set(response=orjson.dumps(response).decode("utf-8"), key=key, ttl=ttl)
43
+ return response
44
+
45
+ return __cached
46
+
47
+ return _cached # type: ignore
48
+
49
+ async def remove_by_tag(self, tag: CacheTag) -> None:
50
+ await self.backend.delete_startswith(value=tag.value)
51
+
52
+ async def remove_by_prefix(self, prefix: str) -> None:
53
+ await self.backend.delete_startswith(value=prefix)
54
+
55
+
56
+ Cache = CacheManager()
@@ -0,0 +1,10 @@
1
+ # -*- coding: utf-8 -*-
2
+ from enum import Enum
3
+
4
+
5
+ class CacheTag(Enum):
6
+ GET_HEALTH_CHECK = "get_health_check"
7
+ GET_USER_INFO = "get_user_info"
8
+ GET_CATEGORIES = "get_categories"
9
+ GET_HISTORY = "get_chat_history"
10
+ GET_QUESTION = "get_question"
@@ -0,0 +1,11 @@
1
+ # -*- coding: utf-8 -*-
2
+ from typing import Callable
3
+ import inspect
4
+
5
+ from hypern.caching.base import BaseKeyMaker
6
+
7
+
8
+ class CustomKeyMaker(BaseKeyMaker):
9
+ async def make(self, function: Callable, prefix: str, identify_key: str = "") -> str:
10
+ path = f"{prefix}:{inspect.getmodule(function).__name__}.{function.__name__}:{identify_key}" # type: ignore
11
+ return str(path)
@@ -0,0 +1,3 @@
1
+ from hypern.hypern import RedisBackend
2
+
3
+ __all__ = ["RedisBackend"]
hypern/cli/__init__.py ADDED
File without changes
hypern/cli/commands.py ADDED
File without changes
hypern/config.py ADDED
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import typing
5
+ import warnings
6
+ from pathlib import Path
7
+
8
+ """
9
+
10
+ refer: https://github.com/encode/starlette/blob/master/starlette/config.py
11
+ # Config will be read from environment variables and/or ".env" files.
12
+ config = Config(".env")
13
+
14
+ DEBUG = config('DEBUG', cast=bool, default=False)
15
+ DATABASE_URL = config('DATABASE_URL')
16
+ ALLOWED_HOSTS = config('ALLOWED_HOSTS', cast=CommaSeparatedStrings)
17
+ """
18
+
19
+
20
+ class undefined:
21
+ pass
22
+
23
+
24
+ class EnvironError(Exception):
25
+ pass
26
+
27
+
28
+ class Environ(typing.MutableMapping[str, str]):
29
+ def __init__(self, environ: typing.MutableMapping[str, str] = os.environ):
30
+ self._environ = environ
31
+ self._has_been_read: set[str] = set()
32
+
33
+ def __getitem__(self, key: str) -> str:
34
+ self._has_been_read.add(key)
35
+ return self._environ.__getitem__(key)
36
+
37
+ def __setitem__(self, key: str, value: str) -> None:
38
+ if key in self._has_been_read:
39
+ raise EnvironError(f"Attempting to set environ['{key}'], but the value has already been read.")
40
+ self._environ.__setitem__(key, value)
41
+
42
+ def __delitem__(self, key: str) -> None:
43
+ if key in self._has_been_read:
44
+ raise EnvironError(f"Attempting to delete environ['{key}'], but the value has already been read.")
45
+ self._environ.__delitem__(key)
46
+
47
+ def __iter__(self) -> typing.Iterator[str]:
48
+ return iter(self._environ)
49
+
50
+ def __len__(self) -> int:
51
+ return len(self._environ)
52
+
53
+
54
+ environ = Environ()
55
+
56
+ T = typing.TypeVar("T")
57
+
58
+
59
+ class Config:
60
+ def __init__(
61
+ self,
62
+ env_file: str | Path | None = None,
63
+ environ: typing.Mapping[str, str] = environ,
64
+ env_prefix: str = "",
65
+ ) -> None:
66
+ self.environ = environ
67
+ self.env_prefix = env_prefix
68
+ self.file_values: dict[str, str] = {}
69
+ if env_file is not None:
70
+ if not os.path.isfile(env_file):
71
+ warnings.warn(f"Config file '{env_file}' not found.")
72
+ else:
73
+ self.file_values = self._read_file(env_file)
74
+
75
+ @typing.overload
76
+ def __call__(self, key: str, *, default: None) -> str | None: ...
77
+
78
+ @typing.overload
79
+ def __call__(self, key: str, cast: type[T], default: T = ...) -> T: ...
80
+
81
+ @typing.overload
82
+ def __call__(self, key: str, cast: type[str] = ..., default: str = ...) -> str: ...
83
+
84
+ @typing.overload
85
+ def __call__(
86
+ self,
87
+ key: str,
88
+ cast: typing.Callable[[typing.Any], T] = ...,
89
+ default: typing.Any = ...,
90
+ ) -> T: ...
91
+
92
+ @typing.overload
93
+ def __call__(self, key: str, cast: type[str] = ..., default: T = ...) -> T | str: ...
94
+
95
+ def __call__(
96
+ self,
97
+ key: str,
98
+ cast: typing.Callable[[typing.Any], typing.Any] | None = None,
99
+ default: typing.Any = undefined,
100
+ ) -> typing.Any:
101
+ return self.get(key, cast, default)
102
+
103
+ def get(
104
+ self,
105
+ key: str,
106
+ cast: typing.Callable[[typing.Any], typing.Any] | None = None,
107
+ default: typing.Any = undefined,
108
+ ) -> typing.Any:
109
+ key = self.env_prefix + key
110
+ if key in self.environ:
111
+ value = self.environ[key]
112
+ return self._perform_cast(key, value, cast)
113
+ if key in self.file_values:
114
+ value = self.file_values[key]
115
+ return self._perform_cast(key, value, cast)
116
+ if default is not undefined:
117
+ return self._perform_cast(key, default, cast)
118
+ raise KeyError(f"Config '{key}' is missing, and has no default.")
119
+
120
+ def _read_file(self, file_name: str | Path) -> dict[str, str]:
121
+ file_values: dict[str, str] = {}
122
+ with open(file_name) as input_file:
123
+ for line in input_file.readlines():
124
+ line = line.strip()
125
+ if "=" in line and not line.startswith("#"):
126
+ key, value = line.split("=", 1)
127
+ key = key.strip()
128
+ value = value.strip().strip("\"'")
129
+ file_values[key] = value
130
+ return file_values
131
+
132
+ def _perform_cast(
133
+ self,
134
+ key: str,
135
+ value: typing.Any,
136
+ cast: typing.Callable[[typing.Any], typing.Any] | None = None,
137
+ ) -> typing.Any:
138
+ if cast is None or value is None:
139
+ return value
140
+ elif cast is bool and isinstance(value, str):
141
+ mapping = {"true": True, "1": True, "false": False, "0": False}
142
+ value = value.lower()
143
+ if value not in mapping:
144
+ raise ValueError(f"Config '{key}' has value '{value}'. Not a valid bool.")
145
+ return mapping[value]
146
+ try:
147
+ return cast(value)
148
+ except (TypeError, ValueError):
149
+ raise ValueError(f"Config '{key}' has value '{value}'. Not a valid {cast.__name__}.")
@@ -0,0 +1,27 @@
1
+ from typing import Optional
2
+ from pydantic import BaseModel, AnyUrl, EmailStr
3
+
4
+
5
+ class BaseModelWithConfig(BaseModel):
6
+ model_config = {"extra": "allow"}
7
+
8
+
9
+ class Contact(BaseModelWithConfig):
10
+ name: Optional[str] = None
11
+ url: Optional[AnyUrl] = None
12
+ email: Optional[EmailStr] = None
13
+
14
+
15
+ class License(BaseModelWithConfig):
16
+ name: str
17
+ identifier: Optional[str] = None
18
+ url: Optional[AnyUrl] = None
19
+
20
+
21
+ class Info(BaseModelWithConfig):
22
+ title: str
23
+ summary: Optional[str] = None
24
+ description: Optional[str] = None
25
+ contact: Optional[Contact] = None
26
+ license: Optional[License] = None
27
+ version: str
hypern/db/__init__.py ADDED
File without changes
@@ -0,0 +1,25 @@
1
+ # -*- coding: utf-8 -*-
2
+ from typing import List, TypedDict
3
+
4
+ from uuid import uuid4
5
+ from mongoengine import connect
6
+
7
+
8
+ class TypedDictModel(TypedDict):
9
+ host: str
10
+ alias: str
11
+
12
+
13
+ class NoSqlConfig:
14
+ def __init__(self, dbs_config: List[TypedDictModel]):
15
+ self.dbs_config = dbs_config
16
+
17
+ def _connect_db(self, db_config: TypedDictModel):
18
+ _alias = db_config.get("alias", str(uuid4()))
19
+ connect(host=db_config["host"], alias=_alias)
20
+
21
+ def init_app(self, app):
22
+ self.app = app # noqa
23
+ # connect
24
+ for db_config in self.dbs_config:
25
+ self._connect_db(db_config)
@@ -0,0 +1,4 @@
1
+ from .encrypted import EncryptedField
2
+ from .password import PasswordField
3
+
4
+ __all__ = ["EncryptedField", "PasswordField"]
@@ -0,0 +1,16 @@
1
+ from mongoengine import BaseField
2
+ import re
3
+
4
+
5
+ class ColorField(BaseField):
6
+ def validate(self, value):
7
+ color_regex = r"^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
8
+ if not re.match(color_regex, value):
9
+ self.error("Invalid color format. Use hexadecimal color codes (e.g., #FF0000)")
10
+ return True
11
+
12
+ def to_mongo(self, value):
13
+ return value
14
+
15
+ def to_python(self, value):
16
+ return value
@@ -0,0 +1,30 @@
1
+ from mongoengine import BaseField
2
+ from datetime import datetime
3
+
4
+
5
+ class DateRangeField(BaseField):
6
+ def __init__(self, **kwargs):
7
+ super(DateRangeField, self).__init__(**kwargs)
8
+
9
+ def validate(self, value):
10
+ if not isinstance(value, dict) or "start" not in value or "end" not in value:
11
+ self.error('DateRangeField must be a dictionary with "start" and "end" keys')
12
+ # Use get to safely access keys
13
+ start = value.get("start")
14
+ end = value.get("end")
15
+ # Check if both "start" and "end" are present
16
+ if start is None or end is None:
17
+ self.error('DateRangeField must contain both "start" and "end" keys')
18
+
19
+ # Check if "start" and "end" are datetime objects
20
+ if not isinstance(value["start"], datetime) or not isinstance(value["end"], datetime):
21
+ self.error('DateRangeField "start" and "end" must be datetime objects')
22
+ if value["start"] > value["end"]:
23
+ self.error('DateRangeField "start" must be earlier than "end"')
24
+ return True
25
+
26
+ def to_mongo(self, value):
27
+ return value
28
+
29
+ def to_python(self, value):
30
+ return value
@@ -0,0 +1,53 @@
1
+ import os
2
+ from typing import Any, Optional
3
+ from mongoengine.base import BaseField
4
+
5
+ from cryptography.hazmat.primitives import padding
6
+
7
+ from hypern.security import EDEngine, AESEngine
8
+
9
+
10
+ class EncryptedField(BaseField):
11
+ """
12
+ A custom MongoEngine field that encrypts data using AES-256-CBC.
13
+
14
+ The field automatically handles encryption when saving to MongoDB and
15
+ decryption when retrieving data.
16
+
17
+ Attributes:
18
+ engine: Encryption engine to use. If not provided, will use AES-256-CBC
19
+ """
20
+
21
+ def __init__(self, engine: Optional[EDEngine] = None, **kwargs):
22
+ if not engine:
23
+ key = os.urandom(32)
24
+ iv = os.urandom(16)
25
+ padding_class = padding.PKCS7
26
+ self.engine = AESEngine(secret_key=key, iv=iv, padding_class=padding_class)
27
+ else:
28
+ self.engine = engine # type: ignore
29
+ super(EncryptedField, self).__init__(**kwargs)
30
+
31
+ def to_mongo(self, value: Any) -> Optional[str]:
32
+ """Convert a Python object to a MongoDB-compatible format."""
33
+ if value is None:
34
+ return None
35
+ return self.engine.encrypt(value)
36
+
37
+ def to_python(self, value: Optional[str]) -> Optional[str]:
38
+ """Convert a MongoDB-compatible format to a Python object."""
39
+ if value is None:
40
+ return None
41
+ if isinstance(value, bytes):
42
+ return self.engine.decrypt(value)
43
+ return value
44
+
45
+ def prepare_query_value(self, op, value: Any) -> Optional[str]:
46
+ """Prepare a value used in a query."""
47
+ if value is None:
48
+ return None
49
+
50
+ if op in ("set", "upsert"):
51
+ return self.to_mongo(value)
52
+
53
+ return value