Skip to content

Commit a50b617

Browse files
authored
♻️ servicelib tools: json serialization and fixes responses (ITISFoundation#2582)
* servicelib utils to get httpexc for status-code * Adds richer encoder to client session * pytest-simcore: separated fixture to reduce dependencies * servicelib: adds tests * servicelib: fixes uuid encoding and remove ujson reqs * fixes pytest-mocks * adds pydantic encode to json serialization * fixes type annotation * modifies get_http_error * renames api_version_prefix -> VTAG * removes repeated fixture and replaces by a pytest_plugins reference * servicelib adds faker in tests * removed unused ujson requirement * adds cache to get_http_error * uses servicelib tools in handlers * unused meta module * removes assert * removes meta * @GitHK review: using f-strings * @GitHK review: dict check & get * @colinRawlings review: improved tests and collected all http_errors upon import instead * fixes R1716: Simplify chained comparison between the operands (chained-comparison)
1 parent ef8ab62 commit a50b617

File tree

42 files changed

+371
-199
lines changed

Some content is hidden

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

42 files changed

+371
-199
lines changed

.pylintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# A comma-separated list of package or module names from where C extensions may
44
# be loaded. Extensions are loading into the active Python interpreter and may
55
# run arbitrary code
6-
extension-pkg-whitelist=pydantic,orjson
6+
extension-pkg-whitelist=pydantic,orjson,ujson
77

88
# Add files or directories to the blacklist. They should be base names, not
99
# paths.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import pytest
2+
from aioresponses import aioresponses as AioResponsesMock
3+
4+
PASSTHROUGH_REQUESTS_PREFIXES = ["http://127.0.0.1", "ws://"]
5+
6+
7+
@pytest.fixture
8+
def aioresponses_mocker() -> AioResponsesMock:
9+
"""Generick aioresponses mock
10+
11+
SEE https://github.com/pnuckowski/aioresponses
12+
13+
Usage
14+
15+
async def test_this(aioresponses_mocker):
16+
aioresponses_mocker.get("https://foo.io")
17+
18+
async with aiohttp.ClientSession() as session:
19+
async with session.get("https://foo.io") as response:
20+
assert response.status == 200
21+
"""
22+
with AioResponsesMock(passthrough=PASSTHROUGH_REQUESTS_PREFIXES) as mock:
23+
yield mock

packages/pytest-simcore/src/pytest_simcore/services_api_mocks_for_aiohttp_clients.py

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@
77
from typing import Any, Dict, List
88

99
import pytest
10+
from aiohttp import web
1011
from aioresponses import aioresponses as AioResponsesMock
1112
from aioresponses.core import CallbackResult
1213
from models_library.projects_state import RunningState
1314
from yarl import URL
1415

16+
pytest_plugins = [
17+
"pytest_simcore.aioresponses_mocker",
18+
]
19+
1520
# WARNING: any request done through the client will go through aioresponses. It is
1621
# unfortunate but that means any valid request (like calling the test server) prefix must be set as passthrough.
1722
# Other than that it seems to behave nicely
@@ -56,25 +61,6 @@
5661
}
5762

5863

59-
@pytest.fixture
60-
def aioresponses_mocker() -> AioResponsesMock:
61-
"""Generick aioresponses mock
62-
63-
SEE https://github.com/pnuckowski/aioresponses
64-
65-
Usage
66-
67-
async def test_this(aioresponses_mocker):
68-
aioresponses_mocker.get("https://foo.io")
69-
70-
async with aiohttp.ClientSession() as session:
71-
async with session.get("https://foo.aio") as response:
72-
assert response.status == 200
73-
"""
74-
with AioResponsesMock(passthrough=PASSTHROUGH_REQUESTS_PREFIXES) as mock:
75-
yield mock
76-
77-
7864
def creation_cb(url, **kwargs) -> CallbackResult:
7965

8066
assert "json" in kwargs, f"missing body in call to {url}"
@@ -112,8 +98,9 @@ def creation_cb(url, **kwargs) -> CallbackResult:
11298

