Skip to content

Commit 20e2170

Browse files
committed
errors templates
1 parent 7156c66 commit 20e2170

File tree

2 files changed

+99
-11
lines changed

2 files changed

+99
-11
lines changed

packages/common-library/src/common_library/errors_classes.py

+44
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,47 @@ def error_context(self) -> dict[str, Any]:
5252
def error_code(self) -> str:
5353
assert isinstance(self, Exception), "subclass must be exception" # nosec
5454
return create_error_code(self)
55+
56+
57+
class BaseOsparcError(OsparcErrorMixin, Exception): ...
58+
59+
60+
class NotFoundError(BaseOsparcError):
61+
msg_template = "{resource} not found: id='{resource_id}'"
62+
63+
64+
class ForbiddenError(BaseOsparcError):
65+
msg_template = "Access to {resource} is forbidden: id='{resource_id}'"
66+
67+
68+
def make_resource_error(
69+
resource: str,
70+
error_cls: type[BaseOsparcError],
71+
base_exception: type[Exception] = Exception,
72+
) -> type[BaseOsparcError]:
73+
"""
74+
Factory function to create a custom error class for a specific resource.
75+
76+
This function dynamically generates an error class that inherits from the provided
77+
`error_cls` and optionally a `base_exception`. The generated error class automatically
78+
includes the resource name and resource ID in its context and message.
79+
80+
See usage examples in test_errors_classes.py
81+
82+
LIMITATIONS: for the moment, exceptions produces with this factory cannot be serialized with pickle.
83+
And therefore it cannot be used as exception of RabbitMQ-RPC interface
84+
"""
85+
86+
class _ResourceError(error_cls, base_exception):
87+
def __init__(self, **ctx: Any):
88+
ctx.setdefault("resource", resource)
89+
90+
# guesses identifer e.g. project_id, user_id
91+
if resource_id := ctx.get(f"{resource.lower()}_id"):
92+
ctx.setdefault("resource_id", resource_id)
93+
94+
super().__init__(**ctx)
95+
96+
resource_class_name = "".join(word.capitalize() for word in resource.split("_"))
97+
_ResourceError.__name__ = f"{resource_class_name}{error_cls.__name__}"
98+
return _ResourceError

packages/common-library/tests/test_errors_classes.py

+55-11
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,24 @@
99
from typing import Any
1010

1111
import pytest
12-
from common_library.errors_classes import OsparcErrorMixin
12+
from common_library.errors_classes import (
13+
ForbiddenError,
14+
NotFoundError,
15+
OsparcErrorMixin,
16+
make_resource_error,
17+
)
1318

1419

1520
def test_get_full_class_name():
16-
class A(OsparcErrorMixin):
17-
...
21+
class A(OsparcErrorMixin): ...
1822

19-
class B1(A):
20-
...
23+
class B1(A): ...
2124

22-
class B2(A):
23-
...
25+
class B2(A): ...
2426

25-
class C(B2):
26-
...
27+
class C(B2): ...
2728

28-
class B12(B1, ValueError):
29-
...
29+
class B12(B1, ValueError): ...
3030

3131
assert B1._get_full_class_name() == "A.B1"
3232
assert C._get_full_class_name() == "A.B2.C"
@@ -159,3 +159,47 @@ class MyError(OsparcErrorMixin, ValueError):
159159
"message": "42 and 'missing=?'",
160160
"value": 42,
161161
}
162+
163+
164+
def test_resource_error_factory():
165+
ProjectNotFoundError = make_resource_error("project", NotFoundError)
166+
167+
error_1 = ProjectNotFoundError(resource_id="abc123")
168+
assert "resource_id" in error_1.error_context()
169+
assert error_1.resource_id in error_1.message # type: ignore
170+
171+
172+
def test_resource_error_factory_auto_detect_resource_id():
173+
ProjectForbiddenError = make_resource_error("project", ForbiddenError)
174+
error_2 = ProjectForbiddenError(project_id="abc123", other_id="foo")
175+
assert (
176+
error_2.resource_id == error_2.project_id # type: ignore
177+
), "auto-detects project ids as resourceid"
178+
assert error_2.other_id # type: ignore
179+
assert error_2.code == "BaseOsparcError.ForbiddenError.ProjectForbiddenError"
180+
181+
assert error_2.error_context() == {
182+
"project_id": "abc123",
183+
"other_id": "foo",
184+
"resource": "project",
185+
"resource_id": "abc123",
186+
"message": "Access to project is forbidden: id='abc123'",
187+
"code": "BaseOsparcError.ForbiddenError.ProjectForbiddenError",
188+
}
189+
190+
191+
def test_resource_error_factory_different_base_exception():
192+
193+
class MyServiceError(Exception): ...
194+
195+
OtherProjectForbiddenError = make_resource_error(
196+
"other_project", ForbiddenError, MyServiceError
197+
)
198+
199+
assert issubclass(OtherProjectForbiddenError, MyServiceError)
200+
201+
error_3 = OtherProjectForbiddenError(project_id="abc123")
202+
assert (
203+
error_3.code
204+
== "MyServiceError.BaseOsparcError.ForbiddenError.OtherProjectForbiddenError"
205+
)

0 commit comments

Comments
 (0)