Skip to content

Commit 393bb49

Browse files
authored
feat: add pluggable_override decorator (#64)
[BB-933] * Add `pluggable_override` This adds a new extension point - a `pluggable_override` decorator that allows overriding any function or method by pointing to its alternative implementation in settings. * Address review comments * Fix import
1 parent 71522eb commit 393bb49

File tree

3 files changed

+141
-0
lines changed

3 files changed

+141
-0
lines changed

edx_django_utils/plugins/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from .constants import PluginContexts, PluginSettings, PluginSignals, PluginURLs
8+
from .pluggable_override import pluggable_override
89
from .plugin_apps import get_plugin_apps
910
from .plugin_contexts import get_plugins_view_context
1011
from .plugin_manager import PluginError, PluginManager
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""
2+
Allows overriding existing functions and methods with alternative implementations.
3+
"""
4+
5+
import functools
6+
from importlib import import_module
7+
8+
from django.conf import settings
9+
10+
11+
def pluggable_override(override):
12+
"""
13+
This decorator allows overriding any function or method by pointing to an alternative implementation
14+
with `override` param.
15+
:param override: path to the alternative function
16+
17+
Example usage:
18+
19+
1. Add this decorator to an existing function `OVERRIDE_TRANSFORM` is the variable name in settings that can be
20+
used for overriding this function. Remember to add the `OVERRIDE_` prefix to the name to have the consistent
21+
namespace for the overrides.
22+
>>> @pluggable_override('OVERRIDE_TRANSFORM')
23+
... def transform(value):
24+
... return value + 10
25+
26+
2. Prepare an alternative implementation. It will have the same set of arguments as the original function, with the
27+
`prev_fn` added at the beginning.
28+
>>> def decrement(prev_fn, value):
29+
... if value >= 10:
30+
... return value - 1 # Return the decremented value.
31+
... else:
32+
... return prev_fn(value) - 1 # Call the original `transform` method before decrementing and returning.
33+
34+
3. Specify the path in settings (e.g. in `envs/private.py`):
35+
>>> OVERRIDE_TRANSFORM = 'transform_plugin.decrement'
36+
37+
You can also chain overrides:
38+
>>> OVERRIDE_TRANSFORM = [
39+
... 'transform_plugin.decrement',
40+
... 'transform_plugin.increment',
41+
... ]
42+
43+
Another example:
44+
45+
1. We want to limit access to a Django view (e.g. `common.djangoapps.student.views.dashboard.student_dashboard`)
46+
to allow only logged in users. To do this add `OVERRIDE_DASHBOARD` to the original function:
47+
>>> @pluggable_override('OVERRIDE_DASHBOARD')
48+
... def student_dashboard(request):
49+
... ... # The rest of the implementation is not relevant in this case.
50+
51+
2. Prepare an alternative implementation (e.g. in `envs/private.py` to make this example simpler):
52+
>>> from django.contrib.auth.decorators import login_required
53+
...
54+
... def dashboard(prev_fn, request):
55+
... return login_required(prev_fn)(request)
56+
...
57+
... OVERRIDE_DASHBOARD = 'lms.envs.private.dashboard'
58+
"""
59+
def decorator(f):
60+
@functools.wraps(f)
61+
def wrapper(*args, **kwargs):
62+
prev_fn = f
63+
64+
override_functions = getattr(settings, override, ())
65+
66+
if isinstance(override_functions, str):
67+
override_functions = [override_functions]
68+
69+
for impl in override_functions:
70+
module, function = impl.rsplit('.', 1)
71+
mod = import_module(module)
72+
func = getattr(mod, function)
73+
74+
prev_fn = functools.partial(func, prev_fn)
75+
# Call the last specified function. It can call the previous one, which can call the previous one, etc.
76+
# (until it reaches the base implementation). It can also return without calling `prev_fn`.
77+
return prev_fn(*args, **kwargs)
78+
return wrapper
79+
return decorator
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Tests for utilities.
3+
"""
4+
5+
from django.test import override_settings
6+
7+
from edx_django_utils.plugins import pluggable_override
8+
9+
10+
@pluggable_override('OVERRIDE_TRANSFORM')
11+
def transform(x):
12+
return x + 10
13+
14+
15+
def decrement(prev_fn, x):
16+
if x >= 10:
17+
return x - 1
18+
else:
19+
return prev_fn(x) - 1
20+
21+
22+
def double(prev_fn, x):
23+
if x >= 11:
24+
return x * 2
25+
else:
26+
return prev_fn(x) * 2
27+
28+
29+
def test_no_override():
30+
"""Test that the original function is called when an override is not specified."""
31+
assert transform(10) == 20
32+
33+
34+
@override_settings(OVERRIDE_TRANSFORM="{}.decrement".format(__name__))
35+
def test_override():
36+
"""Test that the overriding function is called."""
37+
assert transform(10) == 9
38+
39+
40+
@override_settings(OVERRIDE_TRANSFORM="{}.decrement".format(__name__))
41+
def test_call_original_function():
42+
"""Test that the overriding function calls the base one."""
43+
assert transform(9) == 18
44+
45+
46+
@override_settings(OVERRIDE_TRANSFORM="{0}.decrement,{0}.double".format(__name__).split(','))
47+
def test_multiple_overrides_call_last_function():
48+
"""Test that the newest (last) overriding function is called when multiple overrides are specified."""
49+
assert transform(11) == 22
50+
51+
52+
@override_settings(OVERRIDE_TRANSFORM="{0}.decrement,{0}.double".format(__name__).split(','))
53+
def test_multiple_overrides_fallback_to_previous_function():
54+
"""Test that the last overriding function can call the previous one from the chain."""
55+
assert transform(10) == 18
56+
57+
58+
@override_settings(OVERRIDE_TRANSFORM="{0}.decrement,{0}.double".format(__name__).split(','))
59+
def test_multiple_overrides_fallback_to_base_function():
60+
"""Test that the overriding functions can eventually call the base one."""
61+
assert transform(9) == 36

0 commit comments

Comments
 (0)