Skip to content

Commit ca68db4

Browse files
committed
✨ [Refactor] Improve app_module_setup decorator documentation and enhance test fixtures
1 parent 7e40b6e commit ca68db4

File tree

2 files changed

+162
-74
lines changed

2 files changed

+162
-74
lines changed

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

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import functools
22
import inspect
33
import logging
4-
import warnings
54
from collections.abc import Callable
65
from contextlib import ContextDecorator
76
from copy import deepcopy
@@ -258,27 +257,42 @@ def app_module_setup(
258257
config_section: str | None = None,
259258
config_enabled: str | None = None,
260259
) -> Callable:
261-
"""Decorator marking a function as a module setup for an application.
262-
263-
Ensures one-time execution (idempotent) and tracks setup completion. For addons, setup can be
264-
toggled via config [deprecated] or settings.
265-
266-
:param settings_name: Field name in app settings for this module
267-
:raises DependencyError: If required dependent modules are not initialized
268-
:raises ApplicationSetupError: If setup fails
269-
:return: True if setup completed, False if skipped
260+
"""Decorator that marks a function as 'a setup function' for a given module in an application
261+
262+
- Marks a function as 'setup' of a given module in an application
263+
- Ensures setup executed ONLY ONCE per app
264+
- Addon modules:
265+
- toggles run using 'enabled' entry in config file
266+
- logs execution
267+
268+
See packages/service-library/tests/test_application_setup.py
269+
270+
:param module_name: typically __name__
271+
:param depends: list of module_names that must be called first, defaults to None
272+
:param config_section: explicit configuration section, defaults to None (i.e. the name of the module, or last entry of the name if dotted)
273+
:param config_enabled: option in config to enable, defaults to None which is '$(module-section).enabled' (config_section and config_enabled are mutually exclusive)
274+
:param settings_name: field name in the app's settings that corresponds to this module. Defaults to the name of the module with app prefix.
275+
:raises DependencyError
276+
:raises ApplicationSetupError
277+
:return: True if setup was completed or False if setup was skipped
270278
:rtype: bool
271279
272280
:Example:
281+
from servicelib.aiohttp.application_setup import app_module_setup
282+
273283
@app_module_setup('mysubsystem', ModuleCategory.SYSTEM, logger=log)
274-
def setup_mysubsystem(app: web.Application):
284+
def setup(app: web.Application):
275285
...
276286
"""
287+
# TODO: resilience to failure. if this setup fails, then considering dependencies, is it fatal or app can start?
288+
# TODO: enforce signature as def setup(app: web.Application, **kwargs) -> web.Application
289+
277290
module_name, depends, section, config_enabled = _parse_and_validate_arguments(
278291
module_name, depends, config_section, config_enabled
279292
)
280293

281-
def _get_metadata() -> SetupMetadataDict:
294+
# metadata info
295+
def _setup_metadata() -> SetupMetadataDict:
282296
return SetupMetadataDict(
283297
module_name=module_name,
284298
dependencies=depends,
@@ -303,11 +317,7 @@ def _wrapper(app: web.Application, *args, **kargs) -> bool:
303317

304318
if settings_name is None:
305319
# Fall back to config if settings_name is not explicitly defined
306-
warnings.warn(
307-
f"Using config-based enabling/disabling for addon '{module_name}' is deprecated. Please use settings instead.",
308-
DeprecationWarning,
309-
stacklevel=2,
310-
)
320+
# TODO: deprecate
311321
cfg = app[APP_CONFIG_KEY]
312322
is_enabled = _is_addon_enabled_from_config(
313323
cfg, config_enabled, section
@@ -357,7 +367,7 @@ def _wrapper(app: web.Application, *args, **kargs) -> bool:
357367
_wrapper.__wrapped__ == setup_func
358368
), "this is added by functools.wraps decorator" # nosec
359369

360-
setattr(_wrapper, "metadata", _get_metadata) # noqa: B010
370+
setattr(_wrapper, "metadata", _setup_metadata) # noqa: B010
361371
setattr(_wrapper, "mark_as_simcore_servicelib_setup_func", True) # noqa: B010
362372

363373
return _wrapper
Lines changed: 134 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,29 @@
1-
# pylint:disable=unused-variable
2-
# pylint:disable=unused-argument
3-
# pylint:disable=redefined-outer-name
1+
# pylint: disable=protected-access
2+
# pylint: disable=redefined-outer-name
3+
# pylint: disable=too-many-arguments
4+
# pylint: disable=unused-argument
5+
# pylint: disable=unused-variable
46

5-
from unittest.mock import Mock
7+
8+
from collections.abc import Callable
69

710
import pytest
811
from aiohttp import web
12+
from pytest_mock import MockerFixture, MockType
913
from servicelib.aiohttp.application_keys import APP_CONFIG_KEY
1014
from servicelib.aiohttp.application_setup import (
1115
DependencyError,
1216
ModuleCategory,
1317
SkipModuleSetupError,
1418
app_module_setup,
19+
ensure_single_setup,
1520
is_setup_completed,
1621
)
1722

18-
log = Mock()
19-
20-
21-
@app_module_setup("package.bar", ModuleCategory.ADDON, logger=log)
22-
def setup_bar(app: web.Application, arg1, *, raise_skip: bool = False):
23-
return True
24-
25-
26-
@app_module_setup("package.foo", ModuleCategory.ADDON, logger=log)
27-
def setup_foo(app: web.Application, arg1, kargs=33, *, raise_skip: bool = False):
28-
if raise_skip:
29-
raise SkipModuleSetupError(reason="explicit skip")
30-
return True
3123

32-
33-
@app_module_setup(
34-
"package.zee", ModuleCategory.ADDON, config_enabled="main.zee_enabled", logger=log
35-
)
36-
def setup_zee(app: web.Application, arg1, kargs=55):
37-
return True
38-
39-
40-
@app_module_setup(
41-
"package.needs_foo",
42-
ModuleCategory.SYSTEM,
43-
depends=[
44-
"package.foo",
45-
],
46-
logger=log,
47-
)
48-
def setup_needs_foo(app: web.Application, arg1, kargs=55):
49-
return True
24+
@pytest.fixture
25+
def mock_logger(mocker: MockerFixture) -> MockType:
26+
return mocker.patch("logging.getLogger", autospec=True)
5027

5128

5229
@pytest.fixture
@@ -59,55 +36,156 @@ def app_config() -> dict:
5936

6037

6138
@pytest.fixture
62-
def app(app_config):
39+
def app(app_config) -> web.Application:
6340
_app = web.Application()
6441
_app[APP_CONFIG_KEY] = app_config
6542
return _app
6643

6744

68-
def test_setup_config_enabled(app_config, app):
69-
assert setup_zee(app, 1)
45+
@pytest.fixture
46+
def create_setup_bar() -> Callable[[web.Application, str, bool], bool]:
47+
@app_module_setup("package.bar", ModuleCategory.ADDON)
48+
def _setup_bar(
49+
app: web.Application, arg1: str, *, raise_skip: bool = False
50+
) -> bool:
51+
return True
52+
53+
return _setup_bar
54+
55+
56+
@pytest.fixture
57+
def create_setup_foo() -> Callable[[web.Application, str, bool], bool]:
58+
@app_module_setup("package.foo", ModuleCategory.ADDON)
59+
def _setup_foo(
60+
app: web.Application, arg1: str, *, raise_skip: bool = False, **kwargs
61+
) -> bool:
62+
if raise_skip:
63+
raise SkipModuleSetupError(reason="explicit skip")
64+
return True
65+
66+
return _setup_foo
67+
68+
69+
@pytest.fixture
70+
def create_setup_zee() -> Callable[[web.Application, str, int], bool]:
71+
@app_module_setup(
72+
"package.zee", ModuleCategory.ADDON, config_enabled="main.zee_enabled"
73+
)
74+
def _setup_zee(app: web.Application, arg1: str, kargs: int = 55) -> bool:
75+
return True
7076

77+
return _setup_zee
78+
79+
80+
@pytest.fixture
81+
def create_setup_needs_foo() -> Callable[[web.Application, str, int], bool]:
82+
@app_module_setup(
83+
"package.needs_foo",
84+
ModuleCategory.SYSTEM,
85+
depends=[
86+
"package.foo",
87+
],
88+
)
89+
def _setup_needs_foo(app: web.Application, arg1: str, kargs: int = 55) -> bool:
90+
return True
91+
92+
return _setup_needs_foo
93+
94+
95+
def setup_basic(app: web.Application) -> bool:
96+
return True
97+
98+
99+
def setup_that_raises(app: web.Application) -> bool:
100+
error_msg = "Setup failed"
101+
raise ValueError(error_msg)
102+
103+
104+
def test_setup_config_enabled(
105+
app_config: dict, app: web.Application, create_setup_zee: Callable
106+
):
107+
setup_zee = create_setup_zee()
108+
109+
assert setup_zee(app, 1)
71110
assert setup_zee.metadata()["config_enabled"] == "main.zee_enabled"
72111
app_config["main"]["zee_enabled"] = False
73112
assert not setup_zee(app, 2)
74113

75114

76-
def test_setup_dependencies(app_config, app):
115+
def test_setup_dependencies(
116+
app: web.Application,
117+
create_setup_needs_foo: Callable,
118+
create_setup_foo: Callable,
119+
) -> None:
120+
setup_needs_foo = create_setup_needs_foo()
121+
setup_foo = create_setup_foo()
77122

78123
with pytest.raises(DependencyError):
79-
setup_needs_foo(app, 1)
124+
setup_needs_foo(app, "1")
80125

81-
assert setup_foo(app, 1)
82-
assert setup_needs_foo(app, 2)
126+
assert setup_foo(app, "1")
127+
assert setup_needs_foo(app, "2")
83128

84129
assert setup_needs_foo.metadata()["dependencies"] == [
85130
setup_foo.metadata()["module_name"],
86131
]
87132

88133

89-
def test_marked_setup(app_config, app):
90-
assert setup_foo(app, 1)
134+
def test_marked_setup(
135+
app_config: dict, app: web.Application, create_setup_foo: Callable
136+
):
137+
setup_foo = create_setup_foo()
91138

139+
assert setup_foo(app, 1)
92140
assert setup_foo.metadata()["module_name"] == "package.foo"
93141
assert is_setup_completed(setup_foo.metadata()["module_name"], app)
94142

95143
app_config["foo"]["enabled"] = False
96144
assert not setup_foo(app, 2)
97145

98146

99-
def test_skip_setup(app_config, app):
100-
try:
101-
log.reset_mock()
147+
def test_skip_setup(
148+
app: web.Application, mock_logger: MockType, create_setup_foo: Callable
149+
):
150+
setup_foo = create_setup_foo()
151+
assert not setup_foo(app, 1, raise_skip=True)
152+
assert setup_foo(app, 1)
153+
mock_logger.info.assert_called()
154+
155+
156+
def test_ensure_single_setup_runs_once(app: web.Application, mock_logger: Mock) -> None:
157+
decorated = ensure_single_setup("test.module", logger=mock_logger)(setup_basic)
158+
159+
# First call succeeds
160+
assert decorated(app)
161+
assert is_setup_completed("test.module", app)
162+
163+
# Second call skips
164+
assert not decorated(app)
165+
mock_logger.info.assert_called_with(
166+
"Skipping '%s' setup: %s",
167+
"test.module",
168+
"'test.module' was already initialized in <Application. Avoid logging objects like this>. Setup can only be executed once per app.",
169+
)
170+
171+
172+
def test_ensure_single_setup_error_handling(
173+
app: web.Application, mock_logger: Mock
174+
) -> None:
175+
decorated = ensure_single_setup("test.error", logger=mock_logger)(setup_that_raises)
176+
177+
with pytest.raises(ValueError, match="Setup failed"):
178+
decorated(app)
179+
assert not is_setup_completed("test.error", app)
102180

103-
assert not setup_foo(app, 1, raise_skip=True)
104181

105-
# FIXME: mock logger
106-
# assert log.warning.called
107-
# warn_msg = log.warning.call_args()[0]
108-
# assert "package.foo" in warn_msg
109-
# assert "explicit skip" in warn_msg
182+
def test_ensure_single_setup_multiple_modules(
183+
app: web.Application, mock_logger: Mock
184+
) -> None:
185+
decorated1 = ensure_single_setup("module1", logger=mock_logger)(setup_basic)
186+
decorated2 = ensure_single_setup("module2", logger=mock_logger)(setup_basic)
110187

111-
assert setup_foo(app, 1)
112-
finally:
113-
log.reset_mock()
188+
assert decorated1(app)
189+
assert decorated2(app)
190+
assert is_setup_completed("module1", app)
191+
assert is_setup_completed("module2", app)

0 commit comments

Comments
 (0)