Skip to content

Commit 6c97c5e

Browse files
authored
♻️ Is638/dynamic sidecar (round 5), OECs, 🔨 OAS swagger with servers (ITISFoundation#3165)
1 parent 96e2b00 commit 6c97c5e

File tree

48 files changed

+966
-519
lines changed

Some content is hidden

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

48 files changed

+966
-519
lines changed

packages/models-library/src/models_library/basic_types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ class BootModeEnum(str, Enum):
4343
PRODUCTION = "production"
4444
DEVELOPMENT = "development"
4545

46+
def is_devel_mode(self) -> bool:
47+
"""returns True if this boot mode is used for development"""
48+
return self in (self.DEBUG, self.DEVELOPMENT, self.LOCAL)
49+
4650

4751
class BuildTargetEnum(str, Enum):
4852
"""
Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,4 @@
1-
from typing import Optional
1+
EnvVarsDict = dict[str, str]
22

3-
EnvVarsDict = dict[str, Optional[str]]
4-
#
5-
# NOTE: that this means that env vars do not require a value. If that happens a None is assigned
6-
# For instance, a valid env file is
7-
#
8-
# NAME=foo
9-
# INDEX=33
10-
# ONLY_NAME=
11-
#
12-
# will return env: EnvVarsDict = {"NAME": "foo", "INDEX": 33, "ONLY_NAME": None}
13-
#
3+
4+
# SEE packages/pytest-simcore/tests/test_helpers_utils_envs.py

packages/pytest-simcore/src/pytest_simcore/helpers/utils_envs.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,65 @@
11
import os
2+
3+
#
4+
# .env (dotenv) files (or envfile)
5+
#
26
from io import StringIO
7+
from pathlib import Path
8+
from typing import Union
39

4-
from _pytest.monkeypatch import MonkeyPatch
5-
from dotenv import dotenv_values
10+
import dotenv
11+
from pytest import MonkeyPatch
612

713
from .typing_env import EnvVarsDict
814

915

10-
def setenvs_as_envfile(monkeypatch: MonkeyPatch, envfile_text: str) -> EnvVarsDict:
11-
envs = dotenv_values(stream=StringIO(envfile_text))
16+
#
17+
# monkeypatch using dict
18+
#
19+
def setenvs_from_dict(monkeypatch: MonkeyPatch, envs: EnvVarsDict):
1220
for key, value in envs.items():
21+
assert value is not None # None keys cannot be is defined w/o value
1322
monkeypatch.setenv(key, str(value))
23+
return envs
24+
25+
26+
def load_dotenv(envfile_content_or_path: Union[Path, str], **options) -> EnvVarsDict:
27+
"""Convenient wrapper around dotenv.dotenv_values"""
28+
kwargs = options.copy()
29+
if isinstance(envfile_content_or_path, Path):
30+
# path
31+
kwargs["dotenv_path"] = envfile_content_or_path
32+
else:
33+
assert isinstance(envfile_content_or_path, str)
34+
# content
35+
kwargs["stream"] = StringIO(envfile_content_or_path)
36+
37+
return {k: v or "" for k, v in dotenv.dotenv_values(**kwargs).items()}
38+
39+
40+
#
41+
# monkeypath using envfiles ('.env' and also denoted as dotfiles)
42+
#
43+
44+
45+
def setenvs_from_envfile(
46+
monkeypatch: MonkeyPatch, content_or_path: str, **dotenv_kwags
47+
) -> EnvVarsDict:
48+
"""Batch monkeypatch.setenv(...) on all env vars in an envfile"""
49+
envs = load_dotenv(content_or_path, **dotenv_kwags)
50+
setenvs_from_dict(monkeypatch, envs)
1451

1552
assert all(env in os.environ for env in envs)
1653
return envs
1754

1855

19-
def delenvs_as_envfile(
20-
monkeypatch: MonkeyPatch, envfile_text: str, raising: bool
56+
def delenvs_from_envfile(
57+
monkeypatch: MonkeyPatch, content_or_path: str, raising: bool, **dotenv_kwags
2158
) -> EnvVarsDict:
22-
envs = dotenv_values(stream=StringIO(envfile_text))
59+
"""Batch monkeypatch.delenv(...) on all env vars in an envfile"""
60+
envs = load_dotenv(content_or_path, **dotenv_kwags)
2361
for key in envs.keys():
2462
monkeypatch.delenv(key, raising=raising)
2563

2664
assert all(env not in os.environ for env in envs)
2765
return envs
28-
29-
30-
def setenvs_from_dict(monkeypatch: MonkeyPatch, envs: EnvVarsDict):
31-
for key, value in envs.items():
32-
assert value is not None # key is defined w/o value
33-
monkeypatch.setenv(key, str(value))
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from pathlib import Path
2+
from textwrap import dedent
3+
4+
from pytest_simcore.helpers.utils_envs import EnvVarsDict, load_dotenv
5+
6+
7+
def test_load_envfile(tmp_path: Path):
8+
9+
envfile = tmp_path / ".env"
10+
envfile.write_text(
11+
dedent(
12+
"""
13+
NAME=foo
14+
INDEX=33
15+
ONLY_NAME=
16+
NULLED=null
17+
"""
18+
)
19+
)
20+
21+
envs: EnvVarsDict = load_dotenv(envfile, verbose=True)
22+
23+
assert {
24+
"NAME": "foo",
25+
"INDEX": "33",
26+
"NULLED": "null",
27+
"ONLY_NAME": "",
28+
} == envs
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
""" osparc ERROR CODES (OEC)
2+
Unique identifier of an exception instance
3+
Intended to report a user about unexpected errors.
4+
Unexpected exceptions can be traced by matching the
5+
logged error code with that appeneded to the user-friendly message
6+
7+
SEE test_error_codes for some use cases
8+
"""
9+
10+
11+
import re
12+
from typing import TYPE_CHECKING
13+
14+
from pydantic.tools import parse_obj_as
15+
from pydantic.types import constr
16+
17+
_LABEL = "OEC:{}"
18+
_PATTERN = r"OEC:\d+"
19+
20+
if TYPE_CHECKING:
21+
ErrorCodeStr = str
22+
else:
23+
ErrorCodeStr = constr(strip_whitespace=True, regex=_PATTERN)
24+
25+
26+
def create_error_code(exception: Exception) -> ErrorCodeStr:
27+
return parse_obj_as(ErrorCodeStr, _LABEL.format(id(exception)))
28+
29+
30+
def parse_error_code(obj) -> set[ErrorCodeStr]:
31+
return set(re.findall(_PATTERN, f"{obj}"))

packages/service-library/src/servicelib/fastapi/openapi.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,42 @@
33

44
import types
55
from types import FunctionType
6-
from typing import Any, Dict
6+
from typing import Any
77

88
from fastapi.applications import FastAPI
99
from fastapi.routing import APIRoute, APIRouter
1010

1111
from ..functools_utils import copy_func
1212

13+
# Some common values for FastAPI(... server=[ ... ]) parameter
14+
# It will be added to the OpenAPI Specs (OAS).
15+
_OAS_DEVELOPMENT_SERVER = {
16+
"description": "Development server",
17+
"url": "http://{host}:{port}",
18+
"variables": {
19+
"host": {"default": "127.0.0.1"},
20+
"port": {"default": "8000"},
21+
},
22+
}
23+
24+
25+
def get_common_oas_options(is_devel_mode: bool) -> dict[str, Any]:
26+
"""common OAS options for FastAPI constructor"""
27+
servers = None
28+
if is_devel_mode:
29+
# NOTE: for security, only exposed in devel mode
30+
# Make sure also that this is NOT used in edge services
31+
# SEE https://sonarcloud.io/project/security_hotspots?id=ITISFoundation_osparc-simcore&pullRequest=3165&hotspots=AYHPqDfX5LRQZ1Ko6y4-
32+
servers = [
33+
_OAS_DEVELOPMENT_SERVER,
34+
]
35+
36+
return dict(
37+
servers=servers,
38+
docs_url="/dev/doc",
39+
redoc_url=None, # default disabled
40+
)
41+
1342

1443
def redefine_operation_id_in_router(router: APIRouter, operation_id_prefix: str):
1544
"""
@@ -42,14 +71,14 @@ def redefine_operation_id_in_router(router: APIRouter, operation_id_prefix: str)
4271
)
4372

