Skip to content

Commit dac655c

Browse files
authored
🎨 Unified construction of error classes (#5359)
1 parent 283e6b5 commit dac655c

File tree

31 files changed

+384
-194
lines changed

31 files changed

+384
-194
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from pydantic.errors import PydanticErrorMixin
2+
3+
4+
class OsparcErrorMixin(PydanticErrorMixin):
5+
def __new__(cls, *args, **kwargs):
6+
if not hasattr(cls, "code"):
7+
cls.code = cls._get_full_class_name()
8+
return super().__new__(cls, *args, **kwargs)
9+
10+
@classmethod
11+
def _get_full_class_name(cls) -> str:
12+
relevant_classes = [
13+
c.__name__
14+
for c in cls.__mro__[:-1]
15+
if c.__name__
16+
not in (
17+
"PydanticErrorMixin",
18+
"OsparcErrorMixin",
19+
"Exception",
20+
"BaseException",
21+
)
22+
]
23+
return ".".join(reversed(relevant_classes))

packages/models-library/tests/test_errors.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,14 @@
88
from pydantic import BaseModel, ValidationError, conint
99

1010

11-
class B(BaseModel):
12-
y: list[int]
13-
14-
15-
class A(BaseModel):
16-
x: conint(ge=2)
17-
b: B
11+
def test_pydantic_error_dict():
12+
class B(BaseModel):
13+
y: list[int]
1814

15+
class A(BaseModel):
16+
x: conint(ge=2)
17+
b: B
1918

20-
def test_pydantic_error_dict():
2119
with pytest.raises(ValidationError) as exc_info:
2220
A(x=-1, b={"y": [0, "wrong"]})
2321

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# pylint: disable=protected-access
2+
# pylint: disable=redefined-outer-name
3+
# pylint: disable=unused-argument
4+
# pylint: disable=unused-variable
5+
# pylint: disable=no-member
6+
7+
8+
from datetime import datetime
9+
from typing import Any
10+
11+
import pytest
12+
from models_library.errors_classes import OsparcErrorMixin
13+
14+
15+
def test_get_full_class_name():
16+
class A(OsparcErrorMixin):
17+
...
18+
19+
class B1(A):
20+
...
21+
22+
class B2(A):
23+
...
24+
25+
class C(B2):
26+
...
27+
28+
class B12(B1, ValueError):
29+
...
30+
31+
assert B1._get_full_class_name() == "A.B1"
32+
assert C._get_full_class_name() == "A.B2.C"
33+
assert A._get_full_class_name() == "A"
34+
35+
# diamond inheritance (not usual but supported)
36+
assert B12._get_full_class_name() == "ValueError.A.B1.B12"
37+
38+
39+
def test_error_codes_and_msg_template():
40+
class MyBaseError(OsparcErrorMixin, Exception):
41+
def __init__(self, **ctx: Any) -> None:
42+
super().__init__(**ctx) # Do not forget this for base exceptions!
43+
44+
class MyValueError(MyBaseError, ValueError):
45+
msg_template = "Wrong value {value}"
46+
47+
error = MyValueError(value=42)
48+
49+
assert error.code == "ValueError.MyBaseError.MyValueError"
50+
assert f"{error}" == "Wrong value 42"
51+
52+
class MyTypeError(MyBaseError, TypeError):
53+
code = "i_want_this"
54+
msg_template = "Wrong type {type}"
55+
56+
error = MyTypeError(type="int")
57+
58+
assert error.code == "i_want_this"
59+
assert f"{error}" == "Wrong type int"
60+
61+
62+
def test_error_msg_template_override():
63+
class MyError(OsparcErrorMixin, Exception):
64+
msg_template = "Wrong value {value}"
65+
66+
error_override_msg = MyError(msg_template="I want this message")
67+
assert str(error_override_msg) == "I want this message"
68+
69+
error = MyError(value=42)
70+
assert hasattr(error, "value")
71+
assert str(error) == f"Wrong value {error.value}"
72+
73+
74+
def test_error_msg_template_nicer_override():
75+
class MyError(OsparcErrorMixin, Exception):
76+
msg_template = "Wrong value {value}"
77+
78+
def __init__(self, msg=None, **ctx: Any) -> None:
79+
super().__init__(**ctx)
80+
# positional argument msg (if defined) overrides the msg_template
81+
if msg:
82+
self.msg_template = msg
83+
84+
error_override_msg = MyError("I want this message")
85+
assert str(error_override_msg) == "I want this message"
86+
87+
error = MyError(value=42)
88+
assert hasattr(error, "value")
89+
assert str(error) == f"Wrong value {error.value}"
90+
91+
92+
def test_error_with_constructor():
93+
class MyError(OsparcErrorMixin, ValueError):
94+
msg_template = "Wrong value {value}"
95+
96+
# handy e.g. autocompletion
97+
def __init__(self, *, my_value: int = 42, **extra):
98+
super().__init__(**extra)
99+
self.value = my_value
100+
101+
error = MyError(my_value=33, something_else="yes")
102+
assert error.value == 33
103+
assert str(error) == "Wrong value 33"
104+
assert not hasattr(error, "my_value")
105+
106+
# the autocompletion does not see this
107+
assert error.something_else == "yes"
108+
109+
110+
@pytest.mark.parametrize(
111+
"str_format,ctx,expected",
112+
[
113+
pytest.param("{value:10}", {"value": "Python"}, "Python ", id="left-align"),
114+
pytest.param(
115+
"{value:>10}", {"value": "Python"}, " Python", id="right-align"
116+
),
117+
pytest.param(
118+
"{value:^10}", {"value": "Python"}, " Python ", id="center-align"
119+
),
120+
pytest.param("{v:.2f}", {"v": 3.1415926}, "3.14", id="decimals"),
121+
pytest.param(
122+
"{dt:%Y-%m-%d %H:%M}",
123+
{"dt": datetime(2020, 5, 17, 18, 45)},
124+
"2020-05-17 18:45",
125+
id="datetime",
126+
),
127+
],
128+
)
129+
def test_msg_template_with_different_formats(
130+
str_format: str, ctx: dict[str, Any], expected: str
131+
):
132+
class MyError(OsparcErrorMixin, ValueError):
133+
msg_template = str_format
134+
135+
error = MyError(**ctx)
136+
assert str(error) == expected

services/web/server/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ include ../../../scripts/common-service.Makefile
66

77
# overrides since it does not has same directory name
88
APP_NAME := webserver
9-
9+
PY_PACKAGE_NAME := simcore_service_webserver
1010

1111
.PHONY: requirements
1212
requirements: ## compiles pip requirements (.in -> .txt)

services/web/server/src/simcore_service_webserver/db_listener/_db_comp_tasks_listening_task.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ async def _get_project_owner(conn: SAConnection, project_uuid: str) -> PositiveI
3535
select(projects.c.prj_owner).where(projects.c.uuid == project_uuid)
3636
)
3737
if not the_project_owner:
38-
raise exceptions.ProjectOwnerNotFoundError(project_uuid)
38+
raise exceptions.ProjectOwnerNotFoundError(project_uuid=project_uuid)
3939
return the_project_owner
4040

