Skip to content

Commit 50cd541

Browse files
tests passed
1 parent 876a965 commit 50cd541

74 files changed

Lines changed: 693 additions & 759 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/application/repository/interfaces.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Record repository interface."""
22

33
from abc import ABC, abstractmethod
4-
from typing import List, Optional
4+
from typing import List
55

66
from app.domain.entities.sample_record import SampleRecord
77

@@ -34,20 +34,15 @@ async def update(self, record: SampleRecord) -> SampleRecord:
3434
pass
3535

3636
@abstractmethod
37-
async def delete(self, record_id: int) -> int:
37+
async def delete(self, record_id: int) -> None:
3838
"""
3939
Delete a record from the database by provided id
4040
4141
Parameters:
4242
record_id: The unique identifier of the record to be deleted.
4343
4444
Returns:
45-
None
46-
47-
This method does not return any value.
48-
49-
Raises:
50-
NotImplementedError: If this method is not overridden in the implementing class.
45+
An id of the deleted record
5146
"""
5247
pass
5348

app/application/service/interfaces.py

Lines changed: 0 additions & 8 deletions
This file was deleted.

app/application/use_cases/healthcheck.py

Lines changed: 0 additions & 25 deletions
This file was deleted.

app/application/use_cases/interfaces.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55
from app.presentation.bot.schemas.sample_record import (
66
SampleRecordCreateRequestSchema,
7+
SampleRecordResponseListSchema,
78
SampleRecordResponseSchema,
89
SampleRecordUpdateRequestSchema,
9-
SampleRecordResponseListSchema,
1010
)
1111

1212

app/application/use_cases/record_use_cases.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
from app.domain.entities.sample_record import SampleRecord
66
from app.presentation.bot.schemas.sample_record import (
77
SampleRecordCreateRequestSchema,
8+
SampleRecordResponseListSchema,
89
SampleRecordResponseSchema,
910
SampleRecordUpdateRequestSchema,
10-
SampleRecordResponseListSchema,
1111
)
1212

1313

@@ -34,9 +34,9 @@ async def update_record(
3434
updated_record = await self._repo.update(domain_object)
3535
return SampleRecordResponseSchema.from_orm(updated_record)
3636

37-
async def delete_record(self, record_id: int) -> int:
37+
async def delete_record(self, record_id: int) -> None:
3838
"""Delete a record."""
39-
return await self._repo.delete(record_id)
39+
await self._repo.delete(record_id)
4040

4141
async def get_record(self, record_id: int) -> SampleRecordResponseSchema:
4242
"""Get a record by ID."""
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ def _get_user_message(
1919
return exception_message_to_user
2020

2121

22-
def explain_exception_to_user( # noqa: WPS231
23-
mapping: dict[type[Exception], str | Callable[[Exception], str]], # noqa: WPS221
22+
def explain_exception_to_user(
23+
mapping: dict[type[Exception], str | Callable[[Exception], str]],
2424
) -> Callable:
2525
"""
2626
Decorate a function to catch specified exceptions and send a response to the user.
@@ -43,10 +43,10 @@ async def wrapper(bot: Bot, *args, **kwargs) -> Any: # type: ignore
4343
*args,
4444
**kwargs,
4545
)
46-
except tuple(mapping.keys()) as exc: # noqa: WPS455
46+
except tuple(mapping.keys()) as exc:
4747
if (message := _get_user_message(mapping, exc)) is not None:
4848
await bot.answer_message(message)
49-
raise # noqa: WPS220
49+
raise
5050

5151
return wrapper
5252

