Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove run_on_exit support #28

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
*.egg-info
*.lock
*.pyc
dist/
9 changes: 9 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Changelog

All notable changes to this project will be documented in this file.

## v2.0 (pre-release)

* Add support for Python 3.11
* Drop support for Django 3.7
* Remove `run_on_exit` param from `has_side_effects`
19 changes: 0 additions & 19 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,25 +97,6 @@ that has side effects:
def foo(*args, **kwargs):
pass

**Decorating view functions**

By default, the ``has_side_effects`` decorator will run so long as the inner
function does not raise an exception. View functions, however, are a paticular
case where the function may run, and return a perfectly valid ``HttpResponse``
object, but you do **not** want the side effects to run, as the response object
has a ``status_code`` of 404, 500, etc. In this case, you want to inspect the
inner function return value before deciding whether to fire the side effects
functions. In order to support this, the ``has_side_effects`` decorator has
a kwarg ``run_on_exit`` which takes a function that takes a single parameter,
the return value from the inner function, and must return ``True`` or ``False``
which determines whether to run the side effects.

The ``decorators`` module contains the default argument for this kwarg, a
function called ``http_response_check``. This will return ``False`` if the
inner function return value is an ``HttpResponse`` object with a status
code in the 4xx-5xx range.


The second decorator, ``is_side_effect_of``, is used to bind those functions
that implement the side effects to the origin function:

Expand Down
29 changes: 6 additions & 23 deletions side_effects/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ def http_response_check(response: HttpResponse) -> bool:
return True


def has_side_effects(
label: str, run_on_exit: Callable = http_response_check
) -> Callable:
def has_side_effects(label: str) -> Callable:
"""
Run decorated function and raise side_effects signal when complete.

Expand All @@ -34,35 +32,20 @@ def has_side_effects(
module in core. You can use the label to call the appropriate side
effect function.

The run_on_exit kwarg can be used for fine-grained control over the exact
behaviour required. The canonical use case for this is when decorating
view functions - as they will typically always return a valid HttpResponse
object, and use the status_code property to indicate whether the view
function ran OK.

Args:
label: string, an identifier that is used in the receiver to determine
which event has occurred. This is required because the function name
won't be sufficient in most cases.

Kwargs:
run_on_exit: function used to determine whether the side effects should
run, based on the return value of the innner function. This can be
used to inspect the result for fine grained control. The default is
`http_response_check`, which will return False for 4xx, 5xx status
codes.

"""

def decorator(func: Callable) -> Callable:
@wraps(func)
def inner_func(*args: Any, **kwargs: Any) -> Any:
def inner_func(*args: object, **kwargs: object) -> Any:
"""Run the original function and send the signal if successful."""
return_value = func(*args, **kwargs)
if run_on_exit(return_value):
registry.run_side_effects(
label, *args, return_value=return_value, **kwargs
)
kwargs["return_value"] = return_value
registry.run_side_effects(label, *args, **kwargs)
return return_value

return inner_func
Expand All @@ -77,7 +60,7 @@ def decorator(func: Callable) -> Callable:
registry.register_side_effect(label, func)

@wraps(func)
def inner_func(*args: Any, **kwargs: Any) -> Any:
def inner_func(*args: object, **kwargs: object) -> Any:
return func(*args, **kwargs)

return inner_func
Expand All @@ -90,7 +73,7 @@ def disable_side_effects() -> Callable:

def decorator(func: Callable) -> Callable:
@wraps(func)
def inner_func(*args: Any, **kwargs: Any) -> Any:
def inner_func(*args: object, **kwargs: object) -> Any:
with registry.disable_side_effects() as events:
return func(*args, events, **kwargs)

Expand Down
2 changes: 1 addition & 1 deletion side_effects/management/commands/display_side_effects.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Command(BaseCommand):

help = "Displays project side_effects."

def __init__(self, *args: Any, **kwargs: Any) -> None:
def __init__(self, *args: object, **kwargs: object) -> None:
self.missing_docstrings = [] # type: List[str]
super().__init__(*args, **kwargs)

Expand Down
98 changes: 29 additions & 69 deletions side_effects/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,7 @@ def add(self, label: str, func: Callable) -> None:
with self._lock:
self[label].append(func)

def _run_side_effects(
self, label: str, *args: Any, return_value: Any | None = None, **kwargs: Any
) -> None:
if settings.TEST_MODE_FAIL:
raise SideEffectsTestFailure(label)
for func in self[label]:
_run_func(func, *args, return_value=return_value, **kwargs)

def run_side_effects(
self, label: str, *args: Any, return_value: Any | None = None, **kwargs: Any
) -> None:
def run_side_effects(self, label: str, *args: object, **kwargs: object) -> None:
"""
Run registered side-effects functions, or suppress as appropriate.

Expand All @@ -124,33 +114,36 @@ def run_side_effects(
primarily used by the disable_side_effects context manager to register
which side-effects events were suppressed (for testing purposes).

NB even if the side-effects themselves are not run, this method will try
to bind all of the receiver functions - this is to ensure that incompatible
functions fail hard and early.

"""
# TODO: this is all becoming over-complex - need to simplify this
self.try_bind_all(label, *args, return_value=return_value, **kwargs)
if self._suppress or settings.TEST_MODE:
self.suppressed_side_effect.send(Registry, label=label)
else:
self._run_side_effects(label, *args, return_value=return_value, **kwargs)