4473

45-
def patch_openapi_specs(app_openapi: Dict[str, Any]):
74+
def patch_openapi_specs(app_openapi: dict[str, Any]):
4675
"""Patches app.openapi with some fixes and osparc conventions
4776
4877
Modifies fastapi auto-generated OAS to pass our openapi validation.
4978
"""
5079

5180
def _patch(node):
52-
if isinstance(node, Dict):
81+
if isinstance(node, dict):
5382
for key in list(node.keys()):
5483
# SEE fastapi ISSUE: https://github.com/tiangolo/fastapi/issues/240 (test_openap.py::test_exclusive_min_openapi_issue )
5584
# SEE openapi-standard: https://swagger.io/docs/specification/data-models/data-types/#range
@@ -80,7 +109,7 @@ def override_fastapi_openapi_method(app: FastAPI):
80109
# pylint: disable=protected-access
81110
app._original_openapi = types.MethodType(copy_func(app.openapi), app) # type: ignore
82111

83-
def _custom_openapi_method(self: FastAPI) -> Dict:
112+
def _custom_openapi_method(self: FastAPI) -> dict:
84113
"""Overrides FastAPI.openapi member function
85114
returns OAS schema with vendor extensions
86115
"""
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# pylint: disable=broad-except
2+
# pylint: disable=redefined-outer-name
3+
# pylint: disable=unused-argument
4+
# pylint: disable=unused-variable
5+
6+
import logging
7+
8+
from servicelib.error_codes import create_error_code, parse_error_code
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def test_error_code_use_case(caplog):
14+
"""use case for error-codes"""
15+
try:
16+
raise RuntimeError("Something unexpected went wrong")
17+
except Exception as err:
18+
# 1. Unexpected ERROR
19+
20+
# 2. create error-code
21+
error_code = create_error_code(err)
22+
23+
# 3. log all details in service
24+
caplog.clear()
25+
26+
# Can add a formatter that prefix error-codes
27+
syslog = logging.StreamHandler()
28+
syslog.setFormatter(
29+
logging.Formatter("%(asctime)s %(error_code)s : %(message)s")
30+
)
31+
logger.addHandler(syslog)
32+
33+
logger.error("Fake Unexpected error", extra={"error_code": error_code})
34+
35+
# logs something like E.g. 2022-07-06 14:31:13,432 OEC:140350117529856 : Fake Unexpected error
36+
assert parse_error_code(
37+
f"2022-07-06 14:31:13,432 {error_code} : Fake Unexpected error"
38+
) == {
39+
error_code,
40+
}
41+
42+
assert caplog.records[0].error_code == error_code
43+
assert caplog.records[0]
44+
45+
logger.error("Fake without error_code")
46+
47+
# 4. inform user (e.g. with new error or sending message)
48+
user_message = (
49+
f"This is a user-friendly message to inform about an error. [{error_code}]"
50+
)
51+
52+
assert parse_error_code(user_message) == {
53+
error_code,
54+
}

0 commit comments

Comments
 (0)