11399
return CallbackResult(
114100
status=201,
101+
# NOTE: aioresponses uses json.dump which does NOT encode serialization of UUIDs
115102
payload={
116-
"id": kwargs["json"]["project_id"],
103+
"id": str(kwargs["json"]["project_id"]),
117104
"state": state,
118105
"pipeline_details": {
119106
"adjacency_list": pipeline,
@@ -161,16 +148,17 @@ async def director_v2_service_mock(
161148
aioresponses_mocker.post(
162149
create_computation_pattern,
163150
callback=creation_cb,
151+
status=web.HTTPCreated.status_code,
164152
repeat=True,
165153
)
166154
aioresponses_mocker.post(
167155
stop_computation_pattern,
168-
status=204,
156+
status=web.HTTPAccepted.status_code,
169157
repeat=True,
170158
)
171159
aioresponses_mocker.get(
172160
get_computation_pattern,
173-
status=202,
161+
status=web.HTTPAccepted.status_code,
174162
callback=get_computation_cb,
175163
repeat=True,
176164
)
@@ -189,7 +177,8 @@ def get_download_link_cb(url: URL, **kwargs) -> CallbackResult:
189177
file_id = url.path.rsplit("/files/")[1]
190178

191179
return CallbackResult(
192-
status=200, payload={"data": {"link": f"file://{file_id}"}}
180+
status=web.HTTPOk.status_code,
181+
payload={"data": {"link": f"file://{file_id}"}},
193182
)
194183

195184
get_download_link_pattern = re.compile(
@@ -205,7 +194,7 @@ def get_download_link_cb(url: URL, **kwargs) -> CallbackResult:
205194
)
206195
aioresponses_mocker.get(
207196
get_locations_link_pattern,
208-
status=200,
197+
status=web.HTTPOk.status_code,
209198
payload={"data": [{"name": "simcore.s3", "id": "0"}]},
210199
)
211200
return aioresponses_mocker

packages/service-library/requirements/_base.in

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,8 @@
44
--constraint ../../../requirements/constraints.txt
55
--constraint ./constraints.txt
66

7-
ujson
8-
7+
aiodebug
98
pydantic
9+
pyinstrument
1010
pyyaml
11-
12-
# misc
13-
aiodebug
1411
tenacity
15-
16-
# used for monitoring of slow callbacks
17-
pyinstrument

packages/service-library/requirements/_base.txt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,3 @@ tenacity==8.0.1
2020
# via -r requirements/_base.in
2121
typing-extensions==3.10.0.2
2222
# via pydantic
23-
ujson==4.1.0
24-
# via -r requirements/_base.in

packages/service-library/requirements/_test.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pytest-docker
2121
pytest-mock
2222
pytest-sugar
2323

24+
faker
2425

2526
pylint # NOTE: The version in pylint at _text.txt is used as a reference for ci/helpers/install_pylint.bash
2627
coveralls

packages/service-library/requirements/_test.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ docopt==0.6.2
6262
# via
6363
# coveralls
6464
# docker-compose
65+
faker==9.0.0
66+
# via -r requirements/_test.in
6567
idna==2.10
6668
# via
6769
# -c requirements/_aiohttp.txt
@@ -147,6 +149,8 @@ pytest-runner==5.3.1
147149
# via -r requirements/_test.in
148150
pytest-sugar==0.9.4
149151
# via -r requirements/_test.in
152+
python-dateutil==2.8.2
153+
# via faker
150154
python-dotenv==0.19.0
151155
# via docker-compose
152156
pyyaml==5.4.1
@@ -164,16 +168,20 @@ requests==2.26.0
164168
six==1.16.0
165169
# via
166170
# -c requirements/_aiohttp.txt
171+
# -c requirements/_fastapi.txt
167172
# bcrypt
168173
# dockerpty
169174
# isodate
170175
# jsonschema
171176
# openapi-schema-validator
172177
# openapi-spec-validator
173178
# pynacl
179+
# python-dateutil
174180
# websocket-client
175181
termcolor==1.1.0
176182
# via pytest-sugar
183+
text-unidecode==1.3
184+
# via faker
177185
texttable==1.6.4
178186
# via docker-compose
179187
toml==0.10.2

packages/service-library/src/servicelib/aiohttp/client_session.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88

99
from aiohttp import ClientSession, ClientTimeout, web
1010

11-
from .application_keys import APP_CLIENT_SESSION_KEY
11+
from ..json_serialization import json_dumps
1212
from ..utils import (
1313
get_http_client_request_aiohttp_connect_timeout,
1414
get_http_client_request_aiohttp_sock_connect_timeout,
1515
get_http_client_request_total_timeout,
1616
)
17+
from .application_keys import APP_CLIENT_SESSION_KEY
1718

1819
log = logging.getLogger(__name__)
1920

@@ -33,8 +34,12 @@ def get_client_session(app: MutableMapping[str, Any]) -> ClientSession:
3334
total=get_http_client_request_total_timeout(),
3435
connect=get_http_client_request_aiohttp_connect_timeout(),
3536
sock_connect=get_http_client_request_aiohttp_sock_connect_timeout(),
37+
) # type: ignore
38+
39+
app[APP_CLIENT_SESSION_KEY] = session = ClientSession(
40+
timeout=timeout_settings,
41+
json_serialize=json_dumps,
3642
)
37-
app[APP_CLIENT_SESSION_KEY] = session = ClientSession(timeout=timeout_settings)
3843
return session
3944

4045

@@ -61,7 +66,7 @@ async def persistent_client_session(app: web.Application):
6166
)
6267

6368
await session.close()
64-
log.info("Session is actually closed? %s", session.closed)
69+
assert session.closed # nosec
6570

6671

6772
# FIXME: if get_client_session upon startup fails and session is NOT closed. Implement some kind of gracefull shutdonw https://docs.aiohttp.org/en/latest/client_advanced.html#graceful-shutdown

packages/service-library/src/servicelib/aiohttp/rest_responses.py

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
""" Utils to check, convert and compose server responses for the RESTApi
22
33
"""
4+
import inspect
45
from typing import Any, Dict, List, Mapping, Optional, Tuple, Type, Union
56

67
import attr
7-
from aiohttp import web
8+
from aiohttp import web, web_exceptions
9+
from aiohttp.web_exceptions import HTTPError, HTTPException
810

911
from .rest_codecs import json, jsonify
1012
from .rest_models import ErrorItemType, ErrorType, LogMessageType
@@ -89,10 +91,10 @@ def create_data_response(
8991
def create_error_response(
9092
errors: Union[List[Exception], Exception],
9193
reason: Optional[str] = None,
92-
http_error_cls: Type[web.HTTPError] = web.HTTPInternalServerError,
94+
http_error_cls: Type[HTTPError] = web.HTTPInternalServerError,
9395
*,
94-
skip_internal_error_details: bool = False
95-
) -> web.HTTPError:
96+
skip_internal_error_details: bool = False,
97+
) -> HTTPError:
9698
"""
9799
- Response body conforms OAS schema model
98100
- Can skip internal details when 500 status e.g. to avoid transmitting server
@@ -134,3 +136,34 @@ def create_log_response(msg: str, level: str) -> web.Response:
134136
msg = LogMessageType(msg, level)
135137
response = web.json_response(data={"data": attr.asdict(msg), "error": None})
136138
return response
139+
140+
141+
# Inverse map from code to HTTPException classes
142+
def _collect_http_exceptions(exception_cls: Type[HTTPException] = HTTPException):
143+
def _pred(obj) -> bool:
144+
return (
145+
inspect.isclass(obj)
146+
and issubclass(obj, exception_cls)
147+
and getattr(obj, "status_code", 0) > 0
148+
)
149+
150+
found: List[Tuple[str, Any]] = inspect.getmembers(web_exceptions, _pred)
151+
assert found # nosec
152+
153+
http_statuses = {cls.status_code: cls for _, cls in found}
154+
assert len(http_statuses) == len(found), "No duplicates" # nosec
155+
156+
return http_statuses
157+
158+
159+
_STATUS_CODE_TO_HTTP_ERRORS: Dict[int, Type[HTTPError]] = _collect_http_exceptions(
160+
HTTPError
161+
)
162+
163+
164+
def get_http_error(status_code: int) -> Optional[Type[HTTPError]]:
165+
"""Returns aiohttp error class corresponding to a 4XX or 5XX status code
166+
167+
NOTICE that any non-error code (i.e. 2XX, 3XX and 4XX) will return None
168+
"""
169+
return _STATUS_CODE_TO_HTTP_ERRORS.get(status_code)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import json
2+
from typing import Any
3+
4+
from pydantic.json import pydantic_encoder
5+
6+
7+
def json_dumps(obj: Any, **kwargs):
8+
"""json.dumps with rich encoder.
9+
A big applause for pydantic authors here!!!
10+
"""
11+
return json.dumps(obj, default=pydantic_encoder, **kwargs)
12+
13+
14+
# TODO: support for orjson
15+
# TODO: support for ujson (fast but poor encoding, only for basic types)

0 commit comments

Comments
 (0)