4141

services/web/server/src/simcore_service_webserver/director_v2/exceptions.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,43 +2,43 @@
22

33
from typing import Any
44

5-
from pydantic.errors import PydanticErrorMixin
5+
from ..errors import WebServerBaseError
66

77

8-
class DirectorServiceError(PydanticErrorMixin, RuntimeError):
8+
class DirectorServiceError(WebServerBaseError, RuntimeError):
99
"""Basic exception for errors raised by director-v2"""
1010

11-
msg_template = "Unexpected error: director-v2 returned {status!r}, reason {reason!r} after calling {url!r}"
11+
msg_template = "Unexpected error: director-v2 returned '{status}', reason '{reason}' after calling '{url}'"
1212

1313
def __init__(self, *, status: int, reason: str, **ctx: Any) -> None:
14+
super().__init__(**ctx)
1415
self.status = status
1516
self.reason = reason
16-
super().__init__(status=status, reason=reason, **ctx)
1717

1818

1919
class ClusterNotFoundError(DirectorServiceError):
2020
"""Cluster was not found in director-v2"""
2121

22-
msg_template = "Cluster {cluster_id!r} not found"
22+
msg_template = "Cluster '{cluster_id}' not found"
2323

2424

2525
class ClusterAccessForbidden(DirectorServiceError):
2626
"""Cluster access is forbidden"""
2727