app/decorators/exception_mapper.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Decorators to rethrow and log exceptions."""
2+
3+
from abc import ABC, abstractmethod
4+
from functools import cached_property, wraps
5+
from inspect import iscoroutinefunction
6+
from typing import Any, Callable, Type
7+
8+
from cachetools import LRUCache # type:ignore
9+
10+
from app.logger import logger
11+
12+
13+
class ExceptionContext:
14+
SENSITIVE_KEYS: frozenset[str] = frozenset(
15+
("password", "token", "key", "secret", "auth", "credential", "passwd")
16+
)
17+
18+
def __init__(
19+
self,
20+
original_exception: Exception,
21+
func: Callable,
22+
args: tuple[Any, ...],
23+
kwargs: dict[str, Any],
24+
):
25+
self.original_exception = original_exception
26+
self.func = func
27+
self.args = args
28+
self.kwargs = kwargs
29+
30+
@cached_property
31+
def formatted_context(self) -> str:
32+
error_context = [
33+
f"Error in function '{self.func.__module__}.{self.func.__qualname__}'"
34+
]
35+
36+
if self.args:
37+
args_str = ", ".join(self._sanitised_value(arg) for arg in self.args)
38+
error_context.append(f"Args: [{args_str}]")
39+
40+
if self.kwargs:
41+
kwargs_str = ", ".join(
42+
f"{k}={self._sanitised_value(v, k)}" for k, v in self.kwargs.items()
43+
)
44+
error_context.append(f"Kwargs: {kwargs_str}")
45+
46+
return "\n".join(error_context).replace("{", "{{").replace("}", "}}")
47+
48+
def _sanitised_value(
49+
self,
50+
value: Any,
51+
key: str | None = None,
52+
) -> str:
53+
if key is not None and key.lower() in self.SENSITIVE_KEYS:
54+
return "****HIDDEN****"
55+
56+
try:
57+
str_value = str(value)
58+
return f"{str_value[:100]}..." if len(str_value) > 100 else str_value
59+
except Exception:
60+
return f"<{type(value).__name__} object - str() failed>"
61+
62+
63+
class ExceptionFactory(ABC):
64+
"""
65+
Create and describe a factory for exceptions.
66+
67+
This class is an abstract base class meant to define the interface for an
68+
exception factory.
69+
70+
"""
71+
72+
@abstractmethod
73+
def make_exception(self, context: ExceptionContext) -> Exception:
74+
"""Make an exception based on the given context."""
75+
76+
77+
class EnrichedExceptionFactory(ExceptionFactory):
78+
"""
79+
Create and manage enriched exceptions based on a given exception type.
80+
81+
This class provides a mechanism to create exceptions dynamically,
82+
enriching them with a formatted context. It extends the behavior of
83+
the base ExceptionFactory class by incorporating the concept of a
84+
generated error type and formatted context.
85+
86+
:ivar generated_error: The type of exception to generate when creating
87+
an enriched exception.
88+
:type generated_error: type[Exception]
89+
"""
90+
91+
def __init__(self, generated_error: type[Exception]):
92+
self.generated_error = generated_error
93+
94+
def make_exception(self, context: ExceptionContext) -> Exception:
95+
return self.generated_error(context.formatted_context)
96+
97+
98+
ExceptionOrTupleOfExceptions = Type[Exception] | tuple[Type[Exception], ...]
99+
100+
101+
class ExceptionMapper:
102+
"""Exception-mapping decorator with bounded LRU caching and dynamic MRO lookup."""
103+
104+
def __init__(
105+
self,
106+
exception_map: dict[ExceptionOrTupleOfExceptions, ExceptionFactory],
107+
max_cache_size: int = 512,
108+
log_error: bool = True,
109+
is_bound_method: bool = False,
110+
):
111+
self.mapping = self._get_flat_map(exception_map)
112+
self.exception_catchall_factory = self.mapping.pop(Exception, None)
113+
self._lru_cache: LRUCache = LRUCache(maxsize=max_cache_size)
114+
self.log_error = log_error
115+
self.is_bound_method = is_bound_method
116+
117+
def __call__(self, func: Callable) -> Callable:
118+
return (
119+
self._async_wrapper(func)
120+
if iscoroutinefunction(func)
121+
else self._sync_wrapper(func)
122+
)
123+
124+
def _get_flat_map(
125+
self,
126+
exception_map: dict[ExceptionOrTupleOfExceptions, ExceptionFactory],
127+
) -> dict[Type[Exception], ExceptionFactory]:
128+
flat_map: dict[Type[Exception], ExceptionFactory] = {}
129+
for exception_class, factory in exception_map.items():
130+
if isinstance(exception_class, tuple):
131+
for exc_type in exception_class:
132+
flat_map[exc_type] = factory
133+
else:
134+
flat_map[exception_class] = factory
135+
return flat_map
136+
137+
def _async_wrapper(self, func: Callable) -> Callable:
138+
@wraps(func)
139+
async def wrapper(*args: Any, **kwargs: Any) -> Any:
140+
try:
141+
return await func(*args, **kwargs)
142+
except Exception as exc:
143+
self._handle_exception_logic(exc, func, args, kwargs)
144+
145+
return wrapper
146+
147+
def _sync_wrapper(self, func: Callable) -> Callable:
148+
@wraps(func)
149+
def wrapper(*args: Any, **kwargs: Any) -> Any:
150+
try:
151+
return func(*args, **kwargs)
152+
except Exception as exc:
153+
self._handle_exception_logic(exc, func, args, kwargs)
154+
155+
return wrapper
156+
157+
def _filtered_args(self, args: tuple[Any, ...]) -> tuple[Any, ...]:
158+
return args[1:] if args and self.is_bound_method else args
159+
160+
def _handle_exception_logic(
161+
self,
162+
exc: Exception,
163+
func: Callable,
164+
args: tuple[Any, ...],
165+
kwargs: dict[str, Any],
166+
) -> None:
167+
context = ExceptionContext(exc, func, self._filtered_args(args), kwargs)
168+
if self.log_error:
169+
logger.error(context.formatted_context, exc_info=True)
170+
171+
if exception_factory := self._get_exception_factory(type(exc)):
172+
raise exception_factory.make_exception(context) from exc
173+
174+
raise exc
175+
176+
def _get_exception_factory(
177+
self, exc_type: Type[Exception]
178+
) -> ExceptionFactory | None:
179+
# Try to get from_cache
180+
if cached_factory := self._lru_cache.get(exc_type):
181+
return cached_factory
182+
183+
# Try to find exception parents in base mapping and put to cache if found
184+
for exc_class in exc_type.mro():
185+
if target_exception_factory := self.mapping.get(exc_class): # type:ignore
186+
self._lru_cache[exc_type] = target_exception_factory
187+
return target_exception_factory
188+
189+
# exception is not presented in base mapping, but Exception in base mapping
190+
if self.exception_catchall_factory:
191+
self._lru_cache[exc_type] = self.exception_catchall_factory
192+
return self.exception_catchall_factory
193+
194+
return None
Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010
FunctionType = TypeVar("FunctionType", bound=Callable[..., Any])
1111

12-
CatchExceptionClass = Union[Type[Exception], Tuple[Type[Exception], ...]] # noqa: WPS221
13-
T = TypeVar("T") # noqa:WPS111
14-
Decorator = Callable[[Callable[..., T]], Callable[..., T]] # noqa: WPS221
12+
CatchExceptionClass = Union[Type[Exception], Tuple[Type[Exception], ...]]
13+
T = TypeVar("T")
14+
Decorator = Callable[[Callable[..., T]], Callable[..., T]]
1515

1616

1717
def _get_error_message(
@@ -39,7 +39,7 @@ def _get_error_message(
3939

4040
error_context = [
4141
f"Error in function '{func.__module__}.{func.__qualname__}'",
42-
f"Original exception: {ex.__class__.__name__}: {str(ex)}", # noqa: WPS237
42+
f"Original exception: {ex.__class__.__name__}: {str(ex)}",
4343
]
4444

4545
filtered_args = args[1:] if args and inspect.ismethod(func) else args
@@ -49,10 +49,7 @@ def _get_error_message(
4949
error_context.append(f"Args: [{args_str}]")
5050

5151
if kwargs:
52-
kwargs_str = ", ".join(
53-
f"{k}={str(v)[:100]}" # noqa: WPS237, WPS221
54-
for k, v in kwargs.items() # noqa: WPS111
55-
)
52+
kwargs_str = ", ".join(f"{k}={str(v)[:100]}" for k, v in kwargs.items())
5653
error_context.append(f"Kwargs: {kwargs_str}")
5754

5855
return "\n".join(error_context)
@@ -68,7 +65,7 @@ def _create_sync_wrapper(
6865
"""Create a synchronous wrapper function for exception mapping."""
6966

7067
@wraps(func)
71-
def sync_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: WPS430
68+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
7269
try:
7370
return func(*args, **kwargs)
7471
except catch_exceptions as ex:
@@ -92,7 +89,7 @@ def _create_async_wrapper(
9289
"""Create an asynchronous wrapper function for exception mapping."""
9390

9491
@wraps(func)
95-
async def async_wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: WPS430
92+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
9693
try:
9794
return await func(*args, **kwargs)
9895
except catch_exceptions as ex:

app/domain/entities/healthcheck.py

Lines changed: 0 additions & 18 deletions
This file was deleted.

0 commit comments

Comments
 (0)