def try_bind_all(
self, label: str, *args: Any, return_value: Any | None = None, **kwargs: Any
) -> None:
"""
Test all receivers for signature compatibility.

Raise SignatureMismatch if any function does not match.

"""
return
if settings.TEST_MODE_FAIL:
raise SideEffectsTestFailure(label)
for func in self[label]:
if not (
try_bind(func, *args, return_value=return_value, **kwargs)
or try_bind(func, *args, **kwargs)
):
raise SignatureMismatch(func)
self.try_bind(func, *args, **kwargs)
self.run_func(func, *args, **kwargs)

def run_func(self, func: Callable, *args: object, **kwargs: object) -> None:
try:
func(*args, **kwargs)
except Exception: # noqa: B902
logger.exception("Error running side_effect function '%s'", fname(func))
if settings.ABORT_ON_ERROR or settings.TEST_MODE_FAIL:
raise

def try_bind(self, func: Callable, *args: object, **kwargs: object) -> None:
"""Try binding args & kwargs to a given func."""
try:
inspect.signature(func).bind(*args, **kwargs)
except TypeError:
# if the func isn't expecting return_value (which we have added
# to the kwargs in the has_side_effects decorator), then try
# again without it.
if "return_value" in kwargs:
kwargs.pop("return_value", None)
return self.try_bind(func, *args, **kwargs)
raise SignatureMismatch(func)


class disable_side_effects:
Expand Down Expand Up @@ -189,48 +182,15 @@ def register_side_effect(label: str, func: Callable) -> None:
_registry.add(label, func)


def run_side_effects(
label: str, *args: Any, return_value: Any | None = None, **kwargs: Any
) -> None:
def run_side_effects(label: str, *args: object, **kwargs: object) -> None:
"""Run all of the side-effect functions registered for a label."""
if not transaction.get_autocommit():
getattr(logger, settings.ATOMIC_TX_LOG_LEVEL)(
"Side-effects [%s] are being run within the scope of an atomic "
"transaction. This may have unintended consequences.",
label,
)
_registry.run_side_effects(label, *args, return_value=return_value, **kwargs)


def _run_func(
func: Callable, *args: Any, return_value: Any | None = None, **kwargs: Any
) -> None:
"""Run a single side-effect function and handle errors."""
try:
if try_bind(func, *args, return_value=return_value, **kwargs):
func(*args, return_value=return_value, **kwargs)
elif try_bind(func, *args, **kwargs):
func(*args, **kwargs)
else:
raise SignatureMismatch(func)
except SignatureMismatch:
# always re-raise SignatureMismatch as this means we have been unable
# to run the side-effect function at all.
raise
except Exception: # noqa: B902
logger.exception("Error running side_effect function '%s'", fname(func))
if settings.ABORT_ON_ERROR or settings.TEST_MODE_FAIL:
raise


def try_bind(func: Callable, *args: Any, **kwargs: Any) -> bool:
"""Try binding args & kwargs to a given func."""
try:
inspect.signature(func).bind(*args, **kwargs)
except TypeError:
return False
else:
return True
_registry.run_side_effects(label, *args, **kwargs)


# global registry
Expand Down
8 changes: 4 additions & 4 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@

@has_side_effects("foo")
def origin(message: str) -> str:
print(f"origin: {message}") # noqa: T001
print(f"origin: {message}") # noqa: T201
return f"Message received: {message}"


@is_side_effect_of("foo")
def no_docstring(message: str) -> None:
print(f"side-effect.1: message={message}") # noqa: T001
print(f"side-effect.1: message={message}") # noqa: T201


@is_side_effect_of("foo")
def one_line_docstring(message: str) -> None:
"""This is a one-line docstring."""
print(f"side-effect.2: message={message}") # noqa: T001
print(f"side-effect.2: message={message}") # noqa: T201


@is_side_effect_of("foo")
Expand All @@ -27,6 +27,6 @@ def multi_line_docstring(message: str, return_value: str) -> None:
It has more information here.

"""
print( # noqa: T001
print( # noqa: T201
f"Side-effect.3: message={message}, return_value={return_value}"
)
15 changes: 1 addition & 14 deletions tests/test_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,6 @@ def test_func(arg1: int):
func(1)
mock_registry.run_side_effects.assert_called_with("foo", 1, return_value=2)

@mock.patch("side_effects.decorators.registry")
def test_has_side_effects__run_on_exit_false(self, mock_registry):
"""Decorated functions should call run_side_effects."""

def test_func(*args, **kwargs):
pass

func = decorators.has_side_effects("foo", run_on_exit=lambda r: False)(
test_func
)
func("bar")
mock_registry.run_side_effects.assert_not_called()

@mock.patch("side_effects.registry.register_side_effect")
def test_is_side_effect_of(self, mock_register):
"""Decorated functions should be added to the registry."""
Expand Down Expand Up @@ -94,7 +81,7 @@ def foo():


class ContextManagerTests(TestCase):
@mock.patch("side_effects.registry._run_func")
@mock.patch.object(registry.Registry, "run_func")
def test_disable_side_effects(self, mock_func):
"""Side-effects can be temporarily disabled."""

Expand Down
Loading