28-
msg_template = "Cluster {cluster_id!r} access forbidden!"
28+
msg_template = "Cluster '{cluster_id}' access forbidden!"
2929

3030

3131
class ClusterPingError(DirectorServiceError):
3232
"""Cluster ping failed"""
3333

34-
msg_template = "Connection to cluster in {endpoint!r} failed, received {reason!r}"
34+
msg_template = "Connection to cluster in '{endpoint}' failed, received '{reason}'"
3535

3636

3737
class ClusterDefinedPingError(DirectorServiceError):
3838
"""Cluster ping failed"""
3939

40-
msg_template = "Connection to cluster {cluster_id!r} failed, received {reason!r}"
40+
msg_template = "Connection to cluster '{cluster_id}' failed, received '{reason}'"
4141

4242

4343
class ServiceWaitingForManualIntervention(DirectorServiceError):
44-
msg_template = "Service {service_uuid} is waiting for user manual intervention"
44+
msg_template = "Service '{service_uuid}' is waiting for user manual intervention"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from typing import Any
2+
3+
from models_library.errors_classes import OsparcErrorMixin
4+
5+
6+
class WebServerBaseError(OsparcErrorMixin, Exception):
7+
def __init__(self, **ctx: Any) -> None:
8+
super().__init__(**ctx)

services/web/server/src/simcore_service_webserver/groups/_handlers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from ..login.decorators import login_required
2727
from ..products.api import Product, get_current_product
2828
from ..scicrunch.db import ResearchResourceRepository
29-
from ..scicrunch.errors import InvalidRRID, ScicrunchError
29+
from ..scicrunch.errors import InvalidRRIDError, ScicrunchError
3030
from ..scicrunch.models import ResearchResource, ResourceHit
3131
from ..scicrunch.service_client import SciCrunch
3232
from ..security.decorators import permission_required
@@ -295,8 +295,8 @@ async def wrapper(request: web.Request) -> web.Response:
295295
try:
296296
return await handler(request)
297297

298-
except InvalidRRID as err:
299-
raise web.HTTPBadRequest(reason=err.reason) from err
298+
except InvalidRRIDError as err:
299+
raise web.HTTPBadRequest(reason=f"{err}") from err
300300

301301
except ScicrunchError as err:
302302
user_msg = "Cannot get RRID since scicrunch.org service is not reachable."
Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,31 @@
11
"""Defines the different exceptions that may arise in the projects subpackage"""
22

3+
from ..errors import WebServerBaseError
34

4-
class GroupsError(Exception):
5-
"""Basic exception for errors raised in projects"""
5+
6+
class GroupsError(WebServerBaseError):
7+
msg_template = "{msg}"
68

79
def __init__(self, msg: str = None):
8-
super().__init__(msg or "Unexpected error occured in projects subpackage")
10+
super().__init__(msg=msg or "Unexpected error occured in projects subpackage")
911

1012

1113
class GroupNotFoundError(GroupsError):
12-
"""Group was not found in DB"""
14+
msg_template = "Group with id {gid} not found"
1315

14-
def __init__(self, gid: int):
15-
super().__init__(f"Group with id {gid} not found")
16+
def __init__(self, gid, **extras):
17+
super().__init__(**extras)
1618
self.gid = gid
1719

1820

1921
class UserInsufficientRightsError(GroupsError):
20-
"""User has not sufficient rights"""
21-
22-
def __init__(self, msg: str):
23-
super().__init__(msg)
22+
...
2423

2524

2625
class UserInGroupNotFoundError(GroupsError):
27-
"""User in group was not found in DB"""
26+
msg_template = "User id {uid} in Group {gid} not found"
2827

29-
def __init__(self, gid: int, uid: int):
30-
super().__init__(f"User id {uid} in Group {gid} not found")
31-
self.gid = gid
28+
def __init__(self, uid, gid, **extras):
29+
super().__init__(**extras)
3230
self.uid = uid
31+
self.gid = gid

0 commit comments

Comments
